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/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/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/calendar/models/calendar_alarm_manager.py b/addons/calendar/models/calendar_alarm_manager.py index f67a32d5d5e..916ddd7fdc9 100644 --- a/addons/calendar/models/calendar_alarm_manager.py +++ b/addons/calendar/models/calendar_alarm_manager.py @@ -183,7 +183,6 @@ class AlarmManager(models.AbstractModel): for alarm in alarms: alarm_attendees = attendees.filtered(lambda attendee: attendee.event_id.id in events_by_alarm[alarm.id]) alarm_attendees.with_context( - mail_notify_force_send=True, calendar_template_ignore_recurrence=True )._send_mail_to_attendees( alarm.mail_template_id, diff --git a/addons/calendar/models/calendar_event.py b/addons/calendar/models/calendar_event.py index 43bb858577b..9fd4b27f8fd 100644 --- a/addons/calendar/models/calendar_event.py +++ b/addons/calendar/models/calendar_event.py @@ -750,12 +750,11 @@ 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), default_template_id=template_id, default_partner_ids=self.partner_ids.ids, mail_tz=self.env.user.tz, 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 89189d5c4cc..85468e3973d 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -1561,14 +1561,14 @@ 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, }, - subtype_id=self.env.ref('mail.mt_note').id, + subtype_xmlid='mail.mt_note', ) def _format_properties(self): 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/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/crm_iap_enrich/models/crm_lead.py b/addons/crm_iap_enrich/models/crm_lead.py index 9685eab24a5..8c65cd6beba 100644 --- a/addons/crm_iap_enrich/models/crm_lead.py +++ b/addons/crm_iap_enrich/models/crm_lead.py @@ -68,18 +68,20 @@ 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_id=self.env.ref('mail.mt_note').id) + subtype_xmlid='mail.mt_note', + ) continue email_domain = normalized_email.split('@')[1] # 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_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_source( + 'crm_iap_enrich.mail_message_lead_enrich_notfound', + subtype_xmlid='mail.mt_note', + ) continue values = {'iap_enrich_done': True} @@ -156,10 +161,10 @@ 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, - subtype_id=self.env.ref('mail.mt_note').id + render_values=template_values, + 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..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,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_source( + 'iap_mail.enrich_company', + render_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/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/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/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/event_booth/models/event_booth.py b/addons/event_booth/models/event_booth.py index 4ca2433ae0c..592d7223f60 100644 --- a/addons/event_booth/models/event_booth.py +++ b/addons/event_booth/models/event_booth.py @@ -100,12 +100,12 @@ 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_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..521f931d5ce 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -77,10 +77,11 @@ 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}, - subtype_id=self.env.ref('mail.mt_note').id) + render_values={'self': registration, 'origin': registration.sale_order_id}, + subtype_xmlid='mail.mt_note', + ) return registrations def write(self, vals): 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_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index ba758e8b7f7..85204ffd4e8 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -636,8 +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}) + 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): @@ -798,21 +802,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 +1217,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_source( + 'hr_expense.hr_expense_template_refuse_reason', + subtype_id=subtype_id, + render_values={'reason': reason, 'is_sheet': True, 'name': sheet.name}, + ) self.activity_update() def reset_expense_sheets(self): 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/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_presence/models/hr_employee.py b/addons/hr_presence/models/hr_employee.py index 99882f10ad2..3c5fbaf4645 100644 --- a/addons/hr_presence/models/hr_employee.py +++ b/addons/hr_presence/models/hr_employee.py @@ -181,12 +181,11 @@ 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, 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/hr_recruitment/models/hr_applicant.py b/addons/hr_recruitment/models/hr_applicant.py index c31b4e38daf..29eef352c0d 100644 --- a/addons/hr_recruitment/models/hr_applicant.py +++ b/addons/hr_recruitment/models/hr_applicant.py @@ -450,7 +450,7 @@ class Applicant(models.Model): applicant = self[0] if 'stage_id' in changes and applicant.stage_id.template_id: res['stage_id'] = (applicant.stage_id.template_id, { - 'auto_delete_message': True, + 'auto_delete_keep_log': False, 'subtype_id': self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), 'email_layout_xmlid': 'mail.mail_notification_light' }) diff --git a/addons/hr_recruitment/models/hr_employee.py b/addons/hr_recruitment/models/hr_employee.py index cb7d7fe0195..2ea3ad7af52 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 @@ -28,8 +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}, - subtype_id=self.env.ref("hr_recruitment.mt_applicant_hired").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 4f27bea09ad..f348532b00c 100644 --- a/addons/hr_recruitment/wizard/applicant_refuse_reason.py +++ b/addons/hr_recruitment/wizard/applicant_refuse_reason.py @@ -45,8 +45,19 @@ 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' - }) + # TDE note: keeping 16.0 behavior, clean me please + message_values = { + 'email_layout_xmlid' : 'mail.mail_notification_light', + } + if len(applicants) > 1: + applicants.with_context(active_test=True).message_mail_with_source( + self.template_id, + auto_delete_keep_log=False, + **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_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/hr_work_entry_holidays/models/hr_contract.py b/addons/hr_work_entry_holidays/models/hr_contract.py index 22a01619ecc..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): - subtype_note = self.env.ref('mail.mt_note') - 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]}, - subtype_id=subtype_note.id) + 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/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/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/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/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'), ) 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/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 fd4d3a925e8..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_id=self.env['ir.model.data']._xmlid_to_res_id('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 3c0542b110a..9cc3c396fc7 100644 --- a/addons/mail/models/mail_activity_mixin.py +++ b/addons/mail/models/mail_activity_mixin.py @@ -342,11 +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' - ) + 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_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_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_mail.py b/addons/mail/models/mail_mail.py index 02f8edbb129..5356e525ea4 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: @@ -322,31 +323,122 @@ 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 (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 + # 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_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, + }) + # add all cc once, either to the first "To", either as a single entry (do not mix + # with partner-specific sending) + 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 '', + '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_values['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". @@ -432,7 +524,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 @@ -443,40 +534,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 @@ -507,22 +564,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/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_render_mixin.py b/addons/mail/models/mail_render_mixin.py index 63da0106246..6f401a12258 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 @@ -435,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): @@ -467,9 +506,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 +543,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( diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py index 3626feb1a6d..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 " @@ -170,14 +173,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', @@ -191,126 +198,312 @@ 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_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_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. - 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() - for res_id, recipients in default_recipients.items(): - results[res_id].pop('partner_to', None) - results[res_id].update(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 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 - 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} - - 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', '')) - 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', '') - 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_email(self, res_ids, render_fields): - """Generates an email from the template for given the given model based on - records given by res_ids. - - :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. + :return: updated (or new) render_results; """ self.ensure_one() - multi_mode = True - if isinstance(res_ids, int): - res_ids = [res_ids] - multi_mode = False + if render_results is None: + render_results = {} - results = dict() - for lang, (template, template_res_ids) in self._classify_per_lang(res_ids).items(): - for field in render_fields: + # 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, {}) + + # link template attachments directly + if 'attachment_ids' in render_fields: + values['attachment_ids'] = self.attachment_ids.ids + + # generate attachments (reports) + 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 + 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): + """ 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. + + :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 = {} + records_sudo = None + + # 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(): + 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] + + # 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: + 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} + + # 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) + + 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: + 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 + + 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'. + 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 + '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() + render_fields_set = set(render_fields) + fields_specific = { + 'attachment_ids', # attachments + 'email_cc', # recipients + 'email_to', # recipients + 'partner_to', # recipients + 'report_template_ids', # attachments + 'scheduled_date', # specific + # not rendered (static) + 'auto_delete', + 'mail_server_id', + 'model', + 'res_id', + } + + render_results = {} + 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_set + 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 - for res_id in template_res_ids: - values = 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_results.setdefault(res_id, {})[field] = field_value - # 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], + # render recipients + if render_fields_set & {'email_cc', 'email_to', 'partner_to'}: + template._generate_template_recipients( + template_res_ids, render_fields_set, + render_results=render_results, + find_or_create_partners=find_or_create_partners ) - # Add report in attachments: generate once for all template_res_ids - if 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 + # render scheduled_date + if 'scheduled_date' in render_fields_set: + template._generate_template_scheduled_date( + template_res_ids, + render_results=render_results + ) - 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 + # add values static for all res_ids + template._generate_template_static_values( + template_res_ids, + render_fields_set, + render_results=render_results + ) - # TODO in trunk, change return 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)) - results[res_id]['attachments'] = attachments + # generate attachments if requested + if render_fields_set & {'attachment_ids', 'report_template_ids'}: + template._generate_template_attachments( + template_res_ids, + render_fields_set, + render_results=render_results + ) - # 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 - 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 render_results # ------------------------------------------------------------ # EMAIL @@ -342,19 +535,24 @@ 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( - res_id, - ('auto_delete', + values = self._generate_template( + [res_id], + ('attachment_ids', + 'auto_delete', 'body_html', 'email_cc', 'email_from', 'email_to', + 'mail_server_id', + 'model', 'partner_to', 'reply_to', + 'report_template_ids', + 'res_id', '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/models/mail_thread.py b/addons/mail/models/mail_thread.py index 763889896dd..c9ff65b6a8b 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 @@ -1821,36 +1826,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: @@ -1884,23 +1914,18 @@ 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, }) - 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) - - # Set main attachment field if necessary - self._message_set_main_attachment_id(msg_values['attachment_ids']) + msg_values.update( + self._process_attachments_for_post(attachments, attachment_ids, msg_values) + ) # attachement_ids, body + new_message = self._message_create([msg_values]) 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 @@ -1910,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. @@ -1930,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 @@ -2071,95 +2102,257 @@ 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 """ - # 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 + template, view = self._get_source_from_ref(source_ref) - # Create the composer - composer = self.env['mail.compose.message'].with_context( - active_id=res_id, - active_ids=res_ids, - active_model=kwargs.get('model', self._name), - default_composition_mode=kwargs['composition_mode'], - default_email_layout_xmlid=email_layout_xmlid, - default_model=kwargs.get('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) + # 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 + 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, + } + + mails_su = self.env['mail.mail'].sudo() + for subset in [self] if template else self: + composer_ctx['default_res_ids'] = subset.ids + if not template: + composer_values['body'] = bodies[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, + 'mass_mail', + self._name, + subset.ids, + )['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( + default_composition_mode='comment', + default_model=self._name, + default_res_ids=record.ids, + 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.ids, + )['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 + + @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, **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. """ + 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 + 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; + + :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: 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', 'parent_id'} + ) + 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 + + 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 @@ -2173,68 +2366,91 @@ 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_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) + new_message = self._message_create([msg_values]) + self._fallback_lang()._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. - def _message_log(self, *, body='', author_id=None, email_from=None, subject=False, message_type='notification', **kwargs): - """ Shortcut allowing to post note on a document. It does not perform - any notification and pre-computes some values to have a short code + :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='', 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`` + for more details. """ + self.ensure_one() + + return self._message_log_batch( + {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, 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 + 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. """ - self.ensure_one() - author_id, email_from = self._message_compute_author(author_id, email_from, raise_on_email=False) + access rights are already granted to avoid privilege escalation. - msg_values = { - # author - 'author_id': author_id, - 'email_from': email_from, - # document - 'model': kwargs.get('model', self._name), - 'record_name': False, - 'res_id': self.ids[0] if self.ids else False, - # content - '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'), - # 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) + :param bodies: dict {record_id: body} - 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. - - :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 = { @@ -2245,15 +2461,18 @@ 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], } + values_list = [dict(base_message_values, res_id=record.id, body=bodies.get(record.id, '')) @@ -2321,17 +2540,24 @@ 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] def _message_create(self, values_list): - if not isinstance(values_list, (list)): - values_list = [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 + 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 @@ -2346,6 +2572,131 @@ 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_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. + 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 # ------------------------------------------------------ @@ -2418,6 +2769,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) @@ -2690,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') @@ -2748,17 +3104,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/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/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 21dc7f63da5..692d325a6df 100644 --- a/addons/mail/static/src/models/composer_view.js +++ b/addons/mail/static/src/models/composer_view.js @@ -547,10 +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_id: this.composer.activeThread.id, + default_res_ids: [this.composer.activeThread.id], + default_subtype_xmlid: this.composer.isLog ? 'mail.mt_note' : '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..53d21f5f4b8 100644 --- a/addons/mail/static/src/models/mail_template.js +++ b/addons/mail/static/src/models/mail_template.js @@ -16,9 +16,9 @@ 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 b3c9244b2b8..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: @@ -329,18 +330,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 +352,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 +442,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 +1064,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/mail/tests/test_mail_composer.py b/addons/mail/tests/test_mail_composer.py index c0ddca184b5..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, })) @@ -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/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/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/views/res_partner_views.xml b/addons/mail/views/res_partner_views.xml index 08e7835b610..377d3589b84 100644 --- a/addons/mail/views/res_partner_views.xml +++ b/addons/mail/views/res_partner_views.xml @@ -110,7 +110,7 @@ diff --git a/addons/mail/wizard/mail_compose_message.py b/addons/mail/wizard/mail_compose_message.py index b6ae9990cc2..0e6c95bcb7f 100644 --- a/addons/mail/wizard/mail_compose_message.py +++ b/addons/mail/wizard/mail_compose_message.py @@ -3,13 +3,16 @@ import ast import base64 -import re +import datetime +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,25 +44,30 @@ 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 - """ - # backward compatibility of context before addition of - # 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']) + 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. - result = super(MailComposer, self).default_get(fields) + 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'): + self = self.with_context( + default_subtype_id=self.env['ir.model.data']._xmlid_to_res_id( + 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().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: @@ -67,26 +75,46 @@ class MailComposer(models.TransientModel): if missing_author: result['author_id'] = author_id - if 'model' in fields and 'model' not in result: - result['model'] = self._context.get('active_model') - if 'res_id' in fields and 'res_id' not in result: - result['res_id'] = self._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 + # record context management + if 'model' in fields_list and 'model' not in result: + result['model'] = self.env.context.get('active_model') + 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._context: # not context.get() because we want to keep global [] domains - result['active_domain'] = '%s' % self._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 + # comment mode by default removes emails + if 'auto_delete' in fields_list and 'auto_delete' not in result and result.get('composition_mode') == 'comment': + result['auto_delete'] = True + # batch post mode by default use queues for notifications + if 'force_send' in fields_list and 'force_send' not in result: + result['force_send'] = ( + result.get('composition_mode') != 'comment' or + len(self._parse_res_ids(result.get('res_ids') or [])) <= 1 + ) + + return { + fname: result[fname] + for fname in result if fname in fields_list + } def _partner_ids_domain(self): return expression.OR([ @@ -113,15 +141,19 @@ 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') + 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'), @@ -129,11 +161,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')) - notify = fields.Boolean('Notify followers', help='Notify followers of the document (mass post only)') + 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.') @@ -153,8 +184,53 @@ class MailComposer(models.TransientModel): # sending auto_delete = fields.Boolean('Delete Emails', 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)') + auto_delete_keep_log = fields.Boolean( + 'Keep Message Copy', default=True, + help='Keep a copy of the email content if emails are removed (mass mailing only)') + force_send = fields.Boolean( + 'Send mailing or notifications directly') 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.") + use_exclusion_list = fields.Boolean('Check Exclusion List', default=True) + + @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('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): @@ -171,15 +247,6 @@ class MailComposer(models.TransientModel): for composer in self: composer.render_model = composer.model - # Onchanges - - @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'] - 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. @@ -188,32 +255,151 @@ class MailComposer(models.TransientModel): non_mass_mail.can_edit_body = True super(MailComposer, self - non_mass_mail)._compute_can_edit_body() + # Onchanges + + @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._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_ids): + """ Perform the onchange. + + * 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' 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( + (field, template[field]) + for field in ('auto_delete', + 'email_from', + 'reply_to', + 'scheduled_date', + '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 + if not tools.is_html_empty(template.body_html): + values['body'] = template.body_html + 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), + template_res_ids, + ('attachment_ids', + 'auto_delete', + 'body_html', + 'email_cc', + 'email_from', + 'email_to', + 'mail_server_id', + 'partner_ids', + 'reply_to', + 'report_template_ids', + 'scheduled_date', + 'subject', + ) + )[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 = [] + 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_ids=res_ids + ).default_get(['attachment_ids', + 'auto_delete', + 'auto_delete_keep_log', + 'body', + 'composition_mode', + 'email_from', + 'mail_server_id', + 'model', + 'parent_id', + 'partner_ids', + 'reply_to', + 'res_ids', + 'scheduled_date', + 'subject', + ]) + values = dict( + (key, default_values[key]) + for key in ('attachment_ids', + 'auto_delete', + 'auto_delete_keep_log', + 'body', + 'email_from', + 'mail_server_id', + 'partner_ids', + 'reply_to', + 'scheduled_date', + 'subject', + ) if key in default_values) + + # 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): """ Returns a defaults-like dict with initial values for the composition 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 = 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')) - 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_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_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 len(res_ids) == 1: + record = self.env[model].browse(res_ids[0]) + record_name = record.display_name or '' subject = record._message_compute_subject() - 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 @@ -256,91 +442,94 @@ 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 - elif mass_mode and wizard.model and self._context.get('active_ids'): - res_ids = self._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] + res_ids = wizard._evaluate_res_ids() + # in comment mode: raise here as anyway message_post will raise. + 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) + ) - 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_batch: + # add context key to avoid subscribing the author + ActiveModel = ActiveModel.with_context( + 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) + + # as 'send' does not filter out scheduled mails (only 'process_email_queue' + # does) we need to do it manually + if not self.force_send: + continue + 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 + def action_save_as_template(self): """ hit save as template button: current form value will be a new template attached to the current document. """ @@ -367,133 +556,314 @@ class MailComposer(models.TransientModel): # generate the saved template record.write({'template_id': template.id}) record._onchange_template_id_wrapper() - return _reopen(self, record.id, record.model, context=self._context) + return _reopen(self, record.id, record.model, context=self.env.context) # ------------------------------------------------------------ # 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 - 'force_send', (notify parameter) + 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 - 'scheduled_date', + 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' + rendering_mode = email_mode or self.composition_batch - # 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 = { - '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, + additional_values_all = {} + # rendered based on raw content (wizard or template) + if rendering_mode and self.model: + additional_values_all = self._prepare_mail_values_dynamic(res_ids) + # wizard content already rendered + elif not rendering_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.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=self.auto_delete_keep_log, + 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, + force_send=self.force_send, + mail_auto_delete=self.auto_delete, + 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': email_mode}, + ) + emails_from = self._render_field('email_from', res_ids) + + mail_values_all = { + 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 + **( + { + 'auto_delete': self.auto_delete, + 'body_html': bodies[res_id], + 'res_id': res_id, + } if email_mode else {}), } + 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', [])] + # 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_ids', + 'scheduled_date', + ) + ) + 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]) - # 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() + # 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', []) + ] + # email_mode: prepare processed attachments as commands for mail.mail + if email_mode: mail_values['attachment_ids'] = record._process_attachments_for_post( - mail_values.pop('attachments', []), + 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 - results[res_id] = mail_values + # headers + if email_mode: + mail_values['headers'] = repr(record._notify_by_email_get_headers()) - results = self._process_state(results) - return results + # recipients: transform partner_ids (field used in mail_message) into + # recipient_ids, used by mail_mail + 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] - 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 {} + # when having no specific reply_to -> fetch rendered email_from + 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 - 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 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], + 'body': self.body or '', + 'email_from': self.email_from, + 'partner_ids': self.partner_ids.ids, + 'scheduled_date': self.scheduled_date, + 'subject': self.subject or '', } - return recipients_info + for res_id in res_ids + } - def _process_state(self, mail_values_dict): - recipients_info = self._process_recipient_values(mail_values_dict) + 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. + + :param dict mail_values_dict: as generated by '_prepare_mail_values'; + + :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) @@ -536,8 +906,64 @@ class MailComposer(models.TransientModel): return mail_values_dict + 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. + + :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_ids', + '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, + template_fields, + find_or_create_partners=True + ) + + 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 + # --------------------------------------------------------------------- + def _get_blacklist_record_ids(self, mail_values_dict): blacklisted_rec_ids = set() + if not self.use_exclusion_list: + return blacklisted_rec_ids 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") @@ -555,182 +981,102 @@ class MailComposer(models.TransientModel): 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', - '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) + 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 = [ + 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 {} - 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 - 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() - 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 - 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], + 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) + ] } - results[res_id].update(default_recipients.get(res_id, dict())) + return recipients_info - # generate template-based values - if self.template_id: - template_values = self.generate_email_for_composer( - self.template_id.id, res_ids, - ('attachment_ids', - 'email_to', - 'email_cc', - 'mail_server_id', - 'partner_to', - ) + # ---------------------------------------------------------------------- + # 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], ) - else: - template_values = {} + 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 - 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 domain - return multi_mode and template_values or template_values[res_ids[0]] + 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 generate_email_for_composer(self, template_id, res_ids, fields): - """ Call email_template.generate_email(), 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'] - 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) - 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]] + 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 d85d4363564..0419c1ab393 100644 --- a/addons/mail/wizard/mail_compose_message_views.xml +++ b/addons/mail/wizard/mail_compose_message_views.xml @@ -11,29 +11,34 @@ - + + - + + - + + + + + + + - - - PO_{{ (object.name or '').replace('/','_') }} + {{ object.partner_id.lang }} diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 463b6472f6e..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", @@ -750,7 +747,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_source( + template, + email_layout_xmlid="mail.mail_notification_layout_with_responsible_signature", + subtype_xmlid='mail.mt_comment', + ) def send_reminder_preview(self): self.ensure_one() @@ -777,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", @@ -1100,9 +1098,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_source( + 'purchase.track_po_line_template', + render_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']) @@ -1419,10 +1419,10 @@ 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}, - subtype_id=self.env.ref('mail.mt_note').id + render_values={'line': self, 'qty_received': new_qty}, + 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..f771d99c8d1 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( + order.message_post_with_source( 'mail.message_origin_link', - values={'self': order, 'origin': order.requisition_id}, - subtype_id=mt_note) + render_values={'self': order, 'origin': order.requisition_id}, + subtype_xmlid='mail.mt_note', + ) return orders def write(self, vals): @@ -158,10 +158,10 @@ 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}, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') + render_values={'self': order, 'origin': order.requisition_id, 'edit': True}, + 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..642dc4212dc 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -251,9 +251,11 @@ 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}, - subtype_id=self.env.ref('mail.mt_note').id) + picking.message_post_with_source( + 'mail.message_origin_link', + render_values={'self': picking, 'origin': order}, + subtype_xmlid='mail.mt_note', + ) return True def _add_picking_info(self, activity): 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/rating/models/rating_mixin.py b/addons/rating/models/rating_mixin.py index ac2e11e47b3..b06797afd2e 100644 --- a/addons/rating/models/rating_mixin.py +++ b/addons/rating/models/rating_mixin.py @@ -181,12 +181,12 @@ 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_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note'), - ) + self.with_context(mail_notify_force_send=force_send).message_post_with_source( + template, + email_layout_xmlid='mail.mail_notification_light', + force_send=force_send, + subtype_xmlid='mail.mt_note', + ) def rating_apply(self, rate, token=None, rating=None, feedback=None, subtype_xmlid=None, notify_delay_send=False): @@ -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/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/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/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/sale/models/payment_transaction.py b/addons/sale/models/payment_transaction.py index 6567b9ede01..4ad6a75cb54 100644 --- a/addons/sale/models/payment_transaction.py +++ b/addons/sale/models/payment_transaction.py @@ -125,11 +125,15 @@ 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 + 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( @@ -139,11 +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( - int(template_id), - email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', - ) + 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/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 e7e42a0335f..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, @@ -842,10 +841,10 @@ 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, - composition_mode='comment', + 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', ) def action_done(self): @@ -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, @@ -1181,10 +1179,11 @@ 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}, - subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')) + render_values={'self': move, 'origin': move.line_ids.sale_line_ids.order_id}, + 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..3dba4c73252 100644 --- a/addons/sale/tests/test_controllers.py +++ b/addons/sale/tests/test_controllers.py @@ -87,7 +87,9 @@ 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', + ) 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..22a8b31c318 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_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') 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_source( + mail_template, + 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..a7851b69451 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_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') 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..501ab2e3d00 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -186,10 +186,11 @@ 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}, - subtype_id=self.env.ref('mail.mt_note').id) + render_values={'self': invoice, 'origin': order}, + subtype_xmlid='mail.mt_note', + ) return invoice 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/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 43d6895efd0..50ad69b5c21 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -42,10 +42,11 @@ 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}, - subtype_id=self.env.ref('mail.mt_note').id) + render_values={'self': picking_id, 'origin': sale_order_id}, + subtype_xmlid='mail.mt_note', + ) class ProcurementGroup(models.Model): 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/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/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/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/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 7be9f3ccf34..63da5af9af0 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_source( + template, + render_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..1ca08ec2d59 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') + 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, + ) @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/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/stock/wizard/stock_picking_return.py b/addons/stock/wizard/stock_picking_return.py index 241dadf28eb..80db63fcc54 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', - values={'self': new_picking, 'origin': self.picking_id}, - subtype_id=self.env.ref('mail.mt_note').id) + new_picking.message_post_with_source( + 'mail.message_origin_link', + render_values={'self': new_picking, 'origin': self.picking_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/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/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/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/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 @@ + + + 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/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: 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_mail.py b/odoo/addons/base/tests/test_mail.py index e3be6e0d812..75139648b98 100644 --- a/odoo/addons/base/tests/test_mail.py +++ b/odoo/addons/base/tests/test_mail.py @@ -1,14 +1,17 @@ # -*- 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 import email.message 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 = """ 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'