From 04a6b74d096ba63c9b046faec7849547c586596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 9 Dec 2022 15:40:34 +0000 Subject: [PATCH 01/45] [FIX] mail, various: update query counters Update counters according to latest runbot counters. It allows to better spot side effects of upcoming changes. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/crm/tests/test_crm_lead_convert_mass.py | 6 +++--- addons/crm/tests/test_performances.py | 2 +- addons/google_calendar/tests/test_sync_odoo2google.py | 8 ++++---- addons/hr_holidays/tests/test_company_leave.py | 2 +- .../hr_work_entry_holidays/tests/test_performance.py | 10 +++++----- addons/sale_stock/tests/test_create_perf.py | 8 ++++---- addons/test_mass_mailing/tests/test_performance.py | 8 ++++---- odoo/addons/base/tests/test_ir_actions.py | 2 +- odoo/addons/base/tests/test_res_users.py | 10 +++++----- odoo/addons/test_new_api/tests/test_new_fields.py | 4 ++-- 10 files changed, 30 insertions(+), 30 deletions(-) diff --git a/addons/crm/tests/test_crm_lead_convert_mass.py b/addons/crm/tests/test_crm_lead_convert_mass.py index a39dec8e285..09c365cf8e3 100644 --- a/addons/crm/tests/test_crm_lead_convert_mass.py +++ b/addons/crm/tests/test_crm_lead_convert_mass.py @@ -24,7 +24,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): with self.assertQueryCount(user_sales_manager=0): test_leads = self.env['crm.lead'].browse(test_leads.ids) - with self.assertQueryCount(user_sales_manager=543): # crm 537 / com 543 / ent 537 + with self.assertQueryCount(user_sales_manager=543): # crm 537 / com 541 / ent 536 test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=False) self.assertEqual(test_leads.team_id, self.sales_team_convert | self.sales_team_1) @@ -42,7 +42,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): with self.assertQueryCount(user_sales_manager=0): test_leads = self.env['crm.lead'].browse(test_leads.ids) - with self.assertQueryCount(user_sales_manager=524): # crm 521 / com 524 + with self.assertQueryCount(user_sales_manager=524): # crm 521 / com 516 / ent 516 test_leads._handle_salesmen_assignment(user_ids=user_ids, team_id=team_id) self.assertEqual(test_leads.team_id, self.sales_team_convert) @@ -167,7 +167,7 @@ class TestLeadConvertMass(crm_common.TestLeadConvertMassCommon): user_ids = self.assign_users.ids # randomness: at least 1 query - with self.assertQueryCount(user_sales_manager=1704): # crm 1410 / com 1697 + with self.assertQueryCount(user_sales_manager=1704): # crm 1410 / com 1677 / ent 1685 mass_convert = self.env['crm.lead2opportunity.partner.mass'].with_context({ 'active_model': 'crm.lead', 'active_ids': test_leads.ids, diff --git a/addons/crm/tests/test_performances.py b/addons/crm/tests/test_performances.py index 067f7b483b7..7f2500f4d9b 100644 --- a/addons/crm/tests/test_performances.py +++ b/addons/crm/tests/test_performances.py @@ -178,7 +178,7 @@ class TestLeadAssignPerf(TestLeadAssignCommon): # randomness with self.with_user('user_sales_manager'): - with self.assertQueryCount(user_sales_manager=6280): # crm 6226 / com 6276 / ent 6278 + with self.assertQueryCount(user_sales_manager=6280): # crm 6226 / com 6212 / ent 6214 self.env['crm.team'].browse(sales_teams.ids)._action_assign_leads(work_days=30) # teams assign diff --git a/addons/google_calendar/tests/test_sync_odoo2google.py b/addons/google_calendar/tests/test_sync_odoo2google.py index f14de26f1f3..f4a144cbd21 100644 --- a/addons/google_calendar/tests/test_sync_odoo2google.py +++ b/addons/google_calendar/tests/test_sync_odoo2google.py @@ -71,7 +71,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): }) partner_model = self.env.ref('base.model_res_partner') partner = self.env['res.partner'].search([], limit=1) - with self.assertQueryCount(__system__=616): + with self.assertQueryCount(__system__=615): events = self.env['calendar.event'].create([{ 'name': "Event %s" % (i), 'start': datetime(2020, 1, 15, 8, 0), @@ -86,7 +86,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): events._sync_odoo2google(self.google_service) - with self.assertQueryCount(__system__=130): + with self.assertQueryCount(__system__=126): events.unlink() @@ -103,7 +103,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): }) partner_model = self.env.ref('base.model_res_partner') partner = self.env['res.partner'].search([], limit=1) - with self.assertQueryCount(__system__=72): + with self.assertQueryCount(__system__=56): event = self.env['calendar.event'].create({ 'name': "Event", 'start': datetime(2020, 1, 15, 8, 0), @@ -120,7 +120,7 @@ class TestSyncOdoo2Google(TestSyncGoogle): 'res_id': partner.id, }) - with self.assertQueryCount(__system__=35): + with self.assertQueryCount(__system__=31): event.unlink() def test_event_without_user(self): diff --git a/addons/hr_holidays/tests/test_company_leave.py b/addons/hr_holidays/tests/test_company_leave.py index 4f1b7dce92b..78aa100f6e0 100644 --- a/addons/hr_holidays/tests/test_company_leave.py +++ b/addons/hr_holidays/tests/test_company_leave.py @@ -323,7 +323,7 @@ class TestCompanyLeave(TransactionCase): }) company_leave._compute_date_from_to() - with self.assertQueryCount(__system__=774, admin=867): # 770 community + with self.assertQueryCount(__system__=605, admin=867): # 770 community # Original query count: 1987 # Without tracking/activity context keys: 5154 company_leave.action_validate() diff --git a/addons/hr_work_entry_holidays/tests/test_performance.py b/addons/hr_work_entry_holidays/tests/test_performance.py index 869afade403..996b7b2e336 100644 --- a/addons/hr_work_entry_holidays/tests/test_performance.py +++ b/addons/hr_work_entry_holidays/tests/test_performance.py @@ -32,7 +32,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase): self.richard_emp.generate_work_entries(date(2018, 1, 1), date(2018, 1, 2)) leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) - with self.assertQueryCount(__system__=93, admin=94): + with self.assertQueryCount(__system__=79, admin=81): leave.action_validate() leave.action_refuse() @@ -41,14 +41,14 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase): def test_performance_leave_write(self): leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) - with self.assertQueryCount(__system__=21, admin=30): + with self.assertQueryCount(__system__=19, admin=27): leave.date_to = datetime(2018, 1, 1, 19, 0) leave.action_refuse() @users('__system__', 'admin') @warmup def test_performance_leave_create(self): - with self.assertQueryCount(__system__=31, admin=32): # 25/26 com + with self.assertQueryCount(__system__=29, admin=30): leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) leave.action_refuse() @@ -57,7 +57,7 @@ class TestWorkEntryHolidaysPerformance(TestWorkEntryHolidaysBase): def test_performance_leave_confirm(self): leave = self.create_leave(datetime(2018, 1, 1, 7, 0), datetime(2018, 1, 1, 18, 0)) leave.action_draft() - with self.assertQueryCount(__system__=28, admin=29): + with self.assertQueryCount(__system__=25, admin=26): leave.action_confirm() leave.state = 'refuse' @@ -112,7 +112,7 @@ class TestWorkEntryHolidaysPerformancesBigData(TestWorkEntryHolidaysBase): def test_work_entries_generation_perf(self): # Test Case 7: Try to generate work entries for # a hundred employees over a month - with self.assertQueryCount(__system__=2607, admin=2807): + with self.assertQueryCount(__system__=500, admin=2807): work_entries = self.contracts._generate_work_entries(date(2020, 7, 1), date(2020, 8, 31)) # Original work entries to generate when we don't adapt date_generated_from and diff --git a/addons/sale_stock/tests/test_create_perf.py b/addons/sale_stock/tests/test_create_perf.py index ca9adce165f..7010c50d3ac 100644 --- a/addons/sale_stock/tests/test_create_perf.py +++ b/addons/sale_stock/tests/test_create_perf.py @@ -38,7 +38,7 @@ class TestPERF(common.TransactionCase): @users('admin') @warmup def test_empty_sale_order_creation_perf(self): - with self.assertQueryCount(admin=34): + with self.assertQueryCount(admin=33): self.env['sale.order'].create({ 'partner_id': self.partners[0].id, 'user_id': self.salesmans[0].id, @@ -52,7 +52,7 @@ class TestPERF(common.TransactionCase): # + 1 warehouse fetch # + 1 query to get analytic default account # + 1 followers queries ? - with self.assertQueryCount(admin=39): + with self.assertQueryCount(admin=37): self.env['sale.order'].create([{ 'partner_id': self.partners[0].id, 'user_id': self.salesmans[0].id, @@ -63,7 +63,7 @@ class TestPERF(common.TransactionCase): def test_dummy_sales_orders_batch_creation_perf(self): """ Dummy SOlines (notes/sections) should not add any custom queries other than their insert""" # + 2 SOL (batched) insert - with self.assertQueryCount(admin=41): + with self.assertQueryCount(admin=39): self.env['sale.order'].create([{ 'partner_id': self.partners[0].id, 'user_id': self.salesmans[0].id, @@ -80,7 +80,7 @@ class TestPERF(common.TransactionCase): # + 2 SQL insert # + 2 queries to get analytic default tags # + 9 follower queries ? - with self.assertQueryCount(admin=52): + with self.assertQueryCount(admin=49): self.env['sale.order'].create([{ 'partner_id': self.partners[0].id, 'user_id': self.salesmans[0].id, diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py index 6c9fbf5e992..4e48a20cac8 100644 --- a/addons/test_mass_mailing/tests/test_performance.py +++ b/addons/test_mass_mailing/tests/test_performance.py @@ -47,14 +47,14 @@ class TestMassMailPerformance(TestMassMailPerformanceBase): }) # runbot needs +2 compared to local - with self.assertQueryCount(__system__=426, marketing=427): # tm 423/424 + with self.assertQueryCount(__system__=425, marketing=426): # tm 423/424 mailing.action_send_mail() self.assertEqual(mailing.sent, 50) self.assertEqual(mailing.delivered, 50) # runbot needs +3 compared to local - with self.assertQueryCount(__system__=69, marketing=67): # tm 66/66 + with self.assertQueryCount(__system__=68, marketing=67): # tm 66/66 self.env['mail.mail'].sudo().search([('to_delete', '=', True)]).unlink() mails = self.env['mail.mail'].sudo().search([('mailing_id', '=', mailing.id)]) @@ -94,14 +94,14 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase): }) # runbot needs +2 compared to local - with self.assertQueryCount(__system__=488, marketing=489): # tm 485/486 + with self.assertQueryCount(__system__=487, marketing=488): # tm 485/486 mailing.action_send_mail() self.assertEqual(mailing.sent, 50) self.assertEqual(mailing.delivered, 50) # runbot needs +3 compared to local - with self.assertQueryCount(__system__=69, marketing=67): # tm 66/66 + with self.assertQueryCount(__system__=68, marketing=67): # tm 66/66 self.env['mail.mail'].sudo().search([('to_delete', '=', True)]).unlink() cancelled_mail_count = self.env['mail.mail'].sudo().search([('mailing_id', '=', mailing.id)]) diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py index a6f5b81999e..616bcfee551 100644 --- a/odoo/addons/base/tests/test_ir_actions.py +++ b/odoo/addons/base/tests/test_ir_actions.py @@ -536,7 +536,7 @@ class TestCustomFields(common.TransactionCase): # create a non-computed field, and assert how many queries it takes model_id = self.env['ir.model']._get_id('res.partner') - query_count = 41 + query_count = 40 with self.assertQueryCount(query_count): self.env.registry.clear_caches() self.env['ir.model.fields'].create({ diff --git a/odoo/addons/base/tests/test_res_users.py b/odoo/addons/base/tests/test_res_users.py index 5eacb4791c7..4b6fa13b4c9 100644 --- a/odoo/addons/base/tests/test_res_users.py +++ b/odoo/addons/base/tests/test_res_users.py @@ -405,7 +405,7 @@ class TestUsersGroupWarning(TransactionCase): # 97 requests if only base is installed # 412 runbot community # 549 runbot enterprise - with self.assertQueryCount(__system__=549), \ + with self.assertQueryCount(__system__=436), \ Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm: UserForm._values[self.sales_categ_field] = False UserForm._perform_onchange([self.sales_categ_field]) @@ -427,7 +427,7 @@ class TestUsersGroupWarning(TransactionCase): # 97 requests if only base is installed # 412 runbot community # 549 runbot enterprise - with self.assertQueryCount(__system__=549), \ + with self.assertQueryCount(__system__=436), \ Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm: UserForm._values[self.sales_categ_field] = self.group_sales_user.id UserForm._perform_onchange([self.sales_categ_field]) @@ -451,7 +451,7 @@ class TestUsersGroupWarning(TransactionCase): # 101 requests if only base is installed # 416 runbot community # 553 runbot enterprise - with self.assertQueryCount(__system__=553), \ + with self.assertQueryCount(__system__=438), \ Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm: UserForm._values[self.sales_categ_field] = self.group_sales_user.id UserForm._values[self.project_categ_field] = self.group_project_user.id @@ -479,7 +479,7 @@ class TestUsersGroupWarning(TransactionCase): # 98 requests if only base is installed # 413 runbot community # 550 runbot enterprise - with self.assertQueryCount(__system__=550), \ + with self.assertQueryCount(__system__=437), \ Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm: UserForm._values[self.timesheets_categ_field] = self.group_timesheets_user_own_timesheet.id UserForm._perform_onchange([self.timesheets_categ_field]) @@ -500,7 +500,7 @@ class TestUsersGroupWarning(TransactionCase): # 83 requests if only base is installed # 397 runbot community # 534 runbot enterprise - with self.assertQueryCount(__system__=534), \ + with self.assertQueryCount(__system__=420), \ Form(self.test_group_user.with_context(show_user_group_warning=True), view='base.view_users_form') as UserForm: UserForm._values[self.field_service_categ_field] = self.group_field_service_user.id UserForm._perform_onchange([self.field_service_categ_field]) diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 9af27961535..94c6269d160 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -3411,7 +3411,7 @@ class TestSelectionUpdates(common.TransactionCase): self.env[self.MODEL_BASE].create({}) with self.assertQueryCount(1): record = self.env[self.MODEL_BASE].create({'my_selection': 'foo'}) - with self.assertQueryCount(3): # SELECT, SELECT (related field), UPDATE + with self.assertQueryCount(1): # SELECT, SELECT (related field), UPDATE record.my_selection = 'bar' def test_selection_related_readonly(self): @@ -3425,7 +3425,7 @@ class TestSelectionUpdates(common.TransactionCase): related_record = self.env[self.MODEL_BASE].create({'my_selection': 'foo'}) with self.assertQueryCount(2): # defaults (related field), INSERT record = self.env[self.MODEL_RELATED_UPDATE].create({'selection_id': related_record.id}) - with self.assertQueryCount(3): + with self.assertQueryCount(2): record.related_selection = 'bar' From 11906b776e1fc459d1b5eb9e4dc036e7627665d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 13 Dec 2022 14:46:28 +0000 Subject: [PATCH 02/45] [FIX] mail: remove microseconds when parsing vague datetime Mail holds an helper function to convert datetime from a char input into a datetime value. It is used notably when having to store a datetime value (e.g. on a mail_mail) from a string generated by templates with expression like ``{{ datetime.datetime.now() + datetime.timedelta(days=2)}}``. In this commit we explicitly remove microseconds, as otherwise we end up with something that the web client cannot parse, not speaking of even going into server side storage with this value not matching the expected format. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_mail.py | 1 + 1 file changed, 1 insertion(+) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 02f8edbb129..533821fffc8 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -309,6 +309,7 @@ class MailMail(models.Model): except (ValueError, TypeError): parsed_datetime = False if parsed_datetime: + parsed_datetime = parsed_datetime.replace(microsecond=0) if not parsed_datetime.tzinfo: parsed_datetime = pytz.utc.localize(parsed_datetime) else: From 44a316275db8490d7ef2976c56d458453fbb94e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 13 Dec 2022 14:48:58 +0000 Subject: [PATCH 03/45] [FIX] mail: avoid cache miss when opening the template previewer Sometimes the previewer crashes in rendering with cache-based records, hence trying to check directly the id of the related reference record. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_template_preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 24ecbac2533..79644ce3e8f 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -72,7 +72,7 @@ class MailTemplatePreview(models.TransientModel): copy_depends_values = {'lang': self.lang} mail_template = self.mail_template_id.with_context(lang=self.lang) try: - if not self.resource_ref: + if not self.resource_ref.id: self._set_mail_attributes() else: copy_depends_values['resource_ref'] = '%s,%s' % (self.resource_ref._name, self.resource_ref.id) From 7b96e7cb22a873ddf3d5779b7c3febf5e974e74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 13 Dec 2022 13:50:13 +0000 Subject: [PATCH 04/45] [FIX] mail: correctly display errors when previewing templates In this commit we correctly set 'error_msg' field as computed. As it was not defined as computed, it was not really updated and therefore not displayed in the preview form view. It is now the case. ValueError are now also catched, for example when having badly configured 'partner_ids' field we may face this kind of error, which is now logged in the preview wizard. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_template_preview.py | 4 ++-- addons/mail/wizard/mail_template_preview_views.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 79644ce3e8f..5916cabdc69 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -43,7 +43,7 @@ class MailTemplatePreview(models.TransientModel): resource_ref = fields.Reference(string='Record', selection='_selection_target_model') lang = fields.Selection(_selection_languages, string='Template Preview Language') no_record = fields.Boolean('No Record', compute='_compute_no_record') - error_msg = fields.Char('Error Message', readonly=True) + error_msg = fields.Char('Error Message', compute='_compute_mail_template_fields') # Fields same than the mail.template model, computed with resource_ref and lang subject = fields.Char('Subject', compute='_compute_mail_template_fields') email_from = fields.Char('From', compute='_compute_mail_template_fields', help="Sender address") @@ -82,7 +82,7 @@ class MailTemplatePreview(models.TransientModel): ) self._set_mail_attributes(values=mail_values) self.error_msg = False - except UserError as user_error: + except (ValueError, UserError) as user_error: self._set_mail_attributes() self.error_msg = user_error.args[0] finally: diff --git a/addons/mail/wizard/mail_template_preview_views.xml b/addons/mail/wizard/mail_template_preview_views.xml index 228c97956f5..90865d69061 100644 --- a/addons/mail/wizard/mail_template_preview_views.xml +++ b/addons/mail/wizard/mail_template_preview_views.xml @@ -8,7 +8,7 @@

Preview of

From eccac1ce7b7483331b7674e8ff55a73dc571f9e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 12 Dec 2022 13:09:10 +0000 Subject: [PATCH 05/45] [FIX] website(_sale): stop creating message manually In website(_sale) messages are created from website forms. However those are technical models, you should always use the MailThread API notably to ensure values coherency. In our case using message_log seems to be what original committers wanted to do (even creating a message as a comment which has no effect as the notification process is not called that way). Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/website/controllers/form.py | 35 ++++++++++--------------- addons/website_sale/controllers/main.py | 14 +++++----- odoo/addons/base/tests/test_mail.py | 2 +- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/addons/website/controllers/form.py b/addons/website/controllers/form.py index 5c87a5b68b0..3303671a4d8 100644 --- a/addons/website/controllers/form.py +++ b/addons/website/controllers/form.py @@ -4,14 +4,15 @@ import base64 import json +from markupsafe import Markup from psycopg2 import IntegrityError from werkzeug.exceptions import BadRequest from odoo import http, SUPERUSER_ID, _ +from odoo.addons.base.models.ir_qweb_fields import nl2br from odoo.http import request from odoo.tools import plaintext2html from odoo.exceptions import ValidationError, UserError -from odoo.addons.base.models.ir_qweb_fields import nl2br class WebsiteForm(http.Controller): @@ -234,14 +235,11 @@ class WebsiteForm(http.Controller): if default_field.ttype == 'html' or model_name == 'mail.mail': custom_content = nl2br(custom_content) record.update({default_field.name: custom_content}) - else: - values = { - 'body': nl2br(custom_content), - 'model': model_name, - 'message_type': 'comment', - 'res_id': record.id, - } - request.env['mail.message'].with_user(SUPERUSER_ID).create(values) + elif hasattr(record, '_message_log'): + record._message_log( + body=custom_content, + message_type='comment', + ) return record.id @@ -269,20 +267,15 @@ class WebsiteForm(http.Controller): else: orphan_attachment_ids.append(attachment_id.id) - if model_name != 'mail.mail': + if model_name != 'mail.mail' and hasattr(record, '_message_log') and orphan_attachment_ids: # If some attachments didn't match a field on the model, # we create a mail.message to link them to the record - if orphan_attachment_ids: - values = { - 'body': _('

Attached files :

'), - 'model': model_name, - 'message_type': 'comment', - 'res_id': id_record, - 'attachment_ids': [(6, 0, orphan_attachment_ids)], - 'subtype_id': request.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), - } - request.env['mail.message'].with_user(SUPERUSER_ID).create(values) - else: + record._message_log( + attachment_ids=[(6, 0, orphan_attachment_ids)], + body=Markup(_('

Attached files :

')), + message_type='comment', + ) + elif model_name == 'mail.mail' and orphan_attachment_ids: # If the model is mail.mail then we have no other choice but to # attach the custom binary field files on the attachment_ids field. for attachment_id_id in orphan_attachment_ids: diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 04f840eca29..74a7da3dbbf 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -4,14 +4,15 @@ from collections import defaultdict from itertools import product as cartesian_product import json import logging + from datetime import datetime +from markupsafe import Markup from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_decode, url_encode, url_parse from odoo import fields, http, SUPERUSER_ID, tools, _ from odoo.fields import Command from odoo.http import request -from odoo.addons.base.models.ir_qweb_fields import nl2br from odoo.addons.http_routing.models.ir_http import slug from odoo.addons.payment import utils as payment_utils from odoo.addons.payment.controllers import portal as payment_portal @@ -113,13 +114,10 @@ class WebsiteSaleForm(WebsiteForm): order.write(data['record']) if data['custom']: - values = { - 'body': nl2br(data['custom']), - 'model': 'sale.order', - 'message_type': 'comment', - 'res_id': order.id, - } - request.env['mail.message'].with_user(SUPERUSER_ID).create(values) + order._message_log( + body=data['custom'], + message_type='comment', + ) if data['attachments']: self.insert_attachment(model_record, order.id, data['attachments']) diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index e3be6e0d812..b4e91e0fa2b 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - from unittest.mock import patch + import email.policy import email.message import re From 3e7acff8d9302c3332fe3011f75374170484c61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 4 Jan 2023 12:01:34 +0000 Subject: [PATCH 06/45] [FIX] website(_sale): fix logs coming from website forms Purpose of this commit is to avoid html entities in logged message by correctly managing enclosures. For that purpose a new tool 'nl2br_enclose' is added that eases Markup management on top of 'nl2br' simple tool. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/website/controllers/form.py | 4 ++-- addons/website_sale/controllers/main.py | 4 ++-- odoo/addons/base/models/ir_qweb_fields.py | 8 +++++++ odoo/addons/base/tests/test_mail.py | 29 +++++++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/addons/website/controllers/form.py b/addons/website/controllers/form.py index 3303671a4d8..4fb165a19dc 100644 --- a/addons/website/controllers/form.py +++ b/addons/website/controllers/form.py @@ -9,7 +9,7 @@ from psycopg2 import IntegrityError from werkzeug.exceptions import BadRequest from odoo import http, SUPERUSER_ID, _ -from odoo.addons.base.models.ir_qweb_fields import nl2br +from odoo.addons.base.models.ir_qweb_fields import nl2br, nl2br_enclose from odoo.http import request from odoo.tools import plaintext2html from odoo.exceptions import ValidationError, UserError @@ -237,7 +237,7 @@ class WebsiteForm(http.Controller): record.update({default_field.name: custom_content}) elif hasattr(record, '_message_log'): record._message_log( - body=custom_content, + body=nl2br_enclose(custom_content, 'p'), message_type='comment', ) diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 74a7da3dbbf..a8d5af7caaa 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -6,13 +6,13 @@ import json import logging from datetime import datetime -from markupsafe import Markup from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_decode, url_encode, url_parse from odoo import fields, http, SUPERUSER_ID, tools, _ from odoo.fields import Command from odoo.http import request +from odoo.addons.base.models.ir_qweb_fields import nl2br_enclose from odoo.addons.http_routing.models.ir_http import slug from odoo.addons.payment import utils as payment_utils from odoo.addons.payment.controllers import portal as payment_portal @@ -115,7 +115,7 @@ class WebsiteSaleForm(WebsiteForm): if data['custom']: order._message_log( - body=data['custom'], + body=nl2br_enclose(data['custom'], 'p'), message_type='comment', ) diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py index 4f1cdc16e86..c5e98099a7c 100644 --- a/odoo/addons/base/models/ir_qweb_fields.py +++ b/odoo/addons/base/models/ir_qweb_fields.py @@ -28,6 +28,14 @@ def nl2br(string): """ return pycompat.to_text(string).replace('\n', Markup('
\n')) + +def nl2br_enclose(string, enclosure_tag='div'): + """ Like nl2br, but returns enclosed Markup allowing to better manipulate + trusted and untrusted content. New lines added by use are trusted, other + content is escaped. """ + converted = nl2br(escape(string)) + return Markup(f'<{enclosure_tag}>{converted}') + #-------------------------------------------------------------------- # QWeb Fields converters #-------------------------------------------------------------------- diff --git a/odoo/addons/base/tests/test_mail.py b/odoo/addons/base/tests/test_mail.py index b4e91e0fa2b..75139648b98 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from markupsafe import Markup from unittest.mock import patch import email.policy @@ -9,6 +10,8 @@ import re import threading from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses +from odoo.addons.base.models.ir_qweb_fields import nl2br_enclose +from odoo.tests import tagged from odoo.tests.common import BaseCase, TransactionCase from odoo.tools import ( is_html_empty, html_to_inner_content, html_sanitize, append_content_to_html, plaintext2html, @@ -20,6 +23,7 @@ from odoo.tools import ( from . import test_mail_examples +@tagged('mail_sanitize') class TestSanitizer(BaseCase): """ Test the html sanitizer that filters html to remove unwanted attributes """ @@ -292,6 +296,7 @@ class TestSanitizer(BaseCase): # self.assertNotIn(ext, new_html) +@tagged('mail_sanitize') class TestHtmlTools(BaseCase): """ Test some of our generic utility functions about html """ @@ -356,6 +361,30 @@ class TestHtmlTools(BaseCase): for content in valid_html_samples: self.assertFalse(is_html_empty(content)) + def test_nl2br_enclose(self): + """ Test formatting of nl2br when using Markup: consider new
tags + as trusted without validating the whole input content. """ + source_all = [ + 'coucou', + '

coucou

', + 'coucou\ncoucou', + 'coucou\n\ncoucou', + '

coucou\ncoucou\n\nzbouip

\n', + ] + expected_all = [ + Markup('
coucou
'), + Markup('
<p>coucou</p>
'), + Markup('
coucou
\ncoucou
'), + Markup('
coucou
\n
\ncoucou
'), + Markup('
<p>coucou
\ncoucou
\n
\nzbouip</p>
\n
'), + ] + for source, expected in zip(source_all, expected_all): + with self.subTest(source=source, expected=expected): + self.assertEqual( + nl2br_enclose(source, "div"), + expected, + ) + def test_prepend_html_content(self): body = """ From 7d16ae555199e48d50cd07e98619b259b4d55703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 9 Jan 2023 15:33:54 +0000 Subject: [PATCH 07/45] [FIX] point_of_sale: avoid entities in emails Purpose of this commit is to avoid html entities in sent emails message by correctly using string and html formatting. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/point_of_sale/models/pos_order.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 6ae66174f50..7500422d06e 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from datetime import timedelta +from markupsafe import Markup from functools import partial from itertools import groupby from collections import defaultdict @@ -923,7 +924,12 @@ class PosOrder(models.Model): } def _prepare_mail_values(self, name, client, ticket): - message = _("

Dear %s,
Here is your electronic ticket for the %s.

") % (client['name'], name) + message = Markup( + _("

Dear %(client_name)s,
Here is your electronic ticket for the %(pos_name)s.

") + ) % { + 'client_name': client['name'], + 'pos_name': name, + } return { 'subject': _('Receipt %s', name), From c98f259736b5aa9c37a3580b2dad5795e8d5f6df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 17 Jan 2023 12:54:56 +0000 Subject: [PATCH 08/45] [IMP] mail: improve tests tooling methods In this commit we add a method to check MailMail, based on a given record. When having duplicates to differentiate in a given mailing, having just recipients is not sufficient as multiple emails may match a given recipients list. Checking model / res_id is another method for finding emails. This will be used notably to add tests for exclusion list and duplicates management in standard mail composer, outside of mass mailing context. While being at it, use subTests when having loops checking values. That way it is easier to fix tests that have several failing values in the same global assertMailMail_* . Task-3132710 (Mail: Configurable composer) Part-of: odoo/odoo#99482 --- addons/mail/tests/common.py | 157 +++++++++++-------- addons/mass_mailing/tests/common.py | 2 +- addons/test_mail/tests/test_mail_composer.py | 2 +- 3 files changed, 97 insertions(+), 64 deletions(-) diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py index b3c9244b2b8..853ceb611be 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -329,18 +329,19 @@ class MockEmail(common.BaseCase, MockSmtplibCase): # GATEWAY ASSERTS # ------------------------------------------------------------ - def assertMailMail(self, recipients, status, - mail_message=None, author=None, - content=None, fields_values=None, email_values=None): - """ Assert mail.mail records are created and maybe sent as emails. Allow + def _assertMailMail(self, mail, recipients_list, + status, author=None, + content=None, fields_values=None, email_values=None): + """ Assert mail.mail record values and maybe related emails. Allow asserting their content. Records to check are the one generated when - using mock (mail.mail and outgoing emails). This method takes partners - as source of record fetch and assert. + using mock (mail.mail and outgoing emails). - :param recipients: a ``res.partner`` recordset. See ``_find_mail_mail_wpartners``; + :param mail: a ``mail.mail`` record; + :param recipients_list: an ``res.partner`` recordset or a list of + emails (both are supported, see ``_find_mail_mail_wpartners`` and + ``_find_mail_mail_wemail``); :param status: mail.mail state used to filter mails. If ``sent`` this method also check that emails have been sent trough gateway; - :param mail_message: see ``_find_mail_mail_wpartners``; :param author: see ``_find_mail_mail_wpartners``; :param content: if given, check it is contained within mail html body; :param fields_values: if given, should be a dictionary of field names / @@ -350,52 +351,89 @@ class MockEmail(common.BaseCase, MockSmtplibCase): allowing to check sent email additional values (if any). See ``assertSentEmail``; """ + self.assertTrue(bool(mail)) + if content: + self.assertIn(content, mail.body_html) + for fname, fvalue in (fields_values or {}).items(): + with self.subTest(fname=fname, fvalue=fvalue): + self.assertEqual( + mail[fname], fvalue, + 'Mail: expected %s for %s, got %s' % (fvalue, fname, mail[fname]) + ) + if status == 'sent': + for recipient in recipients_list: + with self.subTest(recipient=recipient): + self.assertSentEmail( + email_values['email_from'] if email_values and email_values.get('email_from') else author, + [recipient], + **(email_values or {}) + ) + + def assertMailMail(self, recipients, status, + mail_message=None, author=None, + content=None, fields_values=None, email_values=None): + """ Assert mail.mail records are created and maybe sent as emails. This + method takes partners as source to find mails and check their content. + See '_assertMailMail' for more details. + + :param recipients: a ``res.partner`` recordset. See + ``_find_mail_mail_wpartners``; + :param mail_message: used to find the related email; + + See '_assertMailMail' for more details about other parameters. + """ found_mail = self._find_mail_mail_wpartners(recipients, status, mail_message=mail_message, author=author) self.assertTrue(bool(found_mail)) - if content: - self.assertIn(content, found_mail.body_html) - for fname, fvalue in (fields_values or {}).items(): - self.assertEqual( - found_mail[fname], fvalue, - 'Mail: expected %s for %s, got %s' % (fvalue, fname, found_mail[fname])) - if status == 'sent': - for recipient in recipients: - self.assertSentEmail(email_values['email_from'] if email_values and email_values.get('email_from') else author, [recipient], **(email_values or {})) + self._assertMailMail( + found_mail, recipients, + status, author=author, + content=content, fields_values=fields_values, email_values=email_values, + ) def assertMailMailWEmails(self, emails, status, mail_message=None, author=None, content=None, fields_values=None, email_values=None): - """ Assert mail.mail records are created and maybe sent as emails. Allow - asserting their content. Records to check are the one generated when - using mock (mail.mail and outgoing emails). This method takes emails - as source of record fetch and assert. + """ Assert mail.mail records are created and maybe sent as emails. This + method takes emails as source to find mails and check their content. + See '_assertMailMail' for more details. :param emails: a list of emails. See ``_find_mail_mail_wemail``; - :param status: mail.mail state used to filter mails. If ``sent`` this method - also check that emails have been sent trough gateway; - :param mail_message: see ``_find_mail_mail_wemail``; - :param author: see ``_find_mail_mail_wemail``;; - :param content: if given, check it is contained within mail html body; - :param fields_values: if given, should be a dictionary of field names / - values allowing to check ``mail.mail`` additional values (subject, - reply_to, ...); - :param email_values: if given, should be a dictionary of keys / values - allowing to check sent email additional values (if any). - See ``assertSentEmail``; + :param mail_message: used to find the related email; + + See '_assertMailMail' for more details about other parameters. """ for email_to in emails: found_mail = self._find_mail_mail_wemail(email_to, status, mail_message=mail_message, author=author) - if content: - self.assertIn(content, found_mail.body_html) - for fname, fvalue in (fields_values or {}).items(): - self.assertEqual( - found_mail[fname], fvalue, - 'Mail: expected %s for %s, got %s' % (fvalue, fname, found_mail[fname])) - if status == 'sent': - for email_to in emails: - self.assertSentEmail(email_values['email_from'] if email_values and email_values.get('email_from') else author, [email_to], **(email_values or {})) + self.assertTrue(bool(found_mail)) + self._assertMailMail( + found_mail, [email_to], + status, author=author, + content=content, fields_values=fields_values, email_values=email_values, + ) + + def assertMailMailWRecord(self, record, recipients, status, + mail_message=None, author=None, + content=None, fields_values=None, email_values=None): + """ Assert mail.mail records are created and maybe sent as emails. This + method takes a record as source to find mails and check their content + using model / res_id. See '_assertMailMail' for more details. + + :param record: a record used to find emails sent related on it. + See ``_find_mail_mail_wrecord``; + :param mail_message: used to find the related email; + + See '_assertMailMail' for more details about other parameters. + """ + found_mail = self._find_mail_mail_wrecord(record, mail_message=mail_message, author=author) + self.assertTrue(bool(found_mail)) + self._assertMailMail( + found_mail, recipients, + status, author=author, + content=content, fields_values=fields_values, email_values=email_values, + ) def assertMailMailWId(self, mail_id, status, + author=None, content=None, fields_values=None): """ Assert mail.mail records are created and maybe sent as emails. Allow asserting their content. Records to check are the one generated when @@ -403,32 +441,27 @@ class MockEmail(common.BaseCase, MockSmtplibCase): as source of record fetch and assert. :param mail_id: a ``mail.mail`` DB ID. See ``_find_mail_mail_wid``; - :param status: mail.mail state to check upon found mail; - :param content: if given, check it is contained within mail html body; - :param fields_values: if given, should be a dictionary of field names / - values allowing to check ``mail.mail`` additional values (subject, - reply_to, ...); + + For other parameters, see ``_assertMailMail``. """ found_mail = self._find_mail_mail_wid(mail_id) self.assertTrue(bool(found_mail)) - if status: - self.assertEqual(found_mail.state, status) - if content: - self.assertIn(content, found_mail.body_html) - for fname, fvalue in (fields_values or {}).items(): - self.assertEqual( - found_mail[fname], fvalue, - 'Mail: expected %s for %s, got %s' % (fvalue, fname, found_mail[fname])) + self._assertMailMail( + found_mail, [], # generally used when recipients are Falsy + status, author=author, + content=content, fields_values=fields_values, + ) def assertMessageFields(self, message, fields_values): """ Just a quick helper to check a mail.message content by giving directly a dict for fields. Allows to hide a lot of assertEqual under a simple call with a dictionary of expected values. """ for fname, fvalue in fields_values.items(): - self.assertEqual( - message[fname], fvalue, - 'Message: expected %s for %s, got %s' % (fvalue, fname, message[fname]) - ) + with self.subTest(fname=fname, fvalue=fvalue): + self.assertEqual( + message[fname], fvalue, + f'Message: expected {fvalue} for {fname}, got {message[fname]}', + ) def assertNoMail(self, recipients, mail_message=None, author=None): """ Check no mail.mail and email was generated during gateway mock. """ @@ -1030,22 +1063,22 @@ class MailCommon(common.TransactionCase, MailCase): return cls.user_portal @classmethod - def _create_records_for_batch(cls, model, count, additional_values=None): + def _create_records_for_batch(cls, model, count, additional_values=None, prefix=None): additional_values = additional_values or {} records = cls.env[model] partners = cls.env['res.partner'] country_id = cls.env.ref('base.be').id base_values = [ - {'name': 'Test_%s' % idx, + {'name': f'{prefix}Test_{idx}', **additional_values, } for idx in range(count) ] if 'customer_id' in cls.env[model]: partners = cls.env['res.partner'].with_context(**cls._test_context).create([{ - 'name': 'Partner_%s' % idx, - 'email': '_test_partner_%s@example.com' % idx, + 'name': f'Partner_{idx}', + 'email': f'{prefix}test_partner_{idx}@example.com', 'country_id': country_id, 'mobile': '047500%02d%02d' % (idx, idx) } for idx in range(count)]) diff --git a/addons/mass_mailing/tests/common.py b/addons/mass_mailing/tests/common.py index 6e160bb84a9..ca5c2671bbf 100644 --- a/addons/mass_mailing/tests/common.py +++ b/addons/mass_mailing/tests/common.py @@ -127,7 +127,7 @@ class MassMailCase(MailCase, MockLinkTracker): self.assertMailMail(partner, state_mapping[status], author=author, content=content, fields_values=fields_values) # specific if email is False -> could have troubles finding it if several falsy traces elif not email and status in ('cancel', 'bounce'): - self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[status], content=content, fields_values=fields_values) + self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[status], author=author, content=content, fields_values=fields_values) else: self.assertMailMailWEmails([email], state_mapping[status], author=author, content=content, fields_values=fields_values) diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 631e4bc9177..73dc63e27e7 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -49,7 +49,7 @@ class TestMailComposer(TestMailCommon, TestRecipients): }) cls.test_records, cls.test_partners = cls._create_records_for_batch( 'mail.test.ticket', 2, - additional_values={'user_id': cls.user_employee_2.id} + additional_values={'user_id': cls.user_employee_2.id}, ) cls.test_report = cls.env['ir.actions.report'].create({ From 13949038739bd7d6c95352200f15614b055cbfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 17 Jan 2023 14:09:41 +0000 Subject: [PATCH 09/45] [IMP] test_mail: add tests for exclusion list and duplicates in composer Purpose of this commit is to add tests for exclusion list and duplicates management in composer. When being in email mode (mass_mail) state of emails is pre-processed in order to already flag emails that should not be sent or will bounce back. Notably emails in exclusion list or duplicates emails are set as cancel with the right failure type. Some tests already exist at higher level in mass mailing but having tests at composer level is better when trying to improve code and add tests for corner cases. Task-3132710 (Mail: Configurable composer) Part-of: odoo/odoo#99482 --- addons/test_mail/models/test_mail_models.py | 26 ++ addons/test_mail/security/ir.model.access.csv | 2 + addons/test_mail/tests/test_mail_composer.py | 289 ++++++++++++++++-- 3 files changed, 288 insertions(+), 29 deletions(-) diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index d27591afd6e..c068b334d1e 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -105,6 +105,7 @@ class MailTestTicket(models.Model): _description = 'Ticket-like model' _name = 'mail.test.ticket' _inherit = ['mail.thread'] + _primary_email = 'email_from' name = fields.Char() email_from = fields.Char(tracking=True) @@ -160,6 +161,29 @@ class MailTestTicket(models.Model): return super(MailTestTicket, self)._track_subtype(init_values) + +class MailTestTicketEL(models.Model): + """ Just mail.test.ticket, but exclusion-list enabled. Kept as different + model to avoid messing with existing tests, notably performance, and ease + backward comparison. """ + _description = 'Ticket-like model with exclusion list' + _name = 'mail.test.ticket.el' + _inherit = [ + 'mail.test.ticket', + 'mail.thread.blacklist', + ] + _primary_email = 'email_from' + + email_from = fields.Char( + 'Email', + compute='_compute_email_from', readonly=False, store=True) + + @api.depends('customer_id') + def _compute_email_from(self): + for ticket in self.filtered(lambda r: r.customer_id and not r.email_from): + ticket.email_from = ticket.customer_id.email_formatted + + class MailTestTicketMC(models.Model): """ Just mail.test.ticket, but multi company. Kept as different model to avoid messing with existing tests, notably performance, and ease backward @@ -167,6 +191,7 @@ class MailTestTicketMC(models.Model): _description = 'Ticket-like model' _name = 'mail.test.ticket.mc' _inherit = ['mail.test.ticket'] + _primary_email = 'email_from' company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company) container_id = fields.Many2one('mail.test.container.mc', tracking=True) @@ -258,6 +283,7 @@ class MailTestComposerSource(models.Model): 'Email', compute='_compute_email_from', readonly=False, store=True) + @api.depends('customer_id') def _compute_email_from(self): for source in self.filtered(lambda r: r.customer_id and not r.email_from): source.email_from = source.customer_id.email_formatted diff --git a/addons/test_mail/security/ir.model.access.csv b/addons/test_mail/security/ir.model.access.csv index 2fac058e400..0c36bef86ce 100644 --- a/addons/test_mail/security/ir.model.access.csv +++ b/addons/test_mail/security/ir.model.access.csv @@ -15,6 +15,8 @@ access_mail_test_field_type_portal,mail.test.field.type.portal,model_mail_test_f access_mail_test_field_type_user,mail.test.field.type.user,model_mail_test_field_type,base.group_user,1,1,1,1 access_mail_test_ticket_portal,mail.test.ticket.portal,model_mail_test_ticket,base.group_portal,1,0,0,0 access_mail_test_ticket_user,mail.test.ticket.user,model_mail_test_ticket,base.group_user,1,1,1,1 +access_mail_test_ticket_el_portal,mail.test.ticket.el.portal,model_mail_test_ticket_el,base.group_portal,1,0,0,0 +access_mail_test_ticket_el_user,mail.test.ticket.el.user,model_mail_test_ticket_el,base.group_user,1,1,1,1 access_mail_test_ticket_mc_portal,mail.test.ticket.mc.portal,model_mail_test_ticket_mc,base.group_portal,1,0,0,0 access_mail_test_ticket_mc_user,mail.test.ticket.mc.user,model_mail_test_ticket_mc,base.group_user,1,1,1,1 access_mail_test_composer_mixin_all,mail.test.composer.mixin.all,model_mail_test_composer_mixin,,0,0,0,0 diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 73dc63e27e7..a7804129349 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -1228,6 +1228,103 @@ class TestComposerResultsComment(TestMailComposer): self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + +@tagged('mail_composer', 'mail_blacklist') +class TestComposerResultsCommentStatus(TestMailComposer): + """ Test cases involving blacklist, opt-out, state management, ... specific + class to avoid bloating the base comment-based composer tests. """ + + @classmethod + def setUpClass(cls): + """ Test data: 4 records with a customer set, then some additional + records based on emails, duplicates, ... + + Record0: partner is blacklisted + Record1: Record4 has the same email (but no customer set) + Record5 and Record6 have same email (notlinked to any customer) + """ + super(TestComposerResultsCommentStatus, cls).setUpClass() + + # ensure employee can create partners, necessary for templates + cls.user_employee.write({ + 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], + }) + + # add 2 new records with customers + cls.test_records, cls.test_partners = cls._create_records_for_batch( + 'mail.test.ticket.el', 4, + additional_values={'user_id': cls.user_employee_2.id}, + prefix='el_' + ) + # create bl / optout / duplicates, see docstring + cls.env['mail.blacklist']._add( + cls.test_partners[0].email_formatted + ) + cls.test_records += cls.env[cls.test_records._name].create([ + { + 'email_from': cls.test_records[1].email_from, + 'name': 'Email of Record2', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (first)', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (second)', + 'user_id': cls.user_employee_2.id, + }, + ]) + cls.template.write({ + 'auto_delete': False, + 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + }) + + def test_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertFalse(self.template.auto_delete) + + self.assertEqual(len(self.test_records), 7) + self.assertEqual(self.test_records.user_id, self.user_employee_2) + self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2) + self.assertEqual(self.test_records[1].email_from, self.test_records[4].email_from) + self.assertEqual(self.test_records[5].email_from, self.test_records[6].email_from) + + self.assertEqual(len(self.test_partners), 4) + self.assertTrue(self.test_partners[0].is_blacklisted) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_comment_blacklist(self): + """ Tests a document-based comment with the excluded emails. It is + currently bypassed, as we consider posting bypasses the exclusion list. + """ + test_record = self.test_records[0].with_env(self.env) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(test_record, add_web=True, + default_template_id=self.template.id) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + + # one mail to the customer, one mail to the follower + message = test_record.message_ids[0] + for recipient in test_record.customer_id + self.partner_employee_2: + with self.subTest(recipient=recipient): + self.assertMailMail( + recipient, 'sent', + mail_message=message, + author=self.partner_employee, # author != email_from (template sets only email_from) + email_values={ + 'email_from': self.user_employee_2.email_formatted, # set by template + }, + ) + self.assertEqual(len(self._mails), 2, 'Should have sent 2 emails, skipping the exclusion list') + + @tagged('mail_composer') class TestComposerResultsMass(TestMailComposer): @@ -1298,35 +1395,6 @@ class TestComposerResultsMass(TestMailComposer): self.assertEqual(len(self._new_msgs), 2, 'Should have created 1 mail.mail per record') self.assertFalse(self._new_msgs.exists(), 'Should have deleted mail.message records') - @users('employee') - @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') - def test_mail_composer_document_based(self): - """ Tests a document-based mass mailing with the same address mails - This should be allowed and not considered as duplicate in this context - """ - self.test_records.write({ - 'customer_id': False, - 'email_from': 'duplicate.email@test.example.com', - }) - self.template.write({ - 'auto_delete': False, # keep sent emails to check content - 'email_to': '{{ object.email_from }}', - 'partner_to': '', - }) - # launch composer in mass mode - composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, - default_template_id=self.template.id) - )) - composer = composer_form.save() - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - composer.with_context(mailing_document_based=False)._action_send_mail() - self.assertEqual(len(self._mails), 1, 'Should have sent 1 email, and skipped a duplicate.') - - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - composer.with_context(mailing_document_based=True)._action_send_mail() - self.assertEqual(len(self._mails), 2, 'Should have sent 2 emails.') - @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl(self): @@ -1599,3 +1667,166 @@ class TestComposerResultsMass(TestMailComposer): )), }, ) + +@tagged('mail_composer', 'mail_blacklist') +class TestComposerResultsMassStatus(TestMailComposer): + """ Test cases involving blacklist, opt-out, state management, ... specific + class to avoid bloating the base mailing-based composer tests. """ + + @classmethod + def setUpClass(cls): + """ Test data: 4 records with a customer set, then some additional + records based on emails, duplicates, ... + + Record0: partner is blacklisted + Record1: Record4 has the same email (but no customer set) + Record5 and Record6 have same email (notlinked to any customer) + """ + super(TestComposerResultsMassStatus, cls).setUpClass() + + # ensure employee can create partners, necessary for templates + cls.user_employee.write({ + 'groups_id': [(4, cls.env.ref('base.group_partner_manager').id)], + }) + + # add 2 new records with customers + cls.test_records, cls.test_partners = cls._create_records_for_batch( + 'mail.test.ticket.el', 4, + additional_values={'user_id': cls.user_employee_2.id}, + prefix='el_' + ) + # create bl / optout / duplicates, see docstring + cls.env['mail.blacklist']._add( + cls.test_partners[0].email_formatted + ) + cls.test_records += cls.env[cls.test_records._name].create([ + { + 'email_from': cls.test_records[1].email_from, + 'name': 'Email of Record2', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (first)', + 'user_id': cls.user_employee_2.id, + }, + { + 'email_from': 'test.duplicate@test.example.com', + 'name': 'Dupe email (second)', + 'user_id': cls.user_employee_2.id, + }, + ]) + cls.template.write({ + 'model_id': cls.env['ir.model']._get_id(cls.test_records._name), + }) + + def test_initial_data(self): + """ Ensure class initial data to ease understanding """ + self.assertTrue(self.template.auto_delete) + + self.assertEqual(len(self.test_records), 7) + self.assertEqual(self.test_records.user_id, self.user_employee_2) + self.assertEqual(self.test_records.message_partner_ids, self.partner_employee_2) + self.assertEqual(self.test_records[1].email_from, self.test_records[4].email_from) + self.assertEqual(self.test_records[5].email_from, self.test_records[6].email_from) + + self.assertEqual(len(self.test_partners), 4) + self.assertTrue(self.test_partners[0].is_blacklisted) + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mailing_blacklist_mixin(self): + """ Tests a document-based mass mailing with excluded emails. Their emails + are canceled if the model inherits from the blacklist mixin. """ + test_records = self.test_records[:2].with_env(self.env) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(test_records, add_web=True, + default_template_id=self.template.id) + )) + composer = composer_form.save() + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + + for record, expected_state, expected_ft in zip( + test_records, + ['cancel', 'sent'], + ['mail_bl', False] + ): + with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): + self.assertMailMail( + record.customer_id, expected_state, + # author is current user, email_from is coming from template (user_id of record) + author=self.user_employee.partner_id, + fields_values={ + 'email_from': self.user_employee_2.email_formatted, + 'failure_reason': False, + 'failure_type': expected_ft, + }, + email_values={ + 'email_from': self.user_employee_2.email_formatted, + } + ) + self.assertEqual(len(self._mails), 1, 'Should have sent 1 email, and skipped an excluded email.') + + @users('employee') + @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + def test_mailing_duplicates_document_based(self): + """ Tests a document-based mass mailing with the same address mails + This should be allowed and not considered as duplicate in this context + """ + test_records = self.test_records.with_env(self.env) + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(test_records, add_web=True, + default_template_id=self.template.id) + )) + composer = composer_form.save() + + # by default duplicates are canceled + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() + + for record, expected_state, expected_ft in zip( + test_records, + ['cancel', 'sent', 'sent', 'sent', 'cancel', 'sent', 'cancel'], + ['mail_bl', False, False, False, 'mail_dup', False, 'mail_dup'] + ): + with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): + self.assertMailMailWRecord( + record, record.customer_id, expected_state, + # author is current user, email_from is coming from template (user_id of record) + author=self.user_employee.partner_id, + fields_values={ + 'email_from': self.user_employee_2.email_formatted, + 'failure_reason': False, + 'failure_type': expected_ft, + }, + email_values={ + 'email_from': self.user_employee_2.email_formatted, + } + ) + self.assertEqual(len(self._mails), 4, 'Should have sent 4 emails, and skipped an excluded and 2 duplicate emails.') + + # magic context key allowing to send duplicates when necessary + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer.with_context(mailing_document_based=True)._action_send_mail() + + for record, expected_state, expected_ft in zip( + test_records, + ['cancel', 'sent', 'sent', 'sent', 'sent', 'sent', 'sent'], + ['mail_bl', False, False, False, False, False, False] + ): + with self.subTest(record=record, expected_state=expected_state, expected_ft=expected_ft): + self.assertMailMailWRecord( + record, record.customer_id, expected_state, + # author is current user, email_from is coming from template (user_id of record) + author=self.user_employee.partner_id, + fields_values={ + 'email_from': self.user_employee_2.email_formatted, + 'failure_reason': False, + 'failure_type': expected_ft, + }, + email_values={ + 'email_from': self.user_employee_2.email_formatted, + } + ) + self.assertEqual(len(self._mails), 6, 'Should have sent 6 emails, and skipped an excluded email') From 18e6f7d376c30c059331c3a1243475731b40718d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 16 Dec 2022 13:30:40 +0000 Subject: [PATCH 10/45] [PERF] mail: skip state computation when posting through composer Composer holds code to check recipients email and avoid sending emails when recipients do not have valid emails. Purpose is to avoid bloating the email queue with emails that are about to fail. It is done by analyzing recipients of emails, aka checking 'recipient_ids' value before creating the mail.mail. When being in comment mode, this code has no use as recipients are computed by the notification process, not by the composer. We can therefore skip this part. It adds the 'state' and 'failure_type' values that are used on mail model, but not on messages. The same kind of processing could be done in notification process to avoid sending unnecessary notifications, but this improvement belongs to another commit and task. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_compose_message.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index b6ae9990cc2..d70d51fe5be 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -449,7 +449,8 @@ class MailComposer(models.TransientModel): results[res_id] = mail_values - results = self._process_state(results) + if mass_mail_mode: + results = self._process_state(results) return results def _process_recipient_values(self, mail_values_dict): From b1d678b12ac5aac4086771f9ae88d401d919c9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 10 Feb 2022 12:06:13 +0000 Subject: [PATCH 11/45] [REF] mail, mass_mailing: extract outgoing email values preparation into a method RATIONALE When sending a ``MailMail`` some data preparation is done. This is done directly in ``_send`` and in sub-methods. As a given MailMail may lead to several emails being sent we have to go from a mail to a list of emails to send : * one email for ``email_to``. They all receive the same email, as those are just a list of emails to contact and no specific post-processing is done; * one email for each partner in ``recipient_ids``. It enables a partner-based update of the body, for example for links or traces in mass mailing; SPECIFICATIONS In this commit we move code so that all preparation is done in a sub-method ``_prepare_outgoing_list``. That way it can be cleanly overridden to add or modify values before sending the actual emails. This commit does not change behavior and calls done to ``build_email``. This will be updated in the next commits, notably to improve "cc" management. Task-2710804 (Mail: Clean MailThread Posting API) Task-3093268 (Mail: Stop spamming CCs) Prepares Task-2684479 (Mail: Better report errors when sending emails) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_mail.py | 165 +++++++++++++++--------- addons/mass_mailing/models/mail_mail.py | 58 +++++---- 2 files changed, 133 insertions(+), 90 deletions(-) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 533821fffc8..ce415643fad 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -323,31 +323,101 @@ class MailMail(models.Model): # mail_mail formatting, tools and send mechanism # ------------------------------------------------------ - def _send_prepare_body(self): + def _prepare_outgoing_body(self): """Return a specific ir_email body. The main purpose of this method is to be inherited to add custom content depending on some module.""" self.ensure_one() - return self.body_html or '' + if tools.is_html_empty(self.body_html): + return '' + return self.env['mail.render.mixin']._replace_local_links(self.body_html) - def _send_prepare_values(self, partner=None): - """Return a dictionary for specific email values, depending on a - partner, or generic to the whole recipients given by mail.email_to. + def _prepare_outgoing_list(self): + """ Return a list of emails to send based on current mail.mail. Each + is a dictionary for specific email values, depending on a partner, or + generic to the whole recipients given by mail.email_to. - :param Model partner: specific recipient partner + :return list: list of dicts used in IrMailServer.build_email() """ self.ensure_one() - body = self._send_prepare_body() + body = self._prepare_outgoing_body() body_alternative = tools.html2plaintext(body) - if partner: - email_to = [tools.formataddr((partner.name or 'False', partner.email or 'False'))] - else: + + # headers + headers = {} + ICP = self.env['ir.config_parameter'].sudo() + bounce_alias = ICP.get_param("mail.bounce.alias") + catchall_domain = ICP.get_param("mail.catchall.domain") + if bounce_alias and catchall_domain: + headers['Return-Path'] = f'{bounce_alias}@{catchall_domain}' + if self.headers: + try: + headers.update(ast.literal_eval(self.headers)) + except Exception: + pass + + # prepare recipients: use email_to if defined then check recipient_ids + # that receive a specific email, notably due to link shortening / redirect + # that is recipients-dependent. Keep original email/partner as this is + # used in post-processing to know failures, like missing recipients + email_list = [] + if self.email_to: email_to = tools.email_split_and_format(self.email_to) - res = { - 'body': body, - 'body_alternative': body_alternative, - 'email_to': email_to, - } - return res + email_list.append({ + 'email_to': email_to, + # keep raw initial value for incoming pre processing of outgoing emails + 'email_to_raw': self.email_to or '', + 'partner_id': False, + }) + # TODO: add all cc once, either to the "To", either as a single entry (do not mix + # with partner-specific sending) + email_cc = tools.email_split(self.email_cc) + # specific behavior to customize the send email for notified partners + for partner in self.recipient_ids: + # check partner email content + email_to = [tools.formataddr((partner.name or '', partner.email or 'False'))] + email_list.append({ + 'email_to': email_to, + # keep raw initial value for incoming pre processing of outgoing emails + 'email_to_raw': partner.email or '', + 'partner_id': partner, + }) + + # prepare attachments: remove attachments if user send the link with the + # access_token. + attachments = self.attachment_ids + if attachments: + if body: + link_ids = {int(link) for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body)} + if link_ids: + attachments = attachments - self.env['ir.attachment'].browse(list(link_ids)) + # load attachment binary data with a separate read(), as prefetching all + # `datas` (binary field) could bloat the browse cache, triggering + # soft/hard mem limits with temporary data. + email_attachments = [ + (a['name'], base64.b64decode(a['datas']), a['mimetype']) + for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if a['datas'] is not False + ] + else: + email_attachments = [] + + return [ + { + 'attachments': email_attachments, + 'body': body, + 'body_alternative': body_alternative, + 'email_cc': email_cc, + 'email_from': self.email_from, + 'email_to': email_values['email_to'], + 'email_to_raw': email_values['email_to_raw'], + 'headers': headers, + 'message_id': self.message_id, + 'object_id': f'{self.res_id}-{self.model}' if self.res_id else '', + 'partner_id': email_values['partner_id'], + 'references': self.references, + 'reply_to': self.reply_to, + 'subject': self.subject, + } for email_values in email_list + ] def _split_by_mail_configuration(self): """Group the based on their "email_from" and their "mail_server_id". @@ -433,7 +503,6 @@ class MailMail(models.Model): def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): IrMailServer = self.env['ir.mail_server'] - IrAttachment = self.env['ir.attachment'] for mail_id in self.ids: success_pids = [] failure_type = None @@ -444,40 +513,6 @@ class MailMail(models.Model): if mail.state != 'outgoing': continue - # remove attachments if user send the link with the access_token - body = mail.body_html or '' - attachments = mail.attachment_ids - for link in re.findall(r'/web/(?:content|image)/([0-9]+)', body): - attachments = attachments - IrAttachment.browse(int(link)) - - # load attachment binary data with a separate read(), as prefetching all - # `datas` (binary field) could bloat the browse cache, triggerring - # soft/hard mem limits with temporary data. - attachments = [(a['name'], base64.b64decode(a['datas']), a['mimetype']) - for a in attachments.sudo().read(['name', 'datas', 'mimetype']) if a['datas'] is not False] - - # specific behavior to customize the send email for notified partners - email_list = [] - if mail.email_to: - email_list.append(mail._send_prepare_values()) - for partner in mail.recipient_ids: - values = mail._send_prepare_values(partner=partner) - values['partner_id'] = partner - email_list.append(values) - - # headers - headers = {} - ICP = self.env['ir.config_parameter'].sudo() - bounce_alias = ICP.get_param("mail.bounce.alias") - catchall_domain = ICP.get_param("mail.catchall.domain") - if bounce_alias and catchall_domain: - headers['Return-Path'] = '%s@%s' % (bounce_alias, catchall_domain) - if mail.headers: - try: - headers.update(ast.literal_eval(mail.headers)) - except Exception: - pass - # Writing on the mail object may fail (e.g. lock on user) which # would trigger a rollback *after* actually sending the email. # To avoid sending twice the same email, provoke the failure earlier @@ -508,22 +543,26 @@ class MailMail(models.Model): res = None # TDE note: could be great to pre-detect missing to/cc and skip sending it # to go directly to failed state update + email_list = mail._prepare_outgoing_list() + + # send each sub-email for email in email_list: msg = IrMailServer.build_email( - email_from=mail.email_from, - email_to=email.get('email_to'), - subject=mail.subject, - body=email.get('body'), - body_alternative=email.get('body_alternative'), - email_cc=tools.email_split(mail.email_cc), - reply_to=mail.reply_to, - attachments=attachments, - message_id=mail.message_id, - references=mail.references, - object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)), + email_from=email['email_from'], + email_to=email['email_to'], + subject=email['subject'], + body=email['body'], + body_alternative=email['body_alternative'], + email_cc=email['email_cc'], + reply_to=email['reply_to'], + attachments=email['attachments'], + message_id=email['message_id'], + references=email['references'], + object_id=email['object_id'], subtype='html', subtype_alternative='plain', - headers=headers) + headers=email['headers'], + ) processing_pid = email.pop("partner_id", None) try: res = IrMailServer.send_email( diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index f0a64a3e961..7d5a6e3d28f 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -35,15 +35,15 @@ class MailMail(models.Model): self.ensure_one() return tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id) - def _send_prepare_body(self): - """ Override to add the tracking URL to the body and to add - trace ID in shortened urls """ - # TDE: temporary addition (mail was parameter) due to semi-new-API + def _prepare_outgoing_body(self): + """ Override to add the tracking URL to the body and to add trace ID in + shortened urls """ self.ensure_one() - body = super(MailMail, self)._send_prepare_body() + # super() already cleans pseudo-void content from editor + body = super()._prepare_outgoing_body() - if self.mailing_id and body and self.mailing_trace_ids: - for match in set(re.findall(tools.URL_REGEX, self.body_html)): + if body and self.mailing_id and self.mailing_trace_ids: + for match in set(re.findall(tools.URL_REGEX, body)): href = match[0] url = match[1] @@ -57,31 +57,35 @@ class MailMail(models.Model): tracking_url = self._get_tracking_url() body = tools.append_content_to_html( body, - '' % tracking_url, + f'', plaintext=False, ) - - body = self.env['mail.render.mixin']._replace_local_links(body) - return body - def _send_prepare_values(self, partner=None): - # TDE: temporary addition (mail was parameter) due to semi-new-API - res = super(MailMail, self)._send_prepare_values(partner) - if self.mailing_id and res.get('body') and res.get('email_to'): - base_url = self.mailing_id.get_base_url() - emails = tools.email_split(res.get('email_to')[0]) - email_to = emails and emails[0] or False + def _prepare_outgoing_list(self): + """ Update mailing specific links to add tracking based on res_id """ + email_list = super()._prepare_outgoing_list() + if not self.res_id or not self.mailing_id: + return email_list - urls_to_replace = [ - (base_url + '/unsubscribe_from_list', self.mailing_id._get_unsubscribe_url(email_to, self.res_id)), - (base_url + '/view', self.mailing_id._get_view_url(email_to, self.res_id)) - ] + base_url = self.mailing_id.get_base_url() + for email_values in email_list: + if tools.is_html_empty(email_values['body']) or not email_values['email_to']: + continue + emails = tools.email_split(email_values['email_to'][0]) + email_to = emails[0] if emails else False - for url_to_replace, new_url in urls_to_replace: - if url_to_replace in res['body']: - res['body'] = res['body'].replace(url_to_replace, new_url if new_url else '#') - return res + if f'{base_url}/unsubscribe_from_list' in email_values['body']: + email_values['body'] = email_values['body'].replace( + f'{base_url}/unsubscribe_from_list', + self.mailing_id._get_unsubscribe_url(email_to, self.res_id), + ) + if f'{base_url}/view' in email_values['body']: + email_values['body'] = email_values['body'].replace( + f'{base_url}/view', + self.mailing_id._get_view_url(email_to, self.res_id), + ) + return email_list def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed @@ -91,4 +95,4 @@ class MailMail(models.Model): mail.mailing_trace_ids.set_sent() elif mail_sent is False and mail.mailing_trace_ids: mail.mailing_trace_ids.set_failed(failure_type=failure_type) - return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type) + return super()._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type) From 91672b1cabd2702a71a5ece19dfc557eb1cdf985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 4 Jan 2023 14:04:40 +0000 Subject: [PATCH 12/45] [IMP] mail: log a warning when having invalid headers on mail.mail In this commit we add some logs when we fail to evaluate the 'headers' field on a 'mail.mail' record, used to populate the headers of outgoing emails. Some tests are added to ensure we do not crash if headers is malformed, and to cover the feature itself as it was still not really done. Task-2710804 (Mail: Clean MailThread Posting API) Task-3093268 (Mail: Stop spamming CCs) Prepares Task-2684479 (Mail: Better report errors when sending emails) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_mail.py | 14 +++++++++-- addons/test_mail/tests/test_mail_mail.py | 31 +++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index ce415643fad..1fed4e7045c 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -352,8 +352,18 @@ class MailMail(models.Model): if self.headers: try: headers.update(ast.literal_eval(self.headers)) - except Exception: - pass + except (ValueError, TypeError) as e: + _logger.warning( + 'Evaluation error when evaluating mail headers (received %r): %s', + self.headers, e, + ) + # global except as we don't want to crash the queue just due to a malformed + # headers value + except Exception as e: + _logger.warning( + 'Unknown error when evaluating mail headers (received %r): %s', + self.headers, e, + ) # prepare recipients: use email_to if defined then check recipient_ids # that receive a specific email, notably due to link shortening / redirect diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index 5979b1a3030..164161079ca 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -11,7 +11,7 @@ from OpenSSL.SSL import Error as SSLError from socket import gaierror, timeout from unittest.mock import call, patch -from odoo import api, Command, tools +from odoo import api, Command from odoo.addons.base.models.ir_mail_server import MailDeliveryException from odoo.addons.test_mail.tests.common import TestMailCommon from odoo.exceptions import AccessError @@ -126,6 +126,35 @@ class TestMailMail(TestMailCommon): self.assertEqual(mail.sudo().restricted_attachment_count, 2) self.assertEqual(len(mail.sudo().unrestricted_attachment_ids), 0) + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_mail_mail_headers(self): + """ Test headers management when set on outgoing mail. """ + # mail without thread-enabled record + base_values = { + 'body_html': '

Test

', + 'email_to': 'test@example.com', + 'headers': {'foo': 'bar'}, + } + + for headers, expected in [ + ({'foo': 'bar'}, {'foo': 'bar'}), + ("{'foo': 'bar'}", {'foo': 'bar'}), + ("{'foo': 'bar', 'baz': '3+2'}", {'foo': 'bar', 'baz': '3+2'}), + (['not_a_dict'], {}), + ('alsonotadict', {}), + ("['not_a_dict']", {}), + ("{'invaliddict'}", {}), + ]: + with self.subTest(headers=headers, expected=expected): + mail = self.env['mail.mail'].create([ + dict(base_values, headers=headers) + ]) + with self.mock_mail_gateway(): + mail.send() + for key, value in expected.items(): + self.assertIn(key, self._mails[0]['headers']) + self.assertEqual(self._mails[0]['headers'][key], value) + @mute_logger('odoo.addons.mail.models.mail_mail') def test_mail_mail_recipients(self): """ Partner_ids is a field used from mail_message, but not from mail_mail. """ From d6aa0050dae663ba3c3259304fd6aea61d9495b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 22 Nov 2022 09:57:40 +0000 Subject: [PATCH 13/45] [FIX] mail: stop spamming CCs of outgoing emails Fix usage of CCs when sending emails. Currently CCs are added to all outgoing emails, which leads to unnecessary spam. Recipients are currently computed like * create an outgoing email based on 'email_to'; * create one outgoing email / partner in 'recipient_ids'; * add CCs to all outgoing emails; They are now sent only once, either with the email_to if given, either as standalone. That way partners receive specific emails (due to tracking and partner specific links management), email_to / email_cc are sent on their own. Task-3093268 (Mail: Stop spamming CCs) Prepares Task-2684479 (Mail: Better report errors when sending emails) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_mail.py | 17 ++++++++++++++--- addons/test_mail/tests/test_mail_mail.py | 5 +++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 1fed4e7045c..5356e525ea4 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -373,19 +373,30 @@ class MailMail(models.Model): if self.email_to: email_to = tools.email_split_and_format(self.email_to) email_list.append({ + 'email_cc': [], 'email_to': email_to, # keep raw initial value for incoming pre processing of outgoing emails 'email_to_raw': self.email_to or '', 'partner_id': False, }) - # TODO: add all cc once, either to the "To", either as a single entry (do not mix + # add all cc once, either to the first "To", either as a single entry (do not mix # with partner-specific sending) - email_cc = tools.email_split(self.email_cc) + if self.email_cc: + if email_list: + email_list[0]['email_cc'] = tools.email_split(self.email_cc) + else: + email_list.append({ + 'email_cc': tools.email_split(self.email_cc), + 'email_to': [], + 'email_to_raw': False, + 'partner_id': False, + }) # specific behavior to customize the send email for notified partners for partner in self.recipient_ids: # check partner email content email_to = [tools.formataddr((partner.name or '', partner.email or 'False'))] email_list.append({ + 'email_cc': [], 'email_to': email_to, # keep raw initial value for incoming pre processing of outgoing emails 'email_to_raw': partner.email or '', @@ -415,7 +426,7 @@ class MailMail(models.Model): 'attachments': email_attachments, 'body': body, 'body_alternative': body_alternative, - 'email_cc': email_cc, + 'email_cc': email_values['email_cc'], 'email_from': self.email_from, 'email_to': email_values['email_to'], 'email_to_raw': email_values['email_to_raw'], diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index 164161079ca..b002fab7d01 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -195,9 +195,10 @@ class TestMailMail(TestMailCommon): self.assertSentEmail(mail.env.user.partner_id, ['test.rec.1@example.com', '"Raoul" '], email_cc=['test.cc.1@example.com', 'test.cc.2@example.com']) - # Mail: currently cc are put as copy of all sent emails (aka spam) + # don't put CCs as copy of each outgoing email, only the first one (and never + # with partner based recipients as those may receive specific links) self.assertSentEmail(mail.env.user.partner_id, [self.user_employee.email_formatted], - email_cc=['test.cc.1@example.com', 'test.cc.2@example.com']) + email_cc=[]) self.assertEqual(len(self._mails), 2) @mute_logger('odoo.addons.mail.models.mail_mail') From 418761e34412677192b57a497f1d78908b7686c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 13 Sep 2022 12:18:53 +0000 Subject: [PATCH 14/45] [LINT] mail, various: use explicit subtype in message_post_{with_...} RATIONALE Purpose of this commit is to be explicit in subtype chosen when invoking the message composer / calling message_post. As default value may not always be clear, better be explicit in case the composer default value changes. SPECIFICATIONS Add explicit references to subtype when it is not obvious what will be the final subtype, notably when using helpers (post_with_view or template which uses the composer that is not crystal clear in its subtype management). In this commit we also add support of XMLID-based subtype when invoking the composer. A ``default_subtype_xmlid`` context key is transformed into a ``default_subtype_id``, to be used notably in JS where we cannot easily use a ``ref``-like statement. Post API now also supports 'subytpe_xmlid' argument allowing to give the xml id and ease calling the methods. Use ``_xmlid_to_res_id`` to get directly the ID of subtypes in order to avoid useless queries from ``ref`` that does an exists. Also remove useless values given to post API, notably author_id that is by default the current users' partner. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/account/wizard/account_invoice_send.py | 4 ++- addons/crm/models/crm_lead.py | 2 +- addons/crm_iap_enrich/models/crm_lead.py | 13 ++++++--- .../models/crm_iap_lead_mining_request.py | 6 +++- addons/event_booth/models/event_booth.py | 2 +- .../event_sale/models/event_registration.py | 3 +- addons/hr_expense/models/hr_expense.py | 28 ++++++++++++------- addons/hr_recruitment/models/hr_employee.py | 4 +-- .../wizard/applicant_refuse_reason.py | 11 ++++---- .../models/hr_contract.py | 4 +-- addons/mail/models/mail_activity.py | 2 +- addons/mail/models/mail_activity_mixin.py | 3 +- addons/mail/models/mail_blacklist.py | 4 +-- addons/mail/models/mail_thread.py | 12 ++++++-- .../mail/static/src/models/composer_view.js | 1 + .../mail/static/src/models/mail_template.js | 1 + addons/mail/views/res_partner_views.xml | 1 + addons/mail/wizard/mail_compose_message.py | 7 +++++ addons/mail_plugin/controllers/mail_plugin.py | 4 +-- addons/mass_mailing/models/mail_thread.py | 5 ++-- addons/mrp/models/mrp_unbuild.py | 3 +- addons/mrp/models/stock_rule.py | 10 +++++-- .../models/res_company.py | 2 +- .../models/res_partner.py | 2 +- .../models/phone_blacklist.py | 4 +-- addons/portal/wizard/portal_share.py | 17 ++++------- .../models/account_move.py | 4 ++- addons/project/wizard/project_share_wizard.py | 5 ++-- addons/purchase/models/purchase.py | 16 +++++++---- .../purchase_requisition/models/purchase.py | 6 ++-- addons/purchase_stock/models/purchase.py | 3 +- addons/rating/models/rating_mixin.py | 5 ++-- addons/sale/models/payment_transaction.py | 10 ++++--- addons/sale/models/sale_order.py | 5 ++-- addons/sale/tests/test_controllers.py | 4 ++- addons/sale/tests/test_sale_order.py | 10 +++++-- addons/sale/tests/test_sale_to_invoice.py | 5 +++- .../sale/wizard/sale_make_invoice_advance.py | 3 +- addons/sale_stock/models/stock.py | 3 +- addons/stock/models/stock_move_line.py | 6 +++- addons/stock/models/stock_picking.py | 7 ++++- addons/stock/wizard/stock_picking_return.py | 6 ++-- addons/test_mail/models/test_mail_models.py | 16 +++++++++-- .../test_mail/static/tests/activity_tests.js | 1 + addons/test_mail/tests/test_mail_composer.py | 10 +++++++ addons/test_mail/tests/test_message_post.py | 6 ++-- addons/test_mail/tests/test_performance.py | 8 +++++- addons/website_blog/models/website_blog.py | 3 +- .../models/crm_reveal_rule.py | 2 +- .../website_event_track/models/event_track.py | 10 +++---- addons/website_forum/models/forum.py | 9 ++++-- 51 files changed, 214 insertions(+), 104 deletions(-) diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index 4c086fe6c8b..2b26e609e3a 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -41,6 +41,7 @@ class AccountInvoiceSend(models.TransientModel): composer = self.env['mail.compose.message'].create({ 'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail', + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), }) res.update({ 'invoice_ids': res_ids, @@ -86,7 +87,8 @@ class AccountInvoiceSend(models.TransientModel): if not self.composer_id: self.composer_id = self.env['mail.compose.message'].create({ 'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail', - 'template_id': self.template_id.id + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + 'template_id': self.template_id.id, }) else: self.composer_id.composition_mode = 'comment' if len(res_ids) == 1 else 'mass_mail' diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 89189d5c4cc..0442fe57ed5 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1568,7 +1568,7 @@ class Lead(models.Model): "opportunities": opportunities_tail, "is_html_empty": is_html_empty, }, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) def _format_properties(self): diff --git a/addons/crm_iap_enrich/models/crm_lead.py b/addons/crm_iap_enrich/models/crm_lead.py index 9685eab24a5..6732e2ee57e 100644 --- a/addons/crm_iap_enrich/models/crm_lead.py +++ b/addons/crm_iap_enrich/models/crm_lead.py @@ -70,7 +70,8 @@ class Lead(models.Model): if not normalized_email: lead.message_post_with_view( 'crm_iap_enrich.mail_message_lead_enrich_no_email', - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) continue email_domain = normalized_email.split('@')[1] @@ -79,7 +80,8 @@ class Lead(models.Model): lead.write({'iap_enrich_done': True}) lead.message_post_with_view( 'crm_iap_enrich.mail_message_lead_enrich_notfound', - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) else: lead_emails[lead.id] = email_domain @@ -126,7 +128,10 @@ class Lead(models.Model): iap_data = iap_response.get(str(lead.id)) if not iap_data: lead.write({'iap_enrich_done': True}) - lead.message_post_with_view('crm_iap_enrich.mail_message_lead_enrich_notfound', subtype_id=self.env.ref('mail.mt_note').id) + lead.message_post_with_view( + 'crm_iap_enrich.mail_message_lead_enrich_notfound', + subtype_xmlid='mail.mt_note', + ) continue values = {'iap_enrich_done': True} @@ -159,7 +164,7 @@ class Lead(models.Model): lead.message_post_with_view( 'iap_mail.enrich_company', values=template_values, - subtype_id=self.env.ref('mail.mt_note').id + subtype_xmlid='mail.mt_note', ) def _merge_get_fields_specific(self): diff --git a/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py b/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py index 5796a61cff9..cb9ef3c4e66 100644 --- a/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py +++ b/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py @@ -278,7 +278,11 @@ class CRMLeadMiningRequest(models.Model): leads = self.env['crm.lead'].create(lead_vals_list) for lead in leads: if messages_to_post.get(lead.reveal_id): - lead.message_post_with_view('iap_mail.enrich_company', values=messages_to_post[lead.reveal_id], subtype_id=self.env.ref('mail.mt_note').id) + lead.message_post_with_view( + 'iap_mail.enrich_company', + values=messages_to_post[lead.reveal_id], + subtype_xmlid='mail.mt_note', + ) # Methods responsible for format response data into valid odoo lead data @api.model diff --git a/addons/event_booth/models/event_booth.py b/addons/event_booth/models/event_booth.py index 4ca2433ae0c..474ea92c545 100644 --- a/addons/event_booth/models/event_booth.py +++ b/addons/event_booth/models/event_booth.py @@ -105,7 +105,7 @@ class EventBooth(models.Model): values={ 'booth': booth, }, - subtype_id=self.env.ref('event_booth.mt_event_booth_booked').id, + subtype_xmlid='event_booth.mt_event_booth_booked', ) def action_confirm(self, additional_values=None): diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py index 46f169a0ffc..95c0f47ec46 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -80,7 +80,8 @@ class EventRegistration(models.Model): registration.message_post_with_view( 'mail.message_origin_link', values={'self': registration, 'origin': registration.sale_order_id}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) return registrations def write(self, vals): diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index ba758e8b7f7..6fd2566fdd8 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -636,8 +636,11 @@ class HrExpense(models.Model): def refuse_expense(self, reason): self.write({'is_refused': True}) self.sheet_id.write({'state': 'cancel'}) - self.sheet_id.message_post_with_view('hr_expense.hr_expense_template_refuse_reason', - values={'reason': reason, 'is_sheet': False, 'name': self.name}) + self.sheet_id.message_post_with_view( + 'hr_expense.hr_expense_template_refuse_reason', + values={'reason': reason, 'is_sheet': False, 'name': self.name}, + subtype_xmlid='mail.mt_comment', + ) @api.model def get_expense_dashboard(self): @@ -798,21 +801,21 @@ class HrExpense(models.Model): # TDE TODO: seems louche, check to use notify if expense.employee_id.user_id.partner_id: expense.message_post( + body=body, + email_layout_xmlid='mail.mail_notification_light', partner_ids=expense.employee_id.user_id.partner_id.ids, subject='Re: %s' % msg_dict.get('subject', ''), - body=body, - subtype_id=self.env.ref('mail.mt_note').id, - email_layout_xmlid='mail.mail_notification_light', + subtype_xmlid='mail.mt_note', ) else: self.env['mail.mail'].sudo().create({ - 'email_from': self.env.user.email_formatted, 'author_id': self.env.user.partner_id.id, - 'body_html': body, - 'subject': 'Re: %s' % msg_dict.get('subject', ''), - 'email_to': msg_dict.get('email_from', False), 'auto_delete': True, + 'body_html': body, + 'email_from': self.env.user.email_formatted, + 'email_to': msg_dict.get('email_from', False), 'references': msg_dict.get('message_id'), + 'subject': 'Re: %s' % msg_dict.get('subject', ''), }).send() @@ -1213,8 +1216,13 @@ class HrExpenseSheet(models.Model): raise UserError(_("You can only refuse your department expenses")) self.write({'state': 'cancel'}) + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for sheet in self: - sheet.message_post_with_view('hr_expense.hr_expense_template_refuse_reason', values={'reason': reason, 'is_sheet': True, 'name': sheet.name}) + sheet.message_post_with_view( + 'hr_expense.hr_expense_template_refuse_reason', + subtype_id=subtype_id, + values={'reason': reason, 'is_sheet': True, 'name': sheet.name}, + ) self.activity_update() def reset_expense_sheets(self): diff --git a/addons/hr_recruitment/models/hr_employee.py b/addons/hr_recruitment/models/hr_employee.py index cb7d7fe0195..4013d5ceebc 100644 --- a/addons/hr_recruitment/models/hr_employee.py +++ b/addons/hr_recruitment/models/hr_employee.py @@ -1,7 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools.translate import _ from datetime import timedelta @@ -31,5 +30,6 @@ class HrEmployee(models.Model): employee.applicant_id.message_post_with_view( 'hr_recruitment.applicant_hired_template', values={'applicant': employee.applicant_id}, - subtype_id=self.env.ref("hr_recruitment.mt_applicant_hired").id) + subtype_xmlid='hr_recruitment.mt_applicant_hired', + ) return employees diff --git a/addons/hr_recruitment/wizard/applicant_refuse_reason.py b/addons/hr_recruitment/wizard/applicant_refuse_reason.py index 4f27bea09ad..d44413b3d94 100644 --- a/addons/hr_recruitment/wizard/applicant_refuse_reason.py +++ b/addons/hr_recruitment/wizard/applicant_refuse_reason.py @@ -45,8 +45,9 @@ class ApplicantGetRefuseReason(models.TransientModel): self.applicant_ids.write({'refuse_reason_id': self.refuse_reason_id.id, 'active': False}) if self.send_mail: applicants = self.applicant_ids.filtered(lambda x: x.email_from or x.partner_id.email) - applicants.with_context(active_test=True).message_post_with_template(self.template_id.id, **{ - 'auto_delete_message': True, - 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - 'email_layout_xmlid': 'mail.mail_notification_light' - }) + applicants.with_context(active_test=True).message_post_with_template( + self.template_id.id, + auto_delete_message=True, + email_layout_xmlid='mail.mail_notification_light', + subtype_xmlid='mail.mt_note', + ) diff --git a/addons/hr_work_entry_holidays/models/hr_contract.py b/addons/hr_work_entry_holidays/models/hr_contract.py index 22a01619ecc..2b608ce38df 100644 --- a/addons/hr_work_entry_holidays/models/hr_contract.py +++ b/addons/hr_work_entry_holidays/models/hr_contract.py @@ -134,9 +134,9 @@ class HrContract(models.Model): ).create(all_new_leave_vals) new_leaves.filtered(lambda l: l.state in 'validate')._validate_leave_request() for index, new_leave in enumerate(new_leaves): - subtype_note = self.env.ref('mail.mt_note') new_leave.message_post_with_view( 'mail.message_origin_link', values={'self': new_leave, 'origin': all_new_leave_origin[index]}, - subtype_id=subtype_note.id) + subtype_xmlid='mail.mt_note', + ) return super(HrContract, self - specific_contracts).write(vals) diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index fd4d3a925e8..593c170c10e 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -581,7 +581,7 @@ class MailActivity(models.Model): 'feedback': feedback, 'display_assignee': activity.user_id != self.env.user }, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_activities'), + subtype_xmlid='mail.mt_activities', mail_activity_type_id=activity.activity_type_id.id, attachment_ids=[Command.link(attachment_id) for attachment_id in attachment_ids] if attachment_ids else [], ) diff --git a/addons/mail/models/mail_activity_mixin.py b/addons/mail/models/mail_activity_mixin.py index 3c0542b110a..3d5fcb94e46 100644 --- a/addons/mail/models/mail_activity_mixin.py +++ b/addons/mail/models/mail_activity_mixin.py @@ -345,7 +345,8 @@ class MailActivityMixin(models.AbstractModel): for record in self: record.message_post_with_template( template_id, - composition_mode='comment' + composition_mode='comment', + subtype_xmlid='mail.mt_comment', ) return True diff --git a/addons/mail/models/mail_blacklist.py b/addons/mail/models/mail_blacklist.py index 7adf2476721..a4fcd1e9cf4 100644 --- a/addons/mail/models/mail_blacklist.py +++ b/addons/mail/models/mail_blacklist.py @@ -81,7 +81,7 @@ class MailBlackList(models.Model): if message: record.with_context(mail_create_nosubscribe=True).message_post( body=message, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) return record @@ -97,7 +97,7 @@ class MailBlackList(models.Model): if message: record.with_context(mail_create_nosubscribe=True).message_post( body=message, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) return record diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 763889896dd..3cbf9e7204a 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2119,6 +2119,10 @@ class MailThread(models.AbstractModel): res_id = kwargs.get('res_id', self.ids and self.ids[0] or 0) res_ids = kwargs.get('res_id') and [kwargs['res_id']] or self.ids + # support xml based subtype id + if kwargs.get('subtype_xmlid') and not kwargs.get('subtype_id'): + kwargs['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id(kwargs.pop('subtype_xmlid')) + # Create the composer composer = self.env['mail.compose.message'].with_context( active_id=res_id, @@ -2139,7 +2143,9 @@ class MailThread(models.AbstractModel): def message_notify(self, *, partner_ids=False, parent_id=False, model=False, res_id=False, - author_id=None, email_from=None, body='', subject=False, **kwargs): + author_id=None, email_from=None, body='', subject=False, + subtype_xmlid=None, + **kwargs): """ Shortcut allowing to notify partners of messages that shouldn't be displayed on a document. It pushes notifications on inbox or by email depending on the user configuration, like other notifications. """ @@ -2181,7 +2187,9 @@ class MailThread(models.AbstractModel): } msg_values.update(msg_kwargs) # add default-like values afterwards, to avoid useless queries - if 'subtype_id' not in msg_values: + if subtype_xmlid: + msg_values['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) + elif 'subtype_id' not in msg_values: msg_values['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') if 'reply_to' not in msg_values: msg_values['reply_to'] = self._notify_get_reply_to(default=email_from)[self.id if self else False] diff --git a/addons/mail/static/src/models/composer_view.js b/addons/mail/static/src/models/composer_view.js index 21dc7f63da5..6ff29361d84 100644 --- a/addons/mail/static/src/models/composer_view.js +++ b/addons/mail/static/src/models/composer_view.js @@ -551,6 +551,7 @@ Model({ default_model: this.composer.activeThread.model, default_partner_ids: this.composer.recipients.map((partner) => partner.id), default_res_id: this.composer.activeThread.id, + default_subtype_xmlid: 'mail.mt_comment', mail_post_autofollow: this.composer.activeThread.hasWriteAccess, }; diff --git a/addons/mail/static/src/models/mail_template.js b/addons/mail/static/src/models/mail_template.js index 0168b6c9603..100afeac761 100644 --- a/addons/mail/static/src/models/mail_template.js +++ b/addons/mail/static/src/models/mail_template.js @@ -19,6 +19,7 @@ Model({ default_res_id: activity.thread.id, default_model: activity.thread.model, default_use_template: true, + default_subtype_xmlid: 'mail.mt_comment', default_template_id: this.id, force_email: true, }, diff --git a/addons/mail/views/res_partner_views.xml b/addons/mail/views/res_partner_views.xml index 08e7835b610..a3fae6a1d55 100644 --- a/addons/mail/views/res_partner_views.xml +++ b/addons/mail/views/res_partner_views.xml @@ -110,6 +110,7 @@ diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index d70d51fe5be..c058b8c0e93 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -54,6 +54,13 @@ class MailComposer(models.TransientModel): # email_layout_xmlid field: to remove in 15.1+ if self._context.get('custom_layout') and 'default_email_layout_xmlid' not in self._context: self = self.with_context(default_email_layout_xmlid=self._context['custom_layout']) + # support subtype xmlid, like ``message_post``, when easier than using ``ref`` + if self.env.context.get('default_subtype_xmlid'): + self = self.with_context( + default_subtype_id=self.env['ir.model.data']._xmlid_to_res_id( + self.env.context['default_subtype_xmlid'] + ) + ) result = super(MailComposer, self).default_get(fields) diff --git a/addons/mail_plugin/controllers/mail_plugin.py b/addons/mail_plugin/controllers/mail_plugin.py index b2478ef6779..22a5506302a 100644 --- a/addons/mail_plugin/controllers/mail_plugin.py +++ b/addons/mail_plugin/controllers/mail_plugin.py @@ -120,7 +120,7 @@ class MailPluginController(http.Controller): partner.message_post_with_view( 'iap_mail.enrich_company', values=iap_data, - subtype_id=request.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) return { @@ -360,7 +360,7 @@ class MailPluginController(http.Controller): new_company.message_post_with_view( 'iap_mail.enrich_company', values=iap_data, - subtype_id=request.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) return new_company, {'type': 'company_created'} diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py index fff1aea4641..7c98349dab6 100644 --- a/addons/mass_mailing/models/mail_thread.py +++ b/addons/mass_mailing/models/mail_thread.py @@ -31,11 +31,10 @@ class MailThread(models.AbstractModel): def message_post_with_template(self, template_id, **kwargs): # avoid having message send through `message_post*` methods being implicitly considered as # mass-mailing - no_massmail = self.with_context( + return super(MailThread, self.with_context( default_mass_mailing_name=False, default_mass_mailing_id=False, - ) - return super(MailThread, no_massmail).message_post_with_template(template_id, **kwargs) + )).message_post_with_template(template_id, **kwargs) @api.model def _routing_handle_bounce(self, email_message, message_dict): diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index 12747d5a732..d37523ef0e1 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -212,7 +212,8 @@ class MrpUnbuild(models.Model): ) self.mo_id.message_post( body=unbuild_msg, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) return self.write({'state': 'done'}) def _generate_consume_moves(self): diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index ae8faf098ef..58e5c7d0a56 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -48,6 +48,7 @@ class StockRule(models.Model): productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom)) + note_subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') for company_id, productions_values in productions_values_by_company.items(): # create the MO as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example) productions = self.env['mrp.production'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create(productions_values) @@ -61,17 +62,20 @@ class StockRule(models.Model): production.message_post( body=_('This production order has been created from Replenishment Report.'), message_type='comment', - subtype_xmlid='mail.mt_note') + subtype_id=note_subtype_id + ) elif orderpoint: production.message_post_with_view( 'mail.message_origin_link', values={'self': production, 'origin': orderpoint}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_id=note_subtype_id, + ) elif origin_production: production.message_post_with_view( 'mail.message_origin_link', values={'self': production, 'origin': origin_production}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_id=note_subtype_id, + ) return True @api.model diff --git a/addons/partner_autocomplete/models/res_company.py b/addons/partner_autocomplete/models/res_company.py index ecaec41dda5..b033898e3a7 100644 --- a/addons/partner_autocomplete/models/res_company.py +++ b/addons/partner_autocomplete/models/res_company.py @@ -84,7 +84,7 @@ class ResCompany(models.Model): self.partner_id.message_post_with_view( 'iap_mail.enrich_company', values=template_values, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) return True diff --git a/addons/partner_autocomplete/models/res_partner.py b/addons/partner_autocomplete/models/res_partner.py index 19a947f64f7..8de11eeaa09 100644 --- a/addons/partner_autocomplete/models/res_partner.py +++ b/addons/partner_autocomplete/models/res_partner.py @@ -169,7 +169,7 @@ class ResPartner(models.Model): partners.message_post_with_view( 'iap_mail.enrich_company', values=template_values, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) partners.write({'additional_info': False}) diff --git a/addons/phone_validation/models/phone_blacklist.py b/addons/phone_validation/models/phone_blacklist.py index 8ce9b5ef4d3..f940a9fb6b9 100644 --- a/addons/phone_validation/models/phone_blacklist.py +++ b/addons/phone_validation/models/phone_blacklist.py @@ -98,7 +98,7 @@ class PhoneBlackList(models.Model): for record in new_records: record.with_context(mail_create_nosubscribe=True).message_post( body=message, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) records += new_records return records @@ -123,7 +123,7 @@ class PhoneBlackList(models.Model): for record in new_records: record.with_context(mail_create_nosubscribe=True).message_post( body=message, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) records += new_records return records diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py index 5f1dc177ceb..1bc76e32418 100644 --- a/addons/portal/wizard/portal_share.py +++ b/addons/portal/wizard/portal_share.py @@ -57,11 +57,7 @@ class PortalShare(models.TransientModel): record = res_model.browse(rec.res_id) rec.access_warning = record.access_warning - @api.model - def _get_note(self): - return self.env.ref('mail.mt_note') - - def _send_public_link(self, note, partners=None): + def _send_public_link(self, partners=None): if partners is None: partners = self.partner_ids for partner in partners: @@ -73,12 +69,12 @@ class PortalShare(models.TransientModel): values={'partner': partner, 'note': self.note, 'record': self.resource_ref, 'share_link': share_link}, subject=_("You are invited to access %s", self.resource_ref.display_name), - subtype_id=note.id, + subtype_xmlid='mail.mt_note', email_layout_xmlid='mail.mail_notification_light', partner_ids=[(6, 0, partner.ids)]) self = self.with_context(lang=saved_lang) - def _send_signup_link(self, note, partners=None): + def _send_signup_link(self, partners=None): if partners is None: partners = self.partner_ids.filtered(lambda partner: not partner.user_ids) for partner in partners: @@ -92,13 +88,12 @@ class PortalShare(models.TransientModel): values={'partner': partner, 'note': self.note, 'record': self.resource_ref, 'share_link': share_link}, subject=_("You are invited to access %s", self.resource_ref.display_name), - subtype_id=note.id, + subtype_xmlid='mail.mt_note', email_layout_xmlid='mail.mail_notification_light', partner_ids=[(6, 0, partner.ids)]) self = self.with_context(lang=saved_lang) def action_send_mail(self): - note = self._get_note() signup_enabled = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope') == 'b2c' if getattr(self.resource_ref, 'access_token', False) or not signup_enabled: @@ -106,9 +101,9 @@ class PortalShare(models.TransientModel): else: partner_ids = self.partner_ids.filtered(lambda x: x.user_ids) # if partner already user or record has access token send common link in batch to all user - self._send_public_link(note, partner_ids) + self._send_public_link(partner_ids) # when partner not user send individual mail with signup token - self._send_signup_link(note, self.partner_ids - partner_ids) + self._send_signup_link(self.partner_ids - partner_ids) # subscribe all recipients so that they receive future communication (better than # using autofollow as more precise) diff --git a/addons/product_email_template/models/account_move.py b/addons/product_email_template/models/account_move.py index 07039372614..0c240542111 100644 --- a/addons/product_email_template/models/account_move.py +++ b/addons/product_email_template/models/account_move.py @@ -15,12 +15,14 @@ class AccountMove(models.Model): # subscribe the partner to the invoice if invoice.partner_id not in invoice.message_partner_ids: invoice.message_subscribe([invoice.partner_id.id]) + comment_subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for line in invoice.invoice_line_ids: if line.product_id.email_template_id: invoice.message_post_with_template( line.product_id.email_template_id.id, composition_mode="comment", - email_layout_xmlid="mail.mail_notification_light" + email_layout_xmlid="mail.mail_notification_light", + subtype_id=comment_subtype_id, ) return True diff --git a/addons/project/wizard/project_share_wizard.py b/addons/project/wizard/project_share_wizard.py index 8aae1700394..0a83e09bfca 100644 --- a/addons/project/wizard/project_share_wizard.py +++ b/addons/project/wizard/project_share_wizard.py @@ -39,10 +39,9 @@ class ProjectShareWizard(models.TransientModel): self.ensure_one() if self.access_mode == 'edit': portal_partners = self.partner_ids.filtered('user_ids') - note = self._get_note() self.resource_ref._add_collaborators(self.partner_ids) - self._send_public_link(note, portal_partners) - self._send_signup_link(note, partners=self.partner_ids - portal_partners) + self._send_public_link(portal_partners) + self._send_signup_link(partners=self.partner_ids - portal_partners) self.resource_ref.message_subscribe(partner_ids=self.partner_ids.ids) return {'type': 'ir.actions.act_window_close'} return super().action_send_mail() diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 463b6472f6e..d8ff5c665ef 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -750,7 +750,11 @@ class PurchaseOrder(models.Model): if send_single: return order._send_reminder_open_composer(template.id) else: - order.with_context(is_reminder=True).message_post_with_template(template.id, email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", composition_mode='comment') + order.with_context(is_reminder=True).message_post_with_template( + template.id, + email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", + subtype_xmlid='mail.mt_comment', + ) def send_reminder_preview(self): self.ensure_one() @@ -1100,9 +1104,11 @@ class PurchaseOrderLine(models.Model): if 'product_qty' in values: for line in self: if line.order_id.state == 'purchase': - line.order_id.message_post_with_view('purchase.track_po_line_template', - values={'line': line, 'product_qty': values['product_qty']}, - subtype_id=self.env.ref('mail.mt_note').id) + line.order_id.message_post_with_view( + 'purchase.track_po_line_template', + values={'line': line, 'product_qty': values['product_qty']}, + subtype_xmlid='mail.mt_note', + ) if 'qty_received' in values: for line in self: line._track_qty_received(values['qty_received']) @@ -1422,7 +1428,7 @@ class PurchaseOrderLine(models.Model): self.order_id.message_post_with_view( 'purchase.track_po_line_qty_received_template', values={'line': self, 'qty_received': new_qty}, - subtype_id=self.env.ref('mail.mt_note').id + subtype_xmlid='mail.mt_note', ) def _validate_analytic_distribution(self): diff --git a/addons/purchase_requisition/models/purchase.py b/addons/purchase_requisition/models/purchase.py index 153e3c1beae..9f36e77e47b 100644 --- a/addons/purchase_requisition/models/purchase.py +++ b/addons/purchase_requisition/models/purchase.py @@ -142,13 +142,13 @@ class PurchaseOrder(models.Model): origin_po_id.purchase_group_id.order_ids |= orders else: self.env['purchase.order.group'].create({'order_ids': [Command.set(origin_po_id.ids + orders.ids)]}) - mt_note = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') for order in orders: if order.requisition_id: order.message_post_with_view( 'mail.message_origin_link', values={'self': order, 'origin': order.requisition_id}, - subtype_id=mt_note) + subtype_xmlid='mail.mt_note', + ) return orders def write(self, vals): @@ -161,7 +161,7 @@ class PurchaseOrder(models.Model): order.message_post_with_view( 'mail.message_origin_link', values={'self': order, 'origin': order.requisition_id, 'edit': True}, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + subtype_xmlid='mail.mt_note', ) if vals.get('alternative_po_ids', False): if not self.purchase_group_id and len(self.alternative_po_ids + self) > len(self): diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 71b69036781..0ce54cb39ab 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -253,7 +253,8 @@ class PurchaseOrder(models.Model): (pickings | forward_pickings).action_confirm() picking.message_post_with_view('mail.message_origin_link', values={'self': picking, 'origin': order}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) return True def _add_picking_info(self, activity): diff --git a/addons/rating/models/rating_mixin.py b/addons/rating/models/rating_mixin.py index ac2e11e47b3..6c60ecda73a 100644 --- a/addons/rating/models/rating_mixin.py +++ b/addons/rating/models/rating_mixin.py @@ -185,7 +185,7 @@ class RatingMixin(models.AbstractModel): record.message_post_with_template( template.id, email_layout_xmlid='mail.mail_notification_light', - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + subtype_xmlid='mail.mt_note', ) def rating_apply(self, rate, token=None, rating=None, feedback=None, @@ -223,7 +223,7 @@ class RatingMixin(models.AbstractModel): if subtype_xmlid is None: subtype_id = self._rating_apply_get_default_subtype_id() else: - subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) + subtype_id = False feedback = tools.plaintext2html(feedback or '') scheduled_datetime = ( @@ -249,6 +249,7 @@ class RatingMixin(models.AbstractModel): rating_id=rating.id, scheduled_date=scheduled_datetime, subtype_id=subtype_id, + subtype_xmlid=subtype_xmlid, ) return rating diff --git a/addons/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py index 6567b9ede01..a5364d45a2e 100644 --- a/addons/sale/models/payment_transaction.py +++ b/addons/sale/models/payment_transaction.py @@ -125,9 +125,10 @@ class PaymentTransaction(models.Model): return super()._reconcile_after_done() def _send_invoice(self): - template_id = self.env['ir.config_parameter'].sudo().get_param( - 'sale.default_invoice_email_template' - ) + template_id = int(self.env['ir.config_parameter'].sudo().get_param( + 'sale.default_invoice_email_template', + default=0 + )) if not template_id: return @@ -141,8 +142,9 @@ class PaymentTransaction(models.Model): invoice_to_send.is_move_sent = True # Mark invoice as sent for invoice in invoice_to_send.with_user(SUPERUSER_ID): invoice.message_post_with_template( - int(template_id), + template_id, email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', + subtype_xmlid='mail.mt_comment', ) def _cron_send_invoice(self): diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index e7e42a0335f..caa59ffa6b4 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -844,8 +844,8 @@ class SaleOrder(models.Model): continue sale_order.with_context(force_send=True).message_post_with_template( mail_template.id, - composition_mode='comment', email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', + subtype_xmlid='mail.mt_comment', ) def action_done(self): @@ -1184,7 +1184,8 @@ class SaleOrder(models.Model): move.message_post_with_view( 'mail.message_origin_link', values={'self': move, 'origin': move.line_ids.sale_line_ids.order_id}, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')) + subtype_xmlid='mail.mt_note', + ) return moves # MAIL # diff --git a/addons/sale/tests/test_controllers.py b/addons/sale/tests/test_controllers.py index 91bcedb3ae2..5c828e479bf 100644 --- a/addons/sale/tests/test_controllers.py +++ b/addons/sale/tests/test_controllers.py @@ -88,6 +88,8 @@ class TestSaleSignature(HttpCaseWithUserPortal): email_act = sales_order.action_quotation_send() email_ctx = email_act.get('context', {}) sales_order.with_context(**email_ctx).message_post_with_template( - email_ctx.get('default_template_id')) + email_ctx.get('default_template_id'), + subtype_xmlid='mail.mt_comment', + ) self.start_tour("/", 'sale_signature', login="portal") diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 5a7442859c6..aa38876de75 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -79,7 +79,10 @@ class TestSaleOrder(SaleCommon): # send quotation email_act = self.sale_order.action_quotation_send() email_ctx = email_act.get('context', {}) - self.sale_order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id')) + self.sale_order.with_context(**email_ctx).message_post_with_template( + email_ctx.get('default_template_id'), + subtype_xmlid='mail.mt_comment', + ) self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong') self.sale_order.order_line._compute_product_updatable() self.assertTrue(self.sale_order.order_line[0].product_updatable) @@ -101,7 +104,10 @@ class TestSaleOrder(SaleCommon): # sent to to author or not (in case author is present in 'Recipients' of composer). mail_template = self.env['mail.template'].browse(email_ctx.get('default_template_id')).copy({'auto_delete': False}) # send the mail with same user as customer - sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_template(mail_template.id) + sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_template( + mail_template.id, + subtype_xmlid='mail.mt_comment', + ) self.assertTrue(sale_order.state == 'sent', 'Sale : state should be changed to sent') mail_message = sale_order.message_ids[0] self.assertEqual(mail_message.author_id, sale_order.partner_id, 'Sale: author should be same as customer') diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py index 400909fd986..c205ae94fc6 100644 --- a/addons/sale/tests/test_sale_to_invoice.py +++ b/addons/sale/tests/test_sale_to_invoice.py @@ -632,7 +632,10 @@ class TestSaleToInvoice(TestSaleCommon): # send quotation email_act = self.sale_order.action_quotation_send() email_ctx = email_act.get('context', {}) - self.sale_order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id')) + self.sale_order.with_context(**email_ctx).message_post_with_template( + email_ctx.get('default_template_id'), + subtype_xmlid='mail.mt_comment', + ) self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong') self.sale_order.order_line._compute_product_updatable() self.assertTrue(self.sale_order.order_line[0].product_updatable) diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py index 9e08b621421..7382c645c14 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -189,7 +189,8 @@ class SaleAdvancePaymentInv(models.TransientModel): invoice.message_post_with_view( 'mail.message_origin_link', values={'self': invoice, 'origin': order}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) return invoice diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 43d6895efd0..6fb7d1db1dc 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -45,7 +45,8 @@ class StockMove(models.Model): picking_id.message_post_with_view( 'mail.message_origin_link', values={'self': picking_id, 'origin': sale_order_id}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) class ProcurementGroup(models.Model): diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 7be9f3ccf34..1e57a205494 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -667,7 +667,11 @@ class StockMoveLine(models.Model): data['result_package_name'] = self.env['stock.quant.package'].browse(vals.get('result_package_id')).name if 'owner_id' in vals and vals['owner_id'] != move.owner_id.id: data['owner_name'] = self.env['res.partner'].browse(vals.get('owner_id')).name - record.message_post_with_view(template, values={'move': move, 'vals': dict(vals, **data)}, subtype_id=self.env.ref('mail.mt_note').id) + record.message_post_with_view( + template, + values={'move': move, 'vals': dict(vals, **data)}, + subtype_xmlid='mail.mt_note', + ) def _free_reservation(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, ml_ids_to_ignore=None): """ When editing a done move line or validating one with some forced quantities, it is diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 79730a6ab77..bf3668979f7 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -884,9 +884,14 @@ class Picking(models.Model): return True def _send_confirmation_email(self): + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for stock_pick in self.filtered(lambda p: p.company_id.stock_move_email_validation and p.picking_type_id.code == 'outgoing'): delivery_template_id = stock_pick.company_id.stock_mail_confirmation_template_id.id - stock_pick.with_context(force_send=True).message_post_with_template(delivery_template_id, email_layout_xmlid='mail.mail_notification_light') + stock_pick.with_context(force_send=True).message_post_with_template( + delivery_template_id, + email_layout_xmlid='mail.mail_notification_light', + subtype_id=subtype_id, + ) @api.depends('state', 'move_ids', 'move_ids.state', 'move_ids.package_level_id', 'move_ids.move_line_ids.package_level_id') def _compute_move_without_package(self): diff --git a/addons/stock/wizard/stock_picking_return.py b/addons/stock/wizard/stock_picking_return.py index 241dadf28eb..1b78158de32 100644 --- a/addons/stock/wizard/stock_picking_return.py +++ b/addons/stock/wizard/stock_picking_return.py @@ -133,9 +133,11 @@ class ReturnPicking(models.TransientModel): # create new picking for returned products new_picking = self.picking_id.copy(self._prepare_picking_default_values()) picking_type_id = new_picking.picking_type_id.id - new_picking.message_post_with_view('mail.message_origin_link', + new_picking.message_post_with_view( + 'mail.message_origin_link', values={'self': new_picking, 'origin': self.picking_id}, - subtype_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) returned_lines = 0 for return_line in self.product_return_moves: if not return_line.move_id: diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index c068b334d1e..60976cb9c9f 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -144,9 +144,21 @@ class MailTestTicket(models.Model): res = super(MailTestTicket, self)._track_template(changes) record = self[0] if 'customer_id' in changes and record.mail_template: - res['customer_id'] = (record.mail_template, {'composition_mode': 'mass_mail'}) + res['customer_id'] = ( + record.mail_template, + { + 'composition_mode': 'mass_mail', + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + } + ) elif 'datetime' in changes: - res['datetime'] = ('test_mail.mail_test_ticket_tracking_view', {'composition_mode': 'mass_mail'}) + res['datetime'] = ( + 'test_mail.mail_test_ticket_tracking_view', + { + 'composition_mode': 'mass_mail', + 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + } + ) return res def _creation_subtype(self): diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index af7f07571b0..7ee548a2915 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -351,6 +351,7 @@ QUnit.module("test_mail", {}, function () { { default_model: "mail.test.activity", default_res_id: mailTestActivityId2, + default_subtype_xmlid: 'mail.mt_comment', default_template_id: mailTemplateId1, default_use_template: true, force_email: true, diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index a7804129349..dfabe4cb5d7 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -1106,6 +1106,16 @@ class TestComposerResultsComment(TestMailComposer): _mail, message = composer._action_send_mail() self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + # subtype through xml id + composer = self.env['mail.compose.message'].with_context( + self._get_web_context(self.test_record), + default_subtype_xmlid='mail.mt_note', + ).create({ + 'body': '

Default subtype through xml id

', + }) + _mail, message = composer._action_send_mail() + self.assertEqual(message.subtype_id, self.env.ref('mail.mt_note')) + @users('employee') @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_recipients(self): diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index eaf18c15b14..a05a89a8554 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -1007,6 +1007,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): _new_mails, _new_messages = test_records.with_user(self.user_employee).message_post_with_template( template.id, composition_mode='mass_mail', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) # created partners from inline email addresses @@ -1056,6 +1057,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): values={'partner': self.user_employee.partner_id}, composition_mode='mass_mail', subject='About mass mailing', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) self.assertEqual(len(new_messages), 0) self.assertEqual(len(self._new_mails), 10) @@ -1351,7 +1353,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): composition_mode='mass_mail', email_layout_xmlid='mail.test_layout', message_type='comment', - subtype_id=self.env.ref('mail.mt_comment').id, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) @@ -1417,7 +1419,7 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): test_template.id, email_layout_xmlid='mail.test_layout', message_type='comment', - subtype_id=self.env.ref('mail.mt_comment').id, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index 1f37ef5fcba..956661d8007 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -866,7 +866,12 @@ class TestMailComplexPerformance(BaseMailPerformance): # about 20 (19 ?) queries per additional customer group with self.assertQueryCount(__system__=42, employee=43): - record.message_post_with_template(template_id, message_type='comment', composition_mode='comment') + record.message_post_with_template( + template_id, + message_type='comment', + composition_mode='comment', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) self.assertEqual(record.message_ids[0].body, '

Adding stuff on %s

' % record.name) self.assertEqual(record.message_ids[0].notified_partner_ids, self.partners | self.user_portal.partner_id | self.customer) @@ -890,6 +895,7 @@ class TestMailComplexPerformance(BaseMailPerformance): messages_as_sudo = test_records.message_post_with_view( 'test_mail.mail_template_simple_test', values={'partner': self.user_test.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') ) self.assertEqual(len(messages_as_sudo), 10) diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index 34f73237214..d9c58799622 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -225,7 +225,8 @@ class BlogPost(models.Model): 'website_blog.blog_post_template_new_post', subject=post.name, values={'post': post}, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('website_blog.mt_blog_blog_published')) + subtype_xmlid='website_blog.mt_blog_blog_published', + ) return True return False diff --git a/addons/website_crm_iap_reveal/models/crm_reveal_rule.py b/addons/website_crm_iap_reveal/models/crm_reveal_rule.py index 33b3d0604ca..e4d35fa39b2 100644 --- a/addons/website_crm_iap_reveal/models/crm_reveal_rule.py +++ b/addons/website_crm_iap_reveal/models/crm_reveal_rule.py @@ -379,7 +379,7 @@ class CRMRevealRule(models.Model): lead.message_post_with_view( 'iap_mail.enrich_company', values=template_values, - subtype_id=self.env.ref('mail.mt_note').id + subtype_xmlid='mail.mt_note', ) return lead diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index bcc6dd8f77e..fe6df7e6681 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -404,16 +404,16 @@ class Track(models.Model): tracks = super(Track, self).create(vals_list) + post_values = {} if self.env.user.email else {'email_from': self.env.company.catchall_formatted} for track in tracks: - email_values = {} if self.env.user.email else {'email_from': self.env.company.catchall_formatted} track.event_id.message_post_with_view( 'website_event_track.event_track_template_new', values={ 'track': track, 'is_html_empty': is_html_empty, }, - subtype_id=self.env.ref('website_event_track.mt_event_track').id, - **email_values, + subtype_xmlid='website_event_track.mt_event_track', + **post_values, ) track._synchronize_with_stage(track.stage_id) @@ -492,10 +492,10 @@ class Track(models.Model): track = self[0] if 'stage_id' in changes and track.stage_id.mail_template_id: res['stage_id'] = (track.stage_id.mail_template_id, { - 'composition_mode': 'comment', 'auto_delete_message': True, + 'composition_mode': 'comment', + 'email_layout_xmlid': 'mail.mail_notification_light', 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - 'email_layout_xmlid': 'mail.mail_notification_light' }) return res diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index c707dccf3ee..6f49b658f58 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -627,13 +627,15 @@ class Post(models.Model): 'website_forum.forum_post_template_new_answer', subject=_('Re: %s', post.parent_id.name), partner_ids=[(4, p.id) for p in tag_partners], - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('website_forum.mt_answer_new')) + subtype_xmlid='website_forum.mt_answer_new', + ) elif post.state == 'active' and not post.parent_id: post.message_post_with_view( 'website_forum.forum_post_template_new_question', subject=post.name, partner_ids=[(4, p.id) for p in tag_partners], - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('website_forum.mt_question_new')) + subtype_xmlid='website_forum.mt_question_new', + ) elif post.state == 'pending' and not post.parent_id: # TDE FIXME: in master, you should probably use a subtype; # however here we remove subtype but set partner_ids @@ -644,7 +646,8 @@ class Post(models.Model): 'website_forum.forum_post_template_validation', subject=post.name, partner_ids=partners.ids, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')) + subtype_xmlid='mail.mt_note', + ) return True def reopen(self): From 023bc673274d1ebc0003d3bdae8faa30950a4851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 30 Sep 2022 13:39:32 +0000 Subject: [PATCH 15/45] [IMP] mail: introduce a batch version of Partner._find_or_create () Purpose is to be able to create find or create partners in batch based on a list of emails. When sending email in batch, notably using templates, doing sequentially a search or create for each potential email to contact is not efficient from computation point of view. This commit introduces a new batch method for that purpose. If no valid email is found for a given email, it is used as it to try to find partners with same invalid email and/or keep the value in order to allow updating it afterwards. Notably with notifications resend it is possible to update emails, if only a typo prevents from having a real email for example. Falsy emails do not create partners as those have no real interest. Task-3034875 (Mail: Speedup and batch partners find or create with templates) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/res_partner.py | 91 ++++++++++ addons/mail/tests/test_res_partner.py | 230 +++++++++++++++++++++++-- odoo/addons/base/models/res_partner.py | 6 +- 3 files changed, 314 insertions(+), 13 deletions(-) diff --git a/addons/mail/models/res_partner.py b/addons/mail/models/res_partner.py index 39751f708cd..cf821a0dbcb 100644 --- a/addons/mail/models/res_partner.py +++ b/addons/mail/models/res_partner.py @@ -102,6 +102,97 @@ class Partner(models.Model): create_values['email'] = parsed_email return self.create(create_values) + @api.model + def _find_or_create_from_emails(self, emails, additional_values=None): + """ Based on a list of emails, find or create partners. Additional values + can be given to newly created partners. If an email is not unique (e.g. + multi-email input), only the first found email is considered. + + If no valid email is found for a given item, the given value is used to + find partners with same invalid email or create a new one with the wrong + value. It allows updating it afterwards. Notably with notifications + resend it is possible to update emails, if only a typo prevents from + having a real email for example. + + :param list emails: list of emails that may be formatted (each input + will be parsed and normalized); + :param dict additional_values: additional values given to create if + the partner is not found. Typically used to propagate a company_id; + + :return: res.partner records in a list, following order of emails. It + is not a recordset, to keep Falsy values. + """ + partners, tocreate_vals_list = self.env['res.partner'], [] + name_emails = [self._parse_partner_name(email) for email in emails] + + # find valid emails_normalized, filtering out false / void values, and search + # for existing partners based on those emails + emails_normalized = {email_normalized + for _name, email_normalized in name_emails + if email_normalized} + # find partners for invalid (but not void) emails, aka either invalid email + # either no email and a name that will be used as email + names = { + name.strip() + for name, email_normalized in name_emails + if not email_normalized and name.strip() + } + if emails_normalized or names: + domains = [] + if emails_normalized: + domains.append([('email_normalized', 'in', list(emails_normalized))]) + if names: + domains.append([('email', 'in', list(names))]) + partners += self.search(expression.OR(domains)) + + # create partners for valid email without any existing partner. Keep + # only first found occurrence of each normalized email, aka: ('Norbert', + # 'norbert@gmail.com'), ('Norbert With Surname', 'norbert@gmail.com')' + # -> a single partner is created for email 'norbert@gmail.com' + seen = set() + notfound_emails = (emails_normalized - set(partners.mapped('email_normalized'))) if partners else emails_normalized + notfound_name_emails = [ + name_email + for name_email in name_emails + if name_email[1] in notfound_emails and name_email[1] not in seen + and not seen.add(name_email[1]) + ] + tocreate_vals_list += [ + { + self._rec_name: name or email_normalized, + 'email': email_normalized, + **(additional_values or {}) + } + for name, email_normalized in notfound_name_emails + ] + + # create partners for invalid emails (aka name and not email_normalized) + # without any existing partner + tocreate_vals_list += [ + { + self._rec_name: name, + 'email': name, + **(additional_values or {}) + } + for name in names if name not in partners.mapped('email') + ] + + # create partners once + if tocreate_vals_list: + partners += self.create(tocreate_vals_list) + + return [ + next( + (partner for partner in partners + if (email_normalized and partner.email_normalized == email_normalized) + or (not email_normalized and email and partner.email == email) + or (not email_normalized and name and partner.name == name) + ), + self.env['res.partner'] + ) + for (name, email_normalized), email in zip(name_emails, emails) + ] + # ------------------------------------------------------------ # DISCUSS # ------------------------------------------------------------ diff --git a/addons/mail/tests/test_res_partner.py b/addons/mail/tests/test_res_partner.py index 8354c7c32f9..061840cacb7 100644 --- a/addons/mail/tests/test_res_partner.py +++ b/addons/mail/tests/test_res_partner.py @@ -1,29 +1,57 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from contextlib import contextmanager +from unittest.mock import patch from uuid import uuid4 +from odoo import tools from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user -from odoo.tests.common import Form, users - +from odoo.tests.common import Form, tagged, users +from odoo.addons.base.models.res_partner import Partner # samples use effective TLDs from the Mozilla public suffix # list at http://publicsuffix.org SAMPLES = [ ('"Raoul Grosbedon" ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'), - ('ryu+giga-Sushi@aizubange.fukushima.jp', '', 'ryu+giga-Sushi@aizubange.fukushima.jp'), + ('ryu+giga-Sushi@aizubange.fukushima.jp', 'ryu+giga-sushi@aizubange.fukushima.jp', 'ryu+giga-Sushi@aizubange.fukushima.jp'), ('Raoul chirurgiens-dentistes.fr', 'Raoul chirurgiens-dentistes.fr', ''), (" Raoul O'hara ", "Raoul O'hara", '!@historicalsociety.museum'), ('Raoul Grosbedon ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'), ('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'), - ('"Patrick Da Beast Poilvache" ', 'Patrick Poilvache', 'patrick@example.com'), - ('Patrick Caché ', 'Patrick Poilvache', 'patrick@example.com'), - ('Patrick Caché <2patrick@EXAMPLE.COM>', 'Patrick Caché', '2patrick@example.com'), - + ('"Patrick Da Beast Poilvache" ', 'Patrick Da Beast Poilvache', 'patrick@example.com'), + ('Patrick Caché ', 'Patrick Da Beast Poilvache', 'patrick@example.com'), + ('Patrick Caché ', 'Patrick Caché', 'patrick.2@example.com'), ] + +@tagged('res_partner') class TestPartner(MailCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._activate_multi_company() + + @contextmanager + def mockPartnerCalls(self): + _original_create = Partner.create + _original_search = Partner.search + self._new_partners = self.env['res.partner'] + + def _res_partner_create(model, *args, **kwargs): + records = _original_create(model, *args, **kwargs) + self._new_partners += records.sudo() + return records + + with patch.object(Partner, 'create', + autospec=True, side_effect=_res_partner_create) as mock_partner_create, \ + patch.object(Partner, 'search', + autospec=True, side_effect=_original_search) as mock_partner_search: + self._mock_partner_create = mock_partner_create + self._mock_partner_search = mock_partner_search + yield + def _check_find_or_create(self, test_string, expected_name, expected_email, expected_email_normalized=False, check_partner=False, should_create=False): expected_email_normalized = expected_email_normalized or expected_email partner = self.env['res.partner'].find_or_create(test_string) @@ -36,16 +64,42 @@ class TestPartner(MailCommon): self.assertEqual(partner.email_normalized or '', expected_email_normalized) return partner - def test_res_partner_find_or_create(self): - Partner = self.env['res.partner'] + def test_parse_partner_name(self): + samples = [ + 'Raoul raoul@grosbedon.fr', + 'Raoul chirurgiens-dentistes.fr', + 'invalid', + 'False', + '', + ' ', + False, + None, + ] + expected = [ + ('Raoul', 'raoul@grosbedon.fr'), + ('Raoul chirurgiens-dentistes.fr', ''), + ('invalid', ''), + ('False', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ] + for (expected_name, expected_email), sample in zip(expected, samples): + parsed = self.env['res.partner']._parse_partner_name(sample) + self.assertEqual(parsed[0], expected_name) + self.assertEqual(parsed[1], expected_email) - partner = Partner.browse(Partner.name_create(SAMPLES[0][0])[0]) + def test_res_partner_find_or_create(self): + PartnerModel = self.env['res.partner'] + + partner = PartnerModel.browse(PartnerModel.name_create(SAMPLES[0][0])[0]) self._check_find_or_create( SAMPLES[0][0], SAMPLES[0][1], SAMPLES[0][2], check_partner=partner, should_create=False ) - partner_2 = Partner.browse(Partner.name_create('sarah.john@connor.com')[0]) + partner_2 = PartnerModel.browse(PartnerModel.name_create('sarah.john@connor.com')[0]) found_2 = self._check_find_or_create( 'john@connor.com', 'john@connor.com', 'john@connor.com', check_partner=partner_2, should_create=True @@ -76,7 +130,7 @@ class TestPartner(MailCommon): check_partner=new4, should_create=True ) - existing = Partner.create({ + existing = PartnerModel.create({ 'name': SAMPLES[6][1], 'email': SAMPLES[6][0], }) @@ -98,6 +152,158 @@ class TestPartner(MailCommon): with self.assertRaises(ValueError): self.env['res.partner'].find_or_create("Raoul chirurgiens-dentistes.fr", assert_valid_email=True) + @users('employee_c2') + def test_res_partner_find_or_create_from_emails(self): + """ Test for _find_or_create_from_emails allowing to find or create + partner based on emails in a batch-enabled and optimized fashion. """ + self.user_employee_c2.write({ + 'groups_id': [(4, self.env.ref('base.group_partner_manager').id)], + }) + + samples_emails = [item[0] for item in SAMPLES] + partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails( + samples_emails, + additional_values=None, + ) + self.assertEqual(len(partners), len(SAMPLES)) + for (sample, exp_name, exp_email), partner in zip(SAMPLES, partners): + exp_email_normalized = tools.email_normalize(exp_email) + self.assertFalse(partner.company_id) + self.assertEqual(partner.email_normalized, exp_email_normalized) + self.assertTrue(partner.id) + self.assertEqual(partner.lang, 'en_US') + self.assertEqual(partner.name, exp_name) + + new_samples = SAMPLES + [ + ('"New Customer" ', 'New Customer', 'new.customer@test.example.com'), + ('"Duplicated Raoul" ', 'Raoul Grosbedon', 'raoul@chirurgiens-dentistes.fr'), + ('Invalid', 'Invalid', ''), + (False, False, False), + (None, False, False), + (' ', False, False), + ('', False, False), + ] + samples_emails = [item[0] for item in new_samples] + partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails( + samples_emails, + additional_values={'company_id': self.env.company.id}, + ) + self.assertEqual(len(partners), len(new_samples)) + for (sample, exp_name, exp_email), partner in zip(new_samples, partners): + with self.subTest(sample=sample, exp_name=exp_name, exp_email=exp_email, partner=partner): + exp_company = self.env.company if sample in [ + '"New Customer" ', # valid email, not known -> new customer + 'Invalid' # invalid email, not known -> create a new partner + ] else self.env['res.company'] + if sample in [False, None, ' ', '']: + self.assertFalse(partner) + else: + exp_email_normalized = tools.email_normalize(exp_email) + self.assertEqual(partner.company_id, exp_company) + self.assertEqual(partner.email_normalized, exp_email_normalized) + self.assertEqual(partner.name, exp_name) + + @users('employee_c2') + def test_res_partner_find_or_create_from_emails_dupes(self): + """ Specific test for duplicates management: based on email to avoid + creating similar partners. """ + self.user_employee_c2.write({ + 'groups_id': [(4, self.env.ref('base.group_partner_manager').id)], + }) + + # all same partner, same email 'test.customer@test.dupe.example.com' + email_dupes_samples = [ + '"Formatted Customer" ', + 'test.customer@test.dupe.example.com', + '"Another Name" ', + '"Mix of both" ', + 'falsy', + ' ', + ] + with self.mockPartnerCalls(): + other_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails( + other_samples, + additional_values=None, + ) + # calls + self.assertEqual(self._mock_partner_create.call_count, 1) + self.assertEqual(self._mock_partner_search.call_count, 1) + self.assertEqual(len(self._new_partners), 3) + # results + self.assertEqual(len(other_partners), len(other_samples)) + self.assertEqual(other_partners[0], new_partners[0], 'Should take already existing partner') + self.assertEqual(other_partners[1], partners[0], 'Should take already existing partner') + self.assertEqual(other_partners[2].email, "test.customer3@test.dupe.example.com") + self.assertEqual(other_partners[2].name, "Third Customer") + self.assertEqual(other_partners[3].email, '"Falsy" ') + self.assertEqual(other_partners[3].name, '"Falsy" ') + self.assertEqual(other_partners[4].email, "falsy") + self.assertEqual(other_partners[4].name, "falsy") + def test_res_partner_log_portal_group(self): Users = self.env['res.users'] subtype_note = self.env.ref('mail.mt_note') diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index f3ce9d1870e..8e4c6ced2e8 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -820,7 +820,11 @@ class Partner(models.Model): Otherwise: default, everything is set as the name. Starting from 13.3 returned email will be normalized to have a coherent encoding. - """ + + :return: name, email (normalized if possible) + """ + if not text or not text.strip(): + return '', '' name, email = '', '' split_results = tools.email_split_tuples(text) if split_results: From 2175bde607c8d36e32157e2ecdbd45abdbfbf35d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 9 Dec 2021 16:10:55 +0000 Subject: [PATCH 16/45] [IMP] mail: add sanity checks for message_post and its helpers Purpose of this commit is to clearly check input of ``message_post`` method and its main helpers in order to prevent wrong usage of message post API. Some values used to populate message fields should not be set directly when posting or logging messages. Indeed they may be part of other process (like notification process managed by ``_notify_thread``, or could be setup by custom routes like 'reaction_ids', or custom usage of 'model' and 'res_id' that could conflicts with record on which methods are called). We therefore add checks and cleanup in those methods to be sure the API is used as intended and avoid unwanted side effects. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_message.py | 6 +- addons/mail/models/mail_thread.py | 251 ++++++++++++++---- .../mail_group/models/mail_group_message.py | 1 + addons/rating/models/mail_thread.py | 10 - addons/sms/models/mail_thread.py | 3 + .../tests/test_mail_thread_internals.py | 1 - addons/test_mail/tests/test_message_post.py | 34 +++ 7 files changed, 238 insertions(+), 68 deletions(-) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 8f28628dc9c..e7c97a9d361 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -571,7 +571,7 @@ class Message(models.Model): if 'record_name' not in values and 'default_record_name' not in self.env.context: values['record_name'] = self._get_record_name(values) - if 'attachment_ids' not in values: + if not values.get('attachment_ids'): values['attachment_ids'] = [] # extract base64 images if 'body' in values: @@ -604,9 +604,9 @@ class Message(models.Model): messages = super(Message, self).create(values_list) check_attachment_access = [] - if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values.get('attachment_ids')): + if all(isinstance(command, int) or command[0] in (4, 6) for values in values_list for command in values['attachment_ids']): for values in values_list: - for command in values.get('attachment_ids'): + for command in values['attachment_ids']: if isinstance(command, int): check_attachment_access += [command] elif command[0] == 6: diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 3cbf9e7204a..5cc47b65136 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1821,36 +1821,61 @@ class MailThread(models.AbstractModel): tuples in the form ``(name,content)`` or ``(name,content, info)`` where content is NOT base64 encoded; :param list attachment_ids: list of existing attachments to link to this message - -Should only be set by chatter - -Attachment object attached to mail.compose.message(0) will be attached - to the related document. + Should not be a list of commands. Attachment records attached to mail + composer will be attached to the related document. Extra keyword arguments will be used either * as default column values for the new mail.message record if they match mail.message fields; - * propagated to notification methods; + * propagated to notification methods if not; :return record: newly create mail.message """ self.ensure_one() # should always be posted on a record, use message_notify if no record - # split message additional values from notify additional values - msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields) - notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs) # preliminary value safety check - partner_ids = set(partner_ids or []) - if self._name == 'mail.thread' or not self.id or message_type == 'user_notification': - raise ValueError(_('Posting a message should be done on a business document. Use message_notify to send a notification to an user.')) - if 'channel_ids' in kwargs: - raise ValueError(_("Posting a message with channels as listeners is not supported since Odoo 14.3+. Please update code accordingly.")) - if 'model' in msg_kwargs or 'res_id' in msg_kwargs: - raise ValueError(_("message_post does not support model and res_id parameters anymore. Please call message_post on record.")) - if 'subtype' in kwargs: - raise ValueError(_("message_post does not support subtype parameter anymore. Please give a valid subtype_id or subtype_xmlid value instead.")) - if any(not isinstance(pc_id, int) for pc_id in partner_ids): - raise ValueError(_('message_post partner_ids and must be integer list, not commands.')) + self._raise_for_invalid_parameters( + set(kwargs.keys()), + forbidden_names={'model', 'res_id', 'subtype'} + ) + if self._name == 'mail.thread' or not self.id: + raise ValueError(_("Posting a message should be done on a business document. Use message_notify to send a notification to an user.")) + if message_type == 'user_notification': + raise ValueError(_("Use message_notify to send a notification to an user.")) + if attachments: + # attachments should be a list (or tuples) of 3-elements list (or tuple) + format_error = not tools.is_list_of(attachments, list) and not tools.is_list_of(attachments, tuple) + if not format_error: + format_error = not all(len(attachment) in {2, 3} for attachment in attachments) + if format_error: + raise ValueError( + _('Posting a message should receive attachments as a list of list or tuples (received %(aids)s)', + aids=repr(attachment_ids), + ) + ) + if attachment_ids and not tools.is_list_of(attachment_ids, int): + raise ValueError( + _('Posting a message should receive attachments records as a list of IDs (received %(aids)s)', + aids=repr(attachment_ids), + ) + ) + attachment_ids = list(attachment_ids or []) + if partner_ids and not tools.is_list_of(partner_ids, int): + raise ValueError( + _('Posting a message should receive partners as a list of IDs (received %(pids)s)', + pids=repr(partner_ids), + ) + ) + partner_ids = list(partner_ids or []) - self = self._fallback_lang() # add lang to context immediately since it will be useful in various flows latter. + # split message additional values from notify additional values + msg_kwargs = {key: val for key, val in kwargs.items() + if key in self.env['mail.message']._fields} + notif_kwargs = {key: val for key, val in kwargs.items() + if key not in msg_kwargs} + + # Add lang to context immediately since it will be useful in various flows later + self = self._fallback_lang() # Find the message's author if self.env.user._is_public() and 'guest' in self.env.context: @@ -1892,12 +1917,10 @@ class MailThread(models.AbstractModel): 'partner_ids': partner_ids, }) - attachments = attachments or [] - attachment_ids = attachment_ids or [] - attachement_values = self._process_attachments_for_post(attachments, attachment_ids, msg_values) - msg_values.update(attachement_values) # attachement_ids, [body] - - new_message = self._message_create(msg_values) + msg_values.update( + self._process_attachments_for_post(attachments, attachment_ids, msg_values) + ) # attachement_ids, body + new_message = self._message_create([msg_values]) # Set main attachment field if necessary self._message_set_main_attachment_id(msg_values['attachment_ids']) @@ -2111,13 +2134,19 @@ class MailThread(models.AbstractModel): :param template_id : the id of the template to render to create the body of the message :param **kwargs : parameter to create a mail.compose.message woaerd (which inherit from mail.message) """ + # preliminary value safety check + self._raise_for_invalid_parameters( + set(kwargs.keys()), + forbidden_names={'model', 'res_id'} + ) + # Get composition mode, or force it according to the number of record in self if not kwargs.get('composition_mode'): kwargs['composition_mode'] = 'comment' if len(self.ids) == 1 else 'mass_mail' if not kwargs.get('message_type'): kwargs['message_type'] = 'notification' - res_id = kwargs.get('res_id', self.ids and self.ids[0] or 0) - res_ids = kwargs.get('res_id') and [kwargs['res_id']] or self.ids + res_id = self.ids[0] if self.ids else 0 + res_ids = self.ids # support xml based subtype id if kwargs.get('subtype_xmlid') and not kwargs.get('subtype_id'): @@ -2127,17 +2156,22 @@ class MailThread(models.AbstractModel): composer = self.env['mail.compose.message'].with_context( active_id=res_id, active_ids=res_ids, - active_model=kwargs.get('model', self._name), + active_model=self._name, default_composition_mode=kwargs['composition_mode'], default_email_layout_xmlid=email_layout_xmlid, - default_model=kwargs.get('model', self._name), + default_model=self._name, default_res_id=res_id, default_template_id=template_id, ).create(kwargs) # Simulate the onchange (like trigger in form the view) only # when having a template in single-email mode if template_id: - update_values = composer._onchange_template_id(template_id, kwargs['composition_mode'], self._name, res_id)['value'] + update_values = composer._onchange_template_id( + template_id, + kwargs['composition_mode'], + self._name, + res_id + )['value'] composer.write(update_values) return composer._action_send_mail(auto_commit=auto_commit) @@ -2151,21 +2185,32 @@ class MailThread(models.AbstractModel): on the user configuration, like other notifications. """ if self: self.ensure_one() - # split message additional values from notify additional values - msg_kwargs = dict((key, val) for key, val in kwargs.items() if key in self.env['mail.message']._fields) - notif_kwargs = dict((key, val) for key, val in kwargs.items() if key not in msg_kwargs) - - author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=True) - if not partner_ids: _logger.warning('Message notify called without recipient_ids, skipping') return self.env['mail.message'] + # preliminary value safety check + self._raise_for_invalid_parameters( + set(kwargs.keys()), + forbidden_names={'message_id', 'message_type'} + ) + if not tools.is_list_of(partner_ids, int): + raise ValueError( + _('Notification should receive partners given as a list of IDs (received %(pids)s)', + pids=repr(partner_ids), + ) + ) + + # split message additional values from notify additional values + msg_kwargs = {key: val for key, val in kwargs.items() if key in self.env['mail.message']._fields} + notif_kwargs = {key: val for key, val in kwargs.items() if key not in msg_kwargs} + + author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=True) + # allow to link a notification to a document that does not inherit from - # MailThread by supporting model / res_id - if not (model and res_id): # both value should be set or none should be set (record) - model = False - res_id = False + # MailThread by supporting model / res_id, but then both value should be set + if not model or not res_id: + model, res_id = False, False msg_values = { # author @@ -2196,7 +2241,7 @@ class MailThread(models.AbstractModel): if 'email_add_signature' not in msg_values: msg_values['email_add_signature'] = True - new_message = self._message_create(msg_values) + new_message = self._message_create([msg_values]) self._notify_thread(new_message, msg_values, **notif_kwargs) return new_message @@ -2204,13 +2249,17 @@ class MailThread(models.AbstractModel): """ Helper method to log a note using a view_id without notifying followers. """ return self._message_compose_with_view(views_or_xmlid, message_log=True, **kwargs) - def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs): + def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, + message_type='notification', + attachment_ids=False, tracking_value_ids=False): """ Shortcut allowing to post note on a document. It does not perform any notification and pre-computes some values to have a short code as optimized as possible. This method is private as it does not check access rights and perform the message creation as sudo to speedup the log process. This method should be called within methods where - access rights are already granted to avoid privilege escalation. """ + access rights are already granted to avoid privilege escalation. + + :return: created message (as sudo)""" self.ensure_one() author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=False) @@ -2219,30 +2268,43 @@ class MailThread(models.AbstractModel): 'author_id': author_id, 'email_from': email_from, # document - 'model': kwargs.get('model', self._name), + 'model': self._name, 'record_name': False, - 'res_id': self.ids[0] if self.ids else False, + 'res_id': self.id, # content + 'attachment_ids': attachment_ids, 'body': body, 'is_internal': True, 'message_type': message_type, 'subject': subject, 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + 'tracking_value_ids': tracking_value_ids, # recipients 'email_add_signature': False, # False as no notification -> no need to compute signature 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False], } - msg_values.update(kwargs) - return self.sudo()._message_create(msg_values) + return self.sudo()._message_create([msg_values]) - def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification'): - """ Shortcut allowing to post notes on a batch of documents. It achieve the - same purpose as _message_log, done in batch to speedup quick note log. + def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, + message_type='notification', + attachment_ids=False, tracking_value_ids=False): + """ Shortcut allowing to post notes on a batch of documents. It does not + perform any notification and pre-computes some values to have a short code + as optimized as possible. This method is private as it does not check + access rights and perform the message creation as sudo to speedup + the log process. This method should be called within methods where + access rights are already granted to avoid privilege escalation. - :param bodies: dict {record_id: body} + :param bodies: dict {record_id: body} + + :return: created messages (as sudo) """ + # protect against side-effect prone usage + if len(self) > 1 and (attachment_ids or tracking_value_ids): + raise ValueError(_('Batch log cannot support attachments or tracking values on more than 1 document')) + author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=False) base_message_values = { @@ -2253,12 +2315,14 @@ class MailThread(models.AbstractModel): 'model': self._name, 'record_name': False, # content + 'attachment_ids': attachment_ids, 'message_type': message_type, 'is_internal': True, 'subject': subject, 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + 'tracking_value_ids': tracking_value_ids, # recipients - 'email_add_signature': False, + 'email_add_signature': False, # False as no notification -> no need to compute signature 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False], } @@ -2337,9 +2401,14 @@ class MailThread(models.AbstractModel): return self.name_get()[0][1] def _message_create(self, values_list): - if not isinstance(values_list, (list)): - values_list = [values_list] create_values_list = [] + + # preliminary value safety check + self._raise_for_invalid_parameters( + {key for values in values_list for key in values.keys()}, + restricting_names=self._get_message_create_valid_field_names() + ) + for values in values_list: create_values = dict(values) # Avoid warnings about non-existing fields @@ -2354,6 +2423,76 @@ class MailThread(models.AbstractModel): clean_context(self.env.context) ).create(create_values_list) + def _get_message_create_valid_field_names(self): + """ Some fields should not be given when creating a mail.message from + mail.thread main API methods (in addition to some API specific check). + Those fields are generally used through UI or dedicated methods. We + therefore give an allowed field names list. """ + return { + 'attachment_ids', + 'author_guest_id', + 'author_id', + 'body', + 'create_date', # anyway limited to admins + 'date', + 'email_add_signature', + 'email_from', + 'email_layout_xmlid', + 'is_internal', + 'mail_activity_type_id', + 'mail_server_id', + 'message_id', + 'message_type', + 'model', + 'parent_id', + 'partner_ids', + 'record_name', + 'reply_to', + 'reply_to_force_new', + 'res_id', + 'subject', + 'subtype_id', + 'tracking_value_ids', + } + + def _get_notify_valid_parameters(self): + """ Several parameters exist for notification methods as business + flows often want to customize the standard notification experience. + In order to ease coding kwargs are frequently used. This method + acts like a filter, allowing to spot parameters that are not + supported. """ + return { + 'force_email_company', + 'force_email_lang', + 'force_send', + 'mail_auto_delete', + 'model_description', + 'resend_existing', + 'scheduled_date', + 'send_after_commit', + 'skip_existing', + 'subtitles', + } + + def _raise_for_invalid_parameters(self, parameter_names, forbidden_names=None, restricting_names=None): + """ Helper to warn about invalid parameters (or fields). + + :param set parameter_names: a set of parameter names; + :param set forbidden_names: set of parameter name that should not be + present in parameter_names; + :param set restricting_names: set of parameters restricting given + parameter_names, parameters not belonging to this list are rejected; + """ + if forbidden_names: + conflicting_names = parameter_names & forbidden_names + elif restricting_names: + conflicting_names = parameter_names - restricting_names + if conflicting_names: + raise ValueError( + _('Those values are not supported when posting or notifying: %(param_names)s', + param_names=', '.join(conflicting_names)) + ) + # ------------------------------------------------------ # NOTIFICATION API # ------------------------------------------------------ @@ -2426,6 +2565,10 @@ class MailThread(models.AbstractModel): """ # add lang to context immediately since it will be useful in various rendering later self = self._fallback_lang() + self._raise_for_invalid_parameters( + set(kwargs.keys()), + restricting_names=self._get_notify_valid_parameters() + ) msg_vals = msg_vals if msg_vals else {} recipients_data = self._notify_get_recipients(message, msg_vals, **kwargs) diff --git a/addons/mail_group/models/mail_group_message.py b/addons/mail_group/models/mail_group_message.py index 09026ac20a5..ae4fb60c70c 100644 --- a/addons/mail_group/models/mail_group_message.py +++ b/addons/mail_group/models/mail_group_message.py @@ -96,6 +96,7 @@ class MailGroupMessage(models.Model): field: vals.pop(field) for field in self.env['mail.message']._fields if field in vals + and field in self.env['mail.thread']._get_message_create_valid_field_names() }).id return super(MailGroupMessage, self).create(values_list) diff --git a/addons/rating/models/mail_thread.py b/addons/rating/models/mail_thread.py index eacfc11931e..c9196018c6c 100644 --- a/addons/rating/models/mail_thread.py +++ b/addons/rating/models/mail_thread.py @@ -30,13 +30,3 @@ class MailThread(models.AbstractModel): self.env['rating.rating'].browse(rating_id).write({'message_id': message.id}) return message - - def _message_create(self, values_list): - """ Force usage of rating-specific methods and API allowing to delegate - computation to records. Keep methods optimized and skip rating_ids - support to simplify MailThrad main API. """ - if not isinstance(values_list, (list)): - values_list = [values_list] - if any(values.get('rating_ids') for values in values_list): - raise ValueError(_("Posting a rating should be done using message post API.")) - return super()._message_create(values_list) diff --git a/addons/sms/models/mail_thread.py b/addons/sms/models/mail_thread.py index cd7a600952e..0c1f3a02e8c 100644 --- a/addons/sms/models/mail_thread.py +++ b/addons/sms/models/mail_thread.py @@ -363,6 +363,9 @@ class MailThread(models.AbstractModel): return True + def _get_notify_valid_parameters(self): + return super()._get_notify_valid_parameters() | {'put_in_queue', 'sms_numbers', 'sms_pid_to_number'} + @api.model def notify_cancel_by_type(self, notification_type): super().notify_cancel_by_type(notification_type) diff --git a/addons/test_mail/tests/test_mail_thread_internals.py b/addons/test_mail/tests/test_mail_thread_internals.py index 39b2c3d1f99..f7bb3470d45 100644 --- a/addons/test_mail/tests/test_mail_thread_internals.py +++ b/addons/test_mail/tests/test_mail_thread_internals.py @@ -361,7 +361,6 @@ class TestDiscuss(TestMailCommon, TestRecipients): channel = self.env['mail.channel'].create({'name': 'testChannel'}) notification_msg = channel.with_user(self.user_admin).message_notify( body='test', - message_type='user_notification', partner_ids=[self.partner_2.id], ) diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index a05a89a8554..389e92a54a2 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -403,6 +403,40 @@ class TestMessageNotify(TestMessagePostCommon): } ) + @users('employee') + def test_notify_parameters(self): + """ Test usage of parameters in notify, both for unwanted side effects + and magic parameters. """ + test_record = self.test_record.with_env(self.env) + + for parameters in [ + {'message_type': 'comment'}, + {'canned_response_ids': []}, + {'child_ids': []}, + {'mail_ids': []}, + {'notification_ids': []}, + {'notified_partner_ids': []}, + {'reaction_ids': []}, + {'starred_partner_ids': []}, + ]: + with self.subTest(parameters=parameters), \ + self.mock_mail_gateway(), \ + self.assertRaises(ValueError): + _new_message = test_record.message_notify( + body='

You will not receive a notification

', + partner_ids=self.partner_1.ids, + subject='This should not be accepted', + **parameters + ) + + # support of subtype xml id + new_message = test_record.message_notify( + body='

You will not receive a notification

', + partner_ids=self.partner_1.ids, + subtype_xmlid='mail.mt_note', + ) + self.assertEqual(new_message.subtype_id, self.env.ref('mail.mt_note')) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_notify_thread(self): From 3a9a791be89485eacbfe2cd7171a251ce865fafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 22 Nov 2022 12:54:09 +0000 Subject: [PATCH 17/45] [IMP] mail: allow to render views from render mixin When calling "_render_template_qweb_view", parameter can currently be an XmlID or an id. It can now be a recordset, allowing easy calls from Odoo. Slug is also added in evaluation context to prepare further cleaning in message_post helpers. Some error messages are also improved while passing by. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_render_mixin.py | 70 ++++++++++++++++++------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 63da0106246..523b122c50c 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -12,6 +12,7 @@ from werkzeug import urls from odoo import _, api, fields, models, tools from odoo.addons.base.models.ir_qweb import QWebException +from odoo.addons.http_routing.models.ir_http import slug from odoo.exceptions import UserError, AccessError from odoo.tools import is_html_empty from odoo.tools.rendering_tools import convert_inline_template_to_qweb, parse_inline_template, render_inline_template, template_env_globals @@ -241,7 +242,10 @@ class MailRenderMixin(models.AbstractModel): def _check_access_right_dynamic_template(self): if not self.env.su and not self.env.user.has_group('mail.group_mail_template_editor') and self._is_dynamic(): group = self.env.ref('mail.group_mail_template_editor') - raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name)) + raise AccessError( + _('Only users belonging to the "%(group_name)s" group can modify dynamic templates.', + group_name=group.name) + ) # ------------------------------------------------------------ # RENDERING @@ -256,14 +260,15 @@ class MailRenderMixin(models.AbstractModel): * various formatting tools; """ render_context = { + 'ctx': self._context, 'format_date': lambda date, date_format=False, lang_code=False: format_date(self.env, date, date_format, lang_code), 'format_datetime': lambda dt, tz=False, dt_format=False, lang_code=False: format_datetime(self.env, dt, tz, dt_format, lang_code), 'format_time': lambda time, tz=False, time_format=False, lang_code=False: format_time(self.env, time, tz, time_format, lang_code), 'format_amount': lambda amount, currency, lang_code=False: tools.format_amount(self.env, amount, currency, lang_code), 'format_duration': lambda value: tools.format_duration(value), - 'user': self.env.user, - 'ctx': self._context, 'is_html_empty': is_html_empty, + 'slug': slug, + 'user': self.env.user, } render_context.update(copy.copy(template_env_globals)) return render_context @@ -314,15 +319,21 @@ class MailRenderMixin(models.AbstractModel): except Exception as e: if isinstance(e, QWebException) and isinstance(e.__cause__, PermissionError): group = self.env.ref('mail.group_mail_template_editor') - raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name)) from e + raise AccessError( + _('Only users belonging to the "%(group_name)s" group can modify dynamic templates.', + group_name=group.name) + ) from e _logger.info("Failed to render template : %s", template_src, exc_info=True) - raise UserError(_("Failed to render QWeb template : %s)", template_src)) from e + raise UserError( + _("Failed to render QWeb template : %(template_src)s)", + template_src=template_src) + ) from e results[record.id] = render_result return results @api.model - def _render_template_qweb_view(self, view_xmlid, model, res_ids, + def _render_template_qweb_view(self, view_ref, model, res_ids, add_context=None, options=None): """ Render a QWeb template based on an ir.ui.view content. @@ -330,8 +341,9 @@ class MailRenderMixin(models.AbstractModel): variables are added: * ``object``: record based on which the template is rendered; - :param str view_xmlid: source QWeb template. It should be a string - XmlID allowing to fetch an ``ir.ui.view``; + :param str/int/record view_ref: source QWeb template. It should be an + XmlID allowing to fetch an ``ir.ui.view``, or an ID of a view or + an ``ir.ui.view`` record; :param str model: see ``MailRenderMixin._render_template()``; :param list res_ids: see ``MailRenderMixin._render_template()``; @@ -352,14 +364,23 @@ class MailRenderMixin(models.AbstractModel): if add_context: variables.update(**add_context) + view_ref = view_ref.id if isinstance(view_ref, models.BaseModel) else view_ref for record in self.env[model].browse(res_ids): variables['object'] = record try: - render_result = self.env['ir.qweb']._render(view_xmlid, variables, minimal_qcontext=True, raise_if_not_found=False, **(options or {})) + render_result = self.env['ir.qweb']._render( + view_ref, + variables, + minimal_qcontext=True, + raise_if_not_found=False, + **(options or {}) + ) results[record.id] = render_result except Exception as e: - _logger.info("Failed to render template : %s", view_xmlid, exc_info=True) - raise UserError(_("Failed to render template : %s") % view_xmlid) + _logger.info("Failed to render template : %s", view_ref, exc_info=True) + raise UserError( + _("Failed to render template : %(view_ref)s", view_ref=view_ref) + ) from e return results @@ -395,7 +416,10 @@ class MailRenderMixin(models.AbstractModel): if (not self._unrestricted_rendering and is_dynamic and not self.env.is_admin() and not self.env.user.has_group('mail.group_mail_template_editor')): group = self.env.ref('mail.group_mail_template_editor') - raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name)) + raise AccessError( + _('Only users belonging to the "%(group_name)s" group can modify dynamic templates.', + group_name=group.name) + ) if not is_dynamic: # Either the content is a raw text without placeholders, either we fail to @@ -414,10 +438,16 @@ class MailRenderMixin(models.AbstractModel): variables['object'] = record try: - results[record.id] = render_inline_template(template_instructions, variables) + results[record.id] = render_inline_template( + template_instructions, + variables + ) except Exception as e: _logger.info("Failed to render inline_template: \n%s", str(template_txt), exc_info=True) - raise UserError(_("Failed to render inline_template template : %s)", e)) + raise UserError( + _("Failed to render inline_template template : %(template_txt)s)", + template_txt=template_txt) + ) from e return results @@ -467,9 +497,15 @@ class MailRenderMixin(models.AbstractModel): options = {} if not isinstance(res_ids, (list, tuple)): - raise ValueError(_('Template rendering should be called only using on a list of IDs.')) + raise ValueError( + _('Template rendering should be called only using on a list of IDs; received %(res_ids)r instead.', + res_ids=res_ids) + ) if engine not in ('inline_template', 'qweb', 'qweb_view'): - raise ValueError(_('Template rendering supports only inline_template, qweb, or qweb_view (view or raw).')) + raise ValueError( + _('Template rendering supports only inline_template, qweb, or qweb_view (view or raw); received %(engine)s instead.', + engine=engine) + ) if engine == 'qweb_view': rendered = self._render_template_qweb_view(template_src, model, res_ids, @@ -498,8 +534,6 @@ class MailRenderMixin(models.AbstractModel): :return dict: {res_id: lang code (i.e. en_US)} """ self.ensure_one() - if not isinstance(res_ids, (list, tuple)): - raise ValueError(_('Template rendering for language should be called with a list of IDs.')) rendered_langs = self._render_template(self.lang, self.render_model, res_ids, engine=engine) return dict( From 4775bd93a2bf9947236f4f7f06d6f8cb7734f7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 2 Sep 2022 13:28:56 +0000 Subject: [PATCH 18/45] [REF] mail: cleanup post with {view, template} wrappers RATIONALE Purpose of this commit is to cleanup main post helpers and have a more easy and understandable way of calling them. SUMMARY We now have two main API methods, based on business flow: either posting on documents, either sending a mass mailing. Indeed those two flows are different * post: create message, then launch notification process by taking into account subtype, followers, ... * mail: create mails in batch with recipients being based on template or given partners. No notifications is involved, only maybe traces if a mass mailing is linked Delegate QWeb rendering to the render mixin (i.e. _render_template_qweb_view) in order to have a single point to forge evaluation context and re-use existing rendering code. SPECIFICATIONS Main API helpers are now * ``message_post_with_source``: (batch) post on records, using an ir.ui.view (given a record or its xml id) or a mail.template record (given a record or its xml id). When using a template, a composer is called to post on each record (as batch post is not yet supported). When using a view, a direct call to message_post using the rendered bodies is done, one record at a time. * ``message_mail_with_source``: send a mass mailing on records, acting like invoking the mail composer in mass mode. Same arguments are valid, either a reference to a view, either a reference to a mail template. Other helpers are * ``_message_log_with_view``: (batch) log on records, using an ir.ui.view to render the body using QWeb (no notification process); * ``_message_log(_batch)``: (batch) log on records (no notification process); * ``message_notify``: notify partners on records (creating notifications specifically for some people while message itself is not displayed in chatter); Code migration * ``message_post_with_template`` in "mass mode": use ``message_mail_with_source`` and set the template record as source; * ``message_post_with_template`` in "comment" mode: use ``message_post_with_source`` and set the template record as source; * ``message_post_with_view``: its main usage was to post on a document, in which case it generally can be replaced by ``message_mail_with_source`` using the view reference as source; Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/calendar/models/calendar_event.py | 4 +- addons/calendar/views/calendar_views.xml | 2 +- addons/crm/models/crm_lead.py | 4 +- addons/crm_iap_enrich/models/crm_lead.py | 10 +- .../models/crm_iap_lead_mining_request.py | 4 +- addons/event_booth/models/event_booth.py | 4 +- .../event_sale/models/event_registration.py | 4 +- addons/hr_expense/models/hr_expense.py | 15 +- addons/hr_recruitment/models/hr_employee.py | 4 +- .../wizard/applicant_refuse_reason.py | 19 +- .../models/hr_contract.py | 4 +- addons/mail/models/ir_actions_server.py | 10 +- addons/mail/models/mail_activity.py | 8 +- addons/mail/models/mail_activity_mixin.py | 10 +- addons/mail/models/mail_thread.py | 399 +++++++++++++----- addons/mail_group/models/mail_group.py | 2 +- addons/mail_plugin/controllers/mail_plugin.py | 8 +- addons/mass_mailing/models/mail_thread.py | 12 +- addons/mrp/models/stock_rule.py | 8 +- .../models/res_company.py | 4 +- .../models/res_partner.py | 4 +- addons/portal/wizard/portal_share.py | 16 +- .../models/account_move.py | 5 +- addons/purchase/models/purchase.py | 12 +- .../purchase_requisition/models/purchase.py | 8 +- addons/purchase_stock/models/purchase.py | 5 +- addons/rating/models/rating_mixin.py | 11 +- addons/sale/models/payment_transaction.py | 14 +- addons/sale/models/sale_order.py | 8 +- addons/sale/tests/test_controllers.py | 4 +- addons/sale/tests/test_sale_order.py | 8 +- addons/sale/tests/test_sale_to_invoice.py | 4 +- .../sale/wizard/sale_make_invoice_advance.py | 4 +- addons/sale_stock/models/stock.py | 4 +- .../tests/test_sale_stock_margin.py | 5 +- addons/stock/models/stock_move_line.py | 4 +- addons/stock/models/stock_picking.py | 6 +- addons/stock/wizard/stock_picking_return.py | 4 +- addons/test_mail/tests/test_message_post.py | 98 +++-- addons/test_mail/tests/test_message_track.py | 2 +- addons/test_mail/tests/test_performance.py | 13 +- addons/website_blog/models/website_blog.py | 4 +- .../models/crm_reveal_rule.py | 4 +- .../website_event_track/models/event_track.py | 4 +- addons/website_forum/models/forum.py | 10 +- 45 files changed, 509 insertions(+), 287 deletions(-) diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 43bb858577b..ca3da46a725 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -750,9 +750,9 @@ class Meeting(models.Model): raise UserError(_("There are no attendees on these events")) template_id = self.env['ir.model.data']._xmlid_to_res_id('calendar.calendar_template_meeting_update', raise_if_not_found=False) # The mail is sent with datetime corresponding to the sending user TZ - composition_mode = self.env.context.get('composition_mode', 'comment') + default_composition_mode = self.env.context.get('default_composition_mode', self.env.context.get('composition_mode', 'comment')) compose_ctx = dict( - default_composition_mode=composition_mode, + default_composition_mode=default_composition_mode, default_model='calendar.event', default_res_ids=self.ids, default_use_template=bool(template_id), diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index d8a21fe6d35..8049ce2d67b 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -73,7 +73,7 @@
-
diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 0442fe57ed5..85468e3973d 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1561,9 +1561,9 @@ class Lead(models.Model): def _merge_log_summary(self, merged_followers, opportunities_tail): """Log the merge message on the lead.""" self.ensure_one() - self.message_post_with_view( + self.message_post_with_source( "crm.crm_lead_merge_summary", - values={ + render_values={ "merged_followers": merged_followers, "opportunities": opportunities_tail, "is_html_empty": is_html_empty, diff --git a/addons/crm_iap_enrich/models/crm_lead.py b/addons/crm_iap_enrich/models/crm_lead.py index 6732e2ee57e..8c65cd6beba 100644 --- a/addons/crm_iap_enrich/models/crm_lead.py +++ b/addons/crm_iap_enrich/models/crm_lead.py @@ -68,7 +68,7 @@ class Lead(models.Model): normalized_email = tools.email_normalize(lead.email_from) if not normalized_email: - lead.message_post_with_view( + lead.message_post_with_source( 'crm_iap_enrich.mail_message_lead_enrich_no_email', subtype_xmlid='mail.mt_note', ) @@ -78,7 +78,7 @@ class Lead(models.Model): # Discard domains of generic email providers as it won't return relevant information if email_domain in iap_tools._MAIL_DOMAIN_BLACKLIST: lead.write({'iap_enrich_done': True}) - lead.message_post_with_view( + lead.message_post_with_source( 'crm_iap_enrich.mail_message_lead_enrich_notfound', subtype_xmlid='mail.mt_note', ) @@ -128,7 +128,7 @@ class Lead(models.Model): iap_data = iap_response.get(str(lead.id)) if not iap_data: lead.write({'iap_enrich_done': True}) - lead.message_post_with_view( + lead.message_post_with_source( 'crm_iap_enrich.mail_message_lead_enrich_notfound', subtype_xmlid='mail.mt_note', ) @@ -161,9 +161,9 @@ class Lead(models.Model): template_values = iap_data template_values['flavor_text'] = _("Lead enriched based on email address") - lead.message_post_with_view( + lead.message_post_with_source( 'iap_mail.enrich_company', - values=template_values, + render_values=template_values, subtype_xmlid='mail.mt_note', ) diff --git a/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py b/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py index cb9ef3c4e66..615ea7c347a 100644 --- a/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py +++ b/addons/crm_iap_mine/models/crm_iap_lead_mining_request.py @@ -278,9 +278,9 @@ class CRMLeadMiningRequest(models.Model): leads = self.env['crm.lead'].create(lead_vals_list) for lead in leads: if messages_to_post.get(lead.reveal_id): - lead.message_post_with_view( + lead.message_post_with_source( 'iap_mail.enrich_company', - values=messages_to_post[lead.reveal_id], + render_values=messages_to_post[lead.reveal_id], subtype_xmlid='mail.mt_note', ) diff --git a/addons/event_booth/models/event_booth.py b/addons/event_booth/models/event_booth.py index 474ea92c545..592d7223f60 100644 --- a/addons/event_booth/models/event_booth.py +++ b/addons/event_booth/models/event_booth.py @@ -100,9 +100,9 @@ class EventBooth(models.Model): def _post_confirmation_message(self): for booth in self: - booth.event_id.message_post_with_view( + booth.event_id.message_post_with_source( 'event_booth.event_booth_booked_template', - values={ + render_values={ 'booth': booth, }, subtype_xmlid='event_booth.mt_event_booth_booked', diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py index 95c0f47ec46..521f931d5ce 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -77,9 +77,9 @@ class EventRegistration(models.Model): registrations = super(EventRegistration, self).create(vals_list) for registration in registrations: if registration.sale_order_id: - registration.message_post_with_view( + registration.message_post_with_source( 'mail.message_origin_link', - values={'self': registration, 'origin': registration.sale_order_id}, + render_values={'self': registration, 'origin': registration.sale_order_id}, subtype_xmlid='mail.mt_note', ) return registrations diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 6fd2566fdd8..85204ffd4e8 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -636,11 +636,12 @@ class HrExpense(models.Model): def refuse_expense(self, reason): self.write({'is_refused': True}) self.sheet_id.write({'state': 'cancel'}) - self.sheet_id.message_post_with_view( - 'hr_expense.hr_expense_template_refuse_reason', - values={'reason': reason, 'is_sheet': False, 'name': self.name}, - subtype_xmlid='mail.mt_comment', - ) + for expense in self: + expense.sheet_id.message_post_with_source( + 'hr_expense.hr_expense_template_refuse_reason', + render_values={'reason': reason, 'is_sheet': False, 'name': expense.name}, + subtype_xmlid='mail.mt_comment', + ) @api.model def get_expense_dashboard(self): @@ -1218,10 +1219,10 @@ class HrExpenseSheet(models.Model): self.write({'state': 'cancel'}) subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for sheet in self: - sheet.message_post_with_view( + sheet.message_post_with_source( 'hr_expense.hr_expense_template_refuse_reason', subtype_id=subtype_id, - values={'reason': reason, 'is_sheet': True, 'name': sheet.name}, + render_values={'reason': reason, 'is_sheet': True, 'name': sheet.name}, ) self.activity_update() diff --git a/addons/hr_recruitment/models/hr_employee.py b/addons/hr_recruitment/models/hr_employee.py index 4013d5ceebc..2ea3ad7af52 100644 --- a/addons/hr_recruitment/models/hr_employee.py +++ b/addons/hr_recruitment/models/hr_employee.py @@ -27,9 +27,9 @@ class HrEmployee(models.Model): employees = super().create(vals_list) for employee in employees: if employee.applicant_id: - employee.applicant_id.message_post_with_view( + employee.applicant_id.message_post_with_source( 'hr_recruitment.applicant_hired_template', - values={'applicant': employee.applicant_id}, + render_values={'applicant': employee.applicant_id}, subtype_xmlid='hr_recruitment.mt_applicant_hired', ) return employees diff --git a/addons/hr_recruitment/wizard/applicant_refuse_reason.py b/addons/hr_recruitment/wizard/applicant_refuse_reason.py index d44413b3d94..b7aea323786 100644 --- a/addons/hr_recruitment/wizard/applicant_refuse_reason.py +++ b/addons/hr_recruitment/wizard/applicant_refuse_reason.py @@ -45,9 +45,16 @@ class ApplicantGetRefuseReason(models.TransientModel): self.applicant_ids.write({'refuse_reason_id': self.refuse_reason_id.id, 'active': False}) if self.send_mail: applicants = self.applicant_ids.filtered(lambda x: x.email_from or x.partner_id.email) - applicants.with_context(active_test=True).message_post_with_template( - self.template_id.id, - auto_delete_message=True, - email_layout_xmlid='mail.mail_notification_light', - subtype_xmlid='mail.mt_note', - ) + # TDE note: keeping 16.0 behavior, clean me please + message_values = { + 'auto_delete_message' : True, + 'email_layout_xmlid' : 'mail.mail_notification_light', + } + if len(applicants) > 1: + applicants.with_context(active_test=True).message_mail_with_source(self.template_id, **message_values) + else: + applicants.with_context(active_test=True).message_post_with_source( + self.template_id, + subtype_xmlid='mail.mt_note', + **message_values + ) diff --git a/addons/hr_work_entry_holidays/models/hr_contract.py b/addons/hr_work_entry_holidays/models/hr_contract.py index 2b608ce38df..7410225015e 100644 --- a/addons/hr_work_entry_holidays/models/hr_contract.py +++ b/addons/hr_work_entry_holidays/models/hr_contract.py @@ -134,9 +134,9 @@ class HrContract(models.Model): ).create(all_new_leave_vals) new_leaves.filtered(lambda l: l.state in 'validate')._validate_leave_request() for index, new_leave in enumerate(new_leaves): - new_leave.message_post_with_view( + new_leave.message_post_with_source( 'mail.message_origin_link', - values={'self': new_leave, 'origin': all_new_leave_origin[index]}, + render_values={'self': new_leave, 'origin': all_new_leave_origin[index]}, subtype_xmlid='mail.mt_note', ) return super(HrContract, self - specific_contracts).write(vals) diff --git a/addons/mail/models/ir_actions_server.py b/addons/mail/models/ir_actions_server.py index 87a7e24f651..2192e3eb34b 100644 --- a/addons/mail/models/ir_actions_server.py +++ b/addons/mail/models/ir_actions_server.py @@ -207,12 +207,10 @@ class ServerActions(models.Model): subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') else: subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') - for record in records: - record.message_post_with_template( - self.template_id.id, - composition_mode='comment', - subtype_id=subtype_id, - ) + records.message_post_with_source( + self.template_id, + subtype_id=subtype_id, + ) else: template = self.template_id.with_context(cleaned_ctx) for res_id in res_ids: diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 593c170c10e..824a0a1179b 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -574,16 +574,16 @@ class MailActivity(models.Model): next_activities_values.append(vals) # post message on activity, before deleting it - activity_message = record.message_post_with_view( + activity_message = record.message_post_with_source( 'mail.message_activity_done', - values={ + attachment_ids=attachment_ids, + render_values={ 'activity': activity, 'feedback': feedback, 'display_assignee': activity.user_id != self.env.user }, - subtype_xmlid='mail.mt_activities', mail_activity_type_id=activity.activity_type_id.id, - attachment_ids=[Command.link(attachment_id) for attachment_id in attachment_ids] if attachment_ids else [], + subtype_xmlid='mail.mt_activities', ) # Moving the attachments in the message diff --git a/addons/mail/models/mail_activity_mixin.py b/addons/mail/models/mail_activity_mixin.py index 3d5fcb94e46..9cc3c396fc7 100644 --- a/addons/mail/models/mail_activity_mixin.py +++ b/addons/mail/models/mail_activity_mixin.py @@ -342,12 +342,10 @@ class MailActivityMixin(models.AbstractModel): template = self.env['mail.template'].browse(template_id).exists() if not template: return False - for record in self: - record.message_post_with_template( - template_id, - composition_mode='comment', - subtype_xmlid='mail.mt_comment', - ) + self.message_post_with_source( + template, + subtype_xmlid='mail.mt_comment', + ) return True def activity_search(self, act_type_xmlids='', user_id=None, additional_domain=None): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 5cc47b65136..d9b0498e65f 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -635,7 +635,7 @@ class MailThread(models.AbstractModel): :param dict changes: mapping {record_id: (changed_field_names, tracking_value_ids)} containing existing records only """ - if not changes: + if not self or not changes: return True # Clean the context to get rid of residual default_* keys # that could cause issues afterward during the mail.message @@ -644,15 +644,18 @@ class MailThread(models.AbstractModel): # its creation, but could refer to wrong parent message id, # leading to a traceback in case the related message_id # doesn't exist - self = self.with_context(clean_context(self._context)) + cleaned_self = self.with_context(clean_context(self._context))._fallback_lang() templates = self._track_template(changes) + default_composition_mode = 'mass_mail' if len(self) != 1 else 'comment' for _field_name, (template, post_kwargs) in templates.items(): if not template: continue - if isinstance(template, str): - self._fallback_lang().message_post_with_view(template, **post_kwargs) + + composition_mode = post_kwargs.pop('composition_mode', default_composition_mode) + if composition_mode == 'mass_mail': + cleaned_self.message_mail_with_source(template, **post_kwargs) else: - self._fallback_lang().message_post_with_template(template.id, **post_kwargs) + cleaned_self.message_post_with_source(template, **post_kwargs) return True def _track_template(self, changes): @@ -1813,6 +1816,8 @@ class MailThread(models.AbstractModel): :param int author_id: optional ID of partner record being the author. See ``_message_compute_author`` that uses it to make email_from / author_id coherent; :param int parent_id: handle thread formation + :param str subtype_xmlid: optional xml id of a mail.message.subtype to + fetch, will force value of subtype_id; :param int subtype_id: subtype_id of the message, used mainly for followers notification mechanism; :param list(int) partner_ids: partner_ids to notify in addition to partners @@ -1909,9 +1914,9 @@ class MailThread(models.AbstractModel): 'res_id': self.id, # content 'body': body, - 'subject': subject or False, 'message_type': message_type, 'parent_id': self._message_compute_parent_id(parent_id), + 'subject': subject or False, 'subtype_id': subtype_id, # recipients 'partner_ids': partner_ids, @@ -2094,95 +2099,203 @@ class MailThread(models.AbstractModel): # MESSAGE POST API / WRAPPERS # ------------------------------------------------------------ - def _message_compose_with_view(self, views_or_xmlid, message_log=False, **kwargs): - """ Helper method to send a mail / post a message / log a note using - a view_id to render using the ir.qweb engine. This method is stand - alone, because there is nothing in template and composer that allows - to handle views in batch. This method should probably disappear when - templates handle ir ui views. """ - values = kwargs.pop('values', None) or dict() - try: - from odoo.addons.http_routing.models.ir_http import slug - values['slug'] = slug - except ImportError: - values['slug'] = lambda self: self.id - view_ref = views_or_xmlid.id if isinstance(views_or_xmlid, models.BaseModel) else views_or_xmlid + def message_mail_with_source(self, source_ref, render_values=None, + message_type='notification', + auto_commit=False, + **kwargs): + """ Send a mass mail on self, using an external source to render part + of the content. It can be either a 'mail.template', either a view used + to render the body using QWeb. - messages_as_sudo = self.env['mail.message'] - for record in self: - values['object'] = record - rendered_template = self.env['ir.qweb']._render(view_ref, values, minimal_qcontext=True, raise_if_not_found=False) - if not rendered_template: - continue - if message_log: - messages_as_sudo += record._message_log(body=rendered_template, **kwargs) - else: - kwargs['body'] = rendered_template - # a bit complicated to handle, to be improved soon in master - _mails_as_sudo, _messages_as_sudo = record.message_post_with_template(False, **kwargs) - if _messages_as_sudo: - messages_as_sudo += _messages_as_sudo + SPOILER: this method currently calls a composer in a loop when using + a view even if it is suboptimal. This is due to current composer + implementation.. This will be cleaned soon to optimize mass mailing + through mail.thread and lessen usage of composer itself. - return messages_as_sudo + Default values + * subtype_id: will be False, forced by composer in mass mode; - def message_post_with_view(self, views_or_xmlid, **kwargs): - """ Helper method to send a mail / post a message using a view_id """ - return self._message_compose_with_view(views_or_xmlid, **kwargs) + :param record/str source_ref: reference to a source for rendering. + It can be one of + * a MailTemplate record. It will be used to render the various + message values (body, subject, recipients, ...). It should behave + like using the mail composer with a template; + * an IrUIView record. It will be used to render the content + (body). Other fields are left to the caller and/or default values + computation; + * an XmlID of a MailTemplate or of an IrUiView: see above; + :param dict render_values: additional rendering values for qweb context; - def message_post_with_template(self, template_id, email_layout_xmlid=None, auto_commit=False, **kwargs): - """ Helper method to send a mail with a template - :param template_id : the id of the template to render to create the body of the message - :param **kwargs : parameter to create a mail.compose.message woaerd (which inherit from mail.message) + :param str message_type: one of 'notification' or 'comment'; + :param bool auto_commit: auto commit after each batch of emails sent + (see ``MailComposer._action_send_mail()``); + :param dict kwargs: additional values given to the 'mail.compose.message' + creation; + + :return: created mail.mail records, as sudo """ + template, view = self._get_source_from_ref(source_ref) + # preliminary value safety check self._raise_for_invalid_parameters( set(kwargs.keys()), - forbidden_names={'model', 'res_id'} + forbidden_names={'body', 'composition_mode', 'model', 'res_id', 'values'} ) - # Get composition mode, or force it according to the number of record in self - if not kwargs.get('composition_mode'): - kwargs['composition_mode'] = 'comment' if len(self.ids) == 1 else 'mass_mail' - if not kwargs.get('message_type'): - kwargs['message_type'] = 'notification' - res_id = self.ids[0] if self.ids else 0 - res_ids = self.ids + # with a view, render bodies in batch (template is managed by composer) + bodies = self.env['mail.render.mixin']._render_template_qweb_view( + view, + self._name, + self.ids, + add_context=render_values, + ) if view else {} - # support xml based subtype id - if kwargs.get('subtype_xmlid') and not kwargs.get('subtype_id'): - kwargs['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id(kwargs.pop('subtype_xmlid')) + # Prepare composer values for creation + composer_values = { + 'composition_mode': 'mass_mail', + 'message_type': message_type, + # subtype is not really used in mass mail mode as it is used mainly + # when posting, but keep it in case it is used in post send + 'subtype_id': kwargs.pop('subtype_id', False) or self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + **kwargs, + } + composer_ctx = { + 'default_composition_mode': 'mass_mail', + 'default_model': self._name, + 'default_template_id': template.id if template else False, + } - # Create the composer - composer = self.env['mail.compose.message'].with_context( - active_id=res_id, - active_ids=res_ids, - active_model=self._name, - default_composition_mode=kwargs['composition_mode'], - default_email_layout_xmlid=email_layout_xmlid, - default_model=self._name, - default_res_id=res_id, - default_template_id=template_id, - ).create(kwargs) - # Simulate the onchange (like trigger in form the view) only - # when having a template in single-email mode - if template_id: - update_values = composer._onchange_template_id( - template_id, - kwargs['composition_mode'], - self._name, - res_id - )['value'] - composer.write(update_values) - return composer._action_send_mail(auto_commit=auto_commit) + mails_su = self.env['mail.mail'].sudo() + for subset in [self] if template else self: + composer_ctx['active_ids'] = subset.ids # still required for mass mail mode + if template: + composer_ctx['default_res_id'] = subset.ids[0] if subset.ids else 0 + else: + composer_values['body'] = bodies[subset.id] + composer_ctx['default_res_id'] = subset.id + + composer = self.env['mail.compose.message'].with_context( + **composer_ctx + ).create(composer_values) + # Simulate the onchange (like trigger in form the view) only + # when having a template in single-email mode + if template: + update_values = composer._onchange_template_id( + template.id, + composer_values['composition_mode'], + self._name, + subset.ids[0] if subset.ids else 0 + )['value'] + composer.write(update_values) + + mails_as_sudo, _messages_as_sudo = composer._action_send_mail(auto_commit=auto_commit) + mails_su += mails_as_sudo + return mails_su + + def message_post_with_source(self, source_ref, render_values=None, + message_type='notification', + subtype_xmlid=False, subtype_id=False, + **kwargs): + """ Post a message on each record of self, using a view to render the + body using QWeb. + + Default values + * subtype_id: if not given, fallback on ``note`` to be consistent + with what message_post does; + + :param record/str source_ref: reference to a source for rendering. + It can be one of + * a MailTemplate record. It will be used to render the various + message values (body, subject, recipients, ...). It should behave + like using the mail composer with a template; + * an IrUIView record. It will be used to render the content + (body). Other fields are left to the caller and/or default values + computation; + * an XmlID of a MailTemplate or of an IrUiView: see above + :param dict render_values: additional rendering values for qweb context; + + :param str message_type: one of 'notification' or 'comment'; + :param str subtype_xmlid: optional xml id of a mail.message.subtype to + fetch, will force value of subtype_id; + :param int subtype_id: subtype_id of the message, used mainly for followers + notification mechanism; + :param dict kwargs: additional values given to the 'mail.compose.message' + creation; + + :return: posted mail.message records + """ + template, view = self._get_source_from_ref(source_ref) + + # preliminary value safety check + self._raise_for_invalid_parameters( + set(kwargs.keys()), + forbidden_names={'body', 'composition_mode', 'model', 'res_id', 'values'} + ) + + # with a view, render bodies in batch (template is managed by composer) + bodies = self.env['mail.render.mixin']._render_template_qweb_view( + view, + self._name, + self.ids, + add_context=render_values, + ) if view else {} + + # Prepare composer values for creation + if subtype_xmlid: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) + if not subtype_id: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + + messages = self.env['mail.message'] + for record in self: + if template: + composer = self.env['mail.compose.message'].with_context( + active_ids=record.ids, # still required for mass mail mode + default_composition_mode='comment', + default_model=self._name, + default_res_id=record.id, + default_template_id=template.id, + ).create({ + 'message_type': message_type, + 'subtype_id': subtype_id, + **kwargs, + }) + + # Simulate the onchange (like trigger in form the view) to do the + # rendering in mono-record mode + update_values = composer._onchange_template_id( + template.id, + 'comment', + self._name, + record.id, + )['value'] + composer.write(update_values) + _mails_as_sudo, messages_as_sudo = composer._action_send_mail() + messages += messages_as_sudo + else: + messages += record.message_post( + body=bodies[record.id], + message_type=message_type, + subtype_id=subtype_id, + **kwargs + ) + return messages def message_notify(self, *, partner_ids=False, parent_id=False, model=False, res_id=False, author_id=None, email_from=None, body='', subject=False, subtype_xmlid=None, **kwargs): - """ Shortcut allowing to notify partners of messages that shouldn't be - displayed on a document. It pushes notifications on inbox or by email depending - on the user configuration, like other notifications. """ + """ Shortcut allowing to notify partners of messages that should not be + displayed on a document. It pushes notifications on inbox or by email + depending on the user configuration, like other notifications. + + Default values + * subtype_id: if not given, fallback on ``note`` to be consistent + with what message_post does; + + :return: posted mail.message records + """ if self: self.ensure_one() if not partner_ids: @@ -2245,47 +2358,51 @@ class MailThread(models.AbstractModel): self._notify_thread(new_message, msg_values, **notif_kwargs) return new_message - def _message_log_with_view(self, views_or_xmlid, **kwargs): - """ Helper method to log a note using a view_id without notifying followers. """ - return self._message_compose_with_view(views_or_xmlid, message_log=True, **kwargs) + def _message_log_with_view(self, view_ref, render_values=None, + message_type='notification', **kwargs): + """ Log a message on each record of self, using a view to render the + body using QWeb. + + :param str/int/record view_ref: source QWeb template. It should be an + XmlID allowing to fetch an ``ir.ui.view``, or an ID of a view or + an ``ir.ui.view`` record; + :param dict render_values: additional rendering values for qweb context; + :param str message_type: one of 'notification' or 'comment'; + :param kwargs: additional values propagated to ``_message_log``; + + :return: posted mail.message records (as sudo) + """ + self._raise_for_invalid_parameters( + set(kwargs.keys()), + forbidden_names={'body', 'bodies'} + ) + + # with a view, render bodies in batch (template is managed by composer) + bodies = self.env['mail.render.mixin']._render_template_qweb_view( + view_ref, + self._name, + self.ids, + add_context=render_values, + ) + + return self._message_log_batch( + bodies=bodies, + message_type=message_type, + **kwargs + ) def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', attachment_ids=False, tracking_value_ids=False): - """ Shortcut allowing to post note on a document. It does not perform - any notification and pre-computes some values to have a short code - as optimized as possible. This method is private as it does not check - access rights and perform the message creation as sudo to speedup - the log process. This method should be called within methods where - access rights are already granted to avoid privilege escalation. - - :return: created message (as sudo)""" + """ Shortcut allowing to post note on a document. See ``_message_log_batch`` + for more details. """ self.ensure_one() - author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=False) - msg_values = { - # author - 'author_id': author_id, - 'email_from': email_from, - # document - 'model': self._name, - 'record_name': False, - 'res_id': self.id, - # content - 'attachment_ids': attachment_ids, - 'body': body, - 'is_internal': True, - 'message_type': message_type, - 'subject': subject, - 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - 'tracking_value_ids': tracking_value_ids, - # recipients - 'email_add_signature': False, # False as no notification -> no need to compute signature - 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify - 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False], - } - - return self.sudo()._message_create([msg_values]) + return self._message_log_batch( + {self.id: body}, author_id=author_id, email_from=email_from, + subject=subject, message_type=message_type, + attachment_ids=attachment_ids, tracking_value_ids=tracking_value_ids + ) def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, message_type='notification', @@ -2326,6 +2443,7 @@ class MailThread(models.AbstractModel): 'message_id': tools.generate_tracking_message_id('message-notify'), # why? this is all but a notify 'reply_to': self.env['mail.thread']._notify_get_reply_to(default=email_from)[False], } + values_list = [dict(base_message_values, res_id=record.id, body=bodies.get(record.id, '')) @@ -2401,6 +2519,8 @@ class MailThread(models.AbstractModel): return self.name_get()[0][1] def _message_create(self, values_list): + """ Low-level helper to create mail.message records. It is mainly used + to hide the cleanup of given values, for mail gateway or helpers.""" create_values_list = [] # preliminary value safety check @@ -2455,6 +2575,61 @@ class MailThread(models.AbstractModel): 'tracking_value_ids', } + def _get_source_from_ref(self, source_ref): + """ From a source_reference, return either a mail template, either + an ir ui view. + + :return tuple(template, view): one is a recordset (may be void if + source_ref is a void recordset, or a singleton), the other one is + False. Always only one is set, as source is either a template, + either a view. + """ + template, view = False, False + if isinstance(source_ref, models.BaseModel): + if source_ref._name == 'mail.template': + template = source_ref + elif source_ref._name == 'ir.ui.view': + view = source_ref + else: + raise ValueError( + _('Invalid template or view source record %(svalue)s, is %(model)s instead', + svalue=source_ref, + model=source_ref._name, + )) + if not template and not view: + raise ValueError( + _('Mailing or posting with a source should not be called with an empty %(source_type)s', + source_type=_('template') if template is not False else _('view')) + ) + elif isinstance(source_ref, str): + try: + res_model, res_id = self.env['ir.model.data']._xmlid_to_res_model_res_id( + source_ref, + raise_if_not_found=True + ) + except ValueError as e: + raise ValueError( + _('Invalid template or view source Xml ID %(source_ref)s does not exist anymore', + source_ref=source_ref) + ) from e + if res_model == 'mail.template': + template = self.env['mail.template'].browse(res_id) + elif res_model == 'ir.ui.view': + view = self.env['ir.ui.view'].browse(res_id) + else: + raise ValueError( + _('Invalid template or view source reference %(svalue)s, is %(model)s instead', + svalue=source_ref, + model=res_model, + )) + else: + raise ValueError( + _('Invalid template or view source %(svalue)s (type %(stype)s), should be a record or an XMLID', + svalue=source_ref, + stype=type(source_ref), + )) + return template, view + def _get_notify_valid_parameters(self): """ Several parameters exist for notification methods as business flows often want to customize the standard notification experience. diff --git a/addons/mail_group/models/mail_group.py b/addons/mail_group/models/mail_group.py index 42aba8fb070..5f3bbde3901 100644 --- a/addons/mail_group/models/mail_group.py +++ b/addons/mail_group/models/mail_group.py @@ -314,7 +314,7 @@ class MailGroup(models.Model): values )) - mail_message = Mailthread._message_create(values) + mail_message = Mailthread._message_create([values]) # Find the parent group_message_parent_id = False diff --git a/addons/mail_plugin/controllers/mail_plugin.py b/addons/mail_plugin/controllers/mail_plugin.py index 22a5506302a..4ca6c7e426f 100644 --- a/addons/mail_plugin/controllers/mail_plugin.py +++ b/addons/mail_plugin/controllers/mail_plugin.py @@ -117,9 +117,9 @@ class MailPluginController(http.Controller): partner.write(partner_values) - partner.message_post_with_view( + partner.message_post_with_source( 'iap_mail.enrich_company', - values=iap_data, + render_values=iap_data, subtype_xmlid='mail.mt_note', ) @@ -357,9 +357,9 @@ class MailPluginController(http.Controller): new_company = request.env['res.partner'].create(new_company_info) - new_company.message_post_with_view( + new_company.message_post_with_source( 'iap_mail.enrich_company', - values=iap_data, + render_values=iap_data, subtype_xmlid='mail.mt_note', ) diff --git a/addons/mass_mailing/models/mail_thread.py b/addons/mass_mailing/models/mail_thread.py index 7c98349dab6..78fe220f218 100644 --- a/addons/mass_mailing/models/mail_thread.py +++ b/addons/mass_mailing/models/mail_thread.py @@ -28,13 +28,21 @@ class MailThread(models.AbstractModel): self.env['mailing.trace'].set_replied(domain=[('message_id', 'in', msg_references)]) return super(MailThread, self)._message_route_process(message, message_dict, routes) - def message_post_with_template(self, template_id, **kwargs): + def message_mail_with_source(self, source_ref, **kwargs): # avoid having message send through `message_post*` methods being implicitly considered as # mass-mailing return super(MailThread, self.with_context( default_mass_mailing_name=False, default_mass_mailing_id=False, - )).message_post_with_template(template_id, **kwargs) + )).message_mail_with_source(source_ref, **kwargs) + + def message_post_with_source(self, source_ref, **kwargs): + # avoid having message send through `message_post*` methods being implicitly considered as + # mass-mailing + return super(MailThread, self.with_context( + default_mass_mailing_name=False, + default_mass_mailing_id=False, + )).message_post_with_source(source_ref, **kwargs) @api.model def _routing_handle_bounce(self, email_message, message_dict): diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index 58e5c7d0a56..44403e8086a 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -65,15 +65,15 @@ class StockRule(models.Model): subtype_id=note_subtype_id ) elif orderpoint: - production.message_post_with_view( + production.message_post_with_source( 'mail.message_origin_link', - values={'self': production, 'origin': orderpoint}, + render_values={'self': production, 'origin': orderpoint}, subtype_id=note_subtype_id, ) elif origin_production: - production.message_post_with_view( + production.message_post_with_source( 'mail.message_origin_link', - values={'self': production, 'origin': origin_production}, + render_values={'self': production, 'origin': origin_production}, subtype_id=note_subtype_id, ) return True diff --git a/addons/partner_autocomplete/models/res_company.py b/addons/partner_autocomplete/models/res_company.py index b033898e3a7..b2c52162548 100644 --- a/addons/partner_autocomplete/models/res_company.py +++ b/addons/partner_autocomplete/models/res_company.py @@ -81,9 +81,9 @@ class ResCompany(models.Model): if additional_data: template_values = json.loads(additional_data) template_values['flavor_text'] = _("Company auto-completed by Odoo Partner Autocomplete Service") - self.partner_id.message_post_with_view( + self.partner_id.message_post_with_source( 'iap_mail.enrich_company', - values=template_values, + render_values=template_values, subtype_xmlid='mail.mt_note', ) return True diff --git a/addons/partner_autocomplete/models/res_partner.py b/addons/partner_autocomplete/models/res_partner.py index 8de11eeaa09..6bef55c8787 100644 --- a/addons/partner_autocomplete/models/res_partner.py +++ b/addons/partner_autocomplete/models/res_partner.py @@ -166,9 +166,9 @@ class ResPartner(models.Model): if partners.additional_info: template_values = json.loads(partners.additional_info) template_values['flavor_text'] = _("Partner created by Odoo Partner Autocomplete Service") - partners.message_post_with_view( + partners.message_post_with_source( 'iap_mail.enrich_company', - values=template_values, + render_values=template_values, subtype_xmlid='mail.mt_note', ) partners.write({'additional_info': False}) diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py index 1bc76e32418..2d942afd964 100644 --- a/addons/portal/wizard/portal_share.py +++ b/addons/portal/wizard/portal_share.py @@ -64,14 +64,14 @@ class PortalShare(models.TransientModel): share_link = self.resource_ref.get_base_url() + self.resource_ref._get_share_url(redirect=True, pid=partner.id) saved_lang = self.env.lang self = self.with_context(lang=partner.lang) - template = self.env.ref('portal.portal_share_template', False) - self.resource_ref.message_post_with_view(template, - values={'partner': partner, 'note': self.note, 'record': self.resource_ref, + self.resource_ref.message_post_with_source( + 'portal.portal_share_template', + render_values={'partner': partner, 'note': self.note, 'record': self.resource_ref, 'share_link': share_link}, subject=_("You are invited to access %s", self.resource_ref.display_name), subtype_xmlid='mail.mt_note', email_layout_xmlid='mail.mail_notification_light', - partner_ids=[(6, 0, partner.ids)]) + partner_ids=partner.ids) self = self.with_context(lang=saved_lang) def _send_signup_link(self, partners=None): @@ -83,14 +83,14 @@ class PortalShare(models.TransientModel): share_link = partner._get_signup_url_for_action(action='/mail/view', res_id=self.res_id, model=self.res_model)[partner.id] saved_lang = self.env.lang self = self.with_context(lang=partner.lang) - template = self.env.ref('portal.portal_share_template', False) - self.resource_ref.message_post_with_view(template, - values={'partner': partner, 'note': self.note, 'record': self.resource_ref, + self.resource_ref.message_post_with_source( + 'portal.portal_share_template', + render_values={'partner': partner, 'note': self.note, 'record': self.resource_ref, 'share_link': share_link}, subject=_("You are invited to access %s", self.resource_ref.display_name), subtype_xmlid='mail.mt_note', email_layout_xmlid='mail.mail_notification_light', - partner_ids=[(6, 0, partner.ids)]) + partner_ids=partner.ids) self = self.with_context(lang=saved_lang) def action_send_mail(self): diff --git a/addons/product_email_template/models/account_move.py b/addons/product_email_template/models/account_move.py index 0c240542111..798b650f6dc 100644 --- a/addons/product_email_template/models/account_move.py +++ b/addons/product_email_template/models/account_move.py @@ -18,9 +18,8 @@ class AccountMove(models.Model): comment_subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for line in invoice.invoice_line_ids: if line.product_id.email_template_id: - invoice.message_post_with_template( - line.product_id.email_template_id.id, - composition_mode="comment", + invoice.message_post_with_source( + line.product_id.email_template_id, email_layout_xmlid="mail.mail_notification_light", subtype_id=comment_subtype_id, ) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index d8ff5c665ef..108e28d89f1 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -750,8 +750,8 @@ class PurchaseOrder(models.Model): if send_single: return order._send_reminder_open_composer(template.id) else: - order.with_context(is_reminder=True).message_post_with_template( - template.id, + order.with_context(is_reminder=True).message_post_with_source( + template, email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", subtype_xmlid='mail.mt_comment', ) @@ -1104,9 +1104,9 @@ class PurchaseOrderLine(models.Model): if 'product_qty' in values: for line in self: if line.order_id.state == 'purchase': - line.order_id.message_post_with_view( + line.order_id.message_post_with_source( 'purchase.track_po_line_template', - values={'line': line, 'product_qty': values['product_qty']}, + render_values={'line': line, 'product_qty': values['product_qty']}, subtype_xmlid='mail.mt_note', ) if 'qty_received' in values: @@ -1425,9 +1425,9 @@ class PurchaseOrderLine(models.Model): def _track_qty_received(self, new_qty): self.ensure_one() if new_qty != self.qty_received and self.order_id.state == 'purchase': - self.order_id.message_post_with_view( + self.order_id.message_post_with_source( 'purchase.track_po_line_qty_received_template', - values={'line': self, 'qty_received': new_qty}, + render_values={'line': self, 'qty_received': new_qty}, subtype_xmlid='mail.mt_note', ) diff --git a/addons/purchase_requisition/models/purchase.py b/addons/purchase_requisition/models/purchase.py index 9f36e77e47b..f771d99c8d1 100644 --- a/addons/purchase_requisition/models/purchase.py +++ b/addons/purchase_requisition/models/purchase.py @@ -144,9 +144,9 @@ class PurchaseOrder(models.Model): self.env['purchase.order.group'].create({'order_ids': [Command.set(origin_po_id.ids + orders.ids)]}) for order in orders: if order.requisition_id: - order.message_post_with_view( + order.message_post_with_source( 'mail.message_origin_link', - values={'self': order, 'origin': order.requisition_id}, + render_values={'self': order, 'origin': order.requisition_id}, subtype_xmlid='mail.mt_note', ) return orders @@ -158,9 +158,9 @@ class PurchaseOrder(models.Model): result = super(PurchaseOrder, self).write(vals) if vals.get('requisition_id'): for order in self: - order.message_post_with_view( + order.message_post_with_source( 'mail.message_origin_link', - values={'self': order, 'origin': order.requisition_id, 'edit': True}, + render_values={'self': order, 'origin': order.requisition_id, 'edit': True}, subtype_xmlid='mail.mt_note', ) if vals.get('alternative_po_ids', False): diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 0ce54cb39ab..642dc4212dc 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -251,8 +251,9 @@ class PurchaseOrder(models.Model): # Get following pickings (created by push rules) to confirm them as well. forward_pickings = self.env['stock.picking']._get_impacted_pickings(moves) (pickings | forward_pickings).action_confirm() - picking.message_post_with_view('mail.message_origin_link', - values={'self': picking, 'origin': order}, + picking.message_post_with_source( + 'mail.message_origin_link', + render_values={'self': picking, 'origin': order}, subtype_xmlid='mail.mt_note', ) return True diff --git a/addons/rating/models/rating_mixin.py b/addons/rating/models/rating_mixin.py index 6c60ecda73a..599fe908804 100644 --- a/addons/rating/models/rating_mixin.py +++ b/addons/rating/models/rating_mixin.py @@ -181,12 +181,11 @@ class RatingMixin(models.AbstractModel): """ if lang: template = template.with_context(lang=lang) - for record in self.with_context(mail_notify_force_send=force_send): - record.message_post_with_template( - template.id, - email_layout_xmlid='mail.mail_notification_light', - subtype_xmlid='mail.mt_note', - ) + self.with_context(mail_notify_force_send=force_send).message_post_with_source( + template, + email_layout_xmlid='mail.mail_notification_light', + subtype_xmlid='mail.mt_note', + ) def rating_apply(self, rate, token=None, rating=None, feedback=None, subtype_xmlid=None, notify_delay_send=False): diff --git a/addons/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py index a5364d45a2e..4ad6a75cb54 100644 --- a/addons/sale/models/payment_transaction.py +++ b/addons/sale/models/payment_transaction.py @@ -131,6 +131,9 @@ class PaymentTransaction(models.Model): )) if not template_id: return + template = self.env['mail.template'].browse(template_id).exists() + if not template: + return for tx in self: tx = tx.with_company(tx.company_id).with_context( @@ -140,12 +143,11 @@ class PaymentTransaction(models.Model): lambda i: not i.is_move_sent and i.state == 'posted' and i._is_ready_to_be_sent() ) invoice_to_send.is_move_sent = True # Mark invoice as sent - for invoice in invoice_to_send.with_user(SUPERUSER_ID): - invoice.message_post_with_template( - template_id, - email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', - subtype_xmlid='mail.mt_comment', - ) + invoice_to_send.with_user(SUPERUSER_ID).message_post_with_source( + template, + email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', + subtype_xmlid='mail.mt_comment', + ) def _cron_send_invoice(self): """ diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index caa59ffa6b4..cc506c77b41 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -842,8 +842,8 @@ class SaleOrder(models.Model): mail_template = sale_order._get_confirmation_template() if not mail_template: continue - sale_order.with_context(force_send=True).message_post_with_template( - mail_template.id, + sale_order.with_context(force_send=True).message_post_with_source( + mail_template, email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', subtype_xmlid='mail.mt_comment', ) @@ -1181,9 +1181,9 @@ class SaleOrder(models.Model): if final: moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_move_type() for move in moves: - move.message_post_with_view( + move.message_post_with_source( 'mail.message_origin_link', - values={'self': move, 'origin': move.line_ids.sale_line_ids.order_id}, + render_values={'self': move, 'origin': move.line_ids.sale_line_ids.order_id}, subtype_xmlid='mail.mt_note', ) return moves diff --git a/addons/sale/tests/test_controllers.py b/addons/sale/tests/test_controllers.py index 5c828e479bf..3dba4c73252 100644 --- a/addons/sale/tests/test_controllers.py +++ b/addons/sale/tests/test_controllers.py @@ -87,8 +87,8 @@ class TestSaleSignature(HttpCaseWithUserPortal): # must be sent to the user so he can see it email_act = sales_order.action_quotation_send() email_ctx = email_act.get('context', {}) - sales_order.with_context(**email_ctx).message_post_with_template( - email_ctx.get('default_template_id'), + sales_order.with_context(**email_ctx).message_post_with_source( + self.env['mail.template'].browse(email_ctx.get('default_template_id')), subtype_xmlid='mail.mt_comment', ) diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index aa38876de75..22a8b31c318 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -79,8 +79,8 @@ class TestSaleOrder(SaleCommon): # send quotation email_act = self.sale_order.action_quotation_send() email_ctx = email_act.get('context', {}) - self.sale_order.with_context(**email_ctx).message_post_with_template( - email_ctx.get('default_template_id'), + self.sale_order.with_context(**email_ctx).message_post_with_source( + self.env['mail.template'].browse(email_ctx.get('default_template_id')), subtype_xmlid='mail.mt_comment', ) self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong') @@ -104,8 +104,8 @@ class TestSaleOrder(SaleCommon): # sent to to author or not (in case author is present in 'Recipients' of composer). mail_template = self.env['mail.template'].browse(email_ctx.get('default_template_id')).copy({'auto_delete': False}) # send the mail with same user as customer - sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_template( - mail_template.id, + sale_order.with_context(**email_ctx).with_user(self.sale_user).message_post_with_source( + mail_template, subtype_xmlid='mail.mt_comment', ) self.assertTrue(sale_order.state == 'sent', 'Sale : state should be changed to sent') diff --git a/addons/sale/tests/test_sale_to_invoice.py b/addons/sale/tests/test_sale_to_invoice.py index c205ae94fc6..a7851b69451 100644 --- a/addons/sale/tests/test_sale_to_invoice.py +++ b/addons/sale/tests/test_sale_to_invoice.py @@ -632,8 +632,8 @@ class TestSaleToInvoice(TestSaleCommon): # send quotation email_act = self.sale_order.action_quotation_send() email_ctx = email_act.get('context', {}) - self.sale_order.with_context(**email_ctx).message_post_with_template( - email_ctx.get('default_template_id'), + self.sale_order.with_context(**email_ctx).message_post_with_source( + self.env['mail.template'].browse(email_ctx.get('default_template_id')), subtype_xmlid='mail.mt_comment', ) self.assertTrue(self.sale_order.state == 'sent', 'Sale: state after sending is wrong') diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py index 7382c645c14..501ab2e3d00 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -186,9 +186,9 @@ class SaleAdvancePaymentInv(models.TransientModel): self._prepare_invoice_values(order, down_payment_so_line) ).with_user(self.env.uid) # Unsudo the invoice after creation - invoice.message_post_with_view( + invoice.message_post_with_source( 'mail.message_origin_link', - values={'self': invoice, 'origin': order}, + render_values={'self': invoice, 'origin': order}, subtype_xmlid='mail.mt_note', ) diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 6fb7d1db1dc..50ad69b5c21 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -42,9 +42,9 @@ class StockMove(models.Model): picking_id = self.mapped('picking_id') sale_order_ids = self.mapped('sale_line_id.order_id') for sale_order_id in sale_order_ids: - picking_id.message_post_with_view( + picking_id.message_post_with_source( 'mail.message_origin_link', - values={'self': picking_id, 'origin': sale_order_id}, + render_values={'self': picking_id, 'origin': sale_order_id}, subtype_xmlid='mail.mt_note', ) diff --git a/addons/sale_stock_margin/tests/test_sale_stock_margin.py b/addons/sale_stock_margin/tests/test_sale_stock_margin.py index 77a6ca3a20e..75228136e3b 100644 --- a/addons/sale_stock_margin/tests/test_sale_stock_margin.py +++ b/addons/sale_stock_margin/tests/test_sale_stock_margin.py @@ -310,7 +310,10 @@ class TestSaleStockMargin(TestStockValuationCommon): so = so_form.save() email_act = so.action_quotation_send() email_ctx = email_act.get('context', {}) - so.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id')) + so.with_context(**email_ctx).message_post_with_source( + self.env['mail.template'].browse(email_ctx.get('default_template_id')), + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) self.assertEqual(so.state, 'sent') self.assertEqual(so.order_line[0].purchase_price, 15) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 1e57a205494..63da5af9af0 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -667,9 +667,9 @@ class StockMoveLine(models.Model): data['result_package_name'] = self.env['stock.quant.package'].browse(vals.get('result_package_id')).name if 'owner_id' in vals and vals['owner_id'] != move.owner_id.id: data['owner_name'] = self.env['res.partner'].browse(vals.get('owner_id')).name - record.message_post_with_view( + record.message_post_with_source( template, - values={'move': move, 'vals': dict(vals, **data)}, + render_values={'move': move, 'vals': dict(vals, **data)}, subtype_xmlid='mail.mt_note', ) diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index bf3668979f7..1ca08ec2d59 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -886,9 +886,9 @@ class Picking(models.Model): def _send_confirmation_email(self): subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') for stock_pick in self.filtered(lambda p: p.company_id.stock_move_email_validation and p.picking_type_id.code == 'outgoing'): - delivery_template_id = stock_pick.company_id.stock_mail_confirmation_template_id.id - stock_pick.with_context(force_send=True).message_post_with_template( - delivery_template_id, + delivery_template = stock_pick.company_id.stock_mail_confirmation_template_id + stock_pick.with_context(force_send=True).message_post_with_source( + delivery_template, email_layout_xmlid='mail.mail_notification_light', subtype_id=subtype_id, ) diff --git a/addons/stock/wizard/stock_picking_return.py b/addons/stock/wizard/stock_picking_return.py index 1b78158de32..80db63fcc54 100644 --- a/addons/stock/wizard/stock_picking_return.py +++ b/addons/stock/wizard/stock_picking_return.py @@ -133,9 +133,9 @@ class ReturnPicking(models.TransientModel): # create new picking for returned products new_picking = self.picking_id.copy(self._prepare_picking_default_values()) picking_type_id = new_picking.picking_type_id.id - new_picking.message_post_with_view( + new_picking.message_post_with_source( 'mail.message_origin_link', - values={'self': new_picking, 'origin': self.picking_id}, + render_values={'self': new_picking, 'origin': self.picking_id}, subtype_xmlid='mail.mt_note', ) returned_lines = 0 diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 389e92a54a2..2e1f05ccf0b 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -404,6 +404,7 @@ class TestMessageNotify(TestMessagePostCommon): ) @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.tests') def test_notify_parameters(self): """ Test usage of parameters in notify, both for unwanted side effects and magic parameters. """ @@ -536,7 +537,7 @@ class TestMessageLog(TestMessagePostCommon): new_notes = test_records._message_log_with_view( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id} + render_values={'partner': self.user_employee.partner_id} ) for test_record, new_note in zip(test_records, new_notes): self.assertMessageFields( @@ -1031,6 +1032,30 @@ class TestMessagePostHelpers(TestMessagePostCommon): # Force the attachments of the template to be in the natural order. cls.test_template.invalidate_recordset(['attachment_ids']) + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_helpers_source_ref(self): + """ Test various sources (record or xml id) to ensure source_ref right + computation. """ + test_records = self.test_records.with_env(self.env) + template = self.test_template.with_env(self.env) + view = self.env.ref('test_mail.mail_template_simple_test') + + for source_ref in ('test_mail.mail_test_ticket_tracking_tpl', template, + 'test_mail.mail_template_simple_test', view): + with self.subTest(source_ref=source_ref), self.mock_mail_gateway(): + _new_mails = test_records.with_user(self.user_employee).message_mail_with_source( + source_ref, + render_values={'partner': self.user_employee.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + + _new_messages = test_records.with_user(self.user_employee).message_post_with_source( + source_ref, + render_values={'partner': self.user_employee.partner_id}, + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), + ) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_mail_with_template(self): @@ -1038,9 +1063,8 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_records = self.test_records.with_env(self.env) template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mails, _new_messages = test_records.with_user(self.user_employee).message_post_with_template( - template.id, - composition_mode='mass_mail', + _new_mails = test_records.with_user(self.user_employee).message_mail_with_source( + template, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) @@ -1086,14 +1110,13 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) with self.mock_mail_gateway(): - new_messages = test_records.message_post_with_view( + new_mails = test_records.message_mail_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id}, - composition_mode='mass_mail', + render_values={'partner': self.user_employee.partner_id}, subject='About mass mailing', subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) - self.assertEqual(len(new_messages), 0) + self.assertEqual(len(new_mails), 10) self.assertEqual(len(self._new_mails), 10) # sent emails (mass mail mode) @@ -1120,6 +1143,19 @@ class TestMessagePostHelpers(TestMessagePostCommon): } ) + @users('employee') + @mute_logger('odoo.addons.mail.models.mail_mail') + def test_message_post_with_source_subtype(self): + """ Test subtype tweaks when posting with a source """ + test_record = self.test_records.with_env(self.env)[0] + test_template = self.test_template.with_env(self.env) + with self.mock_mail_gateway(): + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, + subtype_xmlid='mail.mt_activities', + ) + self.assertEqual(new_message.subtype_id, self.env.ref("mail.mt_activities")) + @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_post_with_template(self): @@ -1128,9 +1164,8 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) test_template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mail, new_message = test_record.with_user(self.user_employee).message_post_with_template( - test_template.id, - composition_mode='comment', + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, message_type='comment', subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) @@ -1165,15 +1200,15 @@ class TestMessagePostHelpers(TestMessagePostCommon): ) @users('employee') - @mute_logger('odoo.addons.mail.models.mail') + @mute_logger('odoo.addons.mail.models.mail_mail') def test_message_post_with_template_defaults(self): """ Test default values, notably subtype being a comment """ test_record = self.test_records.with_env(self.env)[0] test_record.message_subscribe(test_record.customer_id.ids) test_template = self.test_template.with_env(self.env) with self.mock_mail_gateway(): - _new_mail, new_message = test_record.with_user(self.user_employee).message_post_with_template( - test_template.id, + new_message = test_record.with_user(self.user_employee).message_post_with_source( + test_template, ) # created partners from inline email addresses @@ -1192,7 +1227,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): {'partner': new_partners[1], 'type': 'email'}, {'partner': test_record.customer_id, 'type': 'email'}, ], - 'subtype': 'mail.mt_comment', + 'subtype': 'mail.mt_note', }]) self.assertMessageFields( new_message, @@ -1213,10 +1248,10 @@ class TestMessagePostHelpers(TestMessagePostCommon): test_record.message_subscribe(test_record.customer_id.ids) with self.mock_mail_gateway(): - new_message = test_record.message_post_with_view( + new_message = test_record.message_post_with_source( 'test_mail.mail_template_simple_test', message_type='comment', - values={'partner': self.user_employee.partner_id}, + render_values={'partner': self.user_employee.partner_id}, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) @@ -1250,9 +1285,9 @@ class TestMessagePostHelpers(TestMessagePostCommon): # defaults is a note, take into account specified recipients with self.mock_mail_gateway(): - new_message = test_record.message_post_with_view( + new_message = test_record.message_post_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_employee.partner_id}, + render_values={'partner': self.user_employee.partner_id}, partner_ids=test_record.customer_id.ids, ) @@ -1263,7 +1298,7 @@ class TestMessagePostHelpers(TestMessagePostCommon): 'notif': [ {'partner': test_record.customer_id, 'type': 'email'}, ], - 'subtype': 'mail.mt_comment', + 'subtype': 'mail.mt_note', }]) self.assertMessageFields( new_message, @@ -1340,9 +1375,8 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): test_template = self.test_template.with_user(self.env.user) with self.mock_mail_gateway(): - test_record.message_post_with_template( - test_template.id, - composition_mode='comment', + test_record.message_post_with_source( + test_template, email_layout_xmlid='mail.test_layout', message_type='comment', subtype_id=self.env.ref('mail.mt_comment').id, @@ -1382,9 +1416,8 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): test_template = self.test_template.with_user(self.env.user) with self.mock_mail_gateway(): - test_records.message_post_with_template( - test_template.id, - composition_mode='mass_mail', + test_records.message_mail_with_source( + test_template, email_layout_xmlid='mail.test_layout', message_type='comment', subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), @@ -1448,13 +1481,12 @@ class TestMessagePostLang(TestMailCommon, TestRecipients): test_template = self.test_template.with_user(self.env.user) with self.mock_mail_gateway(): - for test_record in test_records: - test_record.message_post_with_template( - test_template.id, - email_layout_xmlid='mail.test_layout', - message_type='comment', - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), - ) + test_records.message_post_with_source( + test_template, + email_layout_xmlid='mail.test_layout', + message_type='comment', + subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), + ) record0_customer = self.env['res.partner'].search([('email_normalized', '=', 'test.record.1@test.customer.com')], limit=1) self.assertTrue(record0_customer, 'Template usage should have created a contact based on record email') diff --git a/addons/test_mail/tests/test_message_track.py b/addons/test_mail/tests/test_message_track.py index b593381af4d..4aea07103f6 100644 --- a/addons/test_mail/tests/test_message_track.py +++ b/addons/test_mail/tests/test_message_track.py @@ -150,7 +150,7 @@ class TestTracking(TestMailCommon): def patched_message_track_post_template(*args, **kwargs): if args[0]._name == "mail.test.track": - args[0].message_post_with_template(template.id) + args[0].message_post_with_source(template) return True with patch('odoo.addons.mail.models.mail_thread.MailThread._message_track_post_template', patched_message_track_post_template): diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index 956661d8007..c3c98458cd3 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -621,7 +621,7 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(__system__=11, employee=11): records._message_log_with_view( 'test_mail.mail_template_simple_test', - values={'partner': self.customer.with_env(self.env)} + render_values={'partner': self.customer.with_env(self.env)} ) @users('__system__', 'employee') @@ -862,14 +862,13 @@ class TestMailComplexPerformance(BaseMailPerformance): def test_complex_message_post_template(self): self.container.message_subscribe(self.user_portal.partner_id.ids) record = self.container.with_user(self.env.user) - template_id = self.env.ref('test_mail.mail_test_container_tpl').id + template = self.env.ref('test_mail.mail_test_container_tpl') # about 20 (19 ?) queries per additional customer group with self.assertQueryCount(__system__=42, employee=43): - record.message_post_with_template( - template_id, + record.message_post_with_source( + template, message_type='comment', - composition_mode='comment', subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment'), ) @@ -892,9 +891,9 @@ class TestMailComplexPerformance(BaseMailPerformance): composer._onchange_template_id_wrapper() with self.assertQueryCount(__system__=121, employee=141): - messages_as_sudo = test_records.message_post_with_view( + messages_as_sudo = test_records.message_post_with_source( 'test_mail.mail_template_simple_test', - values={'partner': self.user_test.partner_id}, + render_values={'partner': self.user_test.partner_id}, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') ) diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index d9c58799622..1089daab36e 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -221,10 +221,10 @@ class BlogPost(models.Model): def _check_for_publication(self, vals): if vals.get('is_published'): for post in self.filtered(lambda p: p.active): - post.blog_id.message_post_with_view( + post.blog_id.message_post_with_source( 'website_blog.blog_post_template_new_post', subject=post.name, - values={'post': post}, + render_values={'post': post}, subtype_xmlid='website_blog.mt_blog_blog_published', ) return True diff --git a/addons/website_crm_iap_reveal/models/crm_reveal_rule.py b/addons/website_crm_iap_reveal/models/crm_reveal_rule.py index e4d35fa39b2..441d8922c10 100644 --- a/addons/website_crm_iap_reveal/models/crm_reveal_rule.py +++ b/addons/website_crm_iap_reveal/models/crm_reveal_rule.py @@ -376,9 +376,9 @@ class CRMRevealRule(models.Model): 'flavor_text': _("Opportunity created by Odoo Lead Generation"), 'people_data': result.get('people_data'), }) - lead.message_post_with_view( + lead.message_post_with_source( 'iap_mail.enrich_company', - values=template_values, + render_values=template_values, subtype_xmlid='mail.mt_note', ) diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index fe6df7e6681..63297d69317 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -406,9 +406,9 @@ class Track(models.Model): post_values = {} if self.env.user.email else {'email_from': self.env.company.catchall_formatted} for track in tracks: - track.event_id.message_post_with_view( + track.event_id.message_post_with_source( 'website_event_track.event_track_template_new', - values={ + render_values={ 'track': track, 'is_html_empty': is_html_empty, }, diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index 6f49b658f58..83bddbd0f69 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -623,17 +623,17 @@ class Post(models.Model): tag_partners = post.tag_ids.sudo().mapped('message_partner_ids') if post.state == 'active' and post.parent_id: - post.parent_id.message_post_with_view( + post.parent_id.message_post_with_source( 'website_forum.forum_post_template_new_answer', subject=_('Re: %s', post.parent_id.name), - partner_ids=[(4, p.id) for p in tag_partners], + partner_ids=tag_partners.ids, subtype_xmlid='website_forum.mt_answer_new', ) elif post.state == 'active' and not post.parent_id: - post.message_post_with_view( + post.message_post_with_source( 'website_forum.forum_post_template_new_question', subject=post.name, - partner_ids=[(4, p.id) for p in tag_partners], + partner_ids=tag_partners.ids, subtype_xmlid='website_forum.mt_question_new', ) elif post.state == 'pending' and not post.parent_id: @@ -642,7 +642,7 @@ class Post(models.Model): partners = post.sudo().message_partner_ids | tag_partners partners = partners.filtered(lambda partner: partner.user_ids and any(user.karma >= post.forum_id.karma_moderate for user in partner.user_ids)) - post.message_post_with_view( + post.message_post_with_source( 'website_forum.forum_post_template_validation', subject=post.name, partner_ids=partners.ids, From 9bbb276c42ac0733e98f28f1a2cbccb072807ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 5 Jan 2023 14:03:18 +0000 Subject: [PATCH 19/45] [REF] mail: cleanup message_{notify, log} wrappers RATIONALE Purpose of this commit is to cleanup main post helpers and have a more easy and understandable way of calling them. SPECIFICATIONS Update methods parameters to look more like 'message_post' definition. Add docstrings to explain parameter usage. Cleanup some parameter we do not want. Add a api.returns on message_notify to wrap the value in case of external call, as this method is public, like message_post. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_thread.py | 62 ++++++++++++++----- .../tests/test_mail_thread_internals.py | 30 ++++++++- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index d9b0498e65f..e76eb1fe214 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2281,10 +2281,12 @@ class MailThread(models.AbstractModel): ) return messages + @api.returns('mail.message', lambda value: value.id) def message_notify(self, *, - partner_ids=False, parent_id=False, model=False, res_id=False, - author_id=None, email_from=None, body='', subject=False, - subtype_xmlid=None, + body='', subject=False, + author_id=None, email_from=None, + model=False, res_id=False, + subtype_xmlid=None, subtype_id=False, partner_ids=False, **kwargs): """ Shortcut allowing to notify partners of messages that should not be displayed on a document. It pushes notifications on inbox or by email @@ -2294,6 +2296,29 @@ class MailThread(models.AbstractModel): * subtype_id: if not given, fallback on ``note`` to be consistent with what message_post does; + :param str body: body of the message, usually raw HTML that will + be sanitized + :param str subject: subject of the message + :param int author_id: optional ID of partner record being the author. See + ``_message_compute_author`` that uses it to make email_from / author_id coherent; + :param str email_from: from address of the author. See ``_message_compute_author`` + that uses it to make email_from / author_id coherent; + :param str model: when invoked on MailThread directly, this method + allows to push a notification on a given record (allows to notify + on not thread-enabled records); + :param int res_id: defines the record in combination with model; + :param str subtype_xmlid: optional xml id of a mail.message.subtype to + fetch, will force value of subtype_id; + :param int subtype_id: subtype_id of the message, used mainly for followers + notification mechanism; + :param list(int) partner_ids: partner_ids to notify in addition to partners + computed based on subtype / followers matching; + + Extra keyword arguments will be used either + * as default column values for the new mail.message record if they match + mail.message fields; + * propagated to notification methods if not; + :return: posted mail.message records """ if self: @@ -2305,7 +2330,7 @@ class MailThread(models.AbstractModel): # preliminary value safety check self._raise_for_invalid_parameters( set(kwargs.keys()), - forbidden_names={'message_id', 'message_type'} + forbidden_names={'message_id', 'message_type', 'parent_id'} ) if not tools.is_list_of(partner_ids, int): raise ValueError( @@ -2325,6 +2350,11 @@ class MailThread(models.AbstractModel): if not model or not res_id: model, res_id = False, False + if subtype_xmlid: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) + if not subtype_id: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + msg_values = { # author 'author_id': author_id, @@ -2337,25 +2367,21 @@ class MailThread(models.AbstractModel): 'body': body, 'is_internal': True, 'message_type': 'user_notification', - 'parent_id': parent_id, 'subject': subject, + 'subtype_id': subtype_id, # recipients 'message_id': tools.generate_tracking_message_id('message-notify'), 'partner_ids': partner_ids, + # notification + 'email_add_signature': True, } msg_values.update(msg_kwargs) # add default-like values afterwards, to avoid useless queries - if subtype_xmlid: - msg_values['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id(subtype_xmlid) - elif 'subtype_id' not in msg_values: - msg_values['subtype_id'] = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') if 'reply_to' not in msg_values: msg_values['reply_to'] = self._notify_get_reply_to(default=email_from)[self.id if self else False] - if 'email_add_signature' not in msg_values: - msg_values['email_add_signature'] = True new_message = self._message_create([msg_values]) - self._notify_thread(new_message, msg_values, **notif_kwargs) + self._fallback_lang()._notify_thread(new_message, msg_values, **notif_kwargs) return new_message def _message_log_with_view(self, view_ref, render_values=None, @@ -2391,7 +2417,9 @@ class MailThread(models.AbstractModel): **kwargs ) - def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, + def _message_log(self, *, + body='', subject=False, + author_id=None, email_from=None, message_type='notification', attachment_ids=False, tracking_value_ids=False): """ Shortcut allowing to post note on a document. See ``_message_log_batch`` @@ -2399,12 +2427,14 @@ class MailThread(models.AbstractModel): self.ensure_one() return self._message_log_batch( - {self.id: body}, author_id=author_id, email_from=email_from, - subject=subject, message_type=message_type, + {self.id: body}, subject=subject, + author_id=author_id, email_from=email_from, + message_type=message_type, attachment_ids=attachment_ids, tracking_value_ids=tracking_value_ids ) - def _message_log_batch(self, bodies, author_id=None, email_from=None, subject=False, + def _message_log_batch(self, bodies, subject=False, + author_id=None, email_from=None, message_type='notification', attachment_ids=False, tracking_value_ids=False): """ Shortcut allowing to post notes on a batch of documents. It does not diff --git a/addons/test_mail/tests/test_mail_thread_internals.py b/addons/test_mail/tests/test_mail_thread_internals.py index f7bb3470d45..56122681bac 100644 --- a/addons/test_mail/tests/test_mail_thread_internals.py +++ b/addons/test_mail/tests/test_mail_thread_internals.py @@ -443,6 +443,8 @@ class TestNoThread(TestMailCommon, TestRecipients): @users('employee') def test_message_notify(self): + """ Test notifying using model / res_id linking to a model not being + mail.thread enabled. """ test_record = self.env['mail.test.nothread'].create({ 'customer_id': self.partner_1.id, 'name': 'Not A Thread', @@ -451,6 +453,7 @@ class TestNoThread(TestMailCommon, TestRecipients): 'content': 'Hello Paulo', 'email_values': { 'reply_to': self.company_admin.catchall_formatted, + 'subject': 'Test Notify', }, 'message_type': 'user_notification', 'notif': [{ @@ -465,7 +468,32 @@ class TestNoThread(TestMailCommon, TestRecipients): _message = self.env['mail.thread'].message_notify( body='

Hello Paulo

', model=test_record._name, + partner_ids=self.partner_2.ids, res_id=test_record.id, subject='Test Notify', - partner_ids=self.partner_2.ids + ) + + @users('employee') + def test_message_notify_norecord(self): + """ Test notifying on no record, just using the abstract model itself. """ + with self.assertPostNotifications([{ + 'content': 'Hello Paulo', + 'email_values': { + 'reply_to': self.company_admin.catchall_formatted, + 'subject': 'Test Notify', + }, + 'message_type': 'user_notification', + 'notif': [{ + 'check_send': True, + 'is_read': True, + 'partner': self.partner_2, + 'status': 'sent', + 'type': 'email', + }], + 'subtype': 'mail.mt_note', + }]): + _message = self.env['mail.thread'].message_notify( + body='

Hello Paulo

', + partner_ids=self.partner_2.ids, + subject='Test Notify', ) From ea3e4d2dc08d1eaefdc3d85a383d176597be9864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 6 Jan 2023 09:55:48 +0000 Subject: [PATCH 20/45] [REF] various: cleanup usage of 'message_notify' Notification method 'message_notify' is sometimes used as a standalone method to send notifications. In that case it is called directly on MailThread abstract model, notably when there is no context record or when it does not inherit from 'mail.thread' directly. This is done mostly in technical models. However in that case some model-specific code is not called, notably methods computing default subject. In this commit we improve some calls to be sure notifications have content enough to be understandable. When possible, we also redirect the notification method on real records inheriting from mail.thread, enabling a more complete process. See community PR for more details. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- .../data_recycle/models/data_recycle_model.py | 30 +++++++++++-------- addons/hr_holidays/models/hr_leave.py | 3 +- addons/lunch/models/lunch_alert.py | 5 ++-- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/addons/data_recycle/models/data_recycle_model.py b/addons/data_recycle/models/data_recycle_model.py index 16c754ad3e9..bfcc1630999 100644 --- a/addons/data_recycle/models/data_recycle_model.py +++ b/addons/data_recycle/models/data_recycle_model.py @@ -172,20 +172,24 @@ class DataRecycleModel(models.Model): ('recycle_model_id', '=', self.id), ('create_date', '>=', last_date) ]) - - if records_count: - partner_ids = self.notify_user_ids.partner_id.ids + partner_ids = self.notify_user_ids.partner_id.ids if records_count else [] + if partner_ids: menu_id = self.env.ref('data_recycle.menu_data_cleaning_root').id - kwargs = { - 'body': self.env['ir.qweb']._render('data_recycle.notification', { - 'records_count': records_count, - 'res_model_label': self.res_model_id.name, - 'recycle_model_id': self.id, - 'menu_id': menu_id - }), - 'partner_ids': partner_ids, - } - self.env['mail.thread'].with_context(mail_notify_author=True).message_notify(**kwargs) + self.env['mail.thread'].with_context(mail_notify_author=True).message_notify( + body=self.env['ir.qweb']._render( + 'data_recycle.notification', + { + 'records_count': records_count, + 'res_model_label': self.res_model_id.name, + 'recycle_model_id': self.id, + 'menu_id': menu_id + } + ), + model=self._name, + partner_ids=partner_ids, + res_id=self.id, + subject=_('Data to Recycle'), + ) def write(self, vals): if 'active' in vals and not vals['active']: diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 7ac352cd106..bbe9a79a2b2 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -1624,7 +1624,8 @@ class HolidaysRequest(models.Model): if recipient: self.env['mail.thread'].sudo().message_notify( body=message, - partner_ids=[recipient] + partner_ids=[recipient], + subject=_('Your Time Off'), ) def _track_subtype(self, init_values): diff --git a/addons/lunch/models/lunch_alert.py b/addons/lunch/models/lunch_alert.py index d3e4b3c140f..85351a4d4e4 100644 --- a/addons/lunch/models/lunch_alert.py +++ b/addons/lunch/models/lunch_alert.py @@ -2,7 +2,7 @@ import pytz import logging -from odoo import api, fields, models +from odoo import api, fields, models, _ from odoo.osv import expression from .lunch_supplier import float_to_time @@ -194,5 +194,6 @@ class LunchAlert(models.Model): if partners: self.env['mail.thread'].message_notify( body=self.message, - partner_ids=partners.ids + partner_ids=partners.ids, + subject=_('Your Lunch Order'), ) From 9180843bb94af980e4ce7a40a3fdcd7162510ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 15 Sep 2022 13:32:36 +0000 Subject: [PATCH 21/45] [MOV] mail: move "main attachment" update as an after-post hook A method ``_message_set_main_attachment_id`` exists to handle main attachment reference, used notably in discuss and accounting. It is now called inside ``_message_post_after_hook``, so that it is part of the post processing after having posted the message. Its API has been updated so that attachment_ids is now a real list of ids, which is clearer. A wrong override of '_message_post_after_hook' dealing with parameters is cleaned while passing by. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_channel.py | 2 +- addons/mail/models/mail_thread.py | 29 ++++++++++++++++------------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index d33abcb0a25..ad29c900621 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -617,7 +617,7 @@ class Channel(models.Model): Automatically set the message posted by the current user as seen for themselves. """ self._set_last_seen_message(message) - return super()._message_post_after_hook(message=message, msg_vals=msg_vals) + return super()._message_post_after_hook(message, msg_vals) def _check_can_update_message_content(self, message): """ We don't call super in this override as we want to ignore the diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index e76eb1fe214..aa1a5621414 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1927,9 +1927,6 @@ class MailThread(models.AbstractModel): ) # attachement_ids, body new_message = self._message_create([msg_values]) - # Set main attachment field if necessary - self._message_set_main_attachment_id(msg_values['attachment_ids']) - if msg_values['author_id'] and msg_values['message_type'] != 'notification' and not self._context.get('mail_create_nosubscribe'): if self.env['res.partner'].browse(msg_values['author_id']).active: # we dont want to add odoobot/inactive as a follower self._message_subscribe(partner_ids=[msg_values['author_id']]) @@ -1938,19 +1935,15 @@ class MailThread(models.AbstractModel): self._notify_thread(new_message, msg_values, **notif_kwargs) return new_message - def _message_set_main_attachment_id(self, attachment_ids): # todo move this out of mail.thread - if not self._abstract and attachment_ids and not self.message_main_attachment_id: - all_attachments = self.env['ir.attachment'].browse([attachment_tuple[1] for attachment_tuple in attachment_ids]) - prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \ - or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \ - or all_attachments - self.sudo().with_context(tracking_disable=True).write({'message_main_attachment_id': prioritary_attachments[0].id}) - - def _message_post_after_hook(self, message, msg_vals): + def _message_post_after_hook(self, message, msg_values): """ Hook to add custom behavior after having posted the message. Both message and computed value are given, to try to lessen query count by using already-computed values instead of having to rebrowse things. """ - return + # Set main attachment field if necessary + self._message_set_main_attachment_id([ + attachment_command[1] + for attachment_command in (msg_values['attachment_ids'] or []) + ]) def _message_mail_after_hook(self, mails): """ Hook to add custom behavior after having sent an mass mailing. @@ -1958,6 +1951,16 @@ class MailThread(models.AbstractModel): :param mail.mail mails: mail.mail records about to be sent""" return + def _message_set_main_attachment_id(self, attachment_ids): + if self._name == 'mail.thread': + return + if attachment_ids and not self.message_main_attachment_id: + all_attachments = self.env['ir.attachment'].browse(attachment_ids) + prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \ + or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \ + or all_attachments + self.sudo().with_context(tracking_disable=True).message_main_attachment_id = prioritary_attachments[0].id + def _process_attachments_for_post(self, attachments, attachment_ids, message_values): """ Preprocess attachments for MailTread.message_post() or MailMail.create(). Purpose is to From 95859f6fb57a9de07691776e136c1223a5944229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 3 Nov 2022 15:49:03 +0000 Subject: [PATCH 22/45] [REF] mail, various: cleanup usage of mail.composer.mixin 'email_from' fields are not necessary on 'sale.order.cancel' and 'survey.invite' as anyway we always use the current user's email and field is not present in form view. Override of default_get to raise about invalid email_from is not necessary as anyway it raises when posting the message. Giving author_id to message_post is sufficient as it computes email_from based on author when given. Add 'render_model' in views, as it is part of the domain given to 'template_id' field of composer mixin. When missing, also add 'lang' and 'template_id' that come from the render mixin. Use '_render_field' when possible instead of '_render_template' as its API is simpler and uses field definition for rendering parameters. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/hr_recruitment/wizard/applicant_send_mail.py | 2 +- .../wizard/applicant_send_mail_views.xml | 3 +++ addons/mail/wizard/mail_compose_message_views.xml | 2 ++ addons/sale/wizard/sale_order_cancel.py | 9 +-------- addons/sale/wizard/sale_order_cancel_views.xml | 2 ++ addons/survey/wizard/survey_invite.py | 9 +-------- addons/survey/wizard/survey_invite_views.xml | 2 ++ addons/website_slides/wizard/slide_channel_invite.py | 10 +++++----- .../wizard/slide_channel_invite_views.xml | 1 + 9 files changed, 18 insertions(+), 22 deletions(-) diff --git a/addons/hr_recruitment/wizard/applicant_send_mail.py b/addons/hr_recruitment/wizard/applicant_send_mail.py index 79e6d884d4b..2b9f3845e4a 100644 --- a/addons/hr_recruitment/wizard/applicant_send_mail.py +++ b/addons/hr_recruitment/wizard/applicant_send_mail.py @@ -41,8 +41,8 @@ class ApplicantSendMail(models.TransientModel): }) applicant.message_post( + author_id=self.author_id.id, body=self.body, - email_from=self.author_id.email, email_layout_xmlid='mail.mail_notification_light', message_type='comment', partner_ids=applicant.partner_id.ids, diff --git a/addons/hr_recruitment/wizard/applicant_send_mail_views.xml b/addons/hr_recruitment/wizard/applicant_send_mail_views.xml index 7e11d5c02fd..f4d927478a1 100644 --- a/addons/hr_recruitment/wizard/applicant_send_mail_views.xml +++ b/addons/hr_recruitment/wizard/applicant_send_mail_views.xml @@ -5,6 +5,9 @@ + + + diff --git a/addons/mail/wizard/mail_compose_message_views.xml b/addons/mail/wizard/mail_compose_message_views.xml index d85d4363564..54b37deca7a 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -15,10 +15,12 @@ + + diff --git a/addons/sale/wizard/sale_order_cancel.py b/addons/sale/wizard/sale_order_cancel.py index 96f1c55633a..1c5a008e8f4 100644 --- a/addons/sale/wizard/sale_order_cancel.py +++ b/addons/sale/wizard/sale_order_cancel.py @@ -11,18 +11,11 @@ class SaleOrderCancel(models.TransientModel): _inherit = 'mail.composer.mixin' _description = "Sales Order Cancel" - @api.model - def _default_email_from(self): - if self.env.user.email: - return formataddr((self.env.user.name, self.env.user.email)) - raise UserError(_("Unable to post message, please configure the sender's email address.")) - @api.model def _default_author_id(self): return self.env.user.partner_id # origin - email_from = fields.Char(string="From", default=_default_email_from) author_id = fields.Many2one( 'res.partner', string="Author", @@ -81,9 +74,9 @@ class SaleOrderCancel(models.TransientModel): def action_send_mail_and_cancel(self): self.ensure_one() self.order_id.message_post( + author_id=self.author_id.id, body=self.body, message_type='comment', - email_from=self.email_from, email_layout_xmlid='mail.mail_notification_light', partner_ids=self.recipient_ids.ids, subject=self.subject, diff --git a/addons/sale/wizard/sale_order_cancel_views.xml b/addons/sale/wizard/sale_order_cancel_views.xml index f2c8d2b4dcd..e3e592ebb31 100644 --- a/addons/sale/wizard/sale_order_cancel_views.xml +++ b/addons/sale/wizard/sale_order_cancel_views.xml @@ -6,6 +6,8 @@ + + diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 7c689c35431..a2192ec54ba 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -18,12 +18,6 @@ class SurveyInvite(models.TransientModel): _inherit = 'mail.composer.mixin' _description = 'Survey Invitation Wizard' - @api.model - def _get_default_from(self): - if self.env.user.email: - return tools.formataddr((self.env.user.name, self.env.user.email)) - raise UserError(_("Unable to post message, please configure the sender's email address.")) - @api.model def _get_default_author(self): return self.env.user.partner_id @@ -33,7 +27,6 @@ class SurveyInvite(models.TransientModel): 'ir.attachment', 'survey_mail_compose_message_ir_attachments_rel', 'wizard_id', 'attachment_id', string='Attachments') # origin - email_from = fields.Char('From', default=_get_default_from) author_id = fields.Many2one( 'res.partner', 'Author', index=True, ondelete='set null', default=_get_default_author) @@ -226,7 +219,7 @@ class SurveyInvite(models.TransientModel): 'auto_delete': True, 'author_id': self.author_id.id, 'body_html': body, - 'email_from': self.email_from, + 'email_from': self.author_id.email_formatted, 'model': None, 'res_id': None, 'subject': subject, diff --git a/addons/survey/wizard/survey_invite_views.xml b/addons/survey/wizard/survey_invite_views.xml index 7bf9e841230..3dc503493a3 100644 --- a/addons/survey/wizard/survey_invite_views.xml +++ b/addons/survey/wizard/survey_invite_views.xml @@ -8,11 +8,13 @@ + + + From a19f2e33907c5ea56b520e9471761fca3778a7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 9 Dec 2021 14:34:06 +0000 Subject: [PATCH 23/45] [REF] mail, various: cleanup input/output of generation methods on template and composer Purpose of this commit is to prepare further improvements in template and composer rendering methods. First step is to * make them private; * correctly name parameters (notably rename fields to render_fields to avoid collision with odoo.fields); * remove the "single / multi" mode. This is a relic of old implementation when rendering was done mainly record by record and sometimes by batch. Now everything should be batched; * split some lines to ease future diff in upcoming commits; * improve some docstrings; Next commits will rewrite and split parts of ``_generate_email``. Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_template.py | 38 +++++++++---------- addons/mail/wizard/mail_compose_message.py | 29 +++++--------- addons/mail/wizard/mail_template_preview.py | 8 ++-- addons/website_slides/models/slide_channel.py | 5 ++- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 3626feb1a6d..3908bd303b9 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -191,7 +191,7 @@ class MailTemplate(models.Model): # MESSAGE/EMAIL VALUES GENERATION # ------------------------------------------------------------ - def generate_recipients(self, results, res_ids): + def _generate_recipients(self, results, res_ids): """Generates the recipients of the template. Default values can ben generated instead of the template values if requested by template or context. Emails (email_to, email_cc) can be transformed into partners if requested @@ -228,24 +228,24 @@ class MailTemplate(models.Model): results[res_id]['partner_ids'] = partner_ids return results - def generate_email(self, res_ids, render_fields): - """Generates an email from the template for given the given model based on - records given by res_ids. + def _generate_template(self, res_ids, render_fields): + """ Render values from template 'self' on records given by 'res_ids'. + Those values are generally used to create a mail.mail or a mail.message. + Model of records is the one defined on template. - :param res_id: id of the record to use for rendering the template (model - is taken from template definition) - :returns: a dict containing all relevant fields for creating a new - mail.mail entry, with one extra key ``attachments``, in the - format [(report_name, data)] where data is base64 encoded. + :param list res_ids: list of record IDs on which template is rendered; + :param list render_fields: list of fields to render on template; + + :returns: a dict of (res_ids, values) where values contains all rendered + fields asked in ``render_fields``. Asking for attachments adds an + 'attachments' key using the format [(report_name, data)] where data + is base64 encoded. Asking for recipients adds a 'partner_ids' key. + Note that 2many fields contain a list of IDs, not commands. """ self.ensure_one() - multi_mode = True - if isinstance(res_ids, int): - res_ids = [res_ids] - multi_mode = False results = dict() - for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): + for _lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): for field in render_fields: generated_field_values = template._render_field( field, template_res_ids @@ -254,7 +254,7 @@ class MailTemplate(models.Model): results.setdefault(res_id, dict())[field] = field_value # compute recipients if any(field in render_fields for field in ['email_to', 'partner_to', 'email_cc']): - results = template.generate_recipients(results, template_res_ids) + results = template._generate_recipients(results, template_res_ids) # update values for all res_ids for res_id in template_res_ids: values = results[res_id] @@ -291,7 +291,7 @@ class MailTemplate(models.Model): raise UserError(_('Unsupported report type %s found.', report.report_type)) result, report_format = res - # TODO in trunk, change return format to binary to match message_post expected format + # TODO in trunk, change return report_format to binary to match message_post expected format result = base64.b64encode(result) if not report_name: report_name = 'report.' + report_service @@ -310,7 +310,7 @@ class MailTemplate(models.Model): results[res_id]['attachment_ids'] += additional_attachments.get('attachment_ids', []) results[res_id]['attachments'] += additional_attachments.get('attachments', []) - return multi_mode and results or results[res_ids[0]] + return results # ------------------------------------------------------------ # EMAIL @@ -342,7 +342,7 @@ class MailTemplate(models.Model): Attachment = self.env['ir.attachment'] # TDE FIXME: should remove default_type from context # create a mail_mail based on values, without attachments - values = self.generate_email( + values = self._generate_template( res_id, ('auto_delete', 'body_html', @@ -354,7 +354,7 @@ class MailTemplate(models.Model): 'scheduled_date', 'subject', ) - ) + )[res_id] values['recipient_ids'] = [Command.link(pid) for pid in values.get('partner_ids', list())] values['attachment_ids'] = [Command.link(aid) for aid in values.get('attachment_ids', list())] values.update(email_values or {}) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index c058b8c0e93..7e35426e066 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -390,7 +390,7 @@ class MailComposer(models.TransientModel): # render all template-based value at once if mass_mail_mode and self.model: - rendered_values = self.render_message(res_ids) + rendered_values = self._render_message(res_ids) # compute alias-based reply-to in batch reply_to_value = dict.fromkeys(res_ids, None) if mass_mail_mode and not self.reply_to_force_new: @@ -585,7 +585,7 @@ class MailComposer(models.TransientModel): if template.mail_server_id: values['mail_server_id'] = template.mail_server_id.id elif template_id: - values = self.generate_email_for_composer( + values = self._generate_email_for_composer( template_id, [res_id], ('attachment_ids', 'body_html', @@ -649,7 +649,7 @@ class MailComposer(models.TransientModel): return {'value': values} - def render_message(self, res_ids): + def _render_message(self, res_ids): """Generate template-based values of wizard, for the document records given by res_ids. This method is meant to be inherited by email_template that will produce a more complete dictionary, using qweb templates. @@ -669,10 +669,6 @@ class MailComposer(models.TransientModel): subject, body, email_from and reply_to """ self.ensure_one() - multi_mode = True - if isinstance(res_ids, int): - multi_mode = False - res_ids = [res_ids] subjects = self._render_field('subject', res_ids) # We want to preserve comments in emails so as to keep mso conditionals @@ -696,7 +692,7 @@ class MailComposer(models.TransientModel): # generate template-based values if self.template_id: - template_values = self.generate_email_for_composer( + template_values = self._generate_email_for_composer( self.template_id.id, res_ids, ('attachment_ids', 'email_to', @@ -721,24 +717,19 @@ class MailComposer(models.TransientModel): # update template values by composer values template_values[res_id].update(results[res_id]) - return multi_mode and template_values or template_values[res_ids[0]] + return template_values @api.model - def generate_email_for_composer(self, template_id, res_ids, fields): - """ Call email_template.generate_email(), get fields relevant for + def _generate_email_for_composer(self, template_id, res_ids, render_fields): + """ Call email_template._generate_template(), get fields relevant for mail.compose.message, transform email_cc and email_to into partner_ids """ - multi_mode = True - if isinstance(res_ids, int): - multi_mode = False - res_ids = [res_ids] - - returned_fields = list(fields) + ['partner_ids', 'attachments'] + returned_fields = list(render_fields) + ['partner_ids', 'attachments'] values = dict.fromkeys(res_ids, False) - template_values = self.env['mail.template'].with_context(tpl_partners_only=True).browse(template_id).generate_email(res_ids, fields) + template_values = self.env['mail.template'].with_context(tpl_partners_only=True).browse(template_id)._generate_template(res_ids, render_fields) for res_id in res_ids: res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) res_id_values['body'] = res_id_values.pop('body_html', '') values[res_id] = res_id_values - return multi_mode and values or values[res_ids[0]] + return values diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 5916cabdc69..1324f4c9e07 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -55,7 +55,7 @@ class MailTemplatePreview(models.TransientModel): help="The queue manager will send the email after the date") body_html = fields.Html('Body', compute='_compute_mail_template_fields', sanitize=False) attachment_ids = fields.Many2many('ir.attachment', 'Attachments', compute='_compute_mail_template_fields') - # Extra fields info generated by generate_email + # Extra fields info generated by _generate_template partner_ids = fields.Many2many('res.partner', string='Recipients', compute='_compute_mail_template_fields') @api.depends('model_id') @@ -76,10 +76,10 @@ class MailTemplatePreview(models.TransientModel): self._set_mail_attributes() else: copy_depends_values['resource_ref'] = '%s,%s' % (self.resource_ref._name, self.resource_ref.id) - mail_values = mail_template.with_context(template_preview_lang=self.lang).generate_email( - self.resource_ref.id, + mail_values = mail_template.with_context(template_preview_lang=self.lang)._generate_template( + [self.resource_ref.id], self._MAIL_TEMPLATE_FIELDS - ) + )[self.resource_ref.id] self._set_mail_attributes(values=mail_values) self.error_msg = False except (ValueError, UserError) as user_error: diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index ce8dd8adc19..35f51524ba0 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -135,7 +135,7 @@ class ChannelUsersRelation(models.Model): record_email_values = dict() for template, records in template_to_records.items(): record_email_values.update( - template.generate_email( + template._generate_template( records.ids, ['body_html', 'email_from', @@ -148,6 +148,9 @@ class ChannelUsersRelation(models.Model): mail_mail_values = [] for record in self: email_values = record_email_values.get(record.id) + # attachments specific not supported currently, only attachment_ids + email_values.pop('attachments', False) + if not email_values or not email_values.get('partner_ids'): continue From 4d571ec8d7efad3a31f986291ea3f5897f692017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 2 Nov 2021 16:02:07 +0000 Subject: [PATCH 24/45] [REF] mail: render only asked fields when generating a template values Currently some value are always computed and added even if not asked by the caller. As asking a template to render some fields is now used notably for a subset of fields (subject and body, from / to, ...) there is no need to automatically add other irrelevant information. Main callers already filtered out returned results (notably composer). However better avoid computing / rendering unnecessary stuff directly at template level. Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_template.py | 75 +++++++++++++------ addons/mail/wizard/mail_compose_message.py | 6 +- addons/mail/wizard/mail_template_preview.py | 8 +- .../tests/test_website_sale_cart_recovery.py | 2 +- addons/website_slides/models/slide_channel.py | 34 ++++++--- 5 files changed, 85 insertions(+), 40 deletions(-) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 3908bd303b9..8b037a6e1e1 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -243,21 +243,38 @@ class MailTemplate(models.Model): Note that 2many fields contain a list of IDs, not commands. """ self.ensure_one() + render_fields_set = set(render_fields) + fields_specific = { + 'attachments', # attachments + 'attachment_ids', # attachments + # not rendered (static) + 'auto_delete', + 'mail_server_id', + 'model', + 'res_id', + } - results = dict() + render_results = {} for _lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): - for field in render_fields: + # render fields not rendered by sub methods + fields_torender = { + field for field in render_fields + if field not in fields_specific + } + for field in fields_torender: generated_field_values = template._render_field( field, template_res_ids ) for res_id, field_value in generated_field_values.items(): - results.setdefault(res_id, dict())[field] = field_value - # compute recipients - if any(field in render_fields for field in ['email_to', 'partner_to', 'email_cc']): - results = template._generate_recipients(results, template_res_ids) - # update values for all res_ids + render_results.setdefault(res_id, {})[field] = field_value + + # render recipients + if render_fields_set & {'email_cc', 'email_to', 'partner_to'}: + render_results = template._generate_recipients(render_results, template_res_ids) + + # add values static for all res_ids for res_id in template_res_ids: - values = results[res_id] + values = render_results[res_id] if values.get('body_html'): values['body'] = tools.html_sanitize(values['body_html']) # if asked in fields to return, parse generated date into tz agnostic UTC as expected by ORM @@ -267,16 +284,19 @@ class MailTemplate(models.Model): values['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False # technical settings - values.update( - mail_server_id=template.mail_server_id.id or False, - auto_delete=template.auto_delete, - model=template.model, - res_id=res_id or False, - attachment_ids=[attach.id for attach in template.attachment_ids], - ) + if 'attachments' in render_fields or 'attachment_ids' in render_fields: + values['attachment_ids'] = template.attachment_ids.ids + if 'auto_delete' in render_fields: + values['auto_delete'] = template.auto_delete + if 'mail_server_id' in render_fields: + values['mail_server_id'] = template.mail_server_id.id + if 'model' in render_fields: + values['model'] = template.model + if 'res_id' in render_fields: + values['res_id'] = res_id or False - # Add report in attachments: generate once for all template_res_ids - if template.report_template: + # render attachments (report part) + if ('attachments' in render_fields or 'attachment_ids' in render_fields) and template.report_template: for res_id in template_res_ids: attachments = [] report_name = template._render_field('report_name', [res_id])[res_id] @@ -299,18 +319,20 @@ class MailTemplate(models.Model): if not report_name.endswith(ext): report_name += ext attachments.append((report_name, result)) - results[res_id]['attachments'] = attachments + render_results[res_id]['attachments'] = attachments # hook for attachments-specific computation, used currently only for accounting - if 'attachments' in render_fields or 'attachment_ids' in render_fields and hasattr(self.env[self.model], '_process_attachments_for_template_post'): + if ('attachments' in render_fields or 'attachment_ids' in render_fields) and hasattr(self.env[self.model], '_process_attachments_for_template_post'): records_attachments = self.env[self.model].browse(template_res_ids)._process_attachments_for_template_post(template) for res_id, additional_attachments in records_attachments.items(): if not additional_attachments: continue - results[res_id]['attachment_ids'] += additional_attachments.get('attachment_ids', []) - results[res_id]['attachments'] += additional_attachments.get('attachments', []) + if additional_attachments.get('attachment_ids'): + render_results[res_id].setdefault('attachment_ids', []).extend(additional_attachments['attachment_ids']) + if additional_attachments.get('attachments'): + render_results[res_id].setdefault('attachments', []).extend(additional_attachments['attachments']) - return results + return render_results # ------------------------------------------------------------ # EMAIL @@ -343,14 +365,19 @@ class MailTemplate(models.Model): # create a mail_mail based on values, without attachments values = self._generate_template( - res_id, - ('auto_delete', + [res_id], + ('attachments', + 'attachment_ids', + 'auto_delete', 'body_html', 'email_cc', 'email_from', 'email_to', + 'mail_server_id', + 'model', 'partner_to', 'reply_to', + 'res_id', 'scheduled_date', 'subject', ) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 7e35426e066..55867bdf9a4 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -587,7 +587,8 @@ class MailComposer(models.TransientModel): elif template_id: values = self._generate_email_for_composer( template_id, [res_id], - ('attachment_ids', + ('attachments', + 'attachment_ids', 'body_html', 'email_cc', 'email_from', @@ -694,7 +695,8 @@ class MailComposer(models.TransientModel): if self.template_id: template_values = self._generate_email_for_composer( self.template_id.id, res_ids, - ('attachment_ids', + ('attachments', + 'attachment_ids', 'email_to', 'email_cc', 'mail_server_id', diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 1324f4c9e07..2de5f26b975 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -8,12 +8,14 @@ from odoo.exceptions import UserError class MailTemplatePreview(models.TransientModel): _name = 'mail.template.preview' _description = 'Email Template Preview' - _MAIL_TEMPLATE_FIELDS = ['attachment_ids', + _MAIL_TEMPLATE_FIELDS = ['attachments', + 'attachment_ids', 'body_html', 'subject', 'email_cc', 'email_from', 'email_to', + 'partner_to', 'reply_to', 'scheduled_date', ] @@ -92,6 +94,10 @@ class MailTemplatePreview(models.TransientModel): def _set_mail_attributes(self, values=None): for field in self._MAIL_TEMPLATE_FIELDS: + if field in ('attachments', 'partner_to'): + # partner_to is used to generate partner_ids, handled here below + # attachments are a list of attachments, no usage here + continue field_value = values.get(field, False) if values else self.mail_template_id[field] self[field] = field_value self.partner_ids = values.get('partner_ids', False) if values else False diff --git a/addons/website_sale/tests/test_website_sale_cart_recovery.py b/addons/website_sale/tests/test_website_sale_cart_recovery.py index eae65eacd97..6ce0c152f82 100644 --- a/addons/website_sale/tests/test_website_sale_cart_recovery.py +++ b/addons/website_sale/tests/test_website_sale_cart_recovery.py @@ -121,6 +121,6 @@ class TestWebsiteSaleCartRecoveryServer(TransactionCase): "Each cart recovery mail has been sent exactly once." ) self.assertTrue( - all(order.access_token in sent_mail[order].body for order in orders), + all(order.access_token in sent_mail[order].body_html for order in orders), "Each mail should contain the access token of the corresponding SO." ) diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 35f51524ba0..26c03c646a0 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -132,24 +132,34 @@ class ChannelUsersRelation(models.Model): template_to_records.setdefault(template, self.env['slide.channel.partner']) template_to_records[template] += record - record_email_values = dict() + record_email_values = {} for template, records in template_to_records.items(): - record_email_values.update( - template._generate_template( - records.ids, - ['body_html', - 'email_from', - 'partner_to', - 'subject', - ] - ) + record_values = template._generate_template( + records.ids, + ['attachments', + 'attachment_ids', + 'body_html', + 'email_cc', + 'email_from', + 'email_to', + 'mail_server_id', + 'model', + 'partner_to', + 'reply_to', + 'res_id', + 'scheduled_date', + 'subject', + ] ) + for res_id, values in record_values.items(): + # attachments specific not supported currently, only attachment_ids + values.pop('attachments', False) + values['body'] = values.get('body_html') # keep body copy in chatter + record_email_values[res_id] = values mail_mail_values = [] for record in self: email_values = record_email_values.get(record.id) - # attachments specific not supported currently, only attachment_ids - email_values.pop('attachments', False) if not email_values or not email_values.get('partner_ids'): continue From 7982c0072d1e3d69ddce93c7be815c400843b5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 3 Nov 2021 13:56:29 +0000 Subject: [PATCH 25/45] [REF] mail: make recipients generation on template independent Purpose of this commit is to improve recipients generation on template. Code is cleaned to better support parameters and be easier to call. It can now be called independently from the main ``_generate_template`` method. Docstrings are updated. Changes * removed support of ``tpl_force_default_to`` context key used to somehow simulate the check of "use default recipients" (??) * removed support of ``tpl_partners_only`` context key used to force to find or create partners based on emails generated by the template. It is replaced by a parameter to propagate; Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_template.py | 93 ++++++++++++++++------ addons/mail/wizard/mail_compose_message.py | 6 +- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 8b037a6e1e1..efc44ed0cde 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -191,50 +191,88 @@ class MailTemplate(models.Model): # MESSAGE/EMAIL VALUES GENERATION # ------------------------------------------------------------ - def _generate_recipients(self, results, res_ids): - """Generates the recipients of the template. Default values can ben generated - instead of the template values if requested by template or context. - Emails (email_to, email_cc) can be transformed into partners if requested - in the context. """ - self.ensure_one() + def _generate_template_recipients(self, res_ids, render_fields, + find_or_create_partners=False, + render_results=None): + """ Render recipients of the template 'self', returning values for records + given by 'res_ids'. Default values can be generated instead of the template + values if requested by template (see 'use_default_to' field). Email fields + ('email_cc', 'email_to') are transformed into partners if requested + (finding or creating partners). 'partner_to' field is transformed into + 'partner_ids' field. - if self.use_default_to or self._context.get('tpl_force_default_to'): - records = self.env[self.model].browse(res_ids).sudo() - default_recipients = records._message_get_default_recipients() + :param list res_ids: list of record IDs on which template is rendered; + :param list render_fields: list of fields to render on template which + are specific to recipients, e.g. email_cc, email_to, partner_to); + :param boolean find_or_create_partners: transform emails into partners + (calling ``find_or_create`` on partner model); + :param dict render_results: res_ids-based dictionary of render values. + For each res_id, a dict of values based on render_fields is given; + + :return: updated (or new) render_results. It holds a 'partner_ids' key + holding partners given by ``_message_get_default_recipients`` and/or + generated based on 'partner_to'. If ``find_or_create_partners`` is + False emails are present, otherwise they are included as partners + contained in ``partner_ids``. + """ + self.ensure_one() + if render_results is None: + render_results = {} + + # if using default recipients -> ``_message_get_default_recipients`` gives + # values for email_to, email_cc and partner_ids + if self.use_default_to and self.model: + records_sudo = self.env[self.model].browse(res_ids).sudo() + default_recipients = records_sudo._message_get_default_recipients() for res_id, recipients in default_recipients.items(): - results[res_id].pop('partner_to', None) - results[res_id].update(recipients) + render_results.setdefault(res_id, {}).update(recipients) + # render fields dynamically which generates recipients + else: + for field in set(render_fields) & {'email_cc', 'email_to', 'partner_to'}: + generated_field_values = self._render_field(field, res_ids) + for res_id in res_ids: + render_results.setdefault(res_id, {})[field] = generated_field_values[res_id] records_company = None - if self._context.get('tpl_partners_only') and self.model and results and 'company_id' in self.env[self.model]._fields: - records = self.env[self.model].browse(results.keys()).read(['company_id']) - records_company = {rec['id']: (rec['company_id'][0] if rec['company_id'] else None) for rec in records} + if find_or_create_partners and self.model and 'company_id' in self.env[self.model]._fields: + records_dict = self.env[self.model].browse(res_ids).sudo().read(['company_id']) + records_company = { + rec['id']: rec['company_id'][0] if rec['company_id'] else False + for rec in records_dict + } - for res_id, values in results.items(): - partner_ids = values.get('partner_ids', list()) - if self._context.get('tpl_partners_only'): - mails = tools.email_split(values.pop('email_to', '')) + tools.email_split(values.pop('email_cc', '')) + # consolidate partner_ids: based on partner_to + create partners if requested + for res_id in res_ids: + record_values = render_results[res_id] + + partner_ids = record_values.get('partner_ids', []) + if find_or_create_partners: + mails = tools.email_split(record_values.pop('email_to', '')) + tools.email_split(record_values.pop('email_cc', '')) Partner = self.env['res.partner'] if records_company: Partner = Partner.with_context(default_company_id=records_company[res_id]) for mail in mails: partner = Partner.find_or_create(mail) partner_ids.append(partner.id) - partner_to = values.pop('partner_to', '') + + partner_to = record_values.pop('partner_to', '') if partner_to: # placeholders could generate '', 3, 2 due to some empty field values tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid] partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids - results[res_id]['partner_ids'] = partner_ids - return results - def _generate_template(self, res_ids, render_fields): + record_values['partner_ids'] = partner_ids + + def _generate_template(self, res_ids, render_fields, + find_or_create_partners=False): """ Render values from template 'self' on records given by 'res_ids'. Those values are generally used to create a mail.mail or a mail.message. Model of records is the one defined on template. :param list res_ids: list of record IDs on which template is rendered; :param list render_fields: list of fields to render on template; + :param boolean find_or_create_partners: transform emails into partners + (see ``_generate_template_recipients``); :returns: a dict of (res_ids, values) where values contains all rendered fields asked in ``render_fields``. Asking for attachments adds an @@ -247,6 +285,9 @@ class MailTemplate(models.Model): fields_specific = { 'attachments', # attachments 'attachment_ids', # attachments + 'email_cc', # recipients + 'email_to', # recipients + 'partner_to', # recipients # not rendered (static) 'auto_delete', 'mail_server_id', @@ -258,7 +299,7 @@ class MailTemplate(models.Model): for _lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): # render fields not rendered by sub methods fields_torender = { - field for field in render_fields + field for field in render_fields_set if field not in fields_specific } for field in fields_torender: @@ -270,7 +311,11 @@ class MailTemplate(models.Model): # render recipients if render_fields_set & {'email_cc', 'email_to', 'partner_to'}: - render_results = template._generate_recipients(render_results, template_res_ids) + template._generate_template_recipients( + template_res_ids, render_fields_set, + render_results=render_results, + find_or_create_partners=find_or_create_partners + ) # add values static for all res_ids for res_id in template_res_ids: diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 55867bdf9a4..38e0e734cbc 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -728,7 +728,11 @@ class MailComposer(models.TransientModel): returned_fields = list(render_fields) + ['partner_ids', 'attachments'] values = dict.fromkeys(res_ids, False) - template_values = self.env['mail.template'].with_context(tpl_partners_only=True).browse(template_id)._generate_template(res_ids, render_fields) + template_values = self.env['mail.template'].browse(template_id)._generate_template( + res_ids, + render_fields, + find_or_create_partners=True + ) for res_id in res_ids: res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) res_id_values['body'] = res_id_values.pop('body_html', '') From b1df143775e6b02803c767d4b183cc43d6f926ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 3 Nov 2021 13:05:13 +0000 Subject: [PATCH 26/45] [REF] mail: make attachments generation on template independent Purpose of this commit is to extract attachments generation on template in its own sub-method. As it contains code specific to attachments, better have it separated from the main global generation method. Code is cleaned to better support parameters and be easier to call. It can now be called independently from the main ``_generate_template`` method. Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_template.py | 117 ++++++++++++------ addons/mail/wizard/mail_compose_message.py | 12 +- addons/mail/wizard/mail_template_preview.py | 8 +- addons/website_slides/models/slide_channel.py | 4 +- 4 files changed, 89 insertions(+), 52 deletions(-) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index efc44ed0cde..09d172b9b68 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -191,6 +191,70 @@ class MailTemplate(models.Model): # MESSAGE/EMAIL VALUES GENERATION # ------------------------------------------------------------ + def _generate_template_attachments(self, res_ids, render_fields, + render_results=None): + """ Render attachments of template 'self', returning values for records + given by 'res_ids'. Note that ``report_template`` returns values for + 'attachments', as we have a list of tuple (report_name, base64 value) + for those reports. It is considered as being the job of callers to + transform those attachments into valid ``ir.attachment`` records. + + :param list res_ids: list of record IDs on which template is rendered; + :param list render_fields: list of fields to render on template which + are specific to attachments, e.g. attachment_ids or report_template; + :param dict render_results: res_ids-based dictionary of render values. + For each res_id, a dict of values based on render_fields is given + + :return: updated (or new) render_results; + """ + self.ensure_one() + if render_results is None: + render_results = {} + + for res_id in res_ids: + values = render_results.setdefault(res_id, {}) + + # link template attachments directly + if 'attachment_ids' in render_fields: + values['attachment_ids'] = self.attachment_ids.ids + + # generate attachments (reports) + if 'report_template' in render_fields and self.report_template: + report = self.report_template + + if report.report_type in ['qweb-html', 'qweb-pdf']: + report_content, report_format = self.env['ir.actions.report']._render_qweb_pdf(report, [res_id]) + else: + render_res = self.env['ir.actions.report']._render(report, [res_id]) + if not render_res: + raise UserError(_('Unsupported report type %s found.', report.report_type)) + report_content, report_format = render_res + report_content = base64.b64encode(report_content) + + report_name = self._render_field('report_name', [res_id])[res_id] + if not report_name: + report_name = 'report.' + report.report_name + ext = "." + report_format + if not report_name.endswith(ext): + report_name += ext + + values['attachments'] = [(report_name, report_content)] + elif 'report_template' in render_fields: + values['attachments'] = [] + + # hook for attachments-specific computation, used currently only for accounting + if hasattr(self.env[self.model], '_process_attachments_for_template_post'): + records_attachments = self.env[self.model].browse(res_ids)._process_attachments_for_template_post(self) + for res_id, additional_attachments in records_attachments.items(): + if not additional_attachments: + continue + if additional_attachments.get('attachment_ids'): + render_results[res_id].setdefault('attachment_ids', []).extend(additional_attachments['attachment_ids']) + if additional_attachments.get('attachments'): + render_results[res_id].setdefault('attachments', []).extend(additional_attachments['attachments']) + + return render_results + def _generate_template_recipients(self, res_ids, render_fields, find_or_create_partners=False, render_results=None): @@ -263,6 +327,8 @@ class MailTemplate(models.Model): record_values['partner_ids'] = partner_ids + return render_results + def _generate_template(self, res_ids, render_fields, find_or_create_partners=False): """ Render values from template 'self' on records given by 'res_ids'. @@ -283,11 +349,11 @@ class MailTemplate(models.Model): self.ensure_one() render_fields_set = set(render_fields) fields_specific = { - 'attachments', # attachments 'attachment_ids', # attachments 'email_cc', # recipients 'email_to', # recipients 'partner_to', # recipients + 'report_template', # attachments # not rendered (static) 'auto_delete', 'mail_server_id', @@ -329,8 +395,6 @@ class MailTemplate(models.Model): values['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False # technical settings - if 'attachments' in render_fields or 'attachment_ids' in render_fields: - values['attachment_ids'] = template.attachment_ids.ids if 'auto_delete' in render_fields: values['auto_delete'] = template.auto_delete if 'mail_server_id' in render_fields: @@ -340,42 +404,13 @@ class MailTemplate(models.Model): if 'res_id' in render_fields: values['res_id'] = res_id or False - # render attachments (report part) - if ('attachments' in render_fields or 'attachment_ids' in render_fields) and template.report_template: - for res_id in template_res_ids: - attachments = [] - report_name = template._render_field('report_name', [res_id])[res_id] - report = template.report_template - report_service = report.report_name - - if report.report_type in ['qweb-html', 'qweb-pdf']: - result, report_format = self.env['ir.actions.report']._render_qweb_pdf(report, [res_id]) - else: - res = self.env['ir.actions.report']._render(report, [res_id]) - if not res: - raise UserError(_('Unsupported report type %s found.', report.report_type)) - result, report_format = res - - # TODO in trunk, change return report_format to binary to match message_post expected format - result = base64.b64encode(result) - if not report_name: - report_name = 'report.' + report_service - ext = "." + report_format - if not report_name.endswith(ext): - report_name += ext - attachments.append((report_name, result)) - render_results[res_id]['attachments'] = attachments - - # hook for attachments-specific computation, used currently only for accounting - if ('attachments' in render_fields or 'attachment_ids' in render_fields) and hasattr(self.env[self.model], '_process_attachments_for_template_post'): - records_attachments = self.env[self.model].browse(template_res_ids)._process_attachments_for_template_post(template) - for res_id, additional_attachments in records_attachments.items(): - if not additional_attachments: - continue - if additional_attachments.get('attachment_ids'): - render_results[res_id].setdefault('attachment_ids', []).extend(additional_attachments['attachment_ids']) - if additional_attachments.get('attachments'): - render_results[res_id].setdefault('attachments', []).extend(additional_attachments['attachments']) + # generate attachments if requested + if render_fields_set & {'attachment_ids', 'report_template'}: + template._generate_template_attachments( + template_res_ids, + render_fields_set, + render_results=render_results + ) return render_results @@ -411,8 +446,7 @@ class MailTemplate(models.Model): # create a mail_mail based on values, without attachments values = self._generate_template( [res_id], - ('attachments', - 'attachment_ids', + ('attachment_ids', 'auto_delete', 'body_html', 'email_cc', @@ -422,6 +456,7 @@ class MailTemplate(models.Model): 'model', 'partner_to', 'reply_to', + 'report_template', 'res_id', 'scheduled_date', 'subject', diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 38e0e734cbc..87fa9fc04c7 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -587,8 +587,7 @@ class MailComposer(models.TransientModel): elif template_id: values = self._generate_email_for_composer( template_id, [res_id], - ('attachments', - 'attachment_ids', + ('attachment_ids', 'body_html', 'email_cc', 'email_from', @@ -596,6 +595,7 @@ class MailComposer(models.TransientModel): 'mail_server_id', 'partner_to', 'reply_to', + 'report_template', 'subject', ) )[res_id] @@ -695,12 +695,12 @@ class MailComposer(models.TransientModel): if self.template_id: template_values = self._generate_email_for_composer( self.template_id.id, res_ids, - ('attachments', - 'attachment_ids', + ('attachment_ids', 'email_to', 'email_cc', 'mail_server_id', 'partner_to', + 'report_template', ) ) else: @@ -725,7 +725,9 @@ class MailComposer(models.TransientModel): def _generate_email_for_composer(self, template_id, res_ids, render_fields): """ Call email_template._generate_template(), get fields relevant for mail.compose.message, transform email_cc and email_to into partner_ids """ - returned_fields = list(render_fields) + ['partner_ids', 'attachments'] + returned_fields = list(render_fields) + ['partner_ids'] + if 'report_template' in render_fields: + returned_fields.append('attachments') values = dict.fromkeys(res_ids, False) template_values = self.env['mail.template'].browse(template_id)._generate_template( diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 2de5f26b975..14367e1e250 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -8,14 +8,14 @@ from odoo.exceptions import UserError class MailTemplatePreview(models.TransientModel): _name = 'mail.template.preview' _description = 'Email Template Preview' - _MAIL_TEMPLATE_FIELDS = ['attachments', - 'attachment_ids', + _MAIL_TEMPLATE_FIELDS = ['attachment_ids', 'body_html', 'subject', 'email_cc', 'email_from', 'email_to', 'partner_to', + 'report_template', 'reply_to', 'scheduled_date', ] @@ -94,9 +94,9 @@ class MailTemplatePreview(models.TransientModel): def _set_mail_attributes(self, values=None): for field in self._MAIL_TEMPLATE_FIELDS: - if field in ('attachments', 'partner_to'): + if field in ('partner_to', 'report_template'): # partner_to is used to generate partner_ids, handled here below - # attachments are a list of attachments, no usage here + # report_template generates attachments, no usage here continue field_value = values.get(field, False) if values else self.mail_template_id[field] self[field] = field_value diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 26c03c646a0..6b3a364d342 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -136,8 +136,7 @@ class ChannelUsersRelation(models.Model): for template, records in template_to_records.items(): record_values = template._generate_template( records.ids, - ['attachments', - 'attachment_ids', + ['attachment_ids', 'body_html', 'email_cc', 'email_from', @@ -146,6 +145,7 @@ class ChannelUsersRelation(models.Model): 'model', 'partner_to', 'reply_to', + 'report_template', 'res_id', 'scheduled_date', 'subject', From d744f86a9812211963bd394350eef1105202026e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 23 Sep 2022 12:26:25 +0000 Subject: [PATCH 27/45] [REF] mail: make other fields generation on template independent Purpose of this commit is to extract fields generation on template for fields not belonging to recipients or attachments in sub-methods. Code is cleaned to better support parameters and be easier to call. It can now be called independently from the main ``_generate_template`` method. First sub method is dedicated to 'scheduled_date' which is dynamic and has its own post processing (to be UTC agnostic as expected by ORM). Second one is dedicated to static values coming directly from template without rendering. Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_render_mixin.py | 9 +++ addons/mail/models/mail_template.py | 85 +++++++++++++++++++------ 2 files changed, 75 insertions(+), 19 deletions(-) diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 523b122c50c..6f401a12258 100644 --- a/addons/mail/models/mail_render_mixin.py +++ b/addons/mail/models/mail_render_mixin.py @@ -465,6 +465,15 @@ class MailRenderMixin(models.AbstractModel): rendered[res_id] = self._replace_local_links(rendered_html) return rendered + @api.model + def _process_scheduled_date(self, scheduled_date): + if scheduled_date: + # parse scheduled_date to make it timezone agnostic UTC as expected + # by the ORM + parsed_datetime = self.env['mail.mail']._parse_scheduled_datetime(scheduled_date) + scheduled_date = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False + return scheduled_date + @api.model def _render_template(self, template_src, model, res_ids, engine='inline_template', add_context=None, options=None): diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 09d172b9b68..edbb9bf8381 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -329,6 +329,59 @@ class MailTemplate(models.Model): return render_results + def _generate_template_scheduled_date(self, res_ids, render_results=None): + """ Render scheduled date based on template 'self'. Specific parsing is + done to ensure value matches ORM expected value: UTC but without + timezone set in value. + + :param list res_ids: list of record IDs on which template is rendered; + :param dict render_results: res_ids-based dictionary of render values. + For each res_id, a dict of values based on render_fields is given; + + :return: updated (or new) render_results; + """ + self.ensure_one() + if render_results is None: + render_results = {} + + scheduled_dates = self._render_field('scheduled_date', res_ids) + for res_id in res_ids: + scheduled_date = self._process_scheduled_date(scheduled_dates.get(res_id)) + render_results.setdefault(res_id, {})['scheduled_date'] = scheduled_date + + return render_results + + def _generate_template_static_values(self, res_ids, render_fields, render_results=None): + """ Return values based on template 'self'. Those are not rendered nor + dynamic, just static values used for configuration of emails. + + :param list res_ids: list of record IDs on which template is rendered; + :param list render_fields: list of fields to render, currently limited + to a subset (i.e. auto_delete, mail_server_id, model, res_id); + :param dict render_results: res_ids-based dictionary of render values. + For each res_id, a dict of values based on render_fields is given; + + :return: updated (or new) render_results; + """ + self.ensure_one() + if render_results is None: + render_results = {} + + for res_id in res_ids: + values = render_results.setdefault(res_id, {}) + + # technical settings + if 'auto_delete' in render_fields: + values['auto_delete'] = self.auto_delete + if 'mail_server_id' in render_fields: + values['mail_server_id'] = self.mail_server_id.id + if 'model' in render_fields: + values['model'] = self.model + if 'res_id' in render_fields: + values['res_id'] = res_id or False + + return render_results + def _generate_template(self, res_ids, render_fields, find_or_create_partners=False): """ Render values from template 'self' on records given by 'res_ids'. @@ -354,6 +407,7 @@ class MailTemplate(models.Model): 'email_to', # recipients 'partner_to', # recipients 'report_template', # attachments + 'scheduled_date', # specific # not rendered (static) 'auto_delete', 'mail_server_id', @@ -383,26 +437,19 @@ class MailTemplate(models.Model): find_or_create_partners=find_or_create_partners ) - # add values static for all res_ids - for res_id in template_res_ids: - values = render_results[res_id] - if values.get('body_html'): - values['body'] = tools.html_sanitize(values['body_html']) - # if asked in fields to return, parse generated date into tz agnostic UTC as expected by ORM - scheduled_date = values.pop('scheduled_date', None) - if 'scheduled_date' in render_fields and scheduled_date: - parsed_datetime = self.env['mail.mail']._parse_scheduled_datetime(scheduled_date) - values['scheduled_date'] = parsed_datetime.replace(tzinfo=None) if parsed_datetime else False + # render scheduled_date + if 'scheduled_date' in render_fields_set: + template._generate_template_scheduled_date( + template_res_ids, + render_results=render_results + ) - # technical settings - if 'auto_delete' in render_fields: - values['auto_delete'] = template.auto_delete - if 'mail_server_id' in render_fields: - values['mail_server_id'] = template.mail_server_id.id - if 'model' in render_fields: - values['model'] = template.model - if 'res_id' in render_fields: - values['res_id'] = res_id or False + # add values static for all res_ids + template._generate_template_static_values( + template_res_ids, + render_fields_set, + render_results=render_results + ) # generate attachments if requested if render_fields_set & {'attachment_ids', 'report_template'}: From ae8f0f2e83ee2c8c69006451ff96f56144d78631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 30 Sep 2022 13:39:47 +0000 Subject: [PATCH 28/45] [REF] mail: improve template recipients rendering and generation When computing recipients based on template we generally create new partners based on emails. This is due to the mail framework managing mainly partners and not low level emails. This code is currently sequential: fetching or creating partners one by one based on found emails, for each record. This is highly inefficient when sending emails on a lot of records based on templates. Purpose of this commit is to improve code and performances by batching. We do queries in batch: one search for all records, create partner in batch. This leads to code being a bit more complicated to correctly remember and dispatch again emails and partners to the correct records. Task-3034875 (Mail: Speedup and batch partners find or create with templates) Part-of: odoo/odoo#99482 --- addons/mail/models/mail_template.py | 72 ++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index edbb9bf8381..d4f4c999944 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -282,6 +282,7 @@ class MailTemplate(models.Model): self.ensure_one() if render_results is None: render_results = {} + records_sudo = None # if using default recipients -> ``_message_get_default_recipients`` gives # values for email_to, email_cc and partner_ids @@ -297,35 +298,60 @@ class MailTemplate(models.Model): for res_id in res_ids: render_results.setdefault(res_id, {})[field] = generated_field_values[res_id] - records_company = None + # classify records per company for partner fetch/creation if find_or_create_partners and self.model and 'company_id' in self.env[self.model]._fields: - records_dict = self.env[self.model].browse(res_ids).sudo().read(['company_id']) - records_company = { - rec['id']: rec['company_id'][0] if rec['company_id'] else False - for rec in records_dict - } + if not records_sudo: + records_sudo = self.env[self.model].browse(res_ids).sudo() + records_per_company = {} + for read_record in records_sudo.read(['company_id']): + company_id = read_record['company_id'][0] if read_record['company_id'] else False + records_per_company.setdefault(company_id, []).append(read_record['id']) + else: + records_per_company = {False: res_ids} - # consolidate partner_ids: based on partner_to + create partners if requested - for res_id in res_ids: - record_values = render_results[res_id] + # create partners from emails if asked to + if find_or_create_partners: + for company_id, record_ids in records_per_company.items(): + all_emails = [] + email_to_res_ids = {} + for res_id in record_ids: + record_values = render_results.setdefault(res_id, {}) + mails = tools.email_split(record_values.pop('email_to', '')) + \ + tools.email_split(record_values.pop('email_cc', '')) + all_emails += mails + for mail in mails: + email_to_res_ids.setdefault(mail, []).append(res_id) - partner_ids = record_values.get('partner_ids', []) - if find_or_create_partners: - mails = tools.email_split(record_values.pop('email_to', '')) + tools.email_split(record_values.pop('email_cc', '')) - Partner = self.env['res.partner'] - if records_company: - Partner = Partner.with_context(default_company_id=records_company[res_id]) - for mail in mails: - partner = Partner.find_or_create(mail) - partner_ids.append(partner.id) + if not all_emails: + continue + additional_values = {} + if company_id: + additional_values['company_id'] = company_id + partners = self.env['res.partner']._find_or_create_from_emails( + all_emails, + additional_values=additional_values + ) + for original_email, partner in zip(all_emails, partners): + if not partner: + continue + for res_id in email_to_res_ids[original_email]: + render_results[res_id].setdefault('partner_ids', []).append(partner.id) + # update 'partner_to' rendered value to 'partner_ids' + all_partner_to = { + int(pid) + for record_values in render_results.values() + for pid in record_values.get('partner_to', '').split(',') + if pid and pid.strip() + } + existing_pids = set() + if all_partner_to: + existing_pids = set(self.env['res.partner'].sudo().browse(list(all_partner_to)).exists().ids) + for res_id, record_values in render_results.items(): partner_to = record_values.pop('partner_to', '') if partner_to: - # placeholders could generate '', 3, 2 due to some empty field values - tpl_partner_ids = [int(pid) for pid in partner_to.split(',') if pid] - partner_ids += self.env['res.partner'].sudo().browse(tpl_partner_ids).exists().ids - - record_values['partner_ids'] = partner_ids + tpl_partner_ids = set(int(pid) for pid in partner_to.split(',') if pid) & existing_pids + record_values.setdefault('partner_ids', []).extend(tpl_partner_ids) return render_results From 26f1f7c4331c45dc6247ed0941933199a16bb1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 16 Dec 2022 14:14:36 +0000 Subject: [PATCH 29/45] [MOV] mail: reorganize composer values generation code Purpose of this commit is to move methods to better prepare future changes. Currently onchange code, value generation and tools for email management are mixed, which leads to the file being quite messy. Separate methods generating mail or message values, from methods doing data marshmalling. Task-2710804 (Mail: Clean MailThread Posting API) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_compose_message.py | 310 +++++++++++---------- 1 file changed, 158 insertions(+), 152 deletions(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 87fa9fc04c7..d3cca56b7d7 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -178,6 +178,14 @@ class MailComposer(models.TransientModel): for composer in self: composer.render_model = composer.model + def _compute_can_edit_body(self): + """Can edit the body if we are not in "mass_mail" mode because the template is + rendered before it's modified. + """ + non_mass_mail = self.filtered(lambda m: m.composition_mode != 'mass_mail') + non_mass_mail.can_edit_body = True + super(MailComposer, self - non_mass_mail)._compute_can_edit_body() + # Onchanges @api.onchange('template_id') @@ -187,13 +195,94 @@ class MailComposer(models.TransientModel): for fname, value in values.items(): setattr(self, fname, value) - def _compute_can_edit_body(self): - """Can edit the body if we are not in "mass_mail" mode because the template is - rendered before it's modified. + def _onchange_template_id(self, template_id, composition_mode, model, res_id): + """ Perform the onchange. + + * mass_mailing: we cannot render, so return the template values + * normal mode: return rendered values + -> for x2many field, this onchange return command instead of ids """ - non_mass_mail = self.filtered(lambda m: m.composition_mode != 'mass_mail') - non_mass_mail.can_edit_body = True - super(MailComposer, self - non_mass_mail)._compute_can_edit_body() + if template_id and composition_mode == 'mass_mail': + template = self.env['mail.template'].browse(template_id) + values = dict( + (field, template[field]) + for field in ('body_html', + 'email_from', + 'mail_server_id', + 'reply_to', + 'subject', + ) + if template[field] + ) + if template.attachment_ids: + values['attachment_ids'] = [att.id for att in template.attachment_ids] + if template.mail_server_id: + values['mail_server_id'] = template.mail_server_id.id + elif template_id: + values = self._generate_email_for_composer( + template_id, [res_id], + ('attachment_ids', + 'body_html', + 'email_cc', + 'email_from', + 'email_to', + 'mail_server_id', + 'partner_to', + 'reply_to', + 'report_template', + 'subject', + ) + )[res_id] + # transform attachments into attachment_ids; not attached to the document because this will + # be done further in the posting process, allowing to clean database if email not send + attachment_ids = [] + Attachment = self.env['ir.attachment'] + for attach_fname, attach_datas in values.pop('attachments', []): + data_attach = { + 'name': attach_fname, + 'datas': attach_datas, + 'res_model': 'mail.compose.message', + 'res_id': 0, + 'type': 'binary', # override default_type from context, possibly meant for another model! + } + attachment_ids.append(Attachment.create(data_attach).id) + if values.get('attachment_ids', []) or attachment_ids: + values['attachment_ids'] = [Command.set(values.get('attachment_ids', []) + attachment_ids)] + else: + default_values = self.with_context( + default_composition_mode=composition_mode, + default_model=model, + default_res_id=res_id + ).default_get(['attachment_ids', + 'body', + 'composition_mode', + 'email_from', + 'mail_server_id', + 'model', + 'parent_id', + 'partner_ids', + 'reply_to', + 'res_id', + 'subject', + ]) + values = dict( + (key, default_values[key]) + for key in ('attachment_ids', + 'body', + 'email_from', + 'mail_server_id', + 'partner_ids', + 'reply_to', + 'subject', + ) if key in default_values) + + if values.get('body_html'): + values['body'] = values.pop('body_html') + + # This onchange should return command instead of ids for x2many field. + values = self._convert_to_write(values) + + return {'value': values} @api.model def get_record_data(self, values): @@ -460,46 +549,6 @@ class MailComposer(models.TransientModel): results = self._process_state(results) return results - def _process_recipient_values(self, mail_values_dict): - # Preprocess res.partners to batch-fetch from db if recipient_ids is present - # it means they are partners (the only object to fill get_default_recipient this way) - recipient_pids = [ - recipient_command[1] - for mail_values in mail_values_dict.values() - # recipient_ids is a list of x2m command tuples at this point - for recipient_command in mail_values.get('recipient_ids') or [] - if recipient_command[1] - ] - recipient_emails = { - p.id: p.email - for p in self.env['res.partner'].browse(set(recipient_pids)) - } if recipient_pids else {} - - recipients_info = {} - for record_id, mail_values in mail_values_dict.items(): - mail_to = [] - if mail_values.get('email_to'): - mail_to += email_re.findall(mail_values['email_to']) - # if unrecognized email in email_to -> keep it as used for further processing - if not mail_to: - mail_to.append(mail_values['email_to']) - # add email from recipients (res.partner) - mail_to += [ - recipient_emails[recipient_command[1]] - for recipient_command in mail_values.get('recipient_ids') or [] - if recipient_command[1] - ] - mail_to = list(set(mail_to)) - recipients_info[record_id] = { - 'mail_to': mail_to, - 'mail_to_normalized': [ - tools.email_normalize(mail) - for mail in mail_to - if tools.email_normalize(mail) - ] - } - return recipients_info - def _process_state(self, mail_values_dict): recipients_info = self._process_recipient_values(mail_values_dict) blacklist_ids = self._get_blacklist_record_ids(mail_values_dict) @@ -544,112 +593,6 @@ class MailComposer(models.TransientModel): return mail_values_dict - def _get_blacklist_record_ids(self, mail_values_dict): - blacklisted_rec_ids = set() - if self.composition_mode == 'mass_mail' and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']): - self.env['mail.blacklist'].flush_model(['email', 'active']) - self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true") - blacklist = {x[0] for x in self._cr.fetchall()} - if blacklist: - targets = self.env[self.model].browse(mail_values_dict.keys()).read(['email_normalized']) - # First extract email from recipient before comparing with blacklist - blacklisted_rec_ids.update(target['id'] for target in targets - if target['email_normalized'] in blacklist) - return blacklisted_rec_ids - - def _get_done_emails(self, mail_values_dict): - return [] - - def _get_optout_emails(self, mail_values_dict): - return [] - - def _onchange_template_id(self, template_id, composition_mode, model, res_id): - """ - mass_mailing: we cannot render, so return the template values - - normal mode: return rendered values - /!\ for x2many field, this onchange return command instead of ids - """ - if template_id and composition_mode == 'mass_mail': - template = self.env['mail.template'].browse(template_id) - values = dict( - (field, template[field]) - for field in ('body_html', - 'email_from', - 'mail_server_id', - 'reply_to', - 'subject', - ) - if template[field] - ) - if template.attachment_ids: - values['attachment_ids'] = [att.id for att in template.attachment_ids] - if template.mail_server_id: - values['mail_server_id'] = template.mail_server_id.id - elif template_id: - values = self._generate_email_for_composer( - template_id, [res_id], - ('attachment_ids', - 'body_html', - 'email_cc', - 'email_from', - 'email_to', - 'mail_server_id', - 'partner_to', - 'reply_to', - 'report_template', - 'subject', - ) - )[res_id] - # transform attachments into attachment_ids; not attached to the document because this will - # be done further in the posting process, allowing to clean database if email not send - attachment_ids = [] - Attachment = self.env['ir.attachment'] - for attach_fname, attach_datas in values.pop('attachments', []): - data_attach = { - 'name': attach_fname, - 'datas': attach_datas, - 'res_model': 'mail.compose.message', - 'res_id': 0, - 'type': 'binary', # override default_type from context, possibly meant for another model! - } - attachment_ids.append(Attachment.create(data_attach).id) - if values.get('attachment_ids', []) or attachment_ids: - values['attachment_ids'] = [Command.set(values.get('attachment_ids', []) + attachment_ids)] - else: - default_values = self.with_context( - default_composition_mode=composition_mode, - default_model=model, - default_res_id=res_id - ).default_get(['attachment_ids', - 'body', - 'composition_mode', - 'email_from', - 'mail_server_id', - 'model', - 'parent_id', - 'partner_ids', - 'reply_to', - 'res_id', - 'subject', - ]) - values = dict( - (key, default_values[key]) - for key in ('attachment_ids', - 'body', - 'email_from', - 'mail_server_id', - 'partner_ids', - 'reply_to', - 'subject', - ) if key in default_values) - - if values.get('body_html'): - values['body'] = values.pop('body_html') - - # This onchange should return command instead of ids for x2many field. - values = self._convert_to_write(values) - - return {'value': values} - def _render_message(self, res_ids): """Generate template-based values of wizard, for the document records given by res_ids. This method is meant to be inherited by email_template that @@ -741,3 +684,66 @@ class MailComposer(models.TransientModel): values[res_id] = res_id_values return values + + # ---------------------------------------------------------------------- + # EMAIL MANAGEMENT + # --------------------------------------------------------------------- + + def _get_blacklist_record_ids(self, mail_values_dict): + blacklisted_rec_ids = set() + if self.composition_mode == 'mass_mail' and issubclass(type(self.env[self.model]), self.pool['mail.thread.blacklist']): + self.env['mail.blacklist'].flush_model(['email', 'active']) + self._cr.execute("SELECT email FROM mail_blacklist WHERE active=true") + blacklist = {x[0] for x in self._cr.fetchall()} + if blacklist: + targets = self.env[self.model].browse(mail_values_dict.keys()).read(['email_normalized']) + # First extract email from recipient before comparing with blacklist + blacklisted_rec_ids.update(target['id'] for target in targets + if target['email_normalized'] in blacklist) + return blacklisted_rec_ids + + def _get_done_emails(self, mail_values_dict): + return [] + + def _get_optout_emails(self, mail_values_dict): + return [] + + def _process_recipient_values(self, mail_values_dict): + # Preprocess res.partners to batch-fetch from db if recipient_ids is present + # it means they are partners (the only object to fill get_default_recipient this way) + recipient_pids = [ + recipient_command[1] + for mail_values in mail_values_dict.values() + # recipient_ids is a list of x2m command tuples at this point + for recipient_command in mail_values.get('recipient_ids') or [] + if recipient_command[1] + ] + recipient_emails = { + p.id: p.email + for p in self.env['res.partner'].browse(set(recipient_pids)) + } if recipient_pids else {} + + recipients_info = {} + for record_id, mail_values in mail_values_dict.items(): + mail_to = [] + if mail_values.get('email_to'): + mail_to += email_re.findall(mail_values['email_to']) + # if unrecognized email in email_to -> keep it as used for further processing + if not mail_to: + mail_to.append(mail_values['email_to']) + # add email from recipients (res.partner) + mail_to += [ + recipient_emails[recipient_command[1]] + for recipient_command in mail_values.get('recipient_ids') or [] + if recipient_command[1] + ] + mail_to = list(set(mail_to)) + recipients_info[record_id] = { + 'mail_to': mail_to, + 'mail_to_normalized': [ + tools.email_normalize(mail) + for mail in mail_to + if tools.email_normalize(mail) + ] + } + return recipients_info From 041e9924c7abd8956639ac0c646f5ba7dad6fb76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 5 Jan 2023 17:12:27 +0000 Subject: [PATCH 30/45] [REF] mail: improve default record-based data in composer Purpose is to ease understanding of those default values and prepare move towards editable stored computed fields by rewriting a bit the code to better understand its purpose. While being at it, some tests introduced recently are moved at their right place now that everything is ready to rewrite the posting API and the composer. Some tests are added to ensure notification-specific methods are called along with the newly-introduced '_message_compute_subject', aka methods to add custom header and custom mail values. Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in compo Part-of: odoo/odoo#99482 --- addons/mail/models/mail_thread.py | 23 +++---- addons/mail/wizard/mail_compose_message.py | 36 ++++++----- addons/test_mail/models/test_mail_models.py | 15 +++++ addons/test_mail/tests/test_mail_composer.py | 67 +++++++++++++------- addons/test_mail/tests/test_message_post.py | 34 +++++----- 5 files changed, 107 insertions(+), 68 deletions(-) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index aa1a5621414..4896930c645 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2544,10 +2544,10 @@ class MailThread(models.AbstractModel): return parent_id def _message_compute_subject(self): - """Overridable method to get the default subject for a message related to this record. + """ Get the default subject for a message posted in this record's + discussion thread. - :return str: - """ + :return str: default subject """ self.ensure_one() return self.name_get()[0][1] @@ -3107,17 +3107,14 @@ class MailThread(models.AbstractModel): to notify (mail_message_id and its fields), server, references, subject. """ mail_subject = message.subject - if not message.subject: - thread = self - if not thread and message.model: - thread = self.env[message.model].browse(message.res_id) - if not thread: - mail_subject = message.record_name - else: - mail_subject = thread._message_compute_subject() + if not mail_subject and self: + mail_subject = self._message_compute_subject() + if not mail_subject: + mail_subject = message.record_name + if mail_subject: + # replace new lines by spaces to conform to email headers requirements + mail_subject = ' '.join(mail_subject.splitlines()) - # Replace new lines by spaces to conform to email headers requirements - mail_subject = ' '.join((mail_subject or '').splitlines()) # compute references: set references to the parent and add current message just to # have a fallback in case replies mess with Messsage-Id in the In-Reply-To (e.g. amazon # SES SMTP may replace Message-Id and In-Reply-To refers an internal ID not stored in Odoo) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index d3cca56b7d7..76a23ba305b 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -290,25 +290,31 @@ class MailComposer(models.TransientModel): wizard when sending an email related a previous email (parent_id) or a document (model, res_id). This is based on previously computed default values. """ - result, subject = {}, False + result, record_name, subject = {}, False, False + model, res_id = values.get('model'), values.get('res_id') if values.get('parent_id'): parent = self.env['mail.message'].browse(values.get('parent_id')) - result['record_name'] = parent.record_name - if not values.get('model'): - result['model'] = parent.model - if not values.get('res_id'): - result['res_id'] = parent.res_id - partner_ids = values.get('partner_ids', list()) + parent.partner_ids.ids - result['partner_ids'] = partner_ids - record = self.env[values.get('model') or result['model']].browse(values.get('res_id') or result['res_id']) - parent_subject = tools.ustr(parent.subject or '') - subject = parent_subject or record._message_compute_subject() - elif values.get('model') and values.get('res_id'): - record = self.env[values['model']].browse(values['res_id']) - doc_name = record.display_name - result['record_name'] = doc_name or '' + if not model: + model = parent.model + result['model'] = model + if not res_id: + res_id = parent.res_id + result['res_id'] = res_id + result['partner_ids'] = values.get('partner_ids', list()) + parent.partner_ids.ids + record_name = parent.record_name + subject = tools.ustr(parent.subject or '') + if (not subject or not record_name) and model and res_id: + record = self.env[model].browse(res_id) + if not record_name: + record_name = record.display_name + if not subject: + subject = record._message_compute_subject() + elif model and res_id: + record = self.env[model].browse(res_id) + record_name = record.display_name or '' subject = record._message_compute_subject() + result['record_name'] = record_name result['subject'] = subject return result diff --git a/addons/test_mail/models/test_mail_models.py b/addons/test_mail/models/test_mail_models.py index 60976cb9c9f..dee3b5da83d 100644 --- a/addons/test_mail/models/test_mail_models.py +++ b/addons/test_mail/models/test_mail_models.py @@ -14,6 +14,21 @@ class MailTestSimple(models.Model): name = fields.Char() email_from = fields.Char() + def _message_compute_subject(self): + """ To ease mocks """ + _a = super()._message_compute_subject() + return _a + + def _notify_by_email_get_final_mail_values(self, *args, **kwargs): + """ To ease mocks """ + _a = super()._notify_by_email_get_final_mail_values(*args, **kwargs) + return _a + + def _notify_by_email_get_headers(self): + headers = super()._notify_by_email_get_headers() + headers['X-Custom'] = 'Done' + return headers + class MailTestGateway(models.Model): """ A very simple model only inheriting from mail.thread to test pure mass diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index dfabe4cb5d7..93ff0335c3d 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -121,6 +121,8 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.reply_to_force_new) self.assertEqual(composer_form.res_id, self.test_record.id) self.assertEqual(composer_form.subject, self.test_record._message_compute_subject()) + self.assertIn(f'Ticket for {self.test_record.name}', composer_form.subject, + 'Check effective content') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) @users('employee') @@ -626,6 +628,8 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.record_name, 'Manual update', 'TODO: Reset not called') self.assertEqual(composer.subject, self.test_record._message_compute_subject()) + self.assertIn(f'Ticket for {self.test_record.name}', composer.subject, + 'Check effective content') else: self.assertFalse(composer.body) # self.assertFalse(composer.mail_server_id.id) @@ -693,25 +697,6 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.record_name, 'CustomName') self.assertEqual(composer.subject, 'My amazing subject') - @users('employee') - def test_mail_composer_default_subject(self): - """Make sure the default subject is applied in the composer.""" - simple_record = self.env['mail.test.simple'].create({'name': 'TestA'}) - ticket_record = self.env['mail.test.ticket'].create({'name': 'Test1'}) - # default behaviour, use the record name - ctx = self._get_web_context(simple_record, add_web=False, default_composition_mode='comment') - _, message = self.env['mail.compose.message'].with_context(ctx).create({ - 'body': '

Test Body

', - })._action_send_mail() - - self.assertEqual(message.subject, simple_record.name) - # custom subject - ctx = self._get_web_context(ticket_record, add_web=False, default_composition_mode='comment') - _, messages = self.env['mail.compose.message'].with_context(ctx).create({ - 'body': '

Test Body

', - })._action_send_mail() - self.assertEqual(messages.subject, ticket_record._message_compute_subject()) - @users('employee') @mute_logger('odoo.models.unlink') def test_mail_composer_recipients(self): @@ -884,10 +869,12 @@ class TestComposerInternals(TestMailComposer): def test_mail_composer_parent(self): """ Test specific management in comment mode when having parent_id set: record_name, subject, parent's partners. """ - subject = "Parent Subject" - parent = self.test_record.message_post(subject=subject, - body='Test', - partner_ids=(self.partner_1 + self.partner_2).ids) + parent_subject = "Parent Subject" + parent = self.test_record.message_post( + body='Test', + partner_ids=(self.partner_1 + self.partner_2).ids, + subject=parent_subject, + ) composer = self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record, add_web=False, default_parent_id=parent.id) @@ -900,7 +887,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.parent_id, parent) self.assertEqual(composer.partner_ids, self.partner_1 + self.partner_2) self.assertEqual(composer.record_name, self.test_record.name) - self.assertEqual(composer.subject, subject) + self.assertEqual(composer.subject, parent_subject) @users('user_rendering_restricted') @mute_logger('odoo.tests', 'odoo.addons.base.models.ir_rule', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') @@ -992,6 +979,38 @@ class TestComposerResultsComment(TestMailComposer): """ Test global output of composer used in comment mode. Test notably notification and emails generated during this process. """ + @users('employee') + def test_mail_composer_default_subject(self): + """ Make sure the default subject is applied in the composer. """ + simple_record = self.env['mail.test.simple'].create({'name': 'TestSimple'}) + ticket_record = self.env['mail.test.ticket'].create({'name': 'TestTicket'}) + + # default behavior: use record name + _, message = self.env['mail.compose.message'].with_context( + self._get_web_context(simple_record, add_web=False, composition_mode='comment') + ).create({ + 'body': '

Test Body

', + })._action_send_mail() + self.assertEqual(message.subject, simple_record.name) + + # overridden in model + _, message = self.env['mail.compose.message'].with_context( + self._get_web_context(ticket_record, add_web=False, composition_mode='comment') + ).create({ + 'body': '

Test Body

', + })._action_send_mail() + self.assertEqual(message.subject, ticket_record._message_compute_subject()) + self.assertIn(f'Ticket for {ticket_record.name}', message.subject) + + # forced value + _, message = self.env['mail.compose.message'].with_context( + self._get_web_context(ticket_record, add_web=False, composition_mode='comment') + ).create({ + 'body': '

Test Body

', + 'subject': 'Forced Subject', + })._action_send_mail() + self.assertEqual(message.subject, 'Forced Subject',) + @users('employee') @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_notifications_delete(self): diff --git a/addons/test_mail/tests/test_message_post.py b/addons/test_mail/tests/test_message_post.py index 2e1f05ccf0b..fe492e9c55d 100644 --- a/addons/test_mail/tests/test_message_post.py +++ b/addons/test_mail/tests/test_message_post.py @@ -325,20 +325,6 @@ class TestMessageNotify(TestMessagePostCommon): subject='Notify Subject', ) - @users('employee') - def test_notify_default_subject(self): - """ Test notify in batch. Currently not supported. """ - test_partner = self.env['res.partner'].sudo().create([{'name': 'John', 'email': 'john@test.lan'}]) - test_record = self.env['mail.test.ticket'].create({'name': 'Test'}) - mt_comment = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') - test_record.message_subscribe([test_partner.id], subtype_ids=[mt_comment]) - - with self.mock_mail_gateway(), self.mock_mail_app(): - test_record.message_post(body="Test Message", message_type="comment", subtype_id=mt_comment) - - self.assertEqual(len(self._new_msgs), 1) - self.assertEqual(self._new_msgs.subject, test_record._message_compute_subject()) - @users('employee') @mute_logger('odoo.addons.mail.models.mail_mail') def test_notify_from_user_id(self): @@ -630,20 +616,35 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): partner_ids=self.partner_portal.ids, ) - @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') + @mute_logger('odoo.addons.mail.models.mail_mail', 'odoo.models.unlink', 'odoo.tests') @users('employee') def test_message_post_defaults(self): """ Test default values when posting a classic message. """ + _original_compute_subject = MailTestSimple._message_compute_subject + _original_notify_headers = MailTestSimple._notify_by_email_get_headers + _original_notify_mailvals = MailTestSimple._notify_by_email_get_final_mail_values test_record = self.env['mail.test.simple'].create([{'name': 'Defaults'}]) creation_msg = test_record.message_ids self.assertEqual(len(creation_msg), 1) - with self.mock_mail_app(): + with patch.object(MailTestSimple, '_message_compute_subject', + autospec=True, side_effect=_original_compute_subject) as mock_compute_subject, \ + patch.object(MailTestSimple, '_notify_by_email_get_headers', + autospec=True, side_effect=_original_notify_headers) as mock_notify_headers, \ + patch.object(MailTestSimple, '_notify_by_email_get_final_mail_values', + autospec=True, side_effect=_original_notify_mailvals) as mock_notify_mailvals, \ + self.mock_mail_app(): new_message = test_record.message_post( body='Body', partner_ids=[self.partner_employee_2.id], ) + self.assertEqual(mock_compute_subject.call_count, 1, + 'Should call model-based subject computation for outgoing emails') + self.assertEqual(mock_notify_headers.call_count, 1, + 'Should call model-based headers computation for outgoing emails') + self.assertEqual(mock_notify_mailvals.call_count, 1, + 'Should call model-based headers computation for outgoing emails') self.assertMessageFields( new_message, {'author_id': self.partner_employee, @@ -657,6 +658,7 @@ class TestMessagePost(TestMessagePostCommon, CronMixinCase): 'record_name': test_record.name, 'reply_to': formataddr((f'{self.company_admin.name} {test_record.name}', f'{self.alias_catchall}@{self.alias_domain}')), 'res_id': test_record.id, + 'subject': test_record.name, 'subtype_id': self.env.ref('mail.mt_note'), } ) From 09a1647c645a6a8bd49a490d7484fea6aea3ff32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 23 Sep 2022 12:36:01 +0000 Subject: [PATCH 31/45] [REF] mail: improve composer call to template values generation Now that template generation methods have been cleaned we can cleanup calls done in composer. We notably * make methods private, improve their naming and docstrings; * better support input / output of asked fields; * be more coherent with composer fields and template fields usage; Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Composer Onchange to Editable Computed Stored) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_compose_message.py | 84 +++++++++++++------- addons/test_mail/tests/test_mail_composer.py | 5 +- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 76a23ba305b..5a472894342 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -203,12 +203,11 @@ class MailComposer(models.TransientModel): -> for x2many field, this onchange return command instead of ids """ if template_id and composition_mode == 'mass_mail': + # copy raw template values (not rendered due to mass mode) template = self.env['mail.template'].browse(template_id) values = dict( (field, template[field]) - for field in ('body_html', - 'email_from', - 'mail_server_id', + for field in ('email_from', 'reply_to', 'subject', ) @@ -218,16 +217,20 @@ class MailComposer(models.TransientModel): values['attachment_ids'] = [att.id for att in template.attachment_ids] if template.mail_server_id: values['mail_server_id'] = template.mail_server_id.id + if not tools.is_html_empty(template.body_html): + values['body'] = template.body_html elif template_id: - values = self._generate_email_for_composer( - template_id, [res_id], + # render template (mono record, comment mode) and set it as composer values + values = self._generate_template_for_composer( + self.env['mail.template'].browse(template_id), + [res_id], ('attachment_ids', 'body_html', 'email_cc', 'email_from', 'email_to', 'mail_server_id', - 'partner_to', + 'partner_ids', 'reply_to', 'report_template', 'subject', @@ -276,9 +279,6 @@ class MailComposer(models.TransientModel): 'subject', ) if key in default_values) - if values.get('body_html'): - values['body'] = values.pop('body_html') - # This onchange should return command instead of ids for x2many field. values = self._convert_to_write(values) @@ -642,13 +642,14 @@ class MailComposer(models.TransientModel): # generate template-based values if self.template_id: - template_values = self._generate_email_for_composer( - self.template_id.id, res_ids, + template_values = self._generate_template_for_composer( + self.template_id, + res_ids, ('attachment_ids', 'email_to', 'email_cc', 'mail_server_id', - 'partner_to', + 'partner_ids', 'report_template', ) ) @@ -670,26 +671,55 @@ class MailComposer(models.TransientModel): return template_values - @api.model - def _generate_email_for_composer(self, template_id, res_ids, render_fields): - """ Call email_template._generate_template(), get fields relevant for - mail.compose.message, transform email_cc and email_to into partner_ids """ - returned_fields = list(render_fields) + ['partner_ids'] - if 'report_template' in render_fields: - returned_fields.append('attachments') - values = dict.fromkeys(res_ids, False) + def _generate_template_for_composer(self, template, res_ids, render_fields, + find_or_create_partners=True): + """ Generate values based on template and relevant values for the + mail.compose.message wizard. - template_values = self.env['mail.template'].browse(template_id)._generate_template( + :param record template: a mail template, as during onchange mode it + may not be set on self (to remove when removing the onchange); + :param list res_ids: list of record IDs on which template is rendered; + :param list render_fields: list of fields to render on template; + :param boolean find_or_create_partners: transform emails into partners + (see ``Template._generate_template_recipients``); + + :returns: a dict containing all asked fields for each record ID given by + res_ids. Note that + + * 'body' comes from template 'body_html' generation; + * 'attachments' is an additional key coming with 'attachment_ids' due + to report generation (in the format [(report_name, data)] where data + is base64 encoded); + * 'partner_ids' is returned due to recipients generation that gives + partner ids coming from default computation as well as from email + to partner convert (see ``find_or_create_partners``); + """ + self.ensure_one() + + # some fields behave / are named differently on template model + mapping = { + 'attachments': 'report_template', + 'body': 'body_html', + 'partner_ids': 'partner_to', + } + template_fields = {mapping.get(fname, fname) for fname in render_fields} + template_values = template._generate_template( res_ids, - render_fields, + template_fields, find_or_create_partners=True ) - for res_id in res_ids: - res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) - res_id_values['body'] = res_id_values.pop('body_html', '') - values[res_id] = res_id_values - return values + exclusion_list = ('email_cc', 'email_to') if find_or_create_partners else () + mapping = {'body_html': 'body'} + render_results = {} + for res_id in res_ids: + render_results[res_id] = { + mapping.get(fname, fname): value + for fname, value in template_values[res_id].items() + if fname not in exclusion_list and value + } + + return render_results # ---------------------------------------------------------------------- # EMAIL MANAGEMENT diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 93ff0335c3d..eadbda57a5c 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -593,14 +593,13 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') # update with template with void values: void value is not forced in - # rendering mode as well as when copying template values, except for - # body in rendering mode + # rendering mode as well as when copying template values composer.write({'template_id': template_void.id}) # currently onchange necessary composer._onchange_template_id_wrapper() if composition_mode == 'comment': - self.assertFalse(composer.body, 'Void template body resets while other fields not, maybe to fix') + self.assertEqual(composer.body, 'Back to my amazing body') self.assertEqual(composer.mail_server_id, self.mail_server_global) self.assertEqual(composer.record_name, 'Manual update') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') From df6171ac3817015c9cd32851c00f7191428cadad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 28 Oct 2022 10:20:33 +0000 Subject: [PATCH 32/45] [REF] mail: rewrite values generation in composer Purpose is to rewrite value generation as code is messy with a lot of dict updates, rewriting key on top of existing keys until reaching the final value. In this commit we better split computation, to have computation that is static (currently, posting a comment as composer holds final code) separated from dynamic computation (posting a mass mailing, as rendering is done based on qweb or inline template value). It allows to better understand how composer and template fields are used when sending emails or posting messages. This commit should not change anything functionally, even if some values are weirdly computed. Future commits will improve support of composer / template fields, notably through computed field and less cross computation. Also split sending methods: do a mailing in batch in case of mass mailing (allowing commit per batch), and simply loop on records to post a message in case of comment (or mass post). Task-2710804 (Mail: Clean MailThread Posting API) Prepares Task-2088884 (Mail: Use editable computed stored fields in composer) Part-of: odoo/odoo#99482 --- addons/mail/tests/test_mail_composer.py | 4 +- addons/mail/tests/test_mail_template.py | 2 +- addons/mail/wizard/mail_compose_message.py | 539 +++++++++++------- addons/mass_mailing/models/mailing_trace.py | 2 +- .../tests/test_mailing_internals.py | 2 +- .../wizard/mail_compose_message.py | 111 ++-- addons/test_mail/tests/test_mail_composer.py | 2 +- 7 files changed, 394 insertions(+), 268 deletions(-) diff --git a/addons/mail/tests/test_mail_composer.py b/addons/mail/tests/test_mail_composer.py index c0ddca184b5..3ad1f8c79b0 100644 --- a/addons/mail/tests/test_mail_composer.py +++ b/addons/mail/tests/test_mail_composer.py @@ -229,7 +229,7 @@ class TestMailComposerRendering(TestMailComposer): 'subject': 'MSO FTW', }) - values = mail_compose_message.get_mail_values(self.partner_employee.ids) + values = mail_compose_message._prepare_mail_values(self.partner_employee.ids) self.assertIn( self.body_html, @@ -254,7 +254,7 @@ class TestMailComposerRendering(TestMailComposer): with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() - values = composer.get_mail_values(self.partner_employee.ids) + values = composer._prepare_mail_values(self.partner_employee.ids) self.assertIn( self.body_html, diff --git a/addons/mail/tests/test_mail_template.py b/addons/mail/tests/test_mail_template.py index 593f199c1d7..5dd24f6e94f 100644 --- a/addons/mail/tests/test_mail_template.py +++ b/addons/mail/tests/test_mail_template.py @@ -45,7 +45,7 @@ class TestMailTemplate(MailCommon): 'subject': '{{ 1 + 5 }}', }) - values = mail_compose_message.get_mail_values(self.partner_employee.ids) + values = mail_compose_message._prepare_mail_values(self.partner_employee.ids) self.assertEqual(values[self.partner_employee.id]['subject'], '6', 'We must trust mail template values') self.assertIn('13', values[self.partner_employee.id]['body_html'], 'We must trust mail template values') diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 5a472894342..d0d5baa7ad4 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -358,38 +358,12 @@ class MailComposer(models.TransientModel): result_messages: in comment mode, posted messages ) """ - # Several custom layouts make use of the model description at rendering, e.g. in the - # 'View ' button. Some models are used for different business concepts, such as - # 'purchase.order' which is used for a RFQ and and PO. To avoid confusion, we must use a - # different wording depending on the state of the object. - # Therefore, we can set the description in the context from the beginning to avoid falling - # back on the regular display_name retrieved in ``_notify_by_email_prepare_rendering_context()``. - model_description = self._context.get('model_description') result_mails_su, result_messages = self.env['mail.mail'].sudo(), self.env['mail.message'] for wizard in self: - # Duplicate attachments linked to the email.template. - # Indeed, basic mail.compose.message wizard duplicates attachments in mass - # mailing mode. But in 'single post' mode, attachments of an email template - # also have to be duplicated to avoid changing their ownership. - if wizard.attachment_ids and wizard.composition_mode != 'mass_mail' and wizard.template_id: - new_attachment_ids = [] - for attachment in wizard.attachment_ids: - if attachment in wizard.template_id.attachment_ids: - new_attachment_ids.append(attachment.copy({'res_model': 'mail.compose.message', 'res_id': wizard.id}).id) - else: - new_attachment_ids.append(attachment.id) - new_attachment_ids.reverse() - wizard.write({'attachment_ids': [Command.set(new_attachment_ids)]}) - # Mass Mailing mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post') - ActiveModel = self.env[wizard.model] if wizard.model and hasattr(self.env[wizard.model], 'message_post') else self.env['mail.thread'] - if wizard.composition_mode == 'mass_post': - # do not send emails directly but use the queue instead - # add context key to avoid subscribing the author - ActiveModel = ActiveModel.with_context(mail_notify_force_send=False, mail_create_nosubscribe=True) # wizard works in batch mode: [res_id] or active_ids or active_domain if mass_mode and wizard.use_active_domain and wizard.model: res_ids = self.env[wizard.model].search(ast.literal_eval(wizard.active_domain)).ids @@ -398,51 +372,68 @@ class MailComposer(models.TransientModel): else: res_ids = [wizard.res_id] - batch_size = int(self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')) or self._batch_size - sliced_res_ids = [res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size)] - - if wizard.composition_mode == 'mass_mail' or wizard.is_log or (wizard.composition_mode == 'mass_post' and not wizard.notify): # log a note: subtype is False - subtype_id = False - elif wizard.subtype_id: - subtype_id = wizard.subtype_id.id + if wizard.composition_mode == 'mass_mail': + result_mails_su += wizard._action_send_mail_mass_mail(res_ids, auto_commit=auto_commit) else: - subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') - - for res_ids in sliced_res_ids: - # mass mail mode: mail are sudo-ed, as when going through get_mail_values - # standard access rights on related records will be checked when browsing them - # to compute mail values. If people have access to the records they have rights - # to create lots of emails in sudo as it is consdiered as a technical model. - batch_mails_sudo = self.env['mail.mail'].sudo() - all_mail_values = wizard.get_mail_values(res_ids) - for res_id, mail_values in all_mail_values.items(): - if wizard.composition_mode == 'mass_mail': - batch_mails_sudo += self.env['mail.mail'].sudo().create(mail_values) - else: - post_params = dict( - subtype_id=subtype_id, - email_layout_xmlid=wizard.email_layout_xmlid, - email_add_signature=not bool(wizard.template_id) and wizard.email_add_signature, - mail_auto_delete=wizard.template_id.auto_delete if wizard.template_id else self._context.get('mail_auto_delete', True), - model_description=model_description) - post_params.update(mail_values) - if ActiveModel._name == 'mail.thread': - if wizard.model: - post_params['model'] = wizard.model - post_params['res_id'] = res_id - if not ActiveModel.message_notify(**post_params): - # if message_notify returns an empty record set, no recipients where found. - raise UserError(_("No recipient found.")) - else: - result_messages += ActiveModel.browse(res_id).message_post(**post_params) - - result_mails_su += batch_mails_sudo - if wizard.composition_mode == 'mass_mail': - ActiveModel.browse(res_ids)._message_mail_after_hook(batch_mails_sudo) - batch_mails_sudo.send(auto_commit=auto_commit) + result_messages += wizard._action_send_mail_comment(res_ids) return result_mails_su, result_messages + def _action_send_mail_comment(self, res_ids): + """ Send in comment mode. It calls message_post on model, or the generic + implementation of it if not available (as message_notify). """ + self.ensure_one() + post_values_all = self._prepare_mail_values(res_ids) + ActiveModel = self.env[self.model] if self.model and hasattr(self.env[self.model], 'message_post') else self.env['mail.thread'] + if self.composition_mode == 'mass_post': + # do not send emails directly but use the queue instead + # add context key to avoid subscribing the author + ActiveModel = ActiveModel.with_context( + mail_notify_force_send=False, + mail_create_nosubscribe=True, + ) + + messages = self.env['mail.message'] + for res_id, post_values in post_values_all.items(): + if ActiveModel._name == 'mail.thread': + if self.model: + post_values['model'] = self.model + post_values['res_id'] = res_id + message = ActiveModel.message_notify(**post_values) + if not message: + # if message_notify returns an empty record set, no recipients where found. + raise UserError(_("No recipient found.")) + messages += message + else: + messages += ActiveModel.browse(res_id).message_post(**post_values) + return messages + + def _action_send_mail_mass_mail(self, res_ids, auto_commit=False): + """ Send in mass mail mode. Mails are sudo-ed, as when going through + _prepare_mail_values standard access rights on related records will be + checked when browsing them to compute mail values. If people have + access to the records they have rights to create lots of emails in + sudo as it is considered as a technical model. """ + mails_sudo = self.env['mail.mail'].sudo() + + batch_size = int(self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')) or self._batch_size + sliced_res_ids = [res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size)] + + for res_ids_iter in sliced_res_ids: + mail_values_all = self._prepare_mail_values(res_ids_iter) + + iter_mails_sudo = self.env['mail.mail'].sudo() + for _res_id, mail_values in mail_values_all.items(): + iter_mails_sudo += mails_sudo.create(mail_values) + mails_sudo += iter_mails_sudo + + records = self.env[self.model].browse(res_ids_iter) if self.model and hasattr(self.env[self.model], 'message_post') else False + if records: + records._message_mail_after_hook(iter_mails_sudo) + iter_mails_sudo.send(auto_commit=auto_commit) + + return mails_sudo + def action_save_as_template(self): """ hit save as template button: current form value will be a new template attached to the current document. """ @@ -475,88 +466,286 @@ class MailComposer(models.TransientModel): # RENDERING / VALUES GENERATION # ------------------------------------------------------------ - def get_mail_values(self, res_ids): - """Generate the values that will be used by send_mail to create mail_messages - or mail_mails. """ + def _prepare_mail_values(self, res_ids): + """Generate the values that will be used by send_mail to create either + mail_messages or mail_mails depending on composition mode. + + Some summarized information on generation: mail versus message fields + (or both), and static (never rendered) versus dynamic (raw or rendered). + + MAIL + STA - 'auto_delete', + DYN - 'body_html', + STA - 'model', + DYN - 'recipient_ids', (from partner_ids) + DYN - 'res_id', + STA - 'is_notification', + + MESSAGE + DYN - 'body', + STA - 'email_add_signature', + STA - 'email_layout_xmlid', + + BOTH + DYN - 'attachment_ids', + STA - 'author_id', (to improve with template) + DYN - 'email_from', + STA - 'mail_activity_type_id', + STA - 'mail_server_id', + STA - 'message_type', + STA - 'parent_id', + DYN - 'partner_ids', + STA - 'record_name', (to check, single post or False in email) + DYN - 'reply_to', + STA - 'reply_to_force_new', + DYN - 'subject', + STA - 'subtype_id', + + :param list res_ids: list of record IDs on which composer runs; + + :return dict: for each res_id, values to create the mail.mail or to + give to message_post, depending on composition mode; + """ self.ensure_one() - results = dict.fromkeys(res_ids, False) - rendered_values = {} - mass_mail_mode = self.composition_mode == 'mass_mail' + email_mode = self.composition_mode == 'mass_mail' - # render all template-based value at once - if mass_mail_mode and self.model: - rendered_values = self._render_message(res_ids) - # compute alias-based reply-to in batch - reply_to_value = dict.fromkeys(res_ids, None) - if mass_mail_mode and not self.reply_to_force_new: - records = self.env[self.model].browse(res_ids) - reply_to_value = records._notify_get_reply_to(default=False) - # when having no specific reply-to, fetch rendered email_from value - for res_id, reply_to in reply_to_value.items(): - if not reply_to: - reply_to_value[res_id] = rendered_values.get(res_id, {}).get('email_from', False) + # values that do not depend on rendering mode + base_values = self._prepare_mail_values_static() - for res_id in res_ids: - # static wizard (mail.message) values - mail_values = { + additional_values_all = {} + # rendered based on raw content (wizard or template) + if email_mode and self.model: + additional_values_all = self._prepare_mail_values_dynamic(res_ids) + # wizard content already rendered + elif not email_mode: + additional_values_all = self._prepare_mail_values_rendered(res_ids) + + mail_values_all = { + res_id: dict( + base_values, + **additional_values_all.get(res_id, {}) + ) + for res_id in res_ids + } + + if email_mode: + mail_values_all = self._process_mail_values_state(mail_values_all) + return mail_values_all + + def _prepare_mail_values_static(self): + """Prepare values always valid, not rendered or dynamic whatever the + composition mode and related records. + + :return dict: a dict of (field name, value) to be used to populate + values for each res_id in '_prepare_mail_values'; + """ + self.ensure_one() + email_mode = self.composition_mode == 'mass_mail' + + if email_mode: + subtype_id = False + elif self.is_log or (self.composition_mode == 'mass_post' and not self.notify): # log a note: subtype is False + subtype_id = False + elif self.subtype_id: + subtype_id = self.subtype_id.id + else: + subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment') + + values = { + 'author_id': self.author_id.id, + 'mail_activity_type_id': self.mail_activity_type_id.id, + 'mail_server_id': self.mail_server_id.id, + 'message_type': 'email' if email_mode else self.message_type, + 'parent_id': self.parent_id.id, + 'record_name': False if email_mode else self.record_name, + 'reply_to_force_new': self.reply_to_force_new, + 'subtype_id': subtype_id, + } + # specific to mass mailing mode + if email_mode: + values.update( + auto_delete=self.auto_delete, + is_notification=not self.auto_delete_message, + model=self.model, + ) + # specific to post mode + else: + # Several custom layouts make use of the model description at rendering, e.g. in the + # 'View ' button. Some models are used for different business concepts, such as + # 'purchase.order' which is used for a RFQ and and PO. To avoid confusion, we must use a + # different wording depending on the state of the object. + # Therefore, we can set the description in the context from the beginning to avoid falling + # back on the regular display_name retrieved in ``_notify_by_email_prepare_rendering_context()``. + model_description = self.env.context.get('model_description') + values.update( + email_add_signature=not bool(self.template_id) and self.email_add_signature, + email_layout_xmlid=self.email_layout_xmlid, + mail_auto_delete=self.template_id.auto_delete if self.template_id else self.env.context.get('mail_auto_delete', True), + model_description=model_description, + ) + return values + + def _prepare_mail_values_dynamic(self, res_ids): + """Generate values based on composer content as well as its template + based on records given by res_ids. + + Part of the advanced rendering is delegated to template, notably + recipients or attachments dynamic generation. See sub methods for + more details. + + :param list res_ids: list of record IDs on which composer runs; + + :return dict results: for each res_id, the generated values used to + populate in '_prepare_mail_values'; + """ + self.ensure_one() + RecordsModel = self.env[self.model].with_prefetch(res_ids) + email_mode = self.composition_mode == 'mass_mail' + + subjects = self._render_field('subject', res_ids) + bodies = self._render_field( + 'body', res_ids, + # We want to preserve comments in emails so as to keep mso conditionals + options={'preserve_comments': self.composition_mode == 'mass_mail'}, + ) + emails_from = self._render_field('email_from', res_ids) + + mail_values_all = { + res_id: { + 'body': bodies[res_id], # should be void + 'body_html': bodies[res_id] if email_mode else False, + 'email_from': emails_from[res_id], + 'res_id': res_id, + 'subject': subjects[res_id], + } + for res_id in res_ids + } + + # generate template-based values + if self.template_id: + template_values = self._generate_template_for_composer( + self.template_id, + res_ids, + ('attachment_ids', + 'auto_delete', + 'email_to', + 'email_cc', + 'mail_server_id', + 'partner_ids', + 'report_template', + ) + ) + for res_id in res_ids: + # remove attachments from template values as they should not be rendered + template_values[res_id].pop('attachment_ids', None) + mail_values_all[res_id].update(template_values[res_id]) + + # Handle recipients. Without template, if no partner_ids is given, update + # recipients using default recipients to be sure to notify someone + if not self.template_id and not self.partner_ids: + default_recipients = RecordsModel.browse(res_ids)._message_get_default_recipients() + for res_id in res_ids: + mail_values_all[res_id].update( + default_recipients.get(res_id, {}) + ) + + # Handle reply-to. In update mode (force_new False), reply-to value is + # computed from the records (to have their alias). In new mode, reply-to + # is coming from reply_to field to render. + if not self.reply_to_force_new: + # compute alias-based reply-to in batch + reply_to_values = RecordsModel.browse(res_ids)._notify_get_reply_to(default=False) + if self.reply_to_force_new: + reply_to_values = self._render_field('reply_to', res_ids) + + # Handle per-record update + for res_id, mail_values in mail_values_all.items(): + record = RecordsModel.browse(res_id) + + # attachments. Copy attachment_ids (each has its own copies), and decode + # attachments as required by _process_attachments_for_post + attachment_ids = [ + attachment.copy({'res_model': self._name, 'res_id': self.id}).id + for attachment in self.attachment_ids + ] + attachment_ids.reverse() + decoded_attachments = [ + (name, base64.b64decode(enc_cont)) + for name, enc_cont in mail_values.pop('attachments', []) + ] + mail_values['attachment_ids'] = record._process_attachments_for_post( + decoded_attachments, + attachment_ids, + {'model': 'mail.message', 'res_id': 0} + )['attachment_ids'] + + # headers + mail_values['headers'] = repr(record._notify_by_email_get_headers()) + + # recipients: transform partner_ids (field used in mail_message) into + # recipient_ids, used by mail_mail + recipient_ids_all = mail_values.pop('partner_ids', []) + self.partner_ids.ids + mail_values['recipient_ids'] = [(4, pid) for pid in recipient_ids_all] + + # when having no specific reply_to -> fetch rendered email_from + reply_to = reply_to_values.get(res_id) + if not reply_to: + reply_to = mail_values.get('email_from', False) + mail_values['reply_to'] = reply_to + + return mail_values_all + + def _prepare_mail_values_rendered(self, res_ids): + """Generate values that are already rendered. This is used mainly in + monorecord mode, when the wizard contains value already generated + (e.g. "Send by email" on a sale order, in form view). + + :param list res_ids: list of record IDs on which composer runs; + + :return dict results: for each res_id, the generated values used to + populate in '_prepare_mail_values'; + """ + self.ensure_one() + + # Duplicate attachments linked to the email.template. Indeed, composer + # duplicates attachments in mass mode. But in 'rendered' mode attachments + # may come from an email template (same IDs). They also have to be + # duplicated to avoid changing their ownership. + if self.composition_mode == 'comment' and self.template_id and self.attachment_ids: + new_attachment_ids = [] + for attachment in self.attachment_ids: + if attachment in self.template_id.attachment_ids: + new_attachment_ids.append(attachment.copy({ + 'res_model': 'mail.compose.message', + 'res_id': self.id, + }).id) + else: + new_attachment_ids.append(attachment.id) + new_attachment_ids.reverse() + self.write({'attachment_ids': [Command.set(new_attachment_ids)]}) + + return { + res_id: { 'attachment_ids': [attach.id for attach in self.attachment_ids], - 'author_id': self.author_id.id, 'body': self.body or '', 'email_from': self.email_from, - 'mail_server_id': self.mail_server_id.id, - 'mail_activity_type_id': self.mail_activity_type_id.id, - 'message_type': 'email' if mass_mail_mode else self.message_type, - 'parent_id': self.parent_id.id, 'partner_ids': self.partner_ids.ids, - 'record_name': self.record_name, - 'reply_to_force_new': self.reply_to_force_new, - 'subject': self.subject, + 'subject': self.subject or '', } + for res_id in res_ids + } - # mass mailing: rendering override wizard static values - if mass_mail_mode and self.model: - record = self.env[self.model].browse(res_id) - mail_values['headers'] = repr(record._notify_by_email_get_headers()) - # keep a copy unless specifically requested, reset record name (avoid browsing records) - mail_values.update(is_notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False) - # auto deletion of mail_mail - if self.auto_delete or self.template_id.auto_delete: - mail_values['auto_delete'] = True - # rendered values using template - email_dict = rendered_values[res_id] - mail_values['partner_ids'] += email_dict.pop('partner_ids', []) - mail_values.update(email_dict) - if not self.reply_to_force_new: - mail_values.pop('reply_to') - if reply_to_value.get(res_id): - mail_values['reply_to'] = reply_to_value[res_id] - if self.reply_to_force_new and not mail_values.get('reply_to'): - mail_values['reply_to'] = mail_values['email_from'] - # mail_mail values: body -> body_html, partner_ids -> recipient_ids - mail_values['body_html'] = mail_values.get('body', '') - mail_values['recipient_ids'] = [Command.link(id) for id in mail_values.pop('partner_ids', [])] + def _process_mail_values_state(self, mail_values_dict): + """ When being in mass mailing, avoid sending emails to void or invalid + emails. For that purpose a processing of generated values allows to + give a state and a failure type to mail.mail records that will be + created at sending time. - # process attachments: should not be encoded before being processed by message_post / mail_mail create - mail_values['attachments'] = [(name, base64.b64decode(enc_cont)) for name, enc_cont in email_dict.pop('attachments', list())] - attachment_ids = [] - for attach_id in mail_values.pop('attachment_ids'): - new_attach_id = self.env['ir.attachment'].browse(attach_id).copy({'res_model': self._name, 'res_id': self.id}) - attachment_ids.append(new_attach_id.id) - attachment_ids.reverse() - mail_values['attachment_ids'] = record._process_attachments_for_post( - mail_values.pop('attachments', []), - attachment_ids, - {'model': 'mail.message', 'res_id': 0} - )['attachment_ids'] + :param dict mail_values_dict: as generated by '_prepare_mail_values'; - results[res_id] = mail_values - - if mass_mail_mode: - results = self._process_state(results) - return results - - def _process_state(self, mail_values_dict): - recipients_info = self._process_recipient_values(mail_values_dict) + :return: updated mail_values_dict + """ + recipients_info = self._get_recipients_data(mail_values_dict) blacklist_ids = self._get_blacklist_record_ids(mail_values_dict) optout_emails = self._get_optout_emails(mail_values_dict) done_emails = self._get_done_emails(mail_values_dict) @@ -599,78 +788,6 @@ class MailComposer(models.TransientModel): return mail_values_dict - def _render_message(self, res_ids): - """Generate template-based values of wizard, for the document records given - by res_ids. This method is meant to be inherited by email_template that - will produce a more complete dictionary, using qweb templates. - - Each template is generated for all res_ids, allowing to parse the template - once, and render it multiple times. This is useful for mass mailing where - template rendering represent a significant part of the process. - - Default recipients are also computed, based on mail_thread method - _message_get_default_recipients. This allows to ensure a mass mailing has - always some recipients specified. - - :param browse wizard: current mail.compose.message browse record - :param list res_ids: list of record ids - - :return dict results: for each res_id, the generated template values for - subject, body, email_from and reply_to - """ - self.ensure_one() - - subjects = self._render_field('subject', res_ids) - # We want to preserve comments in emails so as to keep mso conditionals - bodies = self._render_field('body', res_ids, options={'preserve_comments': self.composition_mode == 'mass_mail'}) - emails_from = self._render_field('email_from', res_ids) - replies_to = self._render_field('reply_to', res_ids) - default_recipients = {} - if not self.partner_ids: - records = self.env[self.model].browse(res_ids).sudo() - default_recipients = records._message_get_default_recipients() - - results = dict.fromkeys(res_ids, False) - for res_id in res_ids: - results[res_id] = { - 'body': bodies[res_id], - 'email_from': emails_from[res_id], - 'reply_to': replies_to[res_id], - 'subject': subjects[res_id], - } - results[res_id].update(default_recipients.get(res_id, dict())) - - # generate template-based values - if self.template_id: - template_values = self._generate_template_for_composer( - self.template_id, - res_ids, - ('attachment_ids', - 'email_to', - 'email_cc', - 'mail_server_id', - 'partner_ids', - 'report_template', - ) - ) - else: - template_values = {} - - for res_id in res_ids: - if template_values.get(res_id): - # recipients are managed by the template - results[res_id].pop('partner_ids', None) - results[res_id].pop('email_to', None) - results[res_id].pop('email_cc', None) - # remove attachments from template values as they should not be rendered - template_values[res_id].pop('attachment_ids', None) - else: - template_values[res_id] = dict() - # update template values by composer values - template_values[res_id].update(results[res_id]) - - return template_values - def _generate_template_for_composer(self, template, res_ids, render_fields, find_or_create_partners=True): """ Generate values based on template and relevant values for the @@ -744,7 +861,7 @@ class MailComposer(models.TransientModel): def _get_optout_emails(self, mail_values_dict): return [] - def _process_recipient_values(self, mail_values_dict): + def _get_recipients_data(self, mail_values_dict): # Preprocess res.partners to batch-fetch from db if recipient_ids is present # it means they are partners (the only object to fill get_default_recipient this way) recipient_pids = [ diff --git a/addons/mass_mailing/models/mailing_trace.py b/addons/mass_mailing/models/mailing_trace.py index a86e3527187..02d88e5a648 100644 --- a/addons/mass_mailing/models/mailing_trace.py +++ b/addons/mass_mailing/models/mailing_trace.py @@ -28,7 +28,7 @@ class MailingTrace(models.Model): # mass_mailing_sms mass mode specific codes 'sms_blacklist', 'sms_duplicate', 'sms_optout', * cancel: - * mail: set in get_mail_values in composer, if email is blacklisted + * mail: set in _prepare_mail_values in composer, if email is blacklisted (mail) or in opt_out / seen list (mass_mailing) or email_to is void or incorrectly formatted (mass_mailing) - based on mail cancel state * sms: set in _prepare_mass_sms_trace_values in composer if sms is diff --git a/addons/mass_mailing/tests/test_mailing_internals.py b/addons/mass_mailing/tests/test_mailing_internals.py index 80b42b26596..76971ab9b04 100644 --- a/addons/mass_mailing/tests/test_mailing_internals.py +++ b/addons/mass_mailing/tests/test_mailing_internals.py @@ -52,7 +52,7 @@ class TestMassMailValues(MassMailCommon): 'mass_mailing_id': mailing.id }) - mail_values = composer.get_mail_values([recipient.id]) + mail_values = composer._prepare_mail_values([recipient.id]) body_html = mail_values[recipient.id]['body_html'] self.assertIn('', body_html) diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index ff4c0148fb1..e49967b1dd3 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -19,72 +19,81 @@ class MailComposeMessage(models.TransientModel): model = self.env['ir.model']._get(composer.model) composer.model_is_thread = model.is_mail_thread - def get_mail_values(self, res_ids): - """ Override method that generated the mail content by creating the - mailing.trace values in the o2m of mail_mail, when doing pure - email mass mailing. """ - self.ensure_one() - res = super(MailComposeMessage, self).get_mail_values(res_ids) - # use only for allowed models in mass mailing + def _action_send_mail(self, auto_commit=False): + """ Override to generate the mass mailing in case only the name was + given. It is used afterwards for traces generation. """ if self.composition_mode == 'mass_mail' and \ - (self.mass_mailing_name or self.mass_mailing_id) and \ + self.mass_mailing_name and not self.mass_mailing_id and \ self.model_is_thread: - mass_mailing = self.mass_mailing_id - if not mass_mailing: - mass_mailing = self.env['mailing.mailing'].create( - self._prepare_mailing_values() + mass_mailing = self.env['mailing.mailing'].create(self._prepare_mailing_values()) + self.mass_mailing_id = mass_mailing.id + return super()._action_send_mail(auto_commit=auto_commit) + + def _prepare_mail_values(self, res_ids): + """ When being in mass mailing mode, add 'mailing.trace' values directly + in the o2m field of mail.mail. """ + mail_values_all = super()._prepare_mail_values(res_ids) + + # use only for allowed models in mass mailing + if (self.composition_mode != 'mass_mail' or + not self.mass_mailing_id or + not self.model_is_thread): + return mail_values_all + + trace_values_all = self._prepare_mail_values_mailing_traces(mail_values_all) + for res_id, mail_values in mail_values_all.items(): + if mail_values.get('body_html'): + body = self.env['ir.qweb']._render( + 'mass_mailing.mass_mailing_mail_layout', + {'body': mail_values['body_html']}, + minimal_qcontext=True, + raise_if_not_found=False ) - self.mass_mailing_id = mass_mailing.id + if body: + mail_values['body_html'] = body - recipients_info = self._process_recipient_values(res) - for res_id in res_ids: - mail_values = res[res_id] - if mail_values.get('body_html'): - body = self.env['ir.qweb']._render( - 'mass_mailing.mass_mailing_mail_layout', - {'body': mail_values['body_html']}, - minimal_qcontext=True, - raise_if_not_found=False - ) - if body: - mail_values['body_html'] = body - - trace_vals = { - # if mail_to is void, keep falsy values to allow searching / debugging traces - 'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '', - 'mass_mailing_id': mass_mailing.id, - 'model': self.model, - 'res_id': res_id, - } - # propagate failed states to trace when still-born - if mail_values.get('state') == 'cancel': - trace_vals['trace_status'] = 'cancel' - elif mail_values.get('state') == 'exception': - trace_vals['trace_status'] = 'error' - if mail_values.get('failure_type'): - trace_vals['failure_type'] = mail_values['failure_type'] - - mail_values.update({ - 'auto_delete': not mass_mailing.keep_archives, - # email-mode: keep original message for routing - 'is_notification': mass_mailing.reply_to_mode == 'update', - 'mailing_id': mass_mailing.id, - 'mailing_trace_ids': [(0, 0, trace_vals)], - }) - return res + mail_values.update({ + 'auto_delete': not self.mass_mailing_id.keep_archives, + # email-mode: keep original message for routing + 'is_notification': self.mass_mailing_id.reply_to_mode == 'update', + 'mailing_id': self.mass_mailing_id.id, + 'mailing_trace_ids': [(0, 0, trace_values_all[res_id])] if res_id in trace_values_all else False, + }) + return mail_values_all def _get_done_emails(self, mail_values_dict): - seen_list = super(MailComposeMessage, self)._get_done_emails(mail_values_dict) + seen_list = super()._get_done_emails(mail_values_dict) if self.mass_mailing_id: seen_list += self.mass_mailing_id._get_seen_list() return seen_list def _get_optout_emails(self, mail_values_dict): - opt_out_list = super(MailComposeMessage, self)._get_optout_emails(mail_values_dict) + opt_out_list = super()._get_optout_emails(mail_values_dict) if self.mass_mailing_id: opt_out_list += self.mass_mailing_id._get_opt_out_list() return opt_out_list + def _prepare_mail_values_mailing_traces(self, mail_values_all): + trace_values_all = dict.fromkeys(mail_values_all.keys(), False) + recipients_info = self._get_recipients_data(mail_values_all) + for res_id, mail_values in mail_values_all.items(): + trace_vals = { + # if mail_to is void, keep falsy values to allow searching / debugging traces + 'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '', + 'mass_mailing_id': self.mass_mailing_id.id, + 'model': self.model, + 'res_id': res_id, + } + # propagate failed states to trace when still-born + if mail_values.get('state') == 'cancel': + trace_vals['trace_status'] = 'cancel' + elif mail_values.get('state') == 'exception': + trace_vals['trace_status'] = 'error' + if mail_values.get('failure_type'): + trace_vals['failure_type'] = mail_values['failure_type'] + trace_values_all[res_id] = trace_vals + return trace_values_all + def _prepare_mailing_values(self): now = fields.Datetime.now() return { diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index eadbda57a5c..4df5607c829 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -599,7 +599,7 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() if composition_mode == 'comment': - self.assertEqual(composer.body, 'Back to my amazing body') + self.assertEqual(composer.body, '

Back to my amazing body Test Body

', - 'partner_ids': [(4, self.partner_1.id), (4, self.partner_2.id)] - }) - with self.mock_mail_gateway(mail_unlink_sent=True): - composer._action_send_mail() - - # notifications - message = self.test_record.message_ids[0] - self.assertEqual(message.notified_partner_ids, self.partner_employee_2 + self.partner_1 + self.partner_2) - - # global outgoing - self.assertEqual(len(self._mails), 3, 'Should have sent an email each recipient') - self.assertEqual(len(self._new_mails), 2, 'Should have created 2 mail.mail (1 for users, 1 for customers)') - self.assertEqual(len(self._new_mails.exists()), 2, 'Should not have deleted mail.mail records') - @users('employee') @mute_logger('odoo.tests', 'odoo.addons.mail.models.mail_mail', 'odoo.models.unlink') def test_mail_composer_post_parameters(self): From 79739607844707f371fadc3aaf973d5b8b5af96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 1 Dec 2022 17:14:54 +0000 Subject: [PATCH 34/45] [FIX] mail: pre-raise when trying to post on no record RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS When trying to use the composer without related records in comment mode message_post raises, as record is mandatory (ensure_one). In this commit we make this raise explicit in the composer itself, in order to be able to assert the behavior. In mass mail mode, having no record to mail is possible and the composer simply skips the mail creation. Indeed when doing mass mailing, notably with crons, records might have been updated / removed and the composer should not crash, just produce nothing. Task-3035101 (Mail: Support batch-posting from composer) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_compose_message.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 4c0da3a300a..d6c4474ac3e 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -366,7 +366,13 @@ class MailComposer(models.TransientModel): elif mass_mode and wizard.model and self.env.context.get('active_ids'): res_ids = self.env.context['active_ids'] else: - res_ids = [wizard.res_id] + res_ids = [wizard.res_id] if wizard.res_id else [] + # in comment mode: raise here as anyway message_post will raise. + if not res_ids and not mass_mode: + raise ValueError( + _('Mail composer in comment mode should run on at least one record. No records found (model %(model_name)s).', + model_name=wizard.model) + ) if wizard.composition_mode == 'mass_mail': result_mails_su += wizard._action_send_mail_mass_mail(res_ids, auto_commit=auto_commit) From 55bcb62131c7293683613b5f09dbf367065034a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 19 Sep 2022 14:27:13 +0000 Subject: [PATCH 35/45] [REM] mail: remove mass post option from mail composer RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Remove deprecated "mass_post" option from composer. It is either a comment (using message_post) either a mass mail (creating emails). Mass post was anyway nor used nor really supported. It will be replaced by supporting having a comment on several IDs (batch comment mode, posting a message on several records instead of being limited to one as currently). This cleaning also allows to remove the 'notify' field that was an option used for mass_post. All this should be supported through subtypes and correct choice of post / mass mailing. Task-3035101 (Mail: Support batch-posting from composer) Part-of: odoo/odoo#99482 --- addons/mail/wizard/mail_compose_message.py | 15 +++++++-------- addons/mail/wizard/mail_compose_message_views.xml | 3 --- .../wizard/mail_compose_message_views.xml | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index d6c4474ac3e..e91f0132728 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -116,10 +116,10 @@ class MailComposer(models.TransientModel): 'res.partner', 'Author', help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") # composition - composition_mode = fields.Selection(selection=[ - ('comment', 'Post on a document'), - ('mass_mail', 'Email Mass Mailing'), - ('mass_post', 'Post on Multiple Documents')], string='Composition mode', default='comment') + composition_mode = fields.Selection( + selection=[('comment', 'Post on a document'), + ('mass_mail', 'Email Mass Mailing')], + string='Composition mode', default='comment') model = fields.Char('Related Document Model') res_id = fields.Integer('Related Document ID') record_name = fields.Char('Message Record Name') @@ -136,7 +136,6 @@ class MailComposer(models.TransientModel): subtype_id = fields.Many2one( 'mail.message.subtype', 'Subtype', ondelete='set null', default=lambda self: self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')) - notify = fields.Boolean('Notify followers', help='Notify followers of the document (mass post only)') mail_activity_type_id = fields.Many2one('mail.activity.type', 'Mail Activity Type', ondelete='set null') # destination reply_to = fields.Char('Reply To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.') @@ -358,7 +357,7 @@ class MailComposer(models.TransientModel): for wizard in self: # Mass Mailing - mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post') + mass_mode = wizard.composition_mode == 'mass_mail' # wizard works in batch mode: [res_id] or active_ids or active_domain if mass_mode and wizard.use_active_domain and wizard.model: @@ -387,7 +386,7 @@ class MailComposer(models.TransientModel): self.ensure_one() post_values_all = self._prepare_mail_values(res_ids) ActiveModel = self.env[self.model] if self.model and hasattr(self.env[self.model], 'message_post') else self.env['mail.thread'] - if self.composition_mode == 'mass_post': + if len(res_ids) > 1: # do not send emails directly but use the queue instead # add context key to avoid subscribing the author ActiveModel = ActiveModel.with_context( @@ -546,7 +545,7 @@ class MailComposer(models.TransientModel): if email_mode: subtype_id = False - elif self.is_log or (self.composition_mode == 'mass_post' and not self.notify): # log a note: subtype is False + elif self.is_log: # log a note: subtype is False subtype_id = False elif self.subtype_id: subtype_id = self.subtype_id.id diff --git a/addons/mail/wizard/mail_compose_message_views.xml b/addons/mail/wizard/mail_compose_message_views.xml index 54b37deca7a..8f7acb29202 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -33,9 +33,6 @@ context="{'force_email':True, 'show_email':True}"/>
- -
diff --git a/addons/mass_mailing/wizard/mail_compose_message_views.xml b/addons/mass_mailing/wizard/mail_compose_message_views.xml index 9b8bcf95be5..51999b12232 100644 --- a/addons/mass_mailing/wizard/mail_compose_message_views.xml +++ b/addons/mass_mailing/wizard/mail_compose_message_views.xml @@ -7,7 +7,7 @@ mail.compose.message - + From 1a1acabd7bbd9c8e0ea83ac609a06df8af833088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 5 Dec 2022 13:42:02 +0000 Subject: [PATCH 36/45] [REF] mail: cleanup mono/multi record composer behavior RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Support a real res_ids field on mail.compose.message model. Instead of relying on active_ids from context, store it once for all at composer level and use it in code. Active_ids usage is still done at default_get level, using it to populate the field. Improve usage of domain, renamed to res_domain to match other document related fields naming. Add support of a res_domain_user_id field allowing to set the user from which the domain should be evaluated. Composer now runs on a list of IDs. Mass mail mode and comment mode are now distinct from running on a singleton or on more records. Rendered or raw mode is not triggered by * mass mailing mode: always display raw mode, whatever the number of records; * comment mode: display rendered mode when having a single record (like the previous comment mode). Display raw mode when having either no records either at least two records. Task-3035101 (Mail: Support batch-posting from composer) Part-of: odoo/odoo#99482 --- addons/account/models/account_move.py | 7 +- addons/account/views/account_payment_view.xml | 2 - addons/calendar/models/calendar_event.py | 1 - addons/crm/views/crm_lead_views.xml | 2 - addons/event/models/event_registration.py | 5 +- addons/hr_presence/models/hr_employee.py | 3 +- addons/loyalty/models/loyalty_card.py | 3 +- addons/mail/models/mail_template.py | 8 +- addons/mail/models/mail_thread.py | 23 +- addons/mail/static/src/js/activity.js | 3 +- .../mail/static/src/models/composer_view.js | 2 +- .../mail/static/src/models/mail_template.js | 3 +- .../components/activity_tests.js | 14 +- addons/mail/tests/common.py | 3 +- addons/mail/tests/test_mail_composer.py | 8 +- addons/mail/views/res_partner_views.xml | 1 - addons/mail/wizard/mail_compose_message.py | 278 ++++++++---- .../wizard/mail_compose_message_views.xml | 5 +- addons/mass_mailing/models/mailing.py | 16 +- .../tests/test_mailing_internals.py | 2 +- .../wizard/mail_compose_message.py | 2 +- addons/project/views/project_views.xml | 2 - addons/purchase/models/purchase.py | 10 +- addons/repair/models/repair.py | 3 +- addons/sale/models/res_company.py | 11 +- addons/sale/models/sale_order.py | 4 +- addons/stock/views/stock_picking_views.xml | 1 - addons/survey/models/survey_survey.py | 1 - .../test_mail/static/tests/activity_tests.js | 3 +- addons/test_mail/tests/test_mail_composer.py | 412 ++++++++++++------ addons/test_mail/tests/test_performance.py | 20 +- addons/test_mail_full/tests/test_portal.py | 3 +- addons/website/models/website_visitor.py | 3 +- addons/website_sale/models/sale_order.py | 4 +- addons/website_slides/models/slide_channel.py | 1 - 35 files changed, 559 insertions(+), 310 deletions(-) diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 269329d4004..7de4485ba16 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -3509,12 +3509,7 @@ class AccountMove(models.Model): compose_form = self.env.ref('account.account_invoice_send_wizard_form', raise_if_not_found=False) ctx = dict( default_model='account.move', - default_res_id=self.id, - # For the sake of consistency we need a default_res_model if - # default_res_id is set. Not renaming default_model as it can - # create many side-effects. - default_res_model='account.move', - default_use_template=bool(template), + default_res_ids=self.ids, default_template_id=template and template.id or False, default_composition_mode='comment', mark_invoice_as_sent=True, diff --git a/addons/account/views/account_payment_view.xml b/addons/account/views/account_payment_view.xml index 7d60a621236..38704aadb77 100644 --- a/addons/account/views/account_payment_view.xml +++ b/addons/account/views/account_payment_view.xml @@ -430,7 +430,6 @@ @@ -447,7 +446,6 @@ diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index ca3da46a725..9fd4b27f8fd 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -755,7 +755,6 @@ class Meeting(models.Model): default_composition_mode=default_composition_mode, default_model='calendar.event', default_res_ids=self.ids, - default_use_template=bool(template_id), default_template_id=template_id, default_partner_ids=self.partner_ids.ids, mail_tz=self.env.user.tz, diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index 289e4886818..8b8c5730bab 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -735,7 +735,6 @@ new form @@ -748,7 +747,6 @@ new list diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index d1ee3e7a368..22043ef9c5e 100644 --- a/addons/event/models/event_registration.py +++ b/addons/event/models/event_registration.py @@ -195,9 +195,8 @@ class EventRegistration(models.Model): compose_form = self.env.ref('mail.email_compose_message_wizard_form') ctx = dict( default_model='event.registration', - default_res_id=self.id, - default_use_template=bool(template), - default_template_id=template and template.id, + default_res_ids=self.ids, + default_template_id=template.id if template else False, default_composition_mode='comment', default_email_layout_xmlid="mail.mail_notification_light", ) diff --git a/addons/hr_presence/models/hr_employee.py b/addons/hr_presence/models/hr_employee.py index 99882f10ad2..28c616b3ef2 100644 --- a/addons/hr_presence/models/hr_employee.py +++ b/addons/hr_presence/models/hr_employee.py @@ -181,8 +181,7 @@ Do not hesitate to contact your manager or the human resource department.""") compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) ctx = dict( default_model="hr.employee", - default_res_id=self.id, - default_use_template=bool(template), + default_res_ids=self.ids, default_template_id=template.id, default_composition_mode='comment', default_is_log=True, diff --git a/addons/loyalty/models/loyalty_card.py b/addons/loyalty/models/loyalty_card.py index 239165b7d52..893980bfc4e 100644 --- a/addons/loyalty/models/loyalty_card.py +++ b/addons/loyalty/models/loyalty_card.py @@ -81,8 +81,7 @@ class LoyaltyCard(models.Model): compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) ctx = dict( default_model='loyalty.card', - default_res_id=self.id, - default_use_template=bool(default_template), + default_res_ids=self.ids, default_template_id=default_template and default_template.id, default_composition_mode='comment', default_email_layout_xmlid='mail.mail_notification_light', diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index d4f4c999944..25c734bbac0 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -170,14 +170,18 @@ class MailTemplate(models.Model): def create_action(self): ActWindow = self.env['ir.actions.act_window'] view = self.env.ref('mail.email_compose_message_wizard_form') - for template in self: + context = { + 'default_composition_mode': 'mass_mail', + 'default_model': template.model, + 'default_template_id' : template.id, + } button_name = _('Send Mail (%s)', template.name) action = ActWindow.create({ 'name': button_name, 'type': 'ir.actions.act_window', 'res_model': 'mail.compose.message', - 'context': "{'default_composition_mode': 'mass_mail', 'default_template_id' : %d, 'default_use_template': True}" % (template.id), + 'context': repr(context), 'view_mode': 'form,tree', 'view_id': view.id, 'target': 'new', diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 4896930c645..c9ff65b6a8b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2170,12 +2170,9 @@ class MailThread(models.AbstractModel): mails_su = self.env['mail.mail'].sudo() for subset in [self] if template else self: - composer_ctx['active_ids'] = subset.ids # still required for mass mail mode - if template: - composer_ctx['default_res_id'] = subset.ids[0] if subset.ids else 0 - else: + composer_ctx['default_res_ids'] = subset.ids + if not template: composer_values['body'] = bodies[subset.id] - composer_ctx['default_res_id'] = subset.id composer = self.env['mail.compose.message'].with_context( **composer_ctx @@ -2185,9 +2182,9 @@ class MailThread(models.AbstractModel): if template: update_values = composer._onchange_template_id( template.id, - composer_values['composition_mode'], + 'mass_mail', self._name, - subset.ids[0] if subset.ids else 0 + subset.ids, )['value'] composer.write(update_values) @@ -2253,10 +2250,9 @@ class MailThread(models.AbstractModel): for record in self: if template: composer = self.env['mail.compose.message'].with_context( - active_ids=record.ids, # still required for mass mail mode default_composition_mode='comment', default_model=self._name, - default_res_id=record.id, + default_res_ids=record.ids, default_template_id=template.id, ).create({ 'message_type': message_type, @@ -2270,7 +2266,7 @@ class MailThread(models.AbstractModel): template.id, 'comment', self._name, - record.id, + record.ids, )['value'] composer.write(update_values) _mails_as_sudo, messages_as_sudo = composer._action_send_mail() @@ -3049,11 +3045,12 @@ class MailThread(models.AbstractModel): lang = False if force_email_lang: lang = force_email_lang - elif {'default_template_id', 'default_model', 'default_res_id'} <= self.env.context.keys(): + elif {'default_template_id', 'default_model', 'default_res_ids'} <= self.env.context.keys(): # TDE FIXME: this whole brol should be cleaned ! + res_ids = self.env['mail.compose.message']._parse_res_ids(self.env.context['default_res_ids']) template = self.env['mail.template'].browse(self.env.context['default_template_id']) - if template and template.lang: - lang = template._render_lang([self.env.context['default_res_id']])[self.env.context['default_res_id']] + if res_ids and template and template.lang: + lang = template._render_lang(res_ids)[res_ids[0]] if not lang: lang = self.env.context.get('lang') diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index 20b2de3c972..2cc5a34b94d 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -341,9 +341,8 @@ const KanbanActivity = AbstractField.extend({ views: [[false, "form"]], target: "new", context: { - default_res_id: this.res_id, + default_res_ids: [this.res_id], default_model: this.model, - default_use_template: true, default_template_id: templateID, force_email: true, }, diff --git a/addons/mail/static/src/models/composer_view.js b/addons/mail/static/src/models/composer_view.js index 6ff29361d84..cecc70bdac3 100644 --- a/addons/mail/static/src/models/composer_view.js +++ b/addons/mail/static/src/models/composer_view.js @@ -550,7 +550,7 @@ Model({ default_is_log: this.composer.isLog, default_model: this.composer.activeThread.model, default_partner_ids: this.composer.recipients.map((partner) => partner.id), - default_res_id: this.composer.activeThread.id, + default_res_ids: [this.composer.activeThread.id], default_subtype_xmlid: 'mail.mt_comment', mail_post_autofollow: this.composer.activeThread.hasWriteAccess, }; diff --git a/addons/mail/static/src/models/mail_template.js b/addons/mail/static/src/models/mail_template.js index 100afeac761..53d21f5f4b8 100644 --- a/addons/mail/static/src/models/mail_template.js +++ b/addons/mail/static/src/models/mail_template.js @@ -16,9 +16,8 @@ Model({ views: [[false, "form"]], target: "new", context: { - default_res_id: activity.thread.id, + default_res_ids: [activity.thread.id], default_model: activity.thread.model, - default_use_template: true, default_subtype_xmlid: 'mail.mt_comment', default_template_id: this.id, force_email: true, diff --git a/addons/mail/static/tests/qunit_suite_tests/components/activity_tests.js b/addons/mail/static/tests/qunit_suite_tests/components/activity_tests.js index 96c9506867f..5960101558b 100644 --- a/addons/mail/static/tests/qunit_suite_tests/components/activity_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/components/activity_tests.js @@ -619,7 +619,7 @@ QUnit.module("mail", {}, function () { }); QUnit.test("activity with mail template: preview mail", async function (assert) { - assert.expect(10); + assert.expect(9); const pyEnv = await startServer(); const resPartnerId1 = pyEnv["res.partner"].create({}); @@ -642,20 +642,16 @@ QUnit.module("mail", {}, function () { patchWithCleanup(env.services.action, { doAction(action) { assert.step("do_action"); - assert.strictEqual( - action.context.default_res_id, - resPartnerId1, - "Action should have the activity res id as default res id in context" + assert.deepEqual( + action.context.default_res_ids, + [resPartnerId1], + "Action should have the activity res id as default res ids in context" ); assert.strictEqual( action.context.default_model, "res.partner", "Action should have the activity res model as default model in context" ); - assert.ok( - action.context.default_use_template, - "Action should have true as default use_template in context" - ); assert.strictEqual( action.context.default_template_id, mailTemplateId1, diff --git a/addons/mail/tests/common.py b/addons/mail/tests/common.py index 853ceb611be..7ab6427c9e4 100644 --- a/addons/mail/tests/common.py +++ b/addons/mail/tests/common.py @@ -69,10 +69,11 @@ class MockEmail(common.BaseCase, MockSmtplibCase): with self.mock_smtplib_connection(), \ patch.object(IrMailServer, 'build_email', autospec=True, wraps=IrMailServer, side_effect=_ir_mail_server_build_email) as build_email_mocked, \ patch.object(IrMailServer, 'send_email', autospec=True, wraps=IrMailServer, side_effect=send_email_origin) as send_email_mocked, \ - patch.object(MailMail, 'create', autospec=True, wraps=MailMail, side_effect=_mail_mail_create), \ + patch.object(MailMail, 'create', autospec=True, wraps=MailMail, side_effect=_mail_mail_create) as mail_mail_create_mocked, \ patch.object(MailMail, 'unlink', autospec=True, wraps=MailMail, side_effect=_mail_mail_unlink): self.build_email_mocked = build_email_mocked self.send_email_mocked = send_email_mocked + self.mail_mail_create_mocked = mail_mail_create_mocked yield if mail_unlink_sent: diff --git a/addons/mail/tests/test_mail_composer.py b/addons/mail/tests/test_mail_composer.py index 3ad1f8c79b0..472fc24d6c5 100644 --- a/addons/mail/tests/test_mail_composer.py +++ b/addons/mail/tests/test_mail_composer.py @@ -84,7 +84,7 @@ class TestMailComposerForm(TestMailComposer): form = Form(self.env['mail.compose.message'].with_context({ 'default_partner_ids': partner_classic.ids, 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, })) form.body = '

Hello

' self.assertEqual( @@ -116,7 +116,7 @@ class TestMailComposerForm(TestMailComposer): form = Form(self.env['mail.compose.message'].with_context({ 'default_partner_ids': (partner_private + partner_classic).ids, 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, })) form.body = '

Hello

' self.assertEqual( @@ -156,7 +156,7 @@ class TestMailComposerForm(TestMailComposer): _form = Form(self.env['mail.compose.message'].with_context({ 'default_partner_ids': (self.partner_private + partner_classic).ids, 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, })) @mute_logger('odoo.addons.mail.models.mail_mail') @@ -177,7 +177,7 @@ class TestMailComposerForm(TestMailComposer): form = Form(self.env['mail.compose.message'].with_context({ 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': template.id, })) diff --git a/addons/mail/views/res_partner_views.xml b/addons/mail/views/res_partner_views.xml index a3fae6a1d55..377d3589b84 100644 --- a/addons/mail/views/res_partner_views.xml +++ b/addons/mail/views/res_partner_views.xml @@ -111,7 +111,6 @@ 'default_composition_mode': 'mass_mail', 'default_partner_to': '{{ object.id or \'\' }}', 'default_subtype_xmlid': 'mail.mt_comment', - 'default_use_template': False, 'default_reply_to_force_new': True, }"/> diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index e91f0132728..d9547eb929e 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -3,13 +3,15 @@ import ast import base64 -import re +import logging from odoo import _, api, fields, models, tools, Command -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.osv import expression from odoo.tools import email_re +_logger = logging.getLogger(__name__) + def _reopen(self, res_id, model, context=None): # save original model in context, because selecting the list of available @@ -41,14 +43,13 @@ class MailComposer(models.TransientModel): _batch_size = 500 @api.model - def default_get(self, fields): - """ Handle composition mode. Some details about context keys: - - comment: default mode, model and ID of a record the user comments - - default_model or active_model - - default_res_id or active_id - - mass_mail: model and IDs of records the user mass-mails - - active_ids: record IDs - - default_model or active_model + def default_get(self, fields_list): + """ Handle composition mode and contextual computation, until moving + to computed fields. Support active_model / active_id(s) as valid default + values, as this comes from standard web client usage. + + Note that supporting active_ids through composer is still done, as we + may have to give a huge list of IDs that won't fit into res_ids field. """ # support subtype xmlid, like ``message_post``, when easier than using ``ref`` if self.env.context.get('default_subtype_xmlid'): @@ -57,12 +58,15 @@ class MailComposer(models.TransientModel): self.env.context['default_subtype_xmlid'] ) ) + # deprecated record context management + if 'default_res_id' in self.env.context: + raise ValueError(_("Deprecated usage of 'default_res_id', should use 'default_res_ids'.")) - result = super(MailComposer, self).default_get(fields) + result = super().default_get(fields_list) # author - missing_author = 'author_id' in fields and 'author_id' not in result - missing_email_from = 'email_from' in fields and 'email_from' not in result + missing_author = 'author_id' in fields_list and 'author_id' not in result + missing_email_from = 'email_from' in fields_list and 'email_from' not in result if missing_author or missing_email_from: author_id, email_from = self.env['mail.thread']._message_compute_author(result.get('author_id'), result.get('email_from'), raise_on_email=False) if missing_email_from: @@ -70,26 +74,36 @@ class MailComposer(models.TransientModel): if missing_author: result['author_id'] = author_id - if 'model' in fields and 'model' not in result: + # record context management + if 'model' in fields_list and 'model' not in result: result['model'] = self.env.context.get('active_model') - if 'res_id' in fields and 'res_id' not in result: - result['res_id'] = self.env.context.get('active_id') - if 'reply_to_mode' in fields and 'reply_to_mode' not in result and result.get('model'): - # doesn't support threading + if 'res_ids' in fields_list and 'res_ids' not in result: + if self.env.context.get('active_ids'): + active_res_ids = self._parse_res_ids(self.env.context['active_ids']) + # beware, field is limited in storage, usage of active_ids in context still required + if active_res_ids and len(active_res_ids) <= self._batch_size: + result['res_ids'] = self.env.context['active_ids'] + elif self.env.context.get('active_id'): + result['res_ids'] = [self.env.context['active_id']] + else: + result['res_ids'] = False + # record / parent based computation + if result.get('composition_mode') == 'comment' and (set(fields_list) & {'model', 'res_ids', 'partner_ids', 'record_name', 'subject'}): + result.update(self.get_record_data(result)) + + # threading support check: 'update' requires to use message_update/post + if 'reply_to_mode' in fields_list and 'reply_to_mode' not in result and result.get('model'): if result['model'] not in self.env or not hasattr(self.env[result['model']], 'message_post'): result['reply_to_mode'] = 'new' - if 'active_domain' in self.env.context: # not context.get() because we want to keep global [] domains - result['active_domain'] = '%r' % self.env.context.get('active_domain') - if result.get('composition_mode') == 'comment' and (set(fields) & set(['model', 'res_id', 'partner_ids', 'record_name', 'subject'])): - result.update(self.get_record_data(result)) - # when being in new mode, create_uid is not granted -> ACLs issue may arise - if 'create_uid' in fields and 'create_uid' not in result: + if 'create_uid' in fields_list and 'create_uid' not in result: result['create_uid'] = self.env.uid - filtered_result = dict((fname, result[fname]) for fname in result if fname in fields) - return filtered_result + return { + fname: result[fname] + for fname in result if fname in fields_list + } def _partner_ids_domain(self): return expression.OR([ @@ -120,11 +134,15 @@ class MailComposer(models.TransientModel): selection=[('comment', 'Post on a document'), ('mass_mail', 'Email Mass Mailing')], string='Composition mode', default='comment') + composition_batch = fields.Boolean( + 'Batch composition', compute='_compute_composition_batch') # more than 1 record (raw source) model = fields.Char('Related Document Model') - res_id = fields.Integer('Related Document ID') + res_ids = fields.Text('Related Document IDs') + res_domain = fields.Text('Active domain') + res_domain_user_id = fields.Many2one( + 'res.users', string='Responsible', + help='Used as context used to evaluate composer domain') record_name = fields.Char('Message Record Name') - use_active_domain = fields.Boolean('Use active domain') - active_domain = fields.Text('Active domain', readonly=True) # characteristics message_type = fields.Selection([ ('comment', 'Comment'), @@ -158,6 +176,35 @@ class MailComposer(models.TransientModel): auto_delete_message = fields.Boolean('Delete Message Copy', help='Do not keep a copy of the email in the document communication history (mass mailing only)') mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') + @api.constrains('res_ids') + def _check_res_ids(self): + """ Check res_ids is a valid list of integers (or Falsy). """ + for composer in self: + composer._evaluate_res_ids() + + @api.constrains('res_domain') + def _check_res_domain(self): + """ Check domain is a valid domain if set (otherwise it is considered + as a Falsy leaf. """ + for composer in self: + composer._evaluate_res_domain() + + @api.depends('res_ids') + def _compute_composition_batch(self): + """ Determine if batch mode is activated: + + * using res_domain: always batch (even if result is singleton at a + given time, it is user and time dependent, hence batch); + * res_ids: if more than one item in the list (void and singleton are + not batch); + """ + for composer in self: + if composer.res_domain: + composer.composition_batch = True + continue + res_ids = composer._evaluate_res_ids() + composer.composition_batch = len(res_ids) > 1 if res_ids else False + @api.depends('reply_to_force_new') def _compute_reply_to_mode(self): for composer in self: @@ -186,18 +233,19 @@ class MailComposer(models.TransientModel): @api.onchange('template_id') def _onchange_template_id_wrapper(self): self.ensure_one() - values = self._onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value'] + values = self._onchange_template_id(self.template_id.id, self.composition_mode, self.model, self._evaluate_res_ids())['value'] for fname, value in values.items(): setattr(self, fname, value) - def _onchange_template_id(self, template_id, composition_mode, model, res_id): + def _onchange_template_id(self, template_id, composition_mode, model, res_ids): """ Perform the onchange. - * mass_mailing: we cannot render, so return the template values + * mass_mailing or comment in batch: we cannot render, so return the + template values * normal mode: return rendered values -> for x2many field, this onchange return command instead of ids """ - if template_id and composition_mode == 'mass_mail': + if template_id and (composition_mode == 'mass_mail' or len(res_ids) > 1): # copy raw template values (not rendered due to mass mode) template = self.env['mail.template'].browse(template_id) values = dict( @@ -214,11 +262,13 @@ class MailComposer(models.TransientModel): values['mail_server_id'] = template.mail_server_id.id if not tools.is_html_empty(template.body_html): values['body'] = template.body_html - elif template_id: + elif template_id and len(res_ids) <= 1: # render template (mono record, comment mode) and set it as composer values + # trick to evaluate qweb even when having no records + template_res_ids = res_ids if res_ids else [0] values = self._generate_template_for_composer( self.env['mail.template'].browse(template_id), - [res_id], + template_res_ids, ('attachment_ids', 'body_html', 'email_cc', @@ -230,7 +280,7 @@ class MailComposer(models.TransientModel): 'report_template', 'subject', ) - )[res_id] + )[template_res_ids[0]] # transform attachments into attachment_ids; not attached to the document because this will # be done further in the posting process, allowing to clean database if email not send attachment_ids = [] @@ -250,7 +300,7 @@ class MailComposer(models.TransientModel): default_values = self.with_context( default_composition_mode=composition_mode, default_model=model, - default_res_id=res_id + default_res_ids=res_ids ).default_get(['attachment_ids', 'body', 'composition_mode', @@ -260,7 +310,7 @@ class MailComposer(models.TransientModel): 'parent_id', 'partner_ids', 'reply_to', - 'res_id', + 'res_ids', 'subject', ]) values = dict( @@ -286,31 +336,33 @@ class MailComposer(models.TransientModel): a document (model, res_id). This is based on previously computed default values. """ result, record_name, subject = {}, False, False - model, res_id = values.get('model'), values.get('res_id') + model = values.get('model') + res_ids = self._parse_res_ids(values['res_ids']) if values.get('res_ids') else [] if values.get('parent_id'): parent = self.env['mail.message'].browse(values.get('parent_id')) if not model: model = parent.model result['model'] = model - if not res_id: - res_id = parent.res_id - result['res_id'] = res_id + if not res_ids: + res_ids = [parent.res_id] + result['res_ids'] = res_ids result['partner_ids'] = values.get('partner_ids', list()) + parent.partner_ids.ids record_name = parent.record_name subject = tools.ustr(parent.subject or '') - if (not subject or not record_name) and model and res_id: - record = self.env[model].browse(res_id) + if (not subject or not record_name) and model and res_ids and len(res_ids) == 1: + record = self.env[model].browse(res_ids[0]) if not record_name: record_name = record.display_name if not subject: subject = record._message_compute_subject() - elif model and res_id: - record = self.env[model].browse(res_id) + elif model and len(res_ids) == 1: + record = self.env[model].browse(res_ids[0]) record_name = record.display_name or '' subject = record._message_compute_subject() - result['record_name'] = record_name - result['subject'] = subject + if values.get('parent_id') or len(res_ids) == 1: # to be cleanup when moving to computed fields + result['record_name'] = record_name + result['subject'] = subject return result @@ -356,18 +408,14 @@ class MailComposer(models.TransientModel): result_mails_su, result_messages = self.env['mail.mail'].sudo(), self.env['mail.message'] for wizard in self: - # Mass Mailing - mass_mode = wizard.composition_mode == 'mass_mail' - - # wizard works in batch mode: [res_id] or active_ids or active_domain - if mass_mode and wizard.use_active_domain and wizard.model: - res_ids = self.env[wizard.model].search(ast.literal_eval(wizard.active_domain)).ids - elif mass_mode and wizard.model and self.env.context.get('active_ids'): - res_ids = self.env.context['active_ids'] + if wizard.res_domain: + search_domain = wizard._evaluate_res_domain() + search_user = wizard.res_domain_user_id or self.env.user + res_ids = self.env[wizard.model].with_user(search_user).search(search_domain).ids else: - res_ids = [wizard.res_id] if wizard.res_id else [] + res_ids = wizard._evaluate_res_ids() # in comment mode: raise here as anyway message_post will raise. - if not res_ids and not mass_mode: + if not res_ids and wizard.composition_mode == 'comment': raise ValueError( _('Mail composer in comment mode should run on at least one record. No records found (model %(model_name)s).', model_name=wizard.model) @@ -386,7 +434,7 @@ class MailComposer(models.TransientModel): self.ensure_one() post_values_all = self._prepare_mail_values(res_ids) ActiveModel = self.env[self.model] if self.model and hasattr(self.env[self.model], 'message_post') else self.env['mail.thread'] - if len(res_ids) > 1: + if self.composition_batch: # do not send emails directly but use the queue instead # add context key to avoid subscribing the author ActiveModel = ActiveModel.with_context( @@ -509,16 +557,17 @@ class MailComposer(models.TransientModel): """ self.ensure_one() email_mode = self.composition_mode == 'mass_mail' + rendering_mode = email_mode or self.composition_batch # values that do not depend on rendering mode base_values = self._prepare_mail_values_static() additional_values_all = {} # rendered based on raw content (wizard or template) - if email_mode and self.model: + if rendering_mode and self.model: additional_values_all = self._prepare_mail_values_dynamic(res_ids) # wizard content already rendered - elif not email_mode: + elif not rendering_mode: additional_values_all = self._prepare_mail_values_rendered(res_ids) mail_values_all = { @@ -607,17 +656,22 @@ class MailComposer(models.TransientModel): bodies = self._render_field( 'body', res_ids, # We want to preserve comments in emails so as to keep mso conditionals - options={'preserve_comments': self.composition_mode == 'mass_mail'}, + options={'preserve_comments': email_mode}, ) emails_from = self._render_field('email_from', res_ids) mail_values_all = { res_id: { 'body': bodies[res_id], # should be void - 'body_html': bodies[res_id] if email_mode else False, 'email_from': emails_from[res_id], - 'res_id': res_id, 'subject': subjects[res_id], + # some fields are specific to mail or message + **( + { + 'auto_delete': self.auto_delete, + 'body_html': bodies[res_id], + 'res_id': res_id, + } if email_mode else {}), } for res_id in res_ids } @@ -639,6 +693,9 @@ class MailComposer(models.TransientModel): for res_id in res_ids: # remove attachments from template values as they should not be rendered template_values[res_id].pop('attachment_ids', None) + # auto_delete is not supported in batch post, only in mass mail + if not email_mode: + template_values[res_id].pop('auto_delete', False) mail_values_all[res_id].update(template_values[res_id]) # Handle recipients. Without template, if no partner_ids is given, update @@ -674,25 +731,34 @@ class MailComposer(models.TransientModel): (name, base64.b64decode(enc_cont)) for name, enc_cont in mail_values.pop('attachments', []) ] - mail_values['attachment_ids'] = record._process_attachments_for_post( - decoded_attachments, - attachment_ids, - {'model': 'mail.message', 'res_id': 0} - )['attachment_ids'] + # email_mode: prepare processed attachments as commands for mail.mail + if email_mode: + mail_values['attachment_ids'] = record._process_attachments_for_post( + decoded_attachments, + attachment_ids, + {'model': 'mail.message', 'res_id': 0} + )['attachment_ids'] + # comment mode: prepare attachments as a list of IDs, to be processed by MailThread + else: + mail_values['attachments'] = decoded_attachments + mail_values['attachment_ids'] = attachment_ids # headers - mail_values['headers'] = repr(record._notify_by_email_get_headers()) + if email_mode: + mail_values['headers'] = repr(record._notify_by_email_get_headers()) # recipients: transform partner_ids (field used in mail_message) into # recipient_ids, used by mail_mail - recipient_ids_all = mail_values.pop('partner_ids', []) + self.partner_ids.ids - mail_values['recipient_ids'] = [(4, pid) for pid in recipient_ids_all] + if email_mode: + recipient_ids_all = mail_values.pop('partner_ids', []) + self.partner_ids.ids + mail_values['recipient_ids'] = [(4, pid) for pid in recipient_ids_all] # when having no specific reply_to -> fetch rendered email_from - reply_to = reply_to_values.get(res_id) - if not reply_to: - reply_to = mail_values.get('email_from', False) - mail_values['reply_to'] = reply_to + if email_mode: + reply_to = reply_to_values.get(res_id) + if not reply_to: + reply_to = mail_values.get('email_from', False) + mail_values['reply_to'] = reply_to return mail_values_all @@ -901,3 +967,63 @@ class MailComposer(models.TransientModel): ] } return recipients_info + + # ---------------------------------------------------------------------- + # MISC UTILS + # ---------------------------------------------------------------------- + + def _evaluate_res_domain(self): + """ Parse composer domain, which can be: an already valid list or + tuple (generally in code), a list or tuple as a string (coming from + actions). Void strings are considered as a falsy domain. + + :return: an Odoo domain (list of leaves) """ + self.ensure_one() + if isinstance(self.res_domain, (str, bool)) and not self.res_domain: + return expression.FALSE_DOMAIN + try: + domain = self.res_domain + if isinstance(self.res_domain, str): + domain = ast.literal_eval(domain) + + expression.expression( + domain, + self.env[self.model], + ) + except (ValueError, AssertionError) as e: + raise ValidationError( + _("Invalid domain %(domain)r (type %(domain_type)s)", + domain=self.res_domain, + domain_type=type(self.res_domain)) + ) from e + + return domain + + def _evaluate_res_ids(self): + """ Parse composer res_ids, which can be: an already valid list or + tuple (generally in code), a list or tuple as a string (coming from + actions). Void strings / missing values are evaluated as an empty list. + + Note that 'active_ids' context key is supported at this point as mailing + on big ID list would create issues if stored in database. + + :return: a list of IDs (empty list in case of falsy strings)""" + self.ensure_one() + return self._parse_res_ids( + self.res_ids or self.env.context.get('active_ids') + ) or [] + + @api.model + def _parse_res_ids(self, res_ids): + if tools.is_list_of(res_ids, int) or not res_ids: + return res_ids + error_msg = _("Invalid res_ids %(res_ids_str)s (type %(res_ids_type)s)", + res_ids_str=res_ids, + res_ids_type=type(res_ids)) + try: + res_ids = ast.literal_eval(res_ids) + except Exception as e: + raise ValidationError(error_msg) from e + if not tools.is_list_of(res_ids, int): + raise ValidationError(error_msg) + return res_ids diff --git a/addons/mail/wizard/mail_compose_message_views.xml b/addons/mail/wizard/mail_compose_message_views.xml index 8f7acb29202..6425d72410a 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -12,6 +12,7 @@ + @@ -21,7 +22,9 @@ - + + + new list @@ -440,7 +439,6 @@ new list diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 108e28d89f1..e1468fc3ba0 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -419,10 +419,7 @@ class PurchaseOrder(models.Model): ctx = dict(self.env.context or {}) ctx.update({ 'default_model': 'purchase.order', - 'active_model': 'purchase.order', - 'active_id': self.ids[0], - 'default_res_id': self.ids[0], - 'default_use_template': bool(template_id), + 'default_res_ids': self.ids, 'default_template_id': template_id, 'default_composition_mode': 'comment', 'default_email_layout_xmlid': "mail.mail_notification_layout_with_responsible_signature", @@ -781,10 +778,7 @@ class PurchaseOrder(models.Model): ctx = dict(self.env.context or {}) ctx.update({ 'default_model': 'purchase.order', - 'active_model': 'purchase.order', - 'active_id': self.ids[0], - 'default_res_id': self.ids[0], - 'default_use_template': bool(template_id), + 'default_res_ids': self.ids, 'default_template_id': template_id, 'default_composition_mode': 'comment', 'default_email_layout_xmlid': "mail.mail_notification_layout_with_responsible_signature", diff --git a/addons/repair/models/repair.py b/addons/repair/models/repair.py index 615e213a2b1..ef7452b97d5 100644 --- a/addons/repair/models/repair.py +++ b/addons/repair/models/repair.py @@ -332,8 +332,7 @@ class Repair(models.Model): template_id = self.env.ref('repair.mail_template_repair_quotation').id ctx = { 'default_model': 'repair.order', - 'default_res_id': self.id, - 'default_use_template': bool(template_id), + 'default_res_ids': self.ids, 'default_template_id': template_id, 'default_composition_mode': 'comment', 'default_email_layout_xmlid': 'mail.mail_notification_light', diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index 90609a9fd16..3380057638c 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -111,21 +111,20 @@ class ResCompany(models.Model): template = self.env.ref('sale.email_template_edi_sale', False) message_composer = self.env['mail.compose.message'].with_context( - default_use_template=bool(template), mark_so_as_sent=True, default_email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', proforma=self.env.context.get('proforma', False), force_email=True, ).create({ - 'res_id': sample_sales_order.id, - 'template_id': template and template.id or False, - 'model': 'sale.order', - 'composition_mode': 'comment' + 'res_ids': sample_sales_order.ids, + 'template_id': template.id if template else False, + 'model': sample_sales_order._name, + 'composition_mode': 'comment', }) # Simulate the onchange (like trigger in form the view) update_values = message_composer._onchange_template_id( - template.id, 'comment', 'sale.order', sample_sales_order.id + template.id, 'comment', sample_sales_order._name, sample_sales_order.ids )['value'] message_composer.write(update_values) diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index cc506c77b41..99375caa545 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -713,8 +713,7 @@ class SaleOrder(models.Model): lang = mail_template._render_lang(self.ids)[self.id] ctx = { 'default_model': 'sale.order', - 'default_res_id': self.id, - 'default_use_template': bool(mail_template), + 'default_res_ids': self.ids, 'default_template_id': mail_template.id if mail_template else None, 'default_composition_mode': 'comment', 'mark_so_as_sent': True, @@ -877,7 +876,6 @@ class SaleOrder(models.Model): if template.lang: lang = template._render_lang(self.ids)[self.id] ctx = { - 'default_use_template': bool(template_id), 'default_template_id': template_id, 'default_order_id': self.id, 'mark_so_as_canceled': True, diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index c6d2cdc26a1..7a8ec892ade 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -460,7 +460,6 @@ new list diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 1c701575a03..b75d5e16b1a 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -904,7 +904,6 @@ class Survey(models.Model): local_context = dict( self.env.context, default_survey_id=self.id, - default_use_template=bool(template), default_template_id=template and template.id or False, default_email_layout_xmlid='mail.mail_notification_light', default_send_email=(self.access_mode != 'public'), diff --git a/addons/test_mail/static/tests/activity_tests.js b/addons/test_mail/static/tests/activity_tests.js index 7ee548a2915..4e44d4326c9 100644 --- a/addons/test_mail/static/tests/activity_tests.js +++ b/addons/test_mail/static/tests/activity_tests.js @@ -350,10 +350,9 @@ QUnit.module("test_mail", {}, function () { assert.deepEqual( { default_model: "mail.test.activity", - default_res_id: mailTestActivityId2, + default_res_ids: [mailTestActivityId2], default_subtype_xmlid: 'mail.mt_comment', default_template_id: mailTemplateId1, - default_use_template: true, force_email: true, }, action.context diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 8e0504ee260..f399f816e29 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from unittest.mock import patch +from ast import literal_eval +from unittest.mock import DEFAULT, patch from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.mail.wizard.mail_compose_message import MailComposer from odoo.addons.test_mail.models.test_mail_models import MailTestTicket from odoo.addons.test_mail.tests.common import TestMailCommon, TestRecipients from odoo.exceptions import AccessError @@ -84,16 +86,16 @@ class TestMailComposer(TestMailCommon, TestRecipients): """ base_context = { 'default_model': records._name, + 'default_res_ids': records.ids, } if len(records) == 1: base_context['default_composition_mode'] = 'comment' - base_context['default_res_id'] = records.id else: base_context['default_composition_mode'] = 'mass_mail' - base_context['active_ids'] = records.ids if add_web: base_context['active_model'] = records._name base_context['active_id'] = records[0].id + base_context['active_ids'] = records.ids if values: base_context.update(**values) return base_context @@ -111,6 +113,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.auto_delete_message) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertFalse(composer_form.body) + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.env.user.email_formatted) self.assertFalse(composer_form.mail_server_id) @@ -119,7 +122,7 @@ class TestComposerForm(TestMailComposer): self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name') self.assertFalse(composer_form.reply_to) self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_record.id) + self.assertEqual(literal_eval(composer_form.res_ids), self.test_record.ids) self.assertEqual(composer_form.subject, self.test_record._message_compute_subject()) self.assertIn(f'Ticket for {self.test_record.name}', composer_form.subject, 'Check effective content') @@ -187,6 +190,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.auto_delete_message) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, '

TemplateBody %s

' % self.test_record.name) + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.user_employee_2.email_formatted) self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) @@ -195,10 +199,39 @@ class TestComposerForm(TestMailComposer): self.assertEqual(composer_form.record_name, self.test_record.name, 'MailComposer: comment mode should compute record name') self.assertEqual(composer_form.reply_to, 'info@test.example.com') self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_record.id) + self.assertEqual(literal_eval(composer_form.res_ids), self.test_record.ids) self.assertEqual(composer_form.subject, 'TemplateSubject %s' % self.test_record.name) self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) + @users('employee') + def test_mail_composer_comment_wtpl_batch(self): + """ Batch mode of composer in comment mode. """ + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context( + self.test_records, + add_web=True, + default_composition_mode='comment', + default_template_id=self.template.id), + )) + # self.assertTrue(composer_form.auto_delete) + self.assertFalse(composer_form.auto_delete) # FIXME: currently not taking template value + self.assertFalse(composer_form.auto_delete_message) + self.assertEqual(composer_form.author_id, self.env.user.partner_id) + self.assertEqual(composer_form.body, self.template.body_html, + 'MailComposer: comment in batch mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) + self.assertEqual(composer_form.composition_mode, 'comment') + self.assertEqual(composer_form.email_from, self.template.email_from, + 'MailComposer: comment in batch mode should have template raw email_from if template') + self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) + self.assertEqual(composer_form.model, self.test_record._name) + self.assertFalse(composer_form.record_name, 'MailComposer: comment in batch mode should have void record name') + self.assertEqual(composer_form.reply_to, self.template.reply_to) + self.assertFalse(composer_form.reply_to_force_new) + self.assertEqual(literal_eval(composer_form.res_ids), self.test_records.ids) + self.assertEqual(composer_form.subject, self.template.subject, + 'MailComposer: comment in batch mode should have template raw subject if template') + @users('employee') def test_mail_composer_comment_wtpl_norecords(self): """ Test specific case when running without records, to see the rendering @@ -213,6 +246,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.auto_delete_message) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, '

TemplateBody

') + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'comment') self.assertEqual(composer_form.email_from, self.env.user.partner_id.email_formatted) self.assertEqual(composer_form.mail_server_id, self.mail_server_domain) @@ -221,7 +255,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.record_name) self.assertEqual(composer_form.reply_to, 'info@test.example.com') self.assertFalse(composer_form.reply_to_force_new) - self.assertFalse(composer_form.res_id) + self.assertFalse(composer_form.res_ids) self.assertEqual(composer_form.subject, 'TemplateSubject ') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) @@ -234,6 +268,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.auto_delete_message) self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertFalse(composer_form.body) + self.assertTrue(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.env.user.email_formatted) self.assertFalse(composer_form.mail_server_id) @@ -241,8 +276,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') self.assertFalse(composer_form.reply_to) self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_records[0].id, - 'MailComposer: even in mass mode web active_id presence may add a res_id') + self.assertEqual(sorted(literal_eval(composer_form.res_ids)), sorted(self.test_records.ids)) self.assertFalse(composer_form.subject, 'MailComposer: mass mode should have void default subject if no template') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) @@ -257,6 +291,7 @@ class TestComposerForm(TestMailComposer): self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, self.template.body_html, 'MailComposer: mass mode should have template raw body if template') + self.assertTrue(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.template.email_from, 'MailComposer: mass mode should have template raw email_from if template') @@ -265,8 +300,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') self.assertEqual(composer_form.reply_to, self.template.reply_to) self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, self.test_records[0].id, - 'MailComposer: even in mass mode web active_id presence may add a res_id') + self.assertEqual(sorted(literal_eval(composer_form.res_ids)), sorted(self.test_records.ids)) self.assertEqual(composer_form.subject, self.template.subject, 'MailComposer: mass mode should have template raw subject if template') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) @@ -286,6 +320,7 @@ class TestComposerForm(TestMailComposer): self.assertEqual(composer_form.author_id, self.env.user.partner_id) self.assertEqual(composer_form.body, self.template.body_html, 'MailComposer: mass mode should have template raw body if template') + self.assertFalse(composer_form.composition_batch) self.assertEqual(composer_form.composition_mode, 'mass_mail') self.assertEqual(composer_form.email_from, self.template.email_from, 'MailComposer: mass mode should have template raw email_from if template') @@ -294,7 +329,7 @@ class TestComposerForm(TestMailComposer): self.assertFalse(composer_form.record_name, 'MailComposer: mass mode should have void record name') self.assertEqual(composer_form.reply_to, self.template.reply_to) self.assertFalse(composer_form.reply_to_force_new) - self.assertEqual(composer_form.res_id, 0) + self.assertFalse(composer_form.res_ids) self.assertEqual(composer_form.subject, self.template.subject, 'MailComposer: mass mode should have template raw subject if template') self.assertEqual(composer_form.subtype_id, self.env.ref('mail.mt_comment')) @@ -321,7 +356,8 @@ class TestComposerInternals(TestMailComposer): attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 3) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): + for composition_mode, batch in (('comment', False), ('comment', True), + ('mass_mail', False), ('mass_mail', True)): with self.subTest(composition_mode=composition_mode, batch=batch): test_records = self.test_records if batch else self.test_record ctx = self._get_web_context( @@ -337,7 +373,7 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values coming from template: attachment_ids + report in comment - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(len(composer.attachment_ids), 4) for attach in attachs: self.assertIn(attach, composer.attachment_ids) @@ -358,7 +394,7 @@ class TestComposerInternals(TestMailComposer): # currently onchange necessary composer._onchange_template_id_wrapper() - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.attachment_ids, attachs + generated, 'TODO: Values are kept (should be reset ?)') else: @@ -370,7 +406,7 @@ class TestComposerInternals(TestMailComposer): # currently onchange necessary composer._onchange_template_id_wrapper() - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.attachment_ids, attachs + generated, 'TODO: Values are kept (should be reset ?)') else: @@ -386,7 +422,8 @@ class TestComposerInternals(TestMailComposer): 'email_from': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): + for composition_mode, batch in (('comment', False), ('comment', True), + ('mass_mail', False), ('mass_mail', True)): with self.subTest(composition_mode=composition_mode, batch=batch): test_records = self.test_records if batch else self.test_record ctx = self._get_web_context( @@ -400,6 +437,7 @@ class TestComposerInternals(TestMailComposer): # default values are current user self.assertEqual(composer.author_id, self.env.user.partner_id) + self.assertEqual(composer.composition_mode, composition_mode) self.assertEqual(composer.email_from, self.env.user.email_formatted) # author values reset email (FIXME: currently not synchronized) @@ -414,12 +452,12 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() self.assertEqual(composer.author_id, self.env.user.partner_id, 'MailComposer: should take value given by user') - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.email_from, self.test_record.user_id.email_formatted, - 'MailComposer: should take email_from rendered from template') + f'MailComposer: should take email_from rendered from template ({composition_mode}-{batch})') else: self.assertEqual(composer.email_from, self.template.email_from, - 'MailComposer: should take email_from raw from template') + f'MailComposer: should take email_from raw from template ({composition_mode}-{batch})') # manual values are kept over template values composer.write({'email_from': self.test_from}) @@ -431,7 +469,7 @@ class TestComposerInternals(TestMailComposer): composer.write({'template_id': template_void.id}) # currently onchange necessary composer._onchange_template_id_wrapper() - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.author_id, self.env.user.partner_id) self.assertEqual(composer.email_from, self.test_from) else: @@ -442,7 +480,7 @@ class TestComposerInternals(TestMailComposer): composer.write({'template_id': False}) # currently onchange necessary composer._onchange_template_id_wrapper() - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.author_id, self.env.user.partner_id) self.assertEqual(composer.email_from, self.env.user.email_formatted) else: @@ -458,7 +496,8 @@ class TestComposerInternals(TestMailComposer): 'auto_delete': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): + for composition_mode, batch in (('comment', False), ('comment', True), + ('mass_mail', False), ('mass_mail', True)): with self.subTest(composition_mode=composition_mode, batch=batch): test_records = self.test_records if batch else self.test_record ctx = self._get_web_context( @@ -539,7 +578,8 @@ class TestComposerInternals(TestMailComposer): 'subject': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): + for composition_mode, batch in (('comment', False), ('comment', True), + ('mass_mail', False), ('mass_mail', True)): with self.subTest(composition_mode=composition_mode, batch=batch): test_records = self.test_records if batch else self.test_record ctx = self._get_web_context( @@ -557,11 +597,11 @@ class TestComposerInternals(TestMailComposer): # creation values are taken self.assertEqual(composer.body, '

Test Body />

') self.assertEqual(composer.mail_server_id, self.mail_server_global) self.assertEqual(composer.record_name, 'Manual update') @@ -618,7 +658,7 @@ class TestComposerInternals(TestMailComposer): # values are reset with default_get call, if it returns value # (aka subject for comment mode), and not record_name because # it was forgotten probably - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertFalse(composer.body) # self.assertFalse(composer.mail_server_id.id) self.assertEqual(composer.mail_server_id, self.mail_server_global, @@ -650,7 +690,7 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.body, f'

TemplateBody {self.test_record.name}

') self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertEqual(composer.record_name, self.test_record.name) @@ -670,7 +710,7 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.body, f'

TemplateBody {self.test_record.name}

') self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertEqual(composer.record_name, self.test_record.name) @@ -713,7 +753,8 @@ class TestComposerInternals(TestMailComposer): 'reply_to': False, }) - for composition_mode, batch in (('comment', False), ('mass_mail', True)): + for composition_mode, batch in (('comment', False), ('comment', True), + ('mass_mail', False), ('mass_mail', True)): with self.subTest(composition_mode=composition_mode, batch=batch): self.assertFalse( self.env['res.partner'].search([ @@ -769,7 +810,7 @@ class TestComposerInternals(TestMailComposer): ) # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(len(new_partners), 2) self.assertEqual(composer.partner_ids, self.partner_1 + new_partners, 'Template took customer_id as set on record') self.assertEqual(composer.reply_to, 'info@test.example.com', 'Template was rendered') @@ -790,7 +831,7 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values are kept, should probably be reset - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.partner_ids, self.partner_admin, 'TODO: Values are kept (should be reset ?)') self.assertEqual(composer.reply_to, 'info@test.example.com', @@ -812,11 +853,11 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.partner_ids, self.partner_1 + new_partners) else: self.assertFalse(composer.partner_ids) - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.reply_to, "info@test.example.com") else: self.assertEqual(composer.reply_to, self.template.reply_to) @@ -832,11 +873,11 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() # values come from template - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.partner_ids, self.partner_1 + new_partners) else: self.assertFalse(composer.partner_ids) - if composition_mode == 'comment': + if composition_mode == 'comment' and not batch: self.assertEqual(composer.reply_to, "info@test.example.com") else: self.assertEqual(composer.reply_to, self.template.reply_to) @@ -1137,10 +1178,11 @@ class TestComposerResultsComment(TestMailComposer): @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_complete(self): """ Test a posting process using a complex template, holding several - additional recipients and attachments. + additional recipients and attachments. It is done in monorecord and + in batch since this is now supported. This tests notifies: 2 new email_to (+ 1 duplicated), 1 email_cc, - test_record followers and partner_admin added in partner_to.""" + test_records followers and partner_admin added in partner_to.""" attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) email_to_1 = 'test.to.1@test.example.com' email_to_2 = 'test.to.2@test.example.com' @@ -1158,82 +1200,97 @@ class TestComposerResultsComment(TestMailComposer): attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) - # ensure initial data - self.assertEqual(self.test_record.user_id, self.user_employee_2) - self.assertEqual(self.test_record.message_partner_ids, self.partner_employee_2) + for test_records, batch in ((self.test_record, False), (self.test_records, True)): + with self.subTest(test_records=test_records, batch=batch): + # ensure initial data + self.assertEqual(len(test_records.customer_id), len(test_records)) + self.assertEqual(test_records.user_id, self.user_employee_2) + self.assertEqual(test_records.message_partner_ids, self.partner_employee_2) - # open a composer and run it in comment mode - composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_record, add_web=True, - default_template_id=self.template.id) - )) - composer = composer_form.save() - self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): - composer._action_send_mail() + # open a composer and run it in comment mode + composer_form = Form(self.env['mail.compose.message'].with_context( + self._get_web_context(test_records, add_web=True, + default_composition_mode='comment', + default_template_id=self.template.id) + )) + composer = composer_form.save() + self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') + with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + composer._action_send_mail() - # check new partners have been created based on emails given - new_partners = self.env['res.partner'].search([ - ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) - ]) - self.assertEqual(len(new_partners), 3) - self.assertEqual(set(new_partners.mapped('email')), - set(['test.to.1@test.example.com', 'test.to.2@test.example.com', 'test.cc.1@test.example.com']) - ) + # check new partners have been created based on emails given + new_partners = self.env['res.partner'].search([ + ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) + ]) + self.assertEqual(len(new_partners), 3) + self.assertEqual(set(new_partners.mapped('email')), + set(['test.to.1@test.example.com', 'test.to.2@test.example.com', 'test.cc.1@test.example.com']) + ) - # global outgoing: one mail.mail (all customer recipients, then all employee recipients) - # and 5 emails, and 1 inbox notification (admin) - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail') - self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient') + # global outgoing: one mail.mail (all customer recipients, then all employee recipients) + # and 5 emails, and 1 inbox notification (admin) + self.assertEqual(len(self._new_mails), 2 * len(test_records), + 'Should have created 1 mail.mail per group per record') + # monorecord: force_send notifications + if not batch: + self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) + # mutlirecord: use email queue + else: + self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') + self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) + # simulate cron sending emails + self.env['mail.mail'].sudo().process_email_queue() - # template is sent only to partners (email_to are transformed) - message = self.test_record.message_ids[0] - self.assertMailMail(self.partner_employee_2, 'sent', - mail_message=message, - author=self.partner_employee, # author != email_from (template sets only email_from) - email_values={ - 'body_content': 'TemplateBody %s' % self.test_record.name, - 'email_from': self.test_record.user_id.email_formatted, # set by template - 'subject': 'TemplateSubject %s' % self.test_record.name, - 'attachments_info': [ - {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, - {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, - {'name': 'TestReport for %s.html' % self.test_record.name, 'type': 'text/plain'}, - ] - }, - fields_values={ - 'mail_server_id': self.mail_server_domain, - }, - ) - self.assertMailMail(self.test_record.customer_id + new_partners, 'sent', - mail_message=message, - author=self.partner_employee, # author != email_from (template sets only email_from) - email_values={ - 'body_content': 'TemplateBody %s' % self.test_record.name, - 'email_from': self.test_record.user_id.email_formatted, # set by template - 'subject': 'TemplateSubject %s' % self.test_record.name, - 'attachments_info': [ - {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, - {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, - {'name': 'TestReport for %s.html' % self.test_record.name, 'type': 'text/plain'}, - ] - }, - fields_values={ - 'mail_server_id': self.mail_server_domain, - }, - ) + # template is sent only to partners (email_to are transformed) + for test_record in test_records: + message = test_record.message_ids[0] + self.assertMailMail(self.partner_employee_2, 'sent', + mail_message=message, + author=self.partner_employee, # author != email_from (template sets only email_from) + email_values={ + 'body_content': f'TemplateBody {test_record.name}', + 'email_from': test_record.user_id.email_formatted, # set by template + 'subject': f'TemplateSubject {test_record.name}', + 'attachments_info': [ + {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, + {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, + {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + ] + }, + fields_values={ + 'mail_server_id': self.mail_server_domain, + }, + ) + self.assertMailMail(test_record.customer_id + new_partners, 'sent', + mail_message=message, + author=self.partner_employee, # author != email_from (template sets only email_from) + email_values={ + 'body_content': f'TemplateBody {test_record.name}', + 'email_from': test_record.user_id.email_formatted, # set by template + 'subject': f'TemplateSubject {test_record.name}', + 'attachments_info': [ + {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, + {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, + {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + ] + }, + fields_values={ + 'mail_server_id': self.mail_server_domain, + }, + ) - # message is posted and notified admin - self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment')) - self.assertNotified(message, [{'partner': self.partner_admin, 'is_read': False, 'type': 'inbox'}]) - # attachments are copied on message and linked to document - self.assertEqual( - set(message.attachment_ids.mapped('name')), - set(['AttFileName_00.txt', 'AttFileName_01.txt', 'TestReport for %s.html' % self.test_record.name]) - ) - self.assertEqual(set(message.attachment_ids.mapped('res_model')), set([self.test_record._name])) - self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(self.test_record.ids)) - self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') + # message is posted and notified admin + self.assertEqual(message.subtype_id, self.env.ref('mail.mt_comment')) + self.assertNotified(message, [{'partner': self.partner_admin, 'is_read': False, 'type': 'inbox'}]) + # attachments are copied on message and linked to document + self.assertEqual( + set(message.attachment_ids.mapped('name')), + set(['AttFileName_00.txt', 'AttFileName_01.txt', f'TestReport for {test_record.name}.html']) + ) + self.assertEqual(set(message.attachment_ids.mapped('res_model')), set([test_record._name])) + self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids)) + self.assertTrue(all(attach not in message.attachment_ids for attach in attachs), 'Should have copied attachments') @@ -1563,51 +1620,66 @@ class TestComposerResultsMass(TestMailComposer): @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_recipients(self): - """ Test various combinations of recipients: active_domain, active_id, + """ Test various combinations of recipients: res_domain, active_id, active_ids, ... to ensure fallback behavior are working. """ - # 1: active_domain + # 1: active ids composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, - default_template_id=self.template.id, - active_ids=[], - default_use_active_domain=True, - default_active_domain=[('id', 'in', self.test_records.ids)]) + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, )) composer = composer_form.save() + self.assertEqual(sorted(literal_eval(composer.res_ids)), sorted(self.test_records.ids)) + with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() + # should create emails in a single batch + self.assertEqual(self.build_email_mocked.call_count, 2, 'One build email per outgoing email') + self.assertEqual(self.mail_mail_create_mocked.call_count, 2, 'Emails are anyway created in a singleton loop') # global outgoing - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on on active_ids') for record in self.test_records: # template is sent directly using customer field, even if author is partner_employee self.assertSentEmail(self.partner_employee_2.email_formatted, record.customer_id) - # 2: active_domain not taken into account if use_active_domain is False + # 2: default_res_ids + active_ids -> res_ids takes lead composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, + self._get_web_context(self.test_record, add_web=False, + default_composition_mode='mass_mail', + default_res_ids=self.test_record.ids, default_template_id=self.template.id, - default_use_active_domain=False, - default_active_domain=[('id', 'in', -1)]) + active_ids=self.test_records.ids, + ) )) composer = composer_form.save() + self.assertEqual(literal_eval(composer.res_ids), self.test_record.ids) + with self.mock_mail_gateway(mail_unlink_sent=True): composer._action_send_mail() # global outgoing - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') - self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record') + self.assertEqual(len(self._new_mails), 1, 'Should have taken default_res_ids (1 record)') + self.assertEqual(len(self._mails), 1, 'Should have taken default_res_ids (1 record)') + + # template is sent directly using customer field, even if author is partner_employee + self.assertSentEmail(self.partner_employee_2.email_formatted, + self.test_record.customer_id) # 3: fallback on active_id if not active_ids composer_form = Form(self.env['mail.compose.message'].with_context( - self._get_web_context(self.test_records, add_web=True, - default_template_id=self.template.id, - active_ids=[]) + active_id=self.test_record.id, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, )) composer = composer_form.save() + self.assertEqual(literal_eval(composer.res_ids), self.test_record.ids) + with self.mock_mail_gateway(mail_unlink_sent=False): composer._action_send_mail() @@ -1615,7 +1687,54 @@ class TestComposerResultsMass(TestMailComposer): self.assertEqual(len(self._new_mails), 1, 'Should have created 1 mail.mail per record') self.assertEqual(len(self._mails), 1, 'Should have sent 1 email per record') - # 3: void is void: raise in comment mode, just don't send anything in mass mail mode + # 4: _batch_size limit for active_ids + with patch.object(MailComposer, '_batch_size', new=1): + composer_form = Form(self.env['mail.compose.message'].with_context( + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, + )) + composer = composer_form.save() + self.assertTrue(composer.composition_batch) + self.assertEqual(composer.composition_mode, 'mass_mail') + self.assertFalse(composer.res_ids) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + # should create emails in 2 batches of 1 + self.assertEqual(self.build_email_mocked.call_count, 2) + self.assertEqual(self.mail_mail_create_mocked.call_count, 2) + # global outgoing + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on on active_ids') + + # 5: mail.batch_size config parameter support, for sending only + self.env['ir.config_parameter'].sudo().set_param('mail.batch_size', 1) + with patch.object(MailComposer, '_batch_size', new=50): + composer_form = Form(self.env['mail.compose.message'].with_context( + active_ids=self.test_records.ids, + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_template_id=self.template.id, + )) + composer = composer_form.save() + self.assertTrue(composer.composition_batch) + self.assertEqual(composer.composition_mode, 'mass_mail') + self.assertEqual(sorted(literal_eval(composer.res_ids)), sorted(self.test_records.ids)) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + # should create emails in 2 batches of 1 + self.assertEqual(self.build_email_mocked.call_count, 2) + self.assertEqual(self.mail_mail_create_mocked.call_count, 2) + # global outgoing + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record based on active_ids') + self.assertEqual(len(self._mails), 2, 'Should have sent 1 email per record based on on active_ids') + + # 6: void is void: raise in comment mode, just don't send anything in mass mail mode composer_form = Form(self.env['mail.compose.message'].with_context( default_model='mail.test.ticket', default_template_id=self.template.id @@ -1637,6 +1756,41 @@ class TestComposerResultsMass(TestMailComposer): composer._action_send_mail() self.assertNotSentEmail() + @users('employee') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_mail_composer_wtpl_recipients_res_domain(self): + """ Test specific use case of res_domain usage, in combination with + res_domain_user_id allowing to set the evaluation environment for + the domain. """ + def _search_as_employee_2(recordset, *args, **kwargs): + """ Mock to return only one record, simulating a specific ir.rule """ + if recordset.env.uid == self.user_employee_2.id: + return self.test_records[0] + return DEFAULT + + composer_form = Form(self.env['mail.compose.message'].with_context( + default_composition_mode='mass_mail', + default_model=self.test_records._name, + default_res_domain=[('id', 'in', self.test_records.ids)], + default_res_domain_user_id=self.user_employee_2.id, + default_template_id=self.template.id, + )) + composer = composer_form.save() + self.assertEqual(literal_eval(composer.res_domain), [('id', 'in', self.test_records.ids)]) + self.assertEqual(composer.res_domain_user_id, self.user_employee_2) + + with self.mock_mail_gateway(mail_unlink_sent=True), \ + patch.object(MailTestTicket, 'search', autospec=True, side_effect=_search_as_employee_2): + composer._action_send_mail() + + # global outgoing + self.assertEqual(len(self._new_mails), 1, 'Should have created 1 mail.mail, search done by employee 2') + self.assertEqual(len(self._mails), 1, 'Should have sent 1 email, search done by employee 2') + + # template is sent directly using customer field, whatever the author + self.assertSentEmail(self.partner_employee_2.email_formatted, + self.test_records[0].customer_id) + @users('employee') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_mail_composer_wtpl_reply_to_force_new(self): diff --git a/addons/test_mail/tests/test_performance.py b/addons/test_mail/tests/test_performance.py index c3c98458cd3..a1ebb139e43 100644 --- a/addons/test_mail/tests/test_performance.py +++ b/addons/test_mail/tests/test_performance.py @@ -341,7 +341,7 @@ class TestMailAPIPerformance(BaseMailPerformance): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }).create({ 'body': '

Test Body

', 'partner_ids': [(4, customer_id)], @@ -361,7 +361,7 @@ class TestMailAPIPerformance(BaseMailPerformance): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }).create({ 'attachment_ids': attachments.ids, 'body': '

Test Body

', @@ -383,7 +383,7 @@ class TestMailAPIPerformance(BaseMailPerformance): self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, }) ) composer_form.body = '

Test Body

' @@ -408,9 +408,9 @@ class TestMailAPIPerformance(BaseMailPerformance): with self.assertQueryCount(__system__=3, employee=3): composer = self.env['mail.compose.message'].with_context({ - 'active_ids': test_records.ids, 'default_composition_mode': 'mass_mail', 'default_model': test_records._name, + 'default_res_ids': test_records.ids, 'default_template_id': test_template.id, }).create({}) composer._onchange_template_id_wrapper() @@ -430,7 +430,7 @@ class TestMailAPIPerformance(BaseMailPerformance): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'mail_auto_delete': False, }).create({ 'body': '

Test Body

', @@ -451,7 +451,7 @@ class TestMailAPIPerformance(BaseMailPerformance): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }).create({}) composer._onchange_template_id_wrapper() @@ -476,7 +476,7 @@ class TestMailAPIPerformance(BaseMailPerformance): composer = self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }).create({}) composer._onchange_template_id_wrapper() @@ -507,7 +507,7 @@ class TestMailAPIPerformance(BaseMailPerformance): self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }) ) @@ -537,7 +537,7 @@ class TestMailAPIPerformance(BaseMailPerformance): self.env['mail.compose.message'].with_context({ 'default_composition_mode': 'comment', 'default_model': test_record._name, - 'default_res_id': test_record.id, + 'default_res_ids': test_record.ids, 'default_template_id': test_template.id, }) ) @@ -883,9 +883,9 @@ class TestMailComplexPerformance(BaseMailPerformance): with self.assertQueryCount(__system__=3, employee=3): composer = self.env['mail.compose.message'].with_context({ - 'active_ids': test_records.ids, 'default_composition_mode': 'mass_mail', 'default_model': test_records._name, + 'default_res_ids': test_records.ids, 'default_template_id': test_template.id, }).create({}) composer._onchange_template_id_wrapper() diff --git a/addons/test_mail_full/tests/test_portal.py b/addons/test_mail_full/tests/test_portal.py index 44a086a9901..a0f72c3d78a 100644 --- a/addons/test_mail_full/tests/test_portal.py +++ b/addons/test_mail_full/tests/test_portal.py @@ -218,9 +218,8 @@ class TestPortalFlow(MailCommon, HttpCase): 'default_composition_mode': 'comment', 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature', 'default_model': self.record_portal._name, - 'default_res_id': self.record_portal.id, + 'default_res_ids': self.record_portal.ids, 'default_template_id': template_id, - 'default_use_template': template_id, 'force_email': True, 'lang': '{{ object.partner_id.lang }}', }) diff --git a/addons/website/models/website_visitor.py b/addons/website/models/website_visitor.py index 48be0b14f4b..27c61d775ac 100644 --- a/addons/website/models/website_visitor.py +++ b/addons/website/models/website_visitor.py @@ -167,7 +167,7 @@ class WebsiteVisitor(models.Model): def _prepare_message_composer_context(self): return { 'default_model': 'res.partner', - 'default_res_id': self.partner_id.id, + 'default_res_ids': self.partner_id.ids, 'default_partner_ids': [self.partner_id.id], } @@ -178,7 +178,6 @@ class WebsiteVisitor(models.Model): visitor_composer_ctx = self._prepare_message_composer_context() compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) compose_ctx = dict( - default_use_template=False, default_composition_mode='comment', ) compose_ctx.update(**visitor_composer_ctx) diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index e6a805326c1..b70e34e747b 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -352,12 +352,10 @@ class SaleOrder(models.Model): 'context': { 'default_composition_mode': 'mass_mail' if len(self.ids) > 1 else 'comment', 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature', - 'default_res_id': self.ids[0], + 'default_res_ids': self.ids, 'default_model': 'sale.order', - 'default_use_template': bool(template_id), 'default_template_id': template_id, 'website_sale_send_recovery_email': True, - 'active_ids': self.ids, }, } diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 6b3a364d342..1399299fbdb 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -651,7 +651,6 @@ class Channel(models.Model): local_context = dict( self.env.context, default_channel_id=self.id, - default_use_template=bool(template), default_template_id=template and template.id or False, default_email_layout_xmlid='website_slides.mail_notification_channel_invite', ) From f652f6a75c2dae4c2d2e89ffdeaa48207fafbbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 19 Dec 2022 11:14:20 +0000 Subject: [PATCH 37/45] [IMP] mass_mailing: improve composer invoke when sending RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Use the new 'res_domain_user_id' field to use in combination to 'res_domain' on composer to delegate res_ids computation to the composer instead of relying on res_ids or active_ids. Records to mail are not stored inside a domain. Set auto_delete values directly when invoking the composer instead of hacking the generate mail values. That way we delegate more to the composer itself. Task-3035101 (Mail: Support batch-posting from composer) Part-of: odoo/odoo#99482 --- addons/mass_mailing/models/mailing.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/addons/mass_mailing/models/mailing.py b/addons/mass_mailing/models/mailing.py index f4342323429..2080a7890a5 100644 --- a/addons/mass_mailing/models/mailing.py +++ b/addons/mass_mailing/models/mailing.py @@ -1046,16 +1046,19 @@ class MassMailing(models.Model): def action_send_mail(self, res_ids=None): author_id = self.env.user.partner_id.id - # If no recipient is passed, we don't want to use the recipients of the first - # mailing for all the others - initial_res_ids = res_ids for mailing in self: - if not initial_res_ids: - res_ids = mailing._get_remaining_recipients() - if not res_ids: + context_user = mailing.user_id or mailing.write_uid or self.env.user + mailing = mailing.with_context( + **self.env['res.users'].with_user(context_user).context_get() + ) + mailing_res_ids = res_ids or mailing._get_remaining_recipients() + if not mailing_res_ids: raise UserError(_('There are no recipients selected.')) composer_values = { + 'auto_delete': not mailing.keep_archives, + # email-mode: keep original message for routing + 'auto_delete_message': not mailing.reply_to_mode == 'update', 'author_id': author_id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], 'body': mailing._prepend_preview(mailing.body_html, mailing.preview), @@ -1074,7 +1077,7 @@ class MassMailing(models.Model): composer_values['reply_to'] = mailing.reply_to composer = self.env['mail.compose.message'].with_context( - active_ids=res_ids, + active_ids=mailing_res_ids, default_composition_mode='mass_mail', **mailing._get_mass_mailing_context() ).create(composer_values) @@ -1113,8 +1116,10 @@ class MassMailing(models.Model): def _process_mass_mailing_queue(self): mass_mailings = self.search([('state', 'in', ('in_queue', 'sending')), '|', ('schedule_date', '<', fields.Datetime.now()), ('schedule_date', '=', False)]) for mass_mailing in mass_mailings: - user = mass_mailing.write_uid or self.env.user - mass_mailing = mass_mailing.with_context(**user.with_user(user).context_get()) + context_user = mass_mailing.user_id or mass_mailing.write_uid or self.env.user + mass_mailing = mass_mailing.with_context( + **self.env['res.users'].with_user(context_user).context_get() + ) if len(mass_mailing._get_remaining_recipients()) > 0: mass_mailing.state = 'sending' mass_mailing.action_send_mail() From fbcc1bf4c05440bcc55d47cd6f2f61326f54aecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Mon, 19 Sep 2022 14:23:43 +0000 Subject: [PATCH 38/45] [IMP] mail: support 'scheduled_date' from template on composer RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Purpose of this task is to correctly support scheduled_date from template on both comment and mass mode in the composer. It is currently mainly supported at template level, when using a template to directly send emails. In this commit we add a field on composer that takes the value from the template, and propagate it to the mail or messages created when validating it. As most template fields it can contain inline template code to be rendered dynamically on target records, hence using a char field. Its rendering is done using template that calls _parse_scheduled_datetime. It allows to have an UTC and timezone agnostic value. SCHEDULED_DATE SUPPORT When posting a comment, scheduled posts uses the 'mail.message.schedule' mechanism that creates the message but send notifications later. When sending a mailing, emails have a scheduled_date set. As the 'send' method does not check for scheduled_date (only the cron queue) we have to filter emails scheduled in the future before calling the 'send' method. Task-2993872 (Mail: Support scheduled date in all composer flows) Part-of: odoo/odoo#99482 --- addons/mail/__manifest__.py | 2 +- addons/mail/wizard/mail_compose_message.py | 26 ++- .../wizard/mail_compose_message_views.xml | 1 + addons/test_mail/tests/test_mail_composer.py | 172 ++++++++++++++---- 4 files changed, 164 insertions(+), 37 deletions(-) diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 15ab81398f7..b3a38a36a40 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -2,7 +2,7 @@ { 'name': 'Discuss', - 'version': '1.11', + 'version': '1.12', 'category': 'Productivity/Discuss', 'sequence': 145, 'summary': 'Chat, mail gateway and private channels', diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index d9547eb929e..b92fdb65867 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -3,6 +3,7 @@ import ast import base64 +import datetime import logging from odoo import _, api, fields, models, tools, Command @@ -175,6 +176,11 @@ class MailComposer(models.TransientModel): help='This option permanently removes any track of email after it\'s been sent, including from the Technical menu in the Settings, in order to preserve storage space of your Odoo database.') auto_delete_message = fields.Boolean('Delete Message Copy', help='Do not keep a copy of the email in the document communication history (mass mailing only)') mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') + scheduled_date = fields.Char( + 'Scheduled Date', + help="In comment mode: if set, postpone notifications sending. " + "In mass mail mode: if sent, send emails after that date. " + "This date is considered as being in UTC timezone.") @api.constrains('res_ids') def _check_res_ids(self): @@ -252,6 +258,7 @@ class MailComposer(models.TransientModel): (field, template[field]) for field in ('email_from', 'reply_to', + 'scheduled_date', 'subject', ) if template[field] @@ -278,6 +285,7 @@ class MailComposer(models.TransientModel): 'partner_ids', 'reply_to', 'report_template', + 'scheduled_date', 'subject', ) )[template_res_ids[0]] @@ -311,6 +319,7 @@ class MailComposer(models.TransientModel): 'partner_ids', 'reply_to', 'res_ids', + 'scheduled_date', 'subject', ]) values = dict( @@ -321,6 +330,7 @@ class MailComposer(models.TransientModel): 'mail_server_id', 'partner_ids', 'reply_to', + 'scheduled_date', 'subject', ) if key in default_values) @@ -479,7 +489,17 @@ class MailComposer(models.TransientModel): records = self.env[self.model].browse(res_ids_iter) if self.model and hasattr(self.env[self.model], 'message_post') else False if records: records._message_mail_after_hook(iter_mails_sudo) - iter_mails_sudo.send(auto_commit=auto_commit) + + # as 'send' does not filter out scheduled mails (only 'process_email_queue' + # does) we need to do it manually + iter_mails_sudo_tosend = iter_mails_sudo.filtered( + lambda mail: ( + not mail.scheduled_date or + mail.scheduled_date <= datetime.datetime.utcnow() + ) + ) + if iter_mails_sudo_tosend: + iter_mails_sudo_tosend.send(auto_commit=auto_commit) return mails_sudo @@ -547,6 +567,7 @@ class MailComposer(models.TransientModel): STA - 'record_name', (to check, single post or False in email) DYN - 'reply_to', STA - 'reply_to_force_new', + DYN - 'scheduled_date', DYN - 'subject', STA - 'subtype_id', @@ -664,6 +685,7 @@ class MailComposer(models.TransientModel): res_id: { 'body': bodies[res_id], # should be void 'email_from': emails_from[res_id], + 'scheduled_date': False, 'subject': subjects[res_id], # some fields are specific to mail or message **( @@ -688,6 +710,7 @@ class MailComposer(models.TransientModel): 'mail_server_id', 'partner_ids', 'report_template', + 'scheduled_date', ) ) for res_id in res_ids: @@ -797,6 +820,7 @@ class MailComposer(models.TransientModel): 'body': self.body or '', 'email_from': self.email_from, 'partner_ids': self.partner_ids.ids, + 'scheduled_date': self.scheduled_date, 'subject': self.subject or '', } for res_id in res_ids diff --git a/addons/mail/wizard/mail_compose_message_views.xml b/addons/mail/wizard/mail_compose_message_views.xml index 6425d72410a..a2627444937 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -25,6 +25,7 @@ + Test Body />

', 'mail_server_id': self.mail_server_global.id, 'record_name': 'Manual update', + 'scheduled_date': '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', 'subject': 'Back to my amazing subject for {{ record.name }}', }) self.assertEqual(composer.body, '

Back to my amazing body />

') self.assertEqual(composer.mail_server_id, self.mail_server_global) self.assertEqual(composer.record_name, 'Manual update') + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}') else: self.assertEqual(composer.body, '

Back to my amazing body />

'}) composer.write({'template_id': False}) # currently onchange necessary composer._onchange_template_id_wrapper() @@ -666,6 +693,9 @@ class TestComposerInternals(TestMailComposer): # self.assertEqual(composer.record_name, self.test_record.name) self.assertEqual(composer.record_name, 'Manual update', 'TODO: Reset not called') + # self.assertFalse(composer.scheduled_date) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', + 'TODO: Values are kept (should be reset ?)') self.assertEqual(composer.subject, self.test_record._message_compute_subject()) self.assertIn(f'Ticket for {self.test_record.name}', composer.subject, 'Check effective content') @@ -677,6 +707,9 @@ class TestComposerInternals(TestMailComposer): # self.assertFalse(composer.record_name) self.assertEqual(composer.record_name, 'Manual update', 'TODO: Reset not called') + # self.assertFalse(composer.scheduled_date) + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', + 'TODO: Values are kept (should be reset ?)') # self.assertFalse(composer.subject) self.assertEqual(composer.subject, 'Back to my amazing subject for {{ record.name }}', 'TODO: Values are kept (should be reset ?)') @@ -694,11 +727,13 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.body, f'

TemplateBody {self.test_record.name}

') self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertEqual(composer.record_name, self.test_record.name) + self.assertEqual(FieldDatetime.from_string(composer.scheduled_date), self.reference_now + timedelta(days=2)) self.assertEqual(composer.subject, f'TemplateSubject {self.test_record.name}') else: self.assertEqual(composer.body, self.template.body_html) self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertFalse(composer.record_name) + self.assertEqual(composer.scheduled_date, self.template.scheduled_date) self.assertEqual(composer.subject, self.template.subject) # 3. check at create @@ -714,11 +749,13 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.body, f'

TemplateBody {self.test_record.name}

') self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertEqual(composer.record_name, self.test_record.name) + self.assertEqual(FieldDatetime.from_string(composer.scheduled_date), self.reference_now + timedelta(days=2)) self.assertEqual(composer.subject, f'TemplateSubject {self.test_record.name}') else: self.assertEqual(composer.body, self.template.body_html) self.assertEqual(composer.mail_server_id, self.template.mail_server_id) self.assertFalse(composer.record_name) + self.assertEqual(composer.scheduled_date, self.template.scheduled_date) self.assertEqual(composer.subject, self.template.subject) # 4. template + user input @@ -727,6 +764,7 @@ class TestComposerInternals(TestMailComposer): 'body': '

Test Body

', 'mail_server_id': False, 'record_name': 'CustomName', + 'scheduled_date': '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}', 'subject': 'My amazing subject', }) @@ -734,6 +772,7 @@ class TestComposerInternals(TestMailComposer): self.assertEqual(composer.body, '

Test Body

') self.assertEqual(composer.mail_server_id.id, False) self.assertEqual(composer.record_name, 'CustomName') + self.assertEqual(composer.scheduled_date, '{{ datetime.datetime(2023, 1, 10, 10, 0, 0) }}') self.assertEqual(composer.subject, 'My amazing subject') @users('employee') @@ -1015,7 +1054,7 @@ class TestComposerInternals(TestMailComposer): @tagged('mail_composer') -class TestComposerResultsComment(TestMailComposer): +class TestComposerResultsComment(TestMailComposer, CronMixinCase): """ Test global output of composer used in comment mode. Test notably notification and emails generated during this process. """ @@ -1175,14 +1214,24 @@ class TestComposerResultsComment(TestMailComposer): self.assertEqual(message.partner_ids, self.partner_1 | self.partner_2) @users('employee') - @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.addons.mail.models.mail_message_schedule') def test_mail_composer_wtpl_complete(self): """ Test a posting process using a complex template, holding several additional recipients and attachments. It is done in monorecord and in batch since this is now supported. This tests notifies: 2 new email_to (+ 1 duplicated), 1 email_cc, - test_records followers and partner_admin added in partner_to.""" + test_records followers and partner_admin added in partner_to. + + Global notification + * monorecord: send notifications right away (force_send=True) + * multirecord: delay notification sending (force_send=False) + + Use cases + * scheduled_date: creates mail.message.schedule (no email sent), then + scheduling send notifications with notification parameters kept + * otherwise: global behavior + """ attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) email_to_1 = 'test.to.1@test.example.com' email_to_2 = 'test.to.2@test.example.com' @@ -1200,8 +1249,14 @@ class TestComposerResultsComment(TestMailComposer): attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) - for test_records, batch in ((self.test_record, False), (self.test_records, True)): - with self.subTest(test_records=test_records, batch=batch): + for batch, scheduled_date in product( + (False, True), + (False, '{{ (object.create_date or datetime.datetime(2022, 12, 26, 18, 0, 0)) + datetime.timedelta(days=2) }}') + ): + with self.subTest(batch=batch, scheduled_date=scheduled_date): + self.template.write({'scheduled_date': scheduled_date}) + test_records = self.test_records if batch else self.test_record + # ensure initial data self.assertEqual(len(test_records.customer_id), len(test_records)) self.assertEqual(test_records.user_id, self.user_employee_2) @@ -1211,37 +1266,72 @@ class TestComposerResultsComment(TestMailComposer): composer_form = Form(self.env['mail.compose.message'].with_context( self._get_web_context(test_records, add_web=True, default_composition_mode='comment', - default_template_id=self.template.id) + default_template_id=self.template.id, + # avoid successive tests issues with followers + mail_create_nosubscribe=True, + ) )) composer = composer_form.save() self.assertFalse(composer.reply_to_force_new, 'Mail: thread-enabled models should use auto thread by default') - with self.mock_mail_gateway(mail_unlink_sent=False), self.mock_mail_app(): + + # due to scheduled_date, cron for sending notification will be used + schedule_cron_id = self.env.ref('mail.ir_cron_send_scheduled_message').id + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(), \ + freeze_time(self.reference_now), \ + self.capture_triggers(schedule_cron_id) as capt: composer._action_send_mail() - # check new partners have been created based on emails given - new_partners = self.env['res.partner'].search([ - ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) - ]) - self.assertEqual(len(new_partners), 3) - self.assertEqual(set(new_partners.mapped('email')), - set(['test.to.1@test.example.com', 'test.to.2@test.example.com', 'test.cc.1@test.example.com']) - ) - - # global outgoing: one mail.mail (all customer recipients, then all employee recipients) - # and 5 emails, and 1 inbox notification (admin) - self.assertEqual(len(self._new_mails), 2 * len(test_records), - 'Should have created 1 mail.mail per group per record') + # notification process should not have been sent + if scheduled_date: + self.assertFalse(self._new_mails) + self.assertFalse(self._mails) # monorecord: force_send notifications - if not batch: - self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + elif not batch: self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) - # mutlirecord: use email queue + self.assertEqual(len(self._mails), 5, 'Should have sent 5 emails, one per recipient per record') + # multirecord: use email queue else: - self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') self.assertEqual(self._new_mails.mapped('state'), ['outgoing'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 0, 'Should have put emails in queue and not sent any emails') # simulate cron sending emails self.env['mail.mail'].sudo().process_email_queue() + # notification process should not have been sent + if scheduled_date: + self.assertEqual( + capt.records.mapped('call_at'), [self.reference_now + timedelta(days=2)] * len(test_records), + msg='Should have created a cron trigger for the scheduled sending' + ) + else: + self.assertFalse(capt.records) + + # check new partners have been created based on emails given + new_partners = self.env['res.partner'].search([ + ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) + ]) + self.assertEqual(len(new_partners), 3) + self.assertEqual( + set(new_partners.mapped('email')), + set(['test.to.1@test.example.com', 'test.to.2@test.example.com', 'test.cc.1@test.example.com']) + ) + + # if scheduled_date is set: simulate cron for sending notifications + if scheduled_date: + # Send the scheduled message from the CRON + with self.mock_mail_gateway(mail_unlink_sent=False), \ + self.mock_mail_app(), \ + freeze_time(self.reference_now + timedelta(days=3)): + self.env['mail.message.schedule'].sudo()._send_notifications_cron() + + # global outgoing: one mail.mail (all customer recipients, then all employee recipients) + # and 5 emails, and 1 inbox notification (admin) + self.assertEqual(len(self._new_mails), 2 * len(test_records), + 'Should have created 1 mail.mail per group per record') + self.assertEqual(self._new_mails.mapped('state'), ['sent'] * len(self._new_mails)) + self.assertEqual(len(self._mails), 5 * len(test_records), + 'Should have sent 5 emails, one per recipient per record') + # template is sent only to partners (email_to are transformed) for test_record in test_records: message = test_record.message_ids[0] @@ -1507,6 +1597,9 @@ class TestComposerResultsMass(TestMailComposer): def test_mail_composer_wtpl_complete(self): """ Test a composer in mass mode with a quite complete template, containing notably email-based recipients and attachments. """ + # as we use the email queue, don't have failing tests due to other outgoing emails + self.env['mail.mail'].sudo().search([]).unlink() + attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) email_to_1 = 'test.to.1@test.example.com' email_to_2 = 'test.to.2@test.example.com' @@ -1534,16 +1627,25 @@ class TestComposerResultsMass(TestMailComposer): default_template_id=self.template.id) )) composer = composer_form.save() - with self.mock_mail_gateway(mail_unlink_sent=False): + with self.mock_mail_gateway(mail_unlink_sent=False), \ + freeze_time(self.reference_now): composer._action_send_mail() - new_partners = self.env['res.partner'].search([ - ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) - ]) - self.assertEqual(len(new_partners), 3) + new_partners = self.env['res.partner'].search([ + ('email', 'in', [email_to_1, email_to_2, email_to_3, email_cc_1]) + ]) + self.assertEqual(len(new_partners), 3) + + # global outgoing: emails not sent due to scheduled_date + self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') + self.assertEqual(len(self._mails), 0, 'Should not send emails, scheduled in the future') + self.assertEqual(self._new_mails.mapped('scheduled_date'), + [self.reference_now + timedelta(days=2)] * 2) + + # simulate cron queue at right time for sending + with freeze_time(self.reference_now + timedelta(days=2)): + self.env['mail.mail'].sudo().process_email_queue() - # global outgoing - self.assertEqual(len(self._new_mails), 2, 'Should have created 1 mail.mail per record') self.assertEqual(len(self._mails), 10, 'Should have sent 5 emails per record') # hack to use assertEmails: filtering on from/to only is not sufficient to distinguish emails From 28b4ba4049b4d3d39d34d7c5ec00c6a1e0697790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 20 Oct 2022 12:13:11 +0000 Subject: [PATCH 39/45] [IMP] mail, various: allow to link multiple reports to templates RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Update report_template field on template model to be a many2many field instead of a many2one. It allows to attach multiple dynamic reports to a given template instead of being limited to a single one. Name should now come from the report itself, which should be considered as complete by itself. Template cannot override report naming anymore. Task-2868153 (Mail: Allow multi reports in mail templates) Part-of: odoo/odoo#99482 --- addons/account/data/mail_template_data.xml | 9 +- addons/event/data/mail_template_data.xml | 6 +- addons/loyalty/data/mail_template_data.xml | 6 +- addons/mail/models/mail_template.py | 74 ++++++++----- addons/mail/views/mail_template_views.xml | 8 +- addons/mail/wizard/mail_compose_message.py | 6 +- addons/mail/wizard/mail_template_preview.py | 6 +- addons/purchase/data/mail_template_data.xml | 9 +- addons/repair/data/mail_template_data.xml | 3 +- addons/sale/data/mail_template_data.xml | 6 +- addons/stock/data/mail_template_data.xml | 3 +- addons/survey/data/mail_template_data.xml | 3 +- .../survey/tests/test_certification_flow.py | 3 +- addons/test_event_full/tests/common.py | 2 +- addons/test_mail/data/mail_template_data.xml | 11 ++ addons/test_mail/tests/test_mail_composer.py | 104 +++++++++++------- addons/website_slides/models/slide_channel.py | 2 +- 17 files changed, 149 insertions(+), 112 deletions(-) diff --git a/addons/account/data/mail_template_data.xml b/addons/account/data/mail_template_data.xml index 81eb47ad08e..13f5c49d452 100644 --- a/addons/account/data/mail_template_data.xml +++ b/addons/account/data/mail_template_data.xml @@ -54,8 +54,7 @@

- - Invoice_{{ (object.name or '').replace('/','_') }}{{ object.state == 'draft' and '_draft' or '' }} + {{ object.partner_id.lang }} @@ -84,8 +83,7 @@

- - {{ (object.name or '').replace('/','-') }} + {{ object.partner_id.lang }} @@ -129,8 +127,7 @@

- - Credit_note_{{ (object.name or '').replace('/','_') }}{{ object.state == 'draft' and '_draft' or '' }} + {{ object.partner_id.lang }} diff --git a/addons/event/data/mail_template_data.xml b/addons/event/data/mail_template_data.xml index 1d5de71bdec..03d541a588d 100644 --- a/addons/event/data/mail_template_data.xml +++ b/addons/event/data/mail_template_data.xml @@ -22,8 +22,7 @@ --
Mitchell Admin
- - Foldable Badge - {{ (object.event_id.name or 'Event').replace('/','_') }} + {{ object.partner_id.lang }} @@ -254,8 +253,7 @@ - - Full Page Ticket - {{ (object.event_id.name or 'Event').replace('/','') }} + {{ object.partner_id.lang }} diff --git a/addons/loyalty/data/mail_template_data.xml b/addons/loyalty/data/mail_template_data.xml index 4c4a8597811..bba70470e01 100644 --- a/addons/loyalty/data/mail_template_data.xml +++ b/addons/loyalty/data/mail_template_data.xml @@ -34,8 +34,7 @@ - - Your Gift Card + @@ -96,8 +95,7 @@ - - Your Coupon Code + diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 25c734bbac0..bccd11d813c 100644 --- a/addons/mail/models/mail_template.py +++ b/addons/mail/models/mail_template.py @@ -7,6 +7,7 @@ import logging from odoo import _, api, fields, models, tools, Command from odoo.exceptions import UserError from odoo.tools import is_html_empty +from odoo.tools.safe_eval import safe_eval, time _logger = logging.getLogger(__name__) @@ -63,10 +64,12 @@ class MailTemplate(models.Model): 'attachment_id', 'Attachments', help="You may attach files to this template, to be added to all " "emails created from this template") - report_name = fields.Char('Report Filename', translate=True, prefetch=True, - help="Name to use for the generated report file (may contain placeholders)\n" - "The extension can be omitted and will then come from the report type.") - report_template = fields.Many2one('ir.actions.report', 'Optional report to print and attach') + report_template_ids = fields.Many2many( + 'ir.actions.report', relation='mail_template_ir_actions_report_rel', + column1='mail_template_id', + column2='ir_actions_report_id', + string='Reports to print and attach', + domain="[('model', '=', model)]") # options mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing Mail Server', readonly=False, help="Optional preferred server for outgoing mails. If not set, the highest " @@ -198,14 +201,14 @@ class MailTemplate(models.Model): def _generate_template_attachments(self, res_ids, render_fields, render_results=None): """ Render attachments of template 'self', returning values for records - given by 'res_ids'. Note that ``report_template`` returns values for + given by 'res_ids'. Note that ``report_template_ids`` returns values for 'attachments', as we have a list of tuple (report_name, base64 value) for those reports. It is considered as being the job of callers to transform those attachments into valid ``ir.attachment`` records. :param list res_ids: list of record IDs on which template is rendered; :param list render_fields: list of fields to render on template which - are specific to attachments, e.g. attachment_ids or report_template; + are specific to attachments, e.g. attachment_ids or report_template_ids; :param dict render_results: res_ids-based dictionary of render values. For each res_id, a dict of values based on render_fields is given @@ -215,6 +218,11 @@ class MailTemplate(models.Model): if render_results is None: render_results = {} + # generating reports is done on a per-record basis, better ensure cache + # is filled up to avoid rendering and browsing in a loop + if res_ids and 'report_template_ids' in render_fields and self.report_template_ids: + self.env[self.model].browse(res_ids) + for res_id in res_ids: values = render_results.setdefault(res_id, {}) @@ -223,27 +231,33 @@ class MailTemplate(models.Model): values['attachment_ids'] = self.attachment_ids.ids # generate attachments (reports) - if 'report_template' in render_fields and self.report_template: - report = self.report_template - - if report.report_type in ['qweb-html', 'qweb-pdf']: - report_content, report_format = self.env['ir.actions.report']._render_qweb_pdf(report, [res_id]) - else: - render_res = self.env['ir.actions.report']._render(report, [res_id]) - if not render_res: - raise UserError(_('Unsupported report type %s found.', report.report_type)) - report_content, report_format = render_res - report_content = base64.b64encode(report_content) - - report_name = self._render_field('report_name', [res_id])[res_id] - if not report_name: - report_name = 'report.' + report.report_name - ext = "." + report_format - if not report_name.endswith(ext): - report_name += ext - - values['attachments'] = [(report_name, report_content)] - elif 'report_template' in render_fields: + if 'report_template_ids' in render_fields and self.report_template_ids: + for report in self.report_template_ids: + # generate content + if report.report_type in ['qweb-html', 'qweb-pdf']: + report_content, report_format = self.env['ir.actions.report']._render_qweb_pdf(report, [res_id]) + else: + render_res = self.env['ir.actions.report']._render(report, [res_id]) + if not render_res: + raise UserError(_('Unsupported report type %s found.', report.report_type)) + report_content, report_format = render_res + report_content = base64.b64encode(report_content) + # generate name + if report.print_report_name: + report_name = safe_eval( + report.print_report_name, + { + 'object': self.env[self.model].browse(res_id), + 'time': time, + } + ) + else: + report_name = _('Report') + extension = "." + report_format + if not report_name.endswith(extension): + report_name += extension + values.setdefault('attachments', []).append((report_name, report_content)) + elif 'report_template_ids' in render_fields: values['attachments'] = [] # hook for attachments-specific computation, used currently only for accounting @@ -436,7 +450,7 @@ class MailTemplate(models.Model): 'email_cc', # recipients 'email_to', # recipients 'partner_to', # recipients - 'report_template', # attachments + 'report_template_ids', # attachments 'scheduled_date', # specific # not rendered (static) 'auto_delete', @@ -482,7 +496,7 @@ class MailTemplate(models.Model): ) # generate attachments if requested - if render_fields_set & {'attachment_ids', 'report_template'}: + if render_fields_set & {'attachment_ids', 'report_template_ids'}: template._generate_template_attachments( template_res_ids, render_fields_set, @@ -533,7 +547,7 @@ class MailTemplate(models.Model): 'model', 'partner_to', 'reply_to', - 'report_template', + 'report_template_ids', 'res_id', 'scheduled_date', 'subject', diff --git a/addons/mail/views/mail_template_views.xml b/addons/mail/views/mail_template_views.xml index 021e0dde091..0be8580fb0d 100644 --- a/addons/mail/views/mail_template_views.xml +++ b/addons/mail/views/mail_template_views.xml @@ -79,8 +79,9 @@ - - + @@ -102,7 +103,6 @@ - @@ -112,7 +112,7 @@ mail.template - + diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index b92fdb65867..3914e6be9d2 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -284,7 +284,7 @@ class MailComposer(models.TransientModel): 'mail_server_id', 'partner_ids', 'reply_to', - 'report_template', + 'report_template_ids', 'scheduled_date', 'subject', ) @@ -709,7 +709,7 @@ class MailComposer(models.TransientModel): 'email_cc', 'mail_server_id', 'partner_ids', - 'report_template', + 'report_template_ids', 'scheduled_date', ) ) @@ -906,7 +906,7 @@ class MailComposer(models.TransientModel): # some fields behave / are named differently on template model mapping = { - 'attachments': 'report_template', + 'attachments': 'report_template_ids', 'body': 'body_html', 'partner_ids': 'partner_to', } diff --git a/addons/mail/wizard/mail_template_preview.py b/addons/mail/wizard/mail_template_preview.py index 14367e1e250..6eb21551de0 100644 --- a/addons/mail/wizard/mail_template_preview.py +++ b/addons/mail/wizard/mail_template_preview.py @@ -15,7 +15,7 @@ class MailTemplatePreview(models.TransientModel): 'email_from', 'email_to', 'partner_to', - 'report_template', + 'report_template_ids', 'reply_to', 'scheduled_date', ] @@ -94,9 +94,9 @@ class MailTemplatePreview(models.TransientModel): def _set_mail_attributes(self, values=None): for field in self._MAIL_TEMPLATE_FIELDS: - if field in ('partner_to', 'report_template'): + if field in ('partner_to', 'report_template_ids'): # partner_to is used to generate partner_ids, handled here below - # report_template generates attachments, no usage here + # report_template_ids generates attachments, no usage here continue field_value = values.get(field, False) if values else self.mail_template_id[field] self[field] = field_value diff --git a/addons/purchase/data/mail_template_data.xml b/addons/purchase/data/mail_template_data.xml index 0b348044df9..3dffc957249 100644 --- a/addons/purchase/data/mail_template_data.xml +++ b/addons/purchase/data/mail_template_data.xml @@ -30,8 +30,7 @@

- - RFQ_{{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} @@ -69,8 +68,7 @@

- - PO_{{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} @@ -109,8 +107,7 @@

- - PO_{{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} diff --git a/addons/repair/data/mail_template_data.xml b/addons/repair/data/mail_template_data.xml index c2eb4f33f4e..cb05cf86397 100644 --- a/addons/repair/data/mail_template_data.xml +++ b/addons/repair/data/mail_template_data.xml @@ -28,8 +28,7 @@

- - {{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml index 545490d9179..be542b12212 100644 --- a/addons/sale/data/mail_template_data.xml +++ b/addons/sale/data/mail_template_data.xml @@ -39,8 +39,7 @@

- - {{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} @@ -234,8 +233,7 @@ - - {{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} diff --git a/addons/stock/data/mail_template_data.xml b/addons/stock/data/mail_template_data.xml index 778fa6ac710..e55142b507c 100644 --- a/addons/stock/data/mail_template_data.xml +++ b/addons/stock/data/mail_template_data.xml @@ -40,8 +40,7 @@

- - {{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} diff --git a/addons/survey/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml index 2bc37df0917..49ed3995a48 100644 --- a/addons/survey/data/mail_template_data.xml +++ b/addons/survey/data/mail_template_data.xml @@ -79,8 +79,7 @@ - - Certification Document + {{ object.partner_id.lang }} diff --git a/addons/survey/tests/test_certification_flow.py b/addons/survey/tests/test_certification_flow.py index b3fddf2d1b2..65205a5a531 100644 --- a/addons/survey/tests/test_certification_flow.py +++ b/addons/survey/tests/test_certification_flow.py @@ -128,7 +128,8 @@ class TestCertificationFlow(common.TestSurveyCommon, HttpCase): self.assertIn("User Certification for SO lines", certification_email.subject) self.assertIn("employee@example.com", certification_email.email_to) self.assertEqual(len(certification_email.attachment_ids), 1) - self.assertEqual(certification_email.attachment_ids[0].name, 'Certification Document.html') + self.assertEqual(certification_email.attachment_ids[0].name, f'Certification - {certification.title}.html', + 'Default certification report print_report_name is "Certification - %s" % (object.survey_id.display_name)') def test_randomized_certification(self): # Step: survey user creates the randomized certification diff --git a/addons/test_event_full/tests/common.py b/addons/test_event_full/tests/common.py index 7231d002406..3b748164c19 100644 --- a/addons/test_event_full/tests/common.py +++ b/addons/test_event_full/tests/common.py @@ -120,7 +120,7 @@ class TestEventFullCommon(EventCrmCase, TestSalesCommon, MockVisitor): # ------------------------------------------------------------ test_registration_report = cls.env.ref('test_event_full.event_registration_report_test') subscription_template = cls.env.ref('event.event_subscription') - subscription_template.write({'report_template': test_registration_report.id}) + subscription_template.write({'report_template_ids': [(6, 0, test_registration_report.ids)]}) cls.test_event_type = cls.env['event.type'].create({ 'auto_confirm': True, 'default_timezone': 'Europe/Paris', diff --git a/addons/test_mail/data/mail_template_data.xml b/addons/test_mail/data/mail_template_data.xml index 944a1b7a95f..f5970d9e211 100644 --- a/addons/test_mail/data/mail_template_data.xml +++ b/addons/test_mail/data/mail_template_data.xml @@ -33,6 +33,17 @@ + + diff --git a/addons/test_mail/tests/test_mail_composer.py b/addons/test_mail/tests/test_mail_composer.py index 0f47784412c..71f40256f1a 100644 --- a/addons/test_mail/tests/test_mail_composer.py +++ b/addons/test_mail/tests/test_mail_composer.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 + from ast import literal_eval from datetime import timedelta from freezegun import freeze_time @@ -61,13 +63,21 @@ class TestMailComposer(TestMailCommon, TestRecipients): additional_values={'user_id': cls.user_employee_2.id}, ) - cls.test_report = cls.env['ir.actions.report'].create({ - 'name': 'Test Report on mail test ticket', - 'model': 'mail.test.ticket', - 'report_type': 'qweb-pdf', - 'report_name': 'test_mail.mail_test_ticket_test_template', - }) - cls.test_record_report = cls.env['ir.actions.report']._render_qweb_pdf(cls.test_report, cls.test_record.ids) + cls.test_report, cls.test_report_2 = cls.env['ir.actions.report'].create([ + { + 'name': 'Test Report on Mail Test Ticket', + 'model': 'mail.test.ticket', + 'print_report_name': "'TestReport for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template', + }, { + 'name': 'Test Report 2 on Mail Test Ticket', + 'model': 'mail.test.ticket', + 'print_report_name': "'TestReport2 for %s' % object.name", + 'report_type': 'qweb-pdf', + 'report_name': 'test_mail.mail_test_ticket_test_template_2', + } + ]) cls.test_from = '"John Doe" ' @@ -145,14 +155,13 @@ class TestComposerForm(TestMailComposer): attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) template_1 = self.template.copy({ 'attachment_ids': [(0, 0, a) for a in attachment_data], - 'report_name': 'TestReport for {{ object.name }}.html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) template_1_attachments = template_1.attachment_ids self.assertEqual(len(template_1_attachments), 2) template_2 = self.template.copy({ 'attachment_ids': False, - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) # begins without attachments @@ -161,12 +170,12 @@ class TestComposerForm(TestMailComposer): )) self.assertEqual(len(composer_form.attachment_ids), 0) - # change template: 2 static (attachment_ids) and 1 dynamic (report) + # change template: 2 static (attachment_ids) and 2 dynamic (reports) composer_form.template_id = template_1 - self.assertEqual(len(composer_form.attachment_ids), 3) + self.assertEqual(len(composer_form.attachment_ids), 4) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] - self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + self.assertEqual(len(report_attachments), 2) + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertEqual(tpl_attachments, template_1_attachments) # change template: 0 static (attachment_ids) and 1 dynamic (report) @@ -174,15 +183,15 @@ class TestComposerForm(TestMailComposer): self.assertEqual(len(composer_form.attachment_ids), 1) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertFalse(tpl_attachments) # change back to template 1 composer_form.template_id = template_1 - self.assertEqual(len(composer_form.attachment_ids), 3) + self.assertEqual(len(composer_form.attachment_ids), 4) report_attachments = [att for att in composer_form.attachment_ids if att not in template_1_attachments] - self.assertEqual(len(report_attachments), 1) - tpl_attachments = composer_form.attachment_ids[:] - report_attachments[0] + self.assertEqual(len(report_attachments), 2) + tpl_attachments = composer_form.attachment_ids[:] - self.env['ir.attachment'].concat(*report_attachments) self.assertEqual(tpl_attachments, template_1_attachments) # reset template @@ -362,16 +371,21 @@ class TestComposerInternals(TestMailComposer): attachment_data = self._generate_attachments_data(3, self.template._name, self.template.id) self.template.write({ 'attachment_ids': [(0, 0, a) for a in attachment_data], - 'report_name': 'TestReport for {{ object.name }}.html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) template_void = self.template.copy(default={ 'attachment_ids': False, - 'report_name': False, - 'report_template': False, + 'report_template_ids': False, }) attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 3) + extra_attach = self.env['ir.attachment'].create({ + 'datas': base64.b64encode(b'ExtraData'), + 'mimetype': 'text/plain', + 'name': 'ExtraAttFileName.txt', + 'res_model': False, + 'res_id': False, + }) for composition_mode, batch in (('comment', False), ('comment', True), ('mass_mail', False), ('mass_mail', True)): @@ -391,14 +405,16 @@ class TestComposerInternals(TestMailComposer): # values coming from template: attachment_ids + report in comment if composition_mode == 'comment' and not batch: - self.assertEqual(len(composer.attachment_ids), 4) + self.assertEqual(len(composer.attachment_ids), 5) for attach in attachs: self.assertIn(attach, composer.attachment_ids) generated = composer.attachment_ids - attachs - self.assertEqual(len(generated), 1, 'MailComposer: should have 1 additional attachment for report') - self.assertEqual(generated.name, f'TestReport for {self.test_record.name}.html') - self.assertEqual(generated.res_model, 'mail.compose.message') - self.assertEqual(generated.res_id, 0) + self.assertEqual(len(generated), 2, 'MailComposer: should have 2 additional attachments for reports') + self.assertEqual( + sorted(generated.mapped('name')), + sorted([f'TestReport for {self.test_record.name}.html', f'TestReport2 for {self.test_record.name}.html'])) + self.assertEqual(generated.mapped('res_model'), ['mail.compose.message'] * 2) + self.assertEqual(generated.mapped('res_id'), [0] * 2) # values coming from template: attachment_ids only (report is dynamic) else: self.assertEqual( @@ -406,16 +422,25 @@ class TestComposerInternals(TestMailComposer): sorted(attachs.ids) ) + # manual update + composer.write({ + 'attachment_ids': [(4, extra_attach.id)], + }) + if composition_mode == 'comment' and not batch: + self.assertEqual(composer.attachment_ids, attachs + extra_attach + generated) + else: + self.assertEqual(composer.attachment_ids, attachs + extra_attach) + # update with template with void values: values are kept composer.write({'template_id': template_void.id}) # currently onchange necessary composer._onchange_template_id_wrapper() if composition_mode == 'comment' and not batch: - self.assertEqual(composer.attachment_ids, attachs + generated, + self.assertEqual(composer.attachment_ids, attachs + extra_attach + generated, 'TODO: Values are kept (should be reset ?)') else: - self.assertEqual(composer.attachment_ids, attachs, + self.assertEqual(composer.attachment_ids, attachs + extra_attach, 'TODO: Values are kept (should be reset ?)') # reset template: values are kept @@ -424,10 +449,10 @@ class TestComposerInternals(TestMailComposer): composer._onchange_template_id_wrapper() if composition_mode == 'comment' and not batch: - self.assertEqual(composer.attachment_ids, attachs + generated, + self.assertEqual(composer.attachment_ids, attachs + extra_attach + generated, 'TODO: Values are kept (should be reset ?)') else: - self.assertEqual(composer.attachment_ids, attachs, + self.assertEqual(composer.attachment_ids, attachs + extra_attach, 'TODO: Values are kept (should be reset ?)') @users('employee') @@ -973,8 +998,7 @@ class TestComposerInternals(TestMailComposer): def test_mail_composer_rights_attachments(self): """ Ensure a user without write access to a template can send an email""" template_1 = self.template.copy({ - 'report_name': 'TestReport for {{ object.name }} (thanks TDE).html', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) attachment_data = self._generate_attachments_data(2, self.template._name, self.template.id) template_1.write({ @@ -986,7 +1010,7 @@ class TestComposerInternals(TestMailComposer): template_1_attachments = template_1.attachment_ids self.assertEqual(len(template_1_attachments), 2) - template_1_attachment_name = list(template_1_attachments.mapped('name')) + ["TestReport for TestRecord (thanks TDE).html"] + template_1_attachment_name = list(template_1_attachments.mapped('name')) + [f"TestReport for {self.test_record.name}.html"] composer = self.env['mail.compose.message'].with_context( self._get_web_context(self.test_record) @@ -1243,8 +1267,7 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): 'email_to': '%s, %s, %s' % (email_to_1, email_to_2, email_to_3), 'email_cc': email_cc_1, 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, - 'report_name': 'TestReport for {{ object.name }}', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, (self.test_report + self.test_report_2).ids)], }) attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) @@ -1346,6 +1369,7 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, ] }, fields_values={ @@ -1363,6 +1387,7 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): {'name': 'AttFileName_00.txt', 'raw': b'AttContent_00', 'type': 'text/plain'}, {'name': 'AttFileName_01.txt', 'raw': b'AttContent_01', 'type': 'text/plain'}, {'name': f'TestReport for {test_record.name}.html', 'type': 'text/plain'}, + {'name': f'TestReport2 for {test_record.name}.html', 'type': 'text/plain'}, ] }, fields_values={ @@ -1376,7 +1401,9 @@ class TestComposerResultsComment(TestMailComposer, CronMixinCase): # attachments are copied on message and linked to document self.assertEqual( set(message.attachment_ids.mapped('name')), - set(['AttFileName_00.txt', 'AttFileName_01.txt', f'TestReport for {test_record.name}.html']) + set(['AttFileName_00.txt', 'AttFileName_01.txt', + f'TestReport for {test_record.name}.html', + f'TestReport2 for {test_record.name}.html']) ) self.assertEqual(set(message.attachment_ids.mapped('res_model')), set([test_record._name])) self.assertEqual(set(message.attachment_ids.mapped('res_id')), set(test_record.ids)) @@ -1611,8 +1638,7 @@ class TestComposerResultsMass(TestMailComposer): 'email_to': '%s, %s, %s' % (email_to_1, email_to_2, email_to_3), 'email_cc': email_cc_1, 'partner_to': '%s, {{ object.customer_id.id if object.customer_id else "" }}' % self.partner_admin.id, - 'report_name': 'TestReport for {{ object.name }}', # test cursor forces html - 'report_template': self.test_report.id, + 'report_template_ids': [(6, 0, self.test_report.ids)], }) attachs = self.env['ir.attachment'].search([('name', 'in', [a['name'] for a in attachment_data])]) self.assertEqual(len(attachs), 2) diff --git a/addons/website_slides/models/slide_channel.py b/addons/website_slides/models/slide_channel.py index 1399299fbdb..f86f527a9b0 100644 --- a/addons/website_slides/models/slide_channel.py +++ b/addons/website_slides/models/slide_channel.py @@ -145,7 +145,7 @@ class ChannelUsersRelation(models.Model): 'model', 'partner_to', 'reply_to', - 'report_template', + 'report_template_ids', 'res_id', 'scheduled_date', 'subject', From 629ba0c3927e735bb9b10c213df5307da8f9a58c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 2 Dec 2022 16:01:42 +0000 Subject: [PATCH 40/45] [REF] mail: replace 'is_log' on composer by posting a note RATIONALE Improve usage of composer in comment or email mode: support batch-posting in comment, support more configuration from templates, improve global model. SPECIFICATIONS Remove 'is_log' field on mail composer. Indeed its usage can be globally replaced by using the 'note' subtype when posting. It is now better matching the result of using the log a note mode of chatter. Posting with a False subtype_id already automatically converts it into a note subtype in 'message_post'. It is now done directly at composer level to lessen magic and have more control on final output. In case of outgoing emails subtype has no usage, as notification process is not called. It is therefore forced to False when creating the mail records. Task-3035101 (Mail: Support batch-posting from composer) Part-of: odoo/odoo#99482 --- addons/hr_presence/models/hr_employee.py | 2 +- .../mail/static/src/models/composer_view.js | 3 +-- addons/mail/wizard/mail_compose_message.py | 10 +++++++--- .../wizard/mail_compose_message_views.xml | 14 ++++++++----- .../wizard/mail_compose_message_views.xml | 4 ++-- addons/test_mail/tests/test_mail_composer.py | 20 ++++++++++--------- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/addons/hr_presence/models/hr_employee.py b/addons/hr_presence/models/hr_employee.py index 28c616b3ef2..3c5fbaf4645 100644 --- a/addons/hr_presence/models/hr_employee.py +++ b/addons/hr_presence/models/hr_employee.py @@ -184,8 +184,8 @@ Do not hesitate to contact your manager or the human resource department.""") default_res_ids=self.ids, default_template_id=template.id, default_composition_mode='comment', - default_is_log=True, default_email_layout_xmlid='mail.mail_notification_light', + default_subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), ) return { 'name': _('Compose Email'), diff --git a/addons/mail/static/src/models/composer_view.js b/addons/mail/static/src/models/composer_view.js index cecc70bdac3..692d325a6df 100644 --- a/addons/mail/static/src/models/composer_view.js +++ b/addons/mail/static/src/models/composer_view.js @@ -547,11 +547,10 @@ Model({ const context = { default_attachment_ids: attachmentIds, default_body: escapeAndCompactTextContent(this.composer.textInputContent), - default_is_log: this.composer.isLog, default_model: this.composer.activeThread.model, default_partner_ids: this.composer.recipients.map((partner) => partner.id), default_res_ids: [this.composer.activeThread.id], - default_subtype_xmlid: 'mail.mt_comment', + default_subtype_xmlid: this.composer.isLog ? 'mail.mt_note' : 'mail.mt_comment', mail_post_autofollow: this.composer.activeThread.hasWriteAccess, }; diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index 3914e6be9d2..3830b62ad9e 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -151,10 +151,10 @@ class MailComposer(models.TransientModel): 'Type', required=True, default='comment', help="Message type: email for email message, notification for system " "message, comment for other messages such as user replies") - is_log = fields.Boolean('Log as Internal Note') subtype_id = fields.Many2one( 'mail.message.subtype', 'Subtype', ondelete='set null', default=lambda self: self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')) + subtype_is_log = fields.Boolean('Is a log', compute='_compute_subtype_is_log') mail_activity_type_id = fields.Many2one('mail.activity.type', 'Mail Activity Type', ondelete='set null') # destination reply_to = fields.Char('Reply To', help='Reply email address. Setting the reply_to bypasses the automatic thread creation.') @@ -211,6 +211,12 @@ class MailComposer(models.TransientModel): res_ids = composer._evaluate_res_ids() composer.composition_batch = len(res_ids) > 1 if res_ids else False + @api.depends('subtype_id') + def _compute_subtype_is_log(self): + note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + for composer in self: + composer.subtype_is_log = not composer.subtype_id or composer.subtype_id.id == note_id + @api.depends('reply_to_force_new') def _compute_reply_to_mode(self): for composer in self: @@ -615,8 +621,6 @@ class MailComposer(models.TransientModel): if email_mode: subtype_id = False - elif self.is_log: # log a note: subtype is False - subtype_id = False elif self.subtype_id: subtype_id = self.subtype_id.id else: diff --git a/addons/mail/wizard/mail_compose_message_views.xml b/addons/mail/wizard/mail_compose_message_views.xml index a2627444937..b634d8d3cf1 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -15,7 +15,6 @@ - @@ -27,11 +26,12 @@ + -