[MERGE] mail, various: improve post and composer usage, coding and performances

Purpose of this task is to cleanup usage of main mail.thread post methods and
helpers as well as composer usage and code. Achieve this by removing or
cleaning deprecated code, simplifying flow / API, improving composer support
of templates, rewrite composer code, ease batch support.

In this task we notably

  * cleanup input / output as well as default values for message_post as well
    as 'with view' and 'with template' helpers. Those are now cleaned and
    two main helpers are introduced: post with source (view or template)
    and mail with source (view or template), depending on main notification
    style.
  * rewrite and improve template code generating values from the template
    definition. It is now cleaner, delegated into sub methods and made
    more efficient in batch mode, notably when computing or creating
    recipients;
  * rewrite and improve composer code generating values for either posting
    either sending a mass mailing. It is now better separated into a static
    rendering and a dynamic rendering;
  * supports a batch post on documents, instead of being limited to a single
     res_id and improve overall code to be batch-enabled;
  * support scheduled_date from template;
  * support multi reports templates;
  * perform various code cleaning, rewriting in order to achieve both a more
    readable code, easier to override and understand as well as better tested
    and documented;
  * cleanup configuration of composer. Use fields, better define and update
    their value. Don't add them in UI but allow more fine-grain usage, notably
    when invoked in code;

METHODOLOGY NOTE

Several points are linked to different tasks. A big PR containing a cleaning
and some improvements in the composer behavior, usage and definition allowed
to ease testing and review.

This PR is build on previously merged content that originated form this PR.
See notably (but not limited to)

  * account, mail, sale: properly locate code
    Task-2792146 - odoo/odoo#106658
  * various: cleanup alias usage and empty list help overrides
    Task-3074358 - odoo/odoo#105992
  * clean parameters propagation in post methods
    Task-2726501 - odoo/odoo#82167
  * rating send request api cleanup
    odoo/odoo#106024
  * empty list help cleanup
    Task-3074357 - odoo/odoo#105992
  * rendering mixin options and usage cleanup
    odoo/odoo#106072
  * improve exclusion list mail logging
    odoo/odoo#106568
  * linting and cleaning
    odoo/odoo#106025

Next step will be to improve composer fields to be editable computed fields
and remove the onchange and hard-to-predict code. Translation support will
also be improved, as well as some performance bits.

LINKS

Task-2710804 (Mail: Clean MailThread API)
Task-2993872 (Mail: Support scheduled date in all composer flows)
Task-3035101 (Mail: Support batch-posting from composer)
Task-3034875 (Mail: Speedup and batch partners find or create with templates)
Task-2868153 (Mail: Allow multi-reports mail templates)
Task-3132710 (Mail: Configurable composer)

Prepares Task-2088884 (Mail: Use editable computed stored fields in composer)
Prepares Task-36879 (Mail: Support MultiCompany Aliases)

closes odoo/odoo#99482

Related: odoo/upgrade#3856
Related: odoo/enterprise#31017
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2023-01-17 20:58:43 +01:00
committed by GitHub
145 changed files with 4007 additions and 1807 deletions
+3 -6
View File
@@ -54,8 +54,7 @@
</p>
</div>
</field>
<field name="report_template" ref="account_invoices"/>
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}{{ object.state == 'draft' and '_draft' or '' }}</field>
<field name="report_template_ids" eval="[(4, ref('account.account_invoices'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -84,8 +83,7 @@
</p>
</div>
</field>
<field name="report_template" ref="account.action_report_payment_receipt"/>
<field name="report_name">{{ (object.name or '').replace('/','-') }}</field>
<field name="report_template_ids" eval="[(4, ref('account.action_report_payment_receipt'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -129,8 +127,7 @@
</p>
</div>
</field>
<field name="report_template" ref="account_invoices"/>
<field name="report_name">Credit_note_{{ (object.name or '').replace('/','_') }}{{ object.state == 'draft' and '_draft' or '' }}</field>
<field name="report_template_ids" eval="[(4, ref('account.account_invoices'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
+1 -6
View File
@@ -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,
@@ -430,7 +430,6 @@
<field name="context" eval="{
'mail_post_autofollow': True,
'default_composition_mode': 'comment',
'default_use_template': True,
'default_template_id': ref('account.mail_template_data_payment_receipt'),
'default_email_layout_xmlid': 'mail.mail_notification_light',
}"/>
@@ -447,7 +446,6 @@
<field name="context" eval="{
'mail_post_autofollow': True,
'default_composition_mode': 'mass_mail',
'default_use_template': True,
'default_template_id': ref('account.mail_template_data_payment_receipt'),
'default_email_layout_xmlid': 'mail.mail_notification_light',
}"/>
@@ -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'
@@ -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,
+2 -3
View File
@@ -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,
+1 -1
View File
@@ -73,7 +73,7 @@
<field name="arch" type="xml">
<tree string="Meetings" sample="1" multi_edit="1">
<header>
<button name="action_open_composer" type="object" context="{'composition_mode':'mass_mail'}"
<button name="action_open_composer" type="object" context="{'default_composition_mode':'mass_mail'}"
string="Send Mail"/>
</header>
<field name="name" string="Subject" decoration-bf="1" attrs="{'readonly':[('recurrency','=',True)]}"/>
+3 -3
View File
@@ -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):
@@ -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,
+1 -1
View File
@@ -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
-2
View File
@@ -735,7 +735,6 @@
<field name="target">new</field>
<field name="context" eval="{
'default_composition_mode': 'comment',
'default_use_template': False,
}"/>
<field name="binding_model_id" ref="model_crm_lead"/>
<field name="binding_view_types">form</field>
@@ -748,7 +747,6 @@
<field name="target">new</field>
<field name="context" eval="{
'default_composition_mode': 'mass_mail',
'default_use_template': False,
}"/>
<field name="binding_model_id" ref="model_crm_lead"/>
<field name="binding_view_types">list</field>
+13 -8
View File
@@ -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):
@@ -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
@@ -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']:
+2 -4
View File
@@ -22,8 +22,7 @@
<t t-out="object.event_id.user_id.signature or ''">--<br/>Mitchell Admin</t>
</t>
</div></field>
<field name="report_template" ref="action_report_event_registration_foldable_badge"/>
<field name="report_name">Foldable Badge - {{ (object.event_id.name or 'Event').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('event.action_report_event_registration_foldable_badge'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -254,8 +253,7 @@
</td></tr>
</table>
</field>
<field name="report_template" ref="action_report_event_registration_full_page_ticket"/>
<field name="report_name">Full Page Ticket - {{ (object.event_id.name or 'Event').replace('/','') }}</field>
<field name="report_template_ids" eval="[(4, ref('event.action_report_event_registration_full_page_ticket'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
</record>
+2 -3
View File
@@ -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",
)
+3 -3
View File
@@ -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):
@@ -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):
@@ -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):
+19 -10
View File
@@ -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):
+2 -1
View File
@@ -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):
@@ -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()
+2 -3
View File
@@ -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'),
+1 -1
View File
@@ -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'
})
+4 -4
View File
@@ -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
@@ -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
)
@@ -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,
@@ -5,6 +5,9 @@
<field name="arch" type="xml">
<form>
<field name="author_id" invisible="1"/>
<field name="lang" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="template_id" invisible="1"/>
<group>
<field name="subject" required="1"/>
<field name="applicant_ids" widget="many2many_tags" context="{'show_partner_name': 1}"/>
@@ -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)
@@ -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
+2 -4
View File
@@ -34,8 +34,7 @@
</div>
</div>
</field>
<field name="report_template" ref="loyalty.report_gift_card"/>
<field name="report_name">Your Gift Card</field>
<field name="report_template_ids" eval="[(4, ref('loyalty.report_gift_card'))]"/>
<field name="auto_delete" eval="True"/>
</record>
@@ -96,8 +95,7 @@
</tr>
</tbody></table>
</field>
<field name="report_template" ref="loyalty.report_loyalty_card"/>
<field name="report_name">Your Coupon Code</field>
<field name="report_template_ids" eval="[(4, ref('loyalty.report_loyalty_card'))]"/>
<field name="auto_delete" eval="True"/>
</record>
</odoo>
+1 -2
View File
@@ -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',
+3 -2
View File
@@ -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'),
)
+1 -1
View File
@@ -2,7 +2,7 @@
{
'name': 'Discuss',
'version': '1.11',
'version': '1.12',
'category': 'Productivity/Discuss',
'sequence': 145,
'summary': 'Chat, mail gateway and private channels',
+4 -6
View File
@@ -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:
+4 -4
View File
@@ -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
+4 -5
View File
@@ -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):
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+124 -63
View File
@@ -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 <mail.mail> 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(
+3 -3
View File
@@ -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:
+61 -18
View File
@@ -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(
+313 -115
View File
@@ -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 {})
+533 -180
View File
@@ -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)
+91
View File
@@ -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
# ------------------------------------------------------------
+1 -2
View File
@@ -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,
},
@@ -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,
};
@@ -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,
},
@@ -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,
+97 -63
View File
@@ -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)])
+6 -6
View File
@@ -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 = '<p>Hello</p>'
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 = '<p>Hello</p>'
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,
+1 -1
View File
@@ -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')
+218 -12
View File
@@ -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@chirurgiens-dentistes.fr> ', '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 <!@historicalsociety.museum>", "Raoul O'hara", '!@historicalsociety.museum'),
('Raoul Grosbedon <raoul@CHIRURGIENS-dentistes.fr> ', 'Raoul Grosbedon', 'raoul@CHIRURGIENS-dentistes.fr'),
('Raoul megaraoul@chirurgiens-dentistes.fr', 'Raoul', 'megaraoul@chirurgiens-dentistes.fr'),
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Poilvache', 'patrick@example.com'),
('Patrick Caché <2patrick@EXAMPLE.COM>', 'Patrick Caché', '2patrick@example.com'),
('"Patrick Da Beast Poilvache" <PATRICK@example.com>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick@EXAMPLE.COM>', 'Patrick Da Beast Poilvache', 'patrick@example.com'),
('Patrick Caché <patrick.2@EXAMPLE.COM>', '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@test.EXAMPLE.com>', 'New Customer', 'new.customer@test.example.com'),
('"Duplicated Raoul" <RAOUL@chirurgiens-dentistes.fr>', '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" <new.customer@test.EXAMPLE.com>', # 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>',
'test.customer@test.dupe.example.com',
'"Another Name" <test.customer@TEST.DUPE.EXAMPLE.COM>',
'"Mix of both" <test.customer@test.dupe.EXAMPLE.COM',
]
email_expected_name = "Formatted Customer" # first found email will setup partner info
email_expected_email = 'test.customer@test.dupe.example.com' # normalized version of given email
# all same partner, same invalid email 'test.customer.invalid.email'
name_dupes_samples = [
'test.customer.invalid.email',
'test.customer.invalid.email',
]
name_expected_name = 'test.customer.invalid.email' # invalid email kept as both name and email
name_expected_email = 'test.customer.invalid.email' # invalid email kept as both name and email
partners = self.env['res.partner']
for samples, (expected_name, expected_email) in [
(email_dupes_samples, (email_expected_name, email_expected_email)),
(name_dupes_samples, (name_expected_name, name_expected_email)),
]:
with self.subTest(samples=samples, expected_name=expected_name, expected_email=expected_email):
with self.mockPartnerCalls():
partner_list = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
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), 1)
# results
self.assertEqual(len(partner_list), len(samples))
self.assertTrue(len(set(partner.id for partner in partner_list)) == 1 and partner_list[0].id, 'Should have a unique new partner')
for partner in partner_list:
self.assertEqual(partner.email, expected_email)
self.assertEqual(partner.name, expected_name)
partners += partner_list[0]
self.assertEqual(len(partners), 2,
'Should have created one partner for valid email, one for invalid email')
new_samples = [
'"Another Customer" <test.customer2@TEST.DUPE.EXAMPLE.COM', # actually a new valid email
'"First Duplicate" <test.customer@TEST.DUPE.example.com', # duplicated of valid email created above
'test.customer.invalid.email', # duplicate of an invalid email created above
]
with self.mockPartnerCalls():
new_partners = self.env['res.partner'].with_context(lang='en_US')._find_or_create_from_emails(
new_samples,
additional_values=None,
)
# calls
self.assertEqual(self._mock_partner_create.call_count, 1)
self.assertEqual(self._mock_partner_search.call_count, 1,
'Search once, even with both normalized and invalid emails')
self.assertEqual(len(self._new_partners), 1)
# results
self.assertEqual(len(new_partners), len(new_samples))
self.assertEqual(new_partners[0].email, "test.customer2@test.dupe.example.com")
self.assertEqual(new_partners[0].name, "Another Customer")
self.assertEqual(new_partners[1], partners[0])
other_samples = [
'"Another Duplicate" <test.customer2@TEST.DUPE.EXAMPLE.COM',
'"First Duplicate2" <test.customer@TEST.DUPE.example.com',
'"Third Customer" <test.customer3@test.dupe.example.com',
'"Falsy" <falsy>',
'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" <falsy>')
self.assertEqual(other_partners[3].name, '"Falsy" <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')
+4 -4
View File
@@ -79,8 +79,9 @@
<field name="lang" placeholder="{{ object.partner_id.lang }}"/>
<field name="mail_server_id"/>
<field name="auto_delete"/>
<field name="report_template" domain="[('model','=',model)]"/>
<field name="report_name" attrs="{'invisible':[('report_template','=',False)]}"/>
<field name="report_template_ids" domain="[('model','=',model)]"
widget="many2many_tags"
options="{'no_create': True}"/>
</group>
</page>
</notebook>
@@ -102,7 +103,6 @@
<field name="email_from" optional="hidden"/>
<field name="email_to" optional="hidden"/>
<field name="partner_to" optional="hidden"/>
<field name="report_name" optional="hidden"/>
</tree>
</field>
</record>
@@ -112,7 +112,7 @@
<field name="model">mail.template</field>
<field name="arch" type="xml">
<search string="Templates">
<field name="name" filter_domain="['|', '|', '|',('name','ilike',self), ('report_name','ilike',self), ('subject','ilike',self), ('email_to','ilike',self)]" string="Templates"/>
<field name="name" filter_domain="['|', '|', ('name','ilike',self), ('subject','ilike',self), ('email_to','ilike',self)]" string="Templates"/>
<field name="lang"/>
<field name="model_id"/>
<filter name="base_templates" string="Base Templates" domain="[('template_category', '=', 'base_template')]"/>
+1 -1
View File
@@ -110,7 +110,7 @@
<field name="context" eval="{
'default_composition_mode': 'mass_mail',
'default_partner_to': '{{ object.id or \'\' }}',
'default_use_template': False,
'default_subtype_xmlid': 'mail.mt_comment',
'default_reply_to_force_new': True,
}"/>
<field name="binding_model_id" ref="base.model_res_partner"/>
File diff suppressed because it is too large Load Diff
@@ -11,29 +11,34 @@
<!-- truly invisible fields for control and options -->
<field name="author_id" invisible="1"/>
<field name="auto_delete" invisible="1"/>
<field name="auto_delete_message" invisible="1"/>
<field name="auto_delete_keep_log" invisible="1"/>
<field name="composition_batch" invisible="1"/>
<field name="composition_mode" invisible="1"/>
<field name="email_layout_xmlid" invisible="1"/>
<field name="is_log" invisible="1"/>
<field name="force_send" invisible="1"/>
<field name="lang" invisible="1"/>
<field name="mail_server_id" invisible="1"/>
<field name="model" invisible="1"/>
<field name="parent_id" invisible="1"/>
<field name="record_name" invisible="1"/>
<field name="res_id" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="res_domain" invisible="1"/>
<field name="res_domain_user_id" invisible="1"/>
<field name="res_ids" invisible="1"/>
<field name="scheduled_date" invisible="1"/>
<field name="subtype_id" invisible="1"/>
<field name="subtype_is_log" invisible="1"/>
<field name="use_exclusion_list" invisible="1"/>
<!-- visible wizard -->
<field name="email_from"
attrs="{'invisible':[('composition_mode', '!=', 'mass_mail')]}"/>
<label for="partner_ids" string="Recipients" attrs="{'invisible': ['|', ('is_log', '=', True), ('composition_mode', '!=', 'comment')]}"/>
<div groups="base.group_user" attrs="{'invisible': ['|', ('is_log', '=', True), ('composition_mode', '!=', 'comment')]}">
<label for="partner_ids" string="Recipients" attrs="{'invisible': [('composition_mode', '!=', 'comment')]}"/>
<div groups="base.group_user" attrs="{'invisible': [('composition_mode', '!=', 'comment')]}">
<span name="document_followers_text" attrs="{'invisible':['|', ('model', '=', False), ('composition_mode', '=', 'mass_mail')]}">Followers of the document and</span>
<field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to notify..."
context="{'force_email':True, 'show_email':True}"/>
</div>
<field name="subject" placeholder="Welcome to MyCompany!" required="True"/>
<!-- mass post -->
<field name="notify"
attrs="{'invisible':[('composition_mode', '!=', 'mass_post')]}"/>
</group>
<field name="can_edit_body" invisible="1"/>
<div attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}">
@@ -67,8 +72,12 @@
</page>
</notebook>
<footer>
<button string="Send" attrs="{'invisible': [('is_log', '=', True)]}" name="action_send_mail" type="object" class="btn-primary o_mail_send" data-hotkey="q"/>
<button string="Log" attrs="{'invisible': [('is_log', '=', False)]}" name="action_send_mail" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Send" name="action_send_mail"
type="object" class="btn-primary o_mail_send" data-hotkey="q"
attrs="{'invisible': [('subtype_is_log', '=', True)]}"/>
<button string="Log" name="action_send_mail"
type="object" class="btn-primary" data-hotkey="q"
attrs="{'invisible': [('subtype_is_log', '=', False)]}"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button icon="fa-lg fa-save" type="object"
name="action_save_as_template" string="Save as new template"
+13 -7
View File
@@ -14,6 +14,8 @@ class MailTemplatePreview(models.TransientModel):
'email_cc',
'email_from',
'email_to',
'partner_to',
'report_template_ids',
'reply_to',
'scheduled_date',
]
@@ -43,7 +45,7 @@ class MailTemplatePreview(models.TransientModel):
resource_ref = fields.Reference(string='Record', selection='_selection_target_model')
lang = fields.Selection(_selection_languages, string='Template Preview Language')
no_record = fields.Boolean('No Record', compute='_compute_no_record')
error_msg = fields.Char('Error Message', readonly=True)
error_msg = fields.Char('Error Message', compute='_compute_mail_template_fields')
# Fields same than the mail.template model, computed with resource_ref and lang
subject = fields.Char('Subject', compute='_compute_mail_template_fields')
email_from = fields.Char('From', compute='_compute_mail_template_fields', help="Sender address")
@@ -55,7 +57,7 @@ class MailTemplatePreview(models.TransientModel):
help="The queue manager will send the email after the date")
body_html = fields.Html('Body', compute='_compute_mail_template_fields', sanitize=False)
attachment_ids = fields.Many2many('ir.attachment', 'Attachments', compute='_compute_mail_template_fields')
# Extra fields info generated by generate_email
# Extra fields info generated by _generate_template
partner_ids = fields.Many2many('res.partner', string='Recipients', compute='_compute_mail_template_fields')
@api.depends('model_id')
@@ -72,17 +74,17 @@ class MailTemplatePreview(models.TransientModel):
copy_depends_values = {'lang': self.lang}
mail_template = self.mail_template_id.with_context(lang=self.lang)
try:
if not self.resource_ref:
if not self.resource_ref.id:
self._set_mail_attributes()
else:
copy_depends_values['resource_ref'] = '%s,%s' % (self.resource_ref._name, self.resource_ref.id)
mail_values = mail_template.with_context(template_preview_lang=self.lang).generate_email(
self.resource_ref.id,
mail_values = mail_template.with_context(template_preview_lang=self.lang)._generate_template(
[self.resource_ref.id],
self._MAIL_TEMPLATE_FIELDS
)
)[self.resource_ref.id]
self._set_mail_attributes(values=mail_values)
self.error_msg = False
except UserError as user_error:
except (ValueError, UserError) as user_error:
self._set_mail_attributes()
self.error_msg = user_error.args[0]
finally:
@@ -92,6 +94,10 @@ class MailTemplatePreview(models.TransientModel):
def _set_mail_attributes(self, values=None):
for field in self._MAIL_TEMPLATE_FIELDS:
if field in ('partner_to', 'report_template_ids'):
# partner_to is used to generate partner_ids, handled here below
# report_template_ids generates attachments, no usage here
continue
field_value = values.get(field, False) if values else self.mail_template_id[field]
self[field] = field_value
self.partner_ids = values.get('partner_ids', False) if values else False
@@ -8,7 +8,7 @@
<form string="Email Preview">
<h3>Preview of <field name="mail_template_id" readonly="1" nolabel="1" options="{'no_open' : True}"/></h3>
<div class="alert alert-danger" role="alert" attrs="{'invisible' : [('error_msg', '=', False)]}">
<field name="error_msg" />
<field name="error_msg"/>
</div>
<field name="no_record" invisible="1"/>
<div class="container">
+1 -1
View File
@@ -314,7 +314,7 @@ class MailGroup(models.Model):
values
))
mail_message = Mailthread._message_create(values)
mail_message = Mailthread._message_create([values])
# Find the <mail.group.message> parent
group_message_parent_id = False
@@ -96,6 +96,7 @@ class MailGroupMessage(models.Model):
field: vals.pop(field)
for field in self.env['mail.message']._fields
if field in vals
and field in self.env['mail.thread']._get_message_create_valid_field_names()
}).id
return super(MailGroupMessage, self).create(values_list)
@@ -117,10 +117,10 @@ class MailPluginController(http.Controller):
partner.write(partner_values)
partner.message_post_with_view(
partner.message_post_with_source(
'iap_mail.enrich_company',
values=iap_data,
subtype_id=request.env.ref('mail.mt_note').id,
render_values=iap_data,
subtype_xmlid='mail.mt_note',
)
return {
@@ -357,10 +357,10 @@ class MailPluginController(http.Controller):
new_company = request.env['res.partner'].create(new_company_info)
new_company.message_post_with_view(
new_company.message_post_with_source(
'iap_mail.enrich_company',
values=iap_data,
subtype_id=request.env.ref('mail.mt_note').id,
render_values=iap_data,
subtype_xmlid='mail.mt_note',
)
return new_company, {'type': 'company_created'}
+31 -27
View File
@@ -35,15 +35,15 @@ class MailMail(models.Model):
self.ensure_one()
return tools.hmac(self.env(su=True), 'mass_mailing-mail_mail-open', self.id)
def _send_prepare_body(self):
""" Override to add the tracking URL to the body and to add
trace ID in shortened urls """
# TDE: temporary addition (mail was parameter) due to semi-new-API
def _prepare_outgoing_body(self):
""" Override to add the tracking URL to the body and to add trace ID in
shortened urls """
self.ensure_one()
body = super(MailMail, self)._send_prepare_body()
# super() already cleans pseudo-void content from editor
body = super()._prepare_outgoing_body()
if self.mailing_id and body and self.mailing_trace_ids:
for match in set(re.findall(tools.URL_REGEX, self.body_html)):
if body and self.mailing_id and self.mailing_trace_ids:
for match in set(re.findall(tools.URL_REGEX, body)):
href = match[0]
url = match[1]
@@ -57,31 +57,35 @@ class MailMail(models.Model):
tracking_url = self._get_tracking_url()
body = tools.append_content_to_html(
body,
'<img src="%s"/>' % tracking_url,
f'<img src="{tracking_url}"/>',
plaintext=False,
)
body = self.env['mail.render.mixin']._replace_local_links(body)
return body
def _send_prepare_values(self, partner=None):
# TDE: temporary addition (mail was parameter) due to semi-new-API
res = super(MailMail, self)._send_prepare_values(partner)
if self.mailing_id and res.get('body') and res.get('email_to'):
base_url = self.mailing_id.get_base_url()
emails = tools.email_split(res.get('email_to')[0])
email_to = emails and emails[0] or False
def _prepare_outgoing_list(self):
""" Update mailing specific links to add tracking based on res_id """
email_list = super()._prepare_outgoing_list()
if not self.res_id or not self.mailing_id:
return email_list
urls_to_replace = [
(base_url + '/unsubscribe_from_list', self.mailing_id._get_unsubscribe_url(email_to, self.res_id)),
(base_url + '/view', self.mailing_id._get_view_url(email_to, self.res_id))
]
base_url = self.mailing_id.get_base_url()
for email_values in email_list:
if tools.is_html_empty(email_values['body']) or not email_values['email_to']:
continue
emails = tools.email_split(email_values['email_to'][0])
email_to = emails[0] if emails else False
for url_to_replace, new_url in urls_to_replace:
if url_to_replace in res['body']:
res['body'] = res['body'].replace(url_to_replace, new_url if new_url else '#')
return res
if f'{base_url}/unsubscribe_from_list' in email_values['body']:
email_values['body'] = email_values['body'].replace(
f'{base_url}/unsubscribe_from_list',
self.mailing_id._get_unsubscribe_url(email_to, self.res_id),
)
if f'{base_url}/view' in email_values['body']:
email_values['body'] = email_values['body'].replace(
f'{base_url}/view',
self.mailing_id._get_view_url(email_to, self.res_id),
)
return email_list
def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None):
mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed
@@ -91,4 +95,4 @@ class MailMail(models.Model):
mail.mailing_trace_ids.set_sent()
elif mail_sent is False and mail.mailing_trace_ids:
mail.mailing_trace_ids.set_failed(failure_type=failure_type)
return super(MailMail, self)._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type)
return super()._postprocess_sent_message(success_pids, failure_reason=failure_reason, failure_type=failure_type)
+11 -4
View File
@@ -28,14 +28,21 @@ class MailThread(models.AbstractModel):
self.env['mailing.trace'].set_replied(domain=[('message_id', 'in', msg_references)])
return super(MailThread, self)._message_route_process(message, message_dict, routes)
def message_post_with_template(self, template_id, **kwargs):
def message_mail_with_source(self, source_ref, **kwargs):
# avoid having message send through `message_post*` methods being implicitly considered as
# mass-mailing
no_massmail = self.with_context(
return super(MailThread, self.with_context(
default_mass_mailing_name=False,
default_mass_mailing_id=False,
)
return super(MailThread, no_massmail).message_post_with_template(template_id, **kwargs)
)).message_mail_with_source(source_ref, **kwargs)
def message_post_with_source(self, source_ref, **kwargs):
# avoid having message send through `message_post*` methods being implicitly considered as
# mass-mailing
return super(MailThread, self.with_context(
default_mass_mailing_name=False,
default_mass_mailing_id=False,
)).message_post_with_source(source_ref, **kwargs)
@api.model
def _routing_handle_bounce(self, email_message, message_dict):
+23 -14
View File
@@ -1046,16 +1046,19 @@ class MassMailing(models.Model):
def action_send_mail(self, res_ids=None):
author_id = self.env.user.partner_id.id
# If no recipient is passed, we don't want to use the recipients of the first
# mailing for all the others
initial_res_ids = res_ids
for mailing in self:
if not initial_res_ids:
res_ids = mailing._get_remaining_recipients()
if not res_ids:
context_user = mailing.user_id or mailing.write_uid or self.env.user
mailing = mailing.with_context(
**self.env['res.users'].with_user(context_user).context_get()
)
mailing_res_ids = res_ids or mailing._get_remaining_recipients()
if not mailing_res_ids:
raise UserError(_('There are no recipients selected.'))
composer_values = {
'auto_delete': not mailing.keep_archives,
# email-mode: keep original message for routing
'auto_delete_keep_log': mailing.reply_to_mode == 'update',
'author_id': author_id,
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
'body': mailing._prepend_preview(mailing.body_html, mailing.preview),
@@ -1068,17 +1071,21 @@ class MassMailing(models.Model):
'record_name': False,
'reply_to_force_new': mailing.reply_to_mode == 'new',
'subject': mailing.subject,
'template_id': None,
'template_id': False,
}
if mailing.reply_to_mode == 'new':
composer_values['reply_to'] = mailing.reply_to
composer = self.env['mail.compose.message'].with_context(active_ids=res_ids).create(composer_values)
extra_context = mailing._get_mass_mailing_context()
composer = composer.with_context(active_ids=res_ids, **extra_context)
composer = self.env['mail.compose.message'].with_context(
active_ids=mailing_res_ids,
default_composition_mode='mass_mail',
**mailing._get_mass_mailing_context()
).create(composer_values)
# auto-commit except in testing mode
auto_commit = not getattr(threading.current_thread(), 'testing', False)
composer._action_send_mail(auto_commit=auto_commit)
composer._action_send_mail(
auto_commit=not getattr(threading.current_thread(), 'testing', False)
)
mailing.write({
'state': 'done',
'sent_date': fields.Datetime.now(),
@@ -1109,8 +1116,10 @@ class MassMailing(models.Model):
def _process_mass_mailing_queue(self):
mass_mailings = self.search([('state', 'in', ('in_queue', 'sending')), '|', ('schedule_date', '<', fields.Datetime.now()), ('schedule_date', '=', False)])
for mass_mailing in mass_mailings:
user = mass_mailing.write_uid or self.env.user
mass_mailing = mass_mailing.with_context(**user.with_user(user).context_get())
context_user = mass_mailing.user_id or mass_mailing.write_uid or self.env.user
mass_mailing = mass_mailing.with_context(
**self.env['res.users'].with_user(context_user).context_get()
)
if len(mass_mailing._get_remaining_recipients()) > 0:
mass_mailing.state = 'sending'
mass_mailing.action_send_mail()
+1 -1
View File
@@ -28,7 +28,7 @@ class MailingTrace(models.Model):
# mass_mailing_sms mass mode specific codes
'sms_blacklist', 'sms_duplicate', 'sms_optout',
* cancel:
* mail: set in get_mail_values in composer, if email is blacklisted
* mail: set in _prepare_mail_values in composer, if email is blacklisted
(mail) or in opt_out / seen list (mass_mailing) or email_to is void
or incorrectly formatted (mass_mailing) - based on mail cancel state
* sms: set in _prepare_mass_sms_trace_values in composer if sms is
+1 -1
View File
@@ -127,7 +127,7 @@ class MassMailCase(MailCase, MockLinkTracker):
self.assertMailMail(partner, state_mapping[status], author=author, content=content, fields_values=fields_values)
# specific if email is False -> could have troubles finding it if several falsy traces
elif not email and status in ('cancel', 'bounce'):
self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[status], content=content, fields_values=fields_values)
self.assertMailMailWId(recipient_trace.mail_mail_id_int, state_mapping[status], author=author, content=content, fields_values=fields_values)
else:
self.assertMailMailWEmails([email], state_mapping[status], author=author, content=content, fields_values=fields_values)
@@ -45,14 +45,14 @@ class TestMassMailValues(MassMailCommon):
composer = self.env['mail.compose.message'].with_user(self.user_marketing).with_context({
'default_composition_mode': 'mass_mail',
'default_model': 'res.partner',
'default_res_id': recipient.id,
'default_res_ids': recipient.ids,
}).create({
'subject': 'Mass Mail Responsive',
'body': 'I am Responsive body',
'mass_mailing_id': mailing.id
})
mail_values = composer.get_mail_values([recipient.id])
mail_values = composer._prepare_mail_values([recipient.id])
body_html = mail_values[recipient.id]['body_html']
self.assertIn('<!DOCTYPE html>', body_html)
@@ -19,72 +19,78 @@ class MailComposeMessage(models.TransientModel):
model = self.env['ir.model']._get(composer.model)
composer.model_is_thread = model.is_mail_thread
def get_mail_values(self, res_ids):
""" Override method that generated the mail content by creating the
mailing.trace values in the o2m of mail_mail, when doing pure
email mass mailing. """
self.ensure_one()
res = super(MailComposeMessage, self).get_mail_values(res_ids)
# use only for allowed models in mass mailing
def _action_send_mail(self, auto_commit=False):
""" Override to generate the mass mailing in case only the name was
given. It is used afterwards for traces generation. """
if self.composition_mode == 'mass_mail' and \
(self.mass_mailing_name or self.mass_mailing_id) and \
self.mass_mailing_name and not self.mass_mailing_id and \
self.model_is_thread:
mass_mailing = self.mass_mailing_id
if not mass_mailing:
mass_mailing = self.env['mailing.mailing'].create(
self._prepare_mailing_values()
mass_mailing = self.env['mailing.mailing'].create(self._prepare_mailing_values())
self.mass_mailing_id = mass_mailing.id
return super()._action_send_mail(auto_commit=auto_commit)
def _prepare_mail_values(self, res_ids):
""" When being in mass mailing mode, add 'mailing.trace' values directly
in the o2m field of mail.mail. """
mail_values_all = super()._prepare_mail_values(res_ids)
# use only for allowed models in mass mailing
if (self.composition_mode != 'mass_mail' or
not self.mass_mailing_id or
not self.model_is_thread):
return mail_values_all
trace_values_all = self._prepare_mail_values_mailing_traces(mail_values_all)
for res_id, mail_values in mail_values_all.items():
if mail_values.get('body_html'):
body = self.env['ir.qweb']._render(
'mass_mailing.mass_mailing_mail_layout',
{'body': mail_values['body_html']},
minimal_qcontext=True,
raise_if_not_found=False
)
self.mass_mailing_id = mass_mailing.id
if body:
mail_values['body_html'] = body
recipients_info = self._process_recipient_values(res)
for res_id in res_ids:
mail_values = res[res_id]
if mail_values.get('body_html'):
body = self.env['ir.qweb']._render(
'mass_mailing.mass_mailing_mail_layout',
{'body': mail_values['body_html']},
minimal_qcontext=True,
raise_if_not_found=False
)
if body:
mail_values['body_html'] = body
trace_vals = {
# if mail_to is void, keep falsy values to allow searching / debugging traces
'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '',
'mass_mailing_id': mass_mailing.id,
'model': self.model,
'res_id': res_id,
}
# propagate failed states to trace when still-born
if mail_values.get('state') == 'cancel':
trace_vals['trace_status'] = 'cancel'
elif mail_values.get('state') == 'exception':
trace_vals['trace_status'] = 'error'
if mail_values.get('failure_type'):
trace_vals['failure_type'] = mail_values['failure_type']
mail_values.update({
'auto_delete': not mass_mailing.keep_archives,
# email-mode: keep original message for routing
'is_notification': mass_mailing.reply_to_mode == 'update',
'mailing_id': mass_mailing.id,
'mailing_trace_ids': [(0, 0, trace_vals)],
})
return res
mail_values.update({
'mailing_id': self.mass_mailing_id.id,
'mailing_trace_ids': [(0, 0, trace_values_all[res_id])] if res_id in trace_values_all else False,
})
return mail_values_all
def _get_done_emails(self, mail_values_dict):
seen_list = super(MailComposeMessage, self)._get_done_emails(mail_values_dict)
seen_list = super()._get_done_emails(mail_values_dict)
if self.mass_mailing_id:
seen_list += self.mass_mailing_id._get_seen_list()
return seen_list
def _get_optout_emails(self, mail_values_dict):
opt_out_list = super(MailComposeMessage, self)._get_optout_emails(mail_values_dict)
opt_out_list = super()._get_optout_emails(mail_values_dict)
if self.mass_mailing_id:
opt_out_list += self.mass_mailing_id._get_opt_out_list()
return opt_out_list
def _prepare_mail_values_mailing_traces(self, mail_values_all):
trace_values_all = dict.fromkeys(mail_values_all.keys(), False)
recipients_info = self._get_recipients_data(mail_values_all)
for res_id, mail_values in mail_values_all.items():
trace_vals = {
# if mail_to is void, keep falsy values to allow searching / debugging traces
'email': recipients_info[res_id]['mail_to'][0] if recipients_info[res_id]['mail_to'] else '',
'mass_mailing_id': self.mass_mailing_id.id,
'model': self.model,
'res_id': res_id,
}
# propagate failed states to trace when still-born
if mail_values.get('state') == 'cancel':
trace_vals['trace_status'] = 'cancel'
elif mail_values.get('state') == 'exception':
trace_vals['trace_status'] = 'error'
if mail_values.get('failure_type'):
trace_vals['failure_type'] = mail_values['failure_type']
trace_values_all[res_id] = trace_vals
return trace_values_all
def _prepare_mailing_values(self):
now = fields.Datetime.now()
return {
@@ -92,7 +98,7 @@ class MailComposeMessage(models.TransientModel):
'body_html': self.body,
'campaign_id': self.campaign_id.id,
'mailing_model_id': self.env['ir.model']._get(self.model).id,
'mailing_domain': self.active_domain,
'mailing_domain': self.res_domain if self.res_domain else f"[('id', 'in', {self.res_ids})]",
'name': self.mass_mailing_name,
'reply_to': self.reply_to if self.reply_to_mode == 'new' else False,
'reply_to_mode': self.reply_to_mode,
@@ -7,7 +7,7 @@
<field name="model">mail.compose.message</field>
<field name="inherit_id" ref="mail.email_compose_message_wizard_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='notify']" position="after">
<xpath expr="//field[@name='auto_delete_keep_log']" position="after">
<field name="model_is_thread" invisible="1"/>
<field name="campaign_id" groups="mass_mailing.group_mass_mailing_campaign"
attrs="{'invisible': ['|', ('composition_mode', '!=', 'mass_mail'), ('model_is_thread', '!=', True)]}"/>
@@ -19,7 +19,7 @@
<attribute name="attrs">
{'invisible': [
'|',
('is_log', '=', False),
('subtype_is_log', '=', False),
'&amp;',
('mass_mailing_name', '!=', ''),
('mass_mailing_name', '!=', False)
@@ -31,7 +31,7 @@
<attribute name="attrs">
{'invisible': [
'|',
('is_log', '=', True),
('subtype_is_log', '=', True),
'&amp;',
('mass_mailing_name', '!=', ''),
('mass_mailing_name', '!=', False)
+2 -1
View File
@@ -212,7 +212,8 @@ class MrpUnbuild(models.Model):
)
self.mo_id.message_post(
body=unbuild_msg,
subtype_id=self.env.ref('mail.mt_note').id)
subtype_xmlid='mail.mt_note',
)
return self.write({'state': 'done'})
def _generate_consume_moves(self):
+11 -7
View File
@@ -48,6 +48,7 @@ class StockRule(models.Model):
productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom))
note_subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
for company_id, productions_values in productions_values_by_company.items():
# create the MO as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example)
productions = self.env['mrp.production'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create(productions_values)
@@ -61,17 +62,20 @@ class StockRule(models.Model):
production.message_post(
body=_('This production order has been created from Replenishment Report.'),
message_type='comment',
subtype_xmlid='mail.mt_note')
subtype_id=note_subtype_id
)
elif orderpoint:
production.message_post_with_view(
production.message_post_with_source(
'mail.message_origin_link',
values={'self': production, 'origin': orderpoint},
subtype_id=self.env.ref('mail.mt_note').id)
render_values={'self': production, 'origin': orderpoint},
subtype_id=note_subtype_id,
)
elif origin_production:
production.message_post_with_view(
production.message_post_with_source(
'mail.message_origin_link',
values={'self': production, 'origin': origin_production},
subtype_id=self.env.ref('mail.mt_note').id)
render_values={'self': production, 'origin': origin_production},
subtype_id=note_subtype_id,
)
return True
@api.model
@@ -81,10 +81,10 @@ class ResCompany(models.Model):
if additional_data:
template_values = json.loads(additional_data)
template_values['flavor_text'] = _("Company auto-completed by Odoo Partner Autocomplete Service")
self.partner_id.message_post_with_view(
self.partner_id.message_post_with_source(
'iap_mail.enrich_company',
values=template_values,
subtype_id=self.env.ref('mail.mt_note').id,
render_values=template_values,
subtype_xmlid='mail.mt_note',
)
return True
@@ -166,10 +166,10 @@ class ResPartner(models.Model):
if partners.additional_info:
template_values = json.loads(partners.additional_info)
template_values['flavor_text'] = _("Partner created by Odoo Partner Autocomplete Service")
partners.message_post_with_view(
partners.message_post_with_source(
'iap_mail.enrich_company',
values=template_values,
subtype_id=self.env.ref('mail.mt_note').id,
render_values=template_values,
subtype_xmlid='mail.mt_note',
)
partners.write({'additional_info': False})
@@ -98,7 +98,7 @@ class PhoneBlackList(models.Model):
for record in new_records:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
subtype_xmlid='mail.mt_note',
)
records += new_records
return records
@@ -123,7 +123,7 @@ class PhoneBlackList(models.Model):
for record in new_records:
record.with_context(mail_create_nosubscribe=True).message_post(
body=message,
subtype_id=self.env.ref('mail.mt_note').id,
subtype_xmlid='mail.mt_note',
)
records += new_records
return records
+7 -1
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
from markupsafe import Markup
from functools import partial
from itertools import groupby
from collections import defaultdict
@@ -923,7 +924,12 @@ class PosOrder(models.Model):
}
def _prepare_mail_values(self, name, client, ticket):
message = _("<p>Dear %s,<br/>Here is your electronic ticket for the %s. </p>") % (client['name'], name)
message = Markup(
_("<p>Dear %(client_name)s,<br/>Here is your electronic ticket for the %(pos_name)s. </p>")
) % {
'client_name': client['name'],
'pos_name': name,
}
return {
'subject': _('Receipt %s', name),
+14 -19
View File
@@ -57,28 +57,24 @@ class PortalShare(models.TransientModel):
record = res_model.browse(rec.res_id)
rec.access_warning = record.access_warning
@api.model
def _get_note(self):
return self.env.ref('mail.mt_note')
def _send_public_link(self, note, partners=None):
def _send_public_link(self, partners=None):
if partners is None:
partners = self.partner_ids
for partner in partners:
share_link = self.resource_ref.get_base_url() + self.resource_ref._get_share_url(redirect=True, pid=partner.id)
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
self.resource_ref.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
self.resource_ref.message_post_with_source(
'portal.portal_share_template',
render_values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
'share_link': share_link},
subject=_("You are invited to access %s", self.resource_ref.display_name),
subtype_id=note.id,
subtype_xmlid='mail.mt_note',
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
partner_ids=partner.ids)
self = self.with_context(lang=saved_lang)
def _send_signup_link(self, note, partners=None):
def _send_signup_link(self, partners=None):
if partners is None:
partners = self.partner_ids.filtered(lambda partner: not partner.user_ids)
for partner in partners:
@@ -87,18 +83,17 @@ class PortalShare(models.TransientModel):
share_link = partner._get_signup_url_for_action(action='/mail/view', res_id=self.res_id, model=self.res_model)[partner.id]
saved_lang = self.env.lang
self = self.with_context(lang=partner.lang)
template = self.env.ref('portal.portal_share_template', False)
self.resource_ref.message_post_with_view(template,
values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
self.resource_ref.message_post_with_source(
'portal.portal_share_template',
render_values={'partner': partner, 'note': self.note, 'record': self.resource_ref,
'share_link': share_link},
subject=_("You are invited to access %s", self.resource_ref.display_name),
subtype_id=note.id,
subtype_xmlid='mail.mt_note',
email_layout_xmlid='mail.mail_notification_light',
partner_ids=[(6, 0, partner.ids)])
partner_ids=partner.ids)
self = self.with_context(lang=saved_lang)
def action_send_mail(self):
note = self._get_note()
signup_enabled = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope') == 'b2c'
if getattr(self.resource_ref, 'access_token', False) or not signup_enabled:
@@ -106,9 +101,9 @@ class PortalShare(models.TransientModel):
else:
partner_ids = self.partner_ids.filtered(lambda x: x.user_ids)
# if partner already user or record has access token send common link in batch to all user
self._send_public_link(note, partner_ids)
self._send_public_link(partner_ids)
# when partner not user send individual mail with signup token
self._send_signup_link(note, self.partner_ids - partner_ids)
self._send_signup_link(self.partner_ids - partner_ids)
# subscribe all recipients so that they receive future communication (better than
# using autofollow as more precise)
@@ -15,12 +15,13 @@ class AccountMove(models.Model):
# subscribe the partner to the invoice
if invoice.partner_id not in invoice.message_partner_ids:
invoice.message_subscribe([invoice.partner_id.id])
comment_subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
for line in invoice.invoice_line_ids:
if line.product_id.email_template_id:
invoice.message_post_with_template(
line.product_id.email_template_id.id,
composition_mode="comment",
email_layout_xmlid="mail.mail_notification_light"
invoice.message_post_with_source(
line.product_id.email_template_id,
email_layout_xmlid="mail.mail_notification_light",
subtype_id=comment_subtype_id,
)
return True
+2 -2
View File
@@ -734,7 +734,7 @@ class Project(models.Model):
project = self[0]
if self.user_has_groups('project.group_project_stages') and 'stage_id' in changes and project.stage_id.mail_template_id:
res['stage_id'] = (project.stage_id.mail_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',
})
@@ -2330,7 +2330,7 @@ class Task(models.Model):
test_task = self[0]
if 'stage_id' in changes and test_task.stage_id.mail_template_id:
res['stage_id'] = (test_task.stage_id.mail_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'
})
-2
View File
@@ -427,7 +427,6 @@
<field name="target">new</field>
<field name="context" eval="{
'default_composition_mode': 'mass_mail',
'default_use_template': False,
}"/>
<field name="binding_model_id" ref="project.model_project_task"/>
<field name="binding_view_types">list</field>
@@ -440,7 +439,6 @@
<field name="target">new</field>
<field name="context" eval="{
'default_composition_mode': 'mass_mail',
'default_use_template': False,
}"/>
<field name="binding_model_id" ref="project.model_project_project"/>
<field name="binding_view_types">list</field>
@@ -39,10 +39,9 @@ class ProjectShareWizard(models.TransientModel):
self.ensure_one()
if self.access_mode == 'edit':
portal_partners = self.partner_ids.filtered('user_ids')
note = self._get_note()
self.resource_ref._add_collaborators(self.partner_ids)
self._send_public_link(note, portal_partners)
self._send_signup_link(note, partners=self.partner_ids - portal_partners)
self._send_public_link(portal_partners)
self._send_signup_link(partners=self.partner_ids - portal_partners)
self.resource_ref.message_subscribe(partner_ids=self.partner_ids.ids)
return {'type': 'ir.actions.act_window_close'}
return super().action_send_mail()
+3 -6
View File
@@ -30,8 +30,7 @@
</t>
</p>
</div></field>
<field name="report_template" ref="report_purchase_quotation"/>
<field name="report_name">RFQ_{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('purchase.report_purchase_quotation'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -69,8 +68,7 @@
<br/><br/>
</p>
</div></field>
<field name="report_template" ref="action_report_purchase_order"/>
<field name="report_name">PO_{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('purchase.action_report_purchase_order'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -109,8 +107,7 @@
<br/><br/>
</p>
</div></field>
<field name="report_template" ref="action_report_purchase_order"/>
<field name="report_name">PO_{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('purchase.action_report_purchase_order'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
+15 -15
View File
@@ -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):
@@ -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):
+5 -3
View File
@@ -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):
-10
View File
@@ -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)
+8 -7
View File
@@ -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
+1 -2
View File
@@ -28,8 +28,7 @@
</t>
</p>
</div></field>
<field name="report_template" ref="action_report_repair_order"/>
<field name="report_name">{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('repair.action_report_repair_order'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
+1 -2
View File
@@ -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',
+2 -4
View File
@@ -39,8 +39,7 @@
</p>
</div>
</field>
<field name="report_template" ref="action_report_saleorder"/>
<field name="report_name">{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('sale.action_report_saleorder'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@@ -234,8 +233,7 @@
</div>
</t>
</div></field>
<field name="report_template" ref="action_report_saleorder"/>
<field name="report_name">{{ (object.name or '').replace('/','_') }}</field>
<field name="report_template_ids" eval="[(4, ref('sale.action_report_saleorder'))]"/>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
+12 -8
View File
@@ -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):
"""
+5 -6
View File
@@ -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)
+8 -9
View File
@@ -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 #
+4 -2
View File
@@ -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")
+8 -2
View File
@@ -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')
+4 -1
View File
@@ -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)
@@ -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
+1 -8
View File
@@ -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,
@@ -6,6 +6,8 @@
<field name="arch" type="xml">
<form>
<group col="1">
<field name="author_id" invisible="1"/>
<field name="lang" invisible="1"/>
<field name="render_model" invisible="1"/>
<field name="order_id" invisible="1"/>
<field name="template_id" invisible="1"/>
+4 -3
View File
@@ -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):

Some files were not shown because too many files have changed in this diff Show More