2018-01-16 06:58:15 +01:00
# -*- coding: utf-8 -*-
2018-01-16 11:34:37 +01:00
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
2018-01-16 06:58:15 +01:00
import babel
import base64
import copy
import datetime
import dateutil . relativedelta as relativedelta
import logging
import functools
import lxml
from werkzeug import urls
2018-01-16 11:34:37 +01:00
from flectra import _ , api , fields , models , tools
from flectra . exceptions import UserError
from flectra . tools import pycompat
2018-01-16 06:58:15 +01:00
_logger = logging . getLogger ( __name__ )
def format_date ( env , date , pattern = False ) :
if not date :
return ' '
try :
return tools . format_date ( env , date , date_format = pattern )
except babel . core . UnknownLocaleError :
return date
def format_tz ( env , dt , tz = False , format = False ) :
record_user_timestamp = env . user . sudo ( ) . with_context ( tz = tz or env . user . sudo ( ) . tz or ' UTC ' )
timestamp = datetime . datetime . strptime ( dt , tools . DEFAULT_SERVER_DATETIME_FORMAT )
ts = fields . Datetime . context_timestamp ( record_user_timestamp , timestamp )
# Babel allows to format datetime in a specific language without change locale
# So month 1 = January in English, and janvier in French
# Be aware that the default value for format is 'medium', instead of 'short'
# medium: Jan 5, 2016, 10:20:31 PM | 5 janv. 2016 22:20:31
# short: 1/5/16, 10:20 PM | 5/01/16 22:20
if env . context . get ( ' use_babel ' ) :
# Formatting available here : http://babel.pocoo.org/en/latest/dates.html#date-fields
from babel . dates import format_datetime
return format_datetime ( ts , format or ' medium ' , locale = env . context . get ( " lang " ) or ' en_US ' )
if format :
return pycompat . text_type ( ts . strftime ( format ) )
else :
lang = env . context . get ( " lang " )
langs = env [ ' res.lang ' ]
if lang :
langs = env [ ' res.lang ' ] . search ( [ ( " code " , " = " , lang ) ] )
format_date = langs . date_format or ' % B- %d - % Y '
format_time = langs . time_format or ' % I- % M % p '
fdate = pycompat . text_type ( ts . strftime ( format_date ) )
ftime = pycompat . text_type ( ts . strftime ( format_time ) )
return u " %s %s %s " % ( fdate , ftime , ( u ' ( %s ) ' % tz ) if tz else u ' ' )
def format_amount ( env , amount , currency ) :
fmt = " % . {0} f " . format ( currency . decimal_places )
lang = env [ ' res.lang ' ] . _lang_get ( env . context . get ( ' lang ' ) or ' en_US ' )
formatted_amount = lang . format ( fmt , currency . round ( amount ) , grouping = True , monetary = True ) \
. replace ( r ' ' , u ' \N{NO-BREAK SPACE} ' ) . replace ( r ' - ' , u ' \u2011 ' )
pre = post = u ' '
if currency . position == ' before ' :
pre = u ' {symbol} \N{NO-BREAK SPACE} ' . format ( symbol = currency . symbol or ' ' )
else :
post = u ' \N{NO-BREAK SPACE} {symbol} ' . format ( symbol = currency . symbol or ' ' )
return u ' {pre} {0} {post} ' . format ( formatted_amount , pre = pre , post = post )
try :
# We use a jinja2 sandboxed environment to render mako templates.
# Note that the rendering does not cover all the mako syntax, in particular
# arbitrary Python statements are not accepted, and not all expressions are
# allowed: only "public" attributes (not starting with '_') of objects may
# be accessed.
# This is done on purpose: it prevents incidental or malicious execution of
# Python code that may break the security of the server.
from jinja2 . sandbox import SandboxedEnvironment
mako_template_env = SandboxedEnvironment (
block_start_string = " < % " ,
block_end_string = " % > " ,
variable_start_string = " $ { " ,
variable_end_string = " } " ,
comment_start_string = " < %d oc> " ,
comment_end_string = " </ %d oc> " ,
line_statement_prefix = " % " ,
line_comment_prefix = " ## " ,
trim_blocks = True , # do not output newline after blocks
autoescape = True , # XML/HTML automatic escaping
)
mako_template_env . globals . update ( {
' str ' : str ,
' quote ' : urls . url_quote ,
' urlencode ' : urls . url_encode ,
' datetime ' : datetime ,
' len ' : len ,
' abs ' : abs ,
' min ' : min ,
' max ' : max ,
' sum ' : sum ,
' filter ' : filter ,
' reduce ' : functools . reduce ,
' map ' : map ,
' round ' : round ,
# dateutil.relativedelta is an old-style class and cannot be directly
# instanciated wihtin a jinja2 expression, so a lambda "proxy" is
# is needed, apparently.
' relativedelta ' : lambda * a , * * kw : relativedelta . relativedelta ( * a , * * kw ) ,
} )
mako_safe_template_env = copy . copy ( mako_template_env )
mako_safe_template_env . autoescape = False
except ImportError :
_logger . warning ( " jinja2 not available, templating features will not work! " )
class MailTemplate ( models . Model ) :
" Templates for sending email "
_name = " mail.template "
_description = ' Email Templates '
_order = ' name '
@api.model
def default_get ( self , fields ) :
res = super ( MailTemplate , self ) . default_get ( fields )
if res . get ( ' model ' ) :
res [ ' model_id ' ] = self . env [ ' ir.model ' ] . _get ( res . pop ( ' model ' ) ) . id
return res
name = fields . Char ( ' Name ' )
model_id = fields . Many2one ( ' ir.model ' , ' Applies to ' , help = " The type of document this template can be used with " )
model = fields . Char ( ' Related Document Model ' , related = ' model_id.model ' , index = True , store = True , readonly = True )
lang = fields . Char ( ' Language ' ,
help = " Optional translation language (ISO code) to select when sending out an email. "
" If not set, the english version will be used. "
" This should usually be a placeholder expression "
" that provides the appropriate language, e.g. "
" $ {object.partner_id.lang} . " ,
placeholder = " $ {object.partner_id.lang} " )
user_signature = fields . Boolean ( ' Add Signature ' ,
help = " If checked, the user ' s signature will be appended to the text version "
" of the message " )
subject = fields . Char ( ' Subject ' , translate = True , help = " Subject (placeholders may be used here) " )
email_from = fields . Char ( ' From ' ,
help = " Sender address (placeholders may be used here). If not set, the default "
" value will be the author ' s email alias if configured, or email address. " )
use_default_to = fields . Boolean (
' Default recipients ' ,
help = " Default recipients of the record: \n "
" - partner (using id on a partner or the partner_id field) OR \n "
" - email (using email_from or email field) " )
email_to = fields . Char ( ' To (Emails) ' , help = " Comma-separated recipient addresses (placeholders may be used here) " )
partner_to = fields . Char ( ' To (Partners) ' , oldname = ' email_recipients ' ,
help = " Comma-separated ids of recipient partners (placeholders may be used here) " )
email_cc = fields . Char ( ' Cc ' , help = " Carbon copy recipients (placeholders may be used here) " )
reply_to = fields . Char ( ' Reply-To ' , help = " Preferred response address (placeholders may be used here) " )
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 "
" priority one will be used. " )
body_html = fields . Html ( ' Body ' , translate = True , sanitize = False )
report_name = fields . Char ( ' Report Filename ' , translate = 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 ' )
ref_ir_act_window = fields . Many2one ( ' ir.actions.act_window ' , ' Sidebar action ' , readonly = True , copy = False ,
help = " Sidebar action to make this template available on records "
" of the related document model " )
attachment_ids = fields . Many2many ( ' ir.attachment ' , ' email_template_attachment_rel ' , ' email_template_id ' ,
' attachment_id ' , ' Attachments ' ,
help = " You may attach files to this template, to be added to all "
" emails created from this template " )
auto_delete = fields . Boolean ( ' Auto Delete ' , default = True , help = " Permanently delete this email after sending it, to save space " )
2018-02-08 10:21:46 +01:00
keep_days = fields . Integer ( ' Keep days ' , default = - 1 ,
help = " This value defines the no. of days "
" the emails should be recorded "
" in the system: \n -1 = Email will be deleted "
" immediately once it is send \n greater than 0 = Email "
" will be deleted after "
" the no. of days are met. " )
2018-01-16 06:58:15 +01:00
# Fake fields used to implement the placeholder assistant
model_object_field = fields . Many2one ( ' ir.model.fields ' , string = " Field " ,
help = " Select target field from the related document model. \n "
" If it is a relationship field you will be able to select "
" a target field at the destination of the relationship. " )
sub_object = fields . Many2one ( ' ir.model ' , ' Sub-model ' , readonly = True ,
help = " When a relationship field is selected as first field, "
" this field shows the document model the relationship goes to. " )
sub_model_object_field = fields . Many2one ( ' ir.model.fields ' , ' Sub-field ' ,
help = " When a relationship field is selected as first field, "
" this field lets you select the target field within the "
" destination document model (sub-model). " )
null_value = fields . Char ( ' Default Value ' , help = " Optional value to use if the target field is empty " )
copyvalue = fields . Char ( ' Placeholder Expression ' , help = " Final placeholder expression, to be copy-pasted in the desired template field. " )
scheduled_date = fields . Char ( ' Scheduled Date ' , help = " If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible. Jinja2 placeholders may be used. " )
@api.onchange ( ' model_id ' )
def onchange_model_id ( self ) :
# TDE CLEANME: should'nt it be a stored related ?
if self . model_id :
self . model = self . model_id . model
else :
self . model = False
2018-02-08 10:21:46 +01:00
@api.onchange ( ' mail_server_id ' )
def onchange_mail_server_id ( self ) :
if self . mail_server_id and self . mail_server_id . keep_days > 0 \
and self . keep_days < 0 :
self . keep_days = self . mail_server_id . keep_days
else :
self . keep_days = - 1
2018-01-16 06:58:15 +01:00
def build_expression ( self , field_name , sub_field_name , null_value ) :
""" Returns a placeholder expression for use in a template field,
based on the values provided in the placeholder assistant .
: param field_name : main field name
: param sub_field_name : sub field name ( M2O )
: param null_value : default value if the target value is empty
: return : final placeholder expression """
expression = ' '
if field_name :
expression = " $ { object. " + field_name
if sub_field_name :
expression + = " . " + sub_field_name
if null_value :
expression + = " or ' ' ' %s ' ' ' " % null_value
expression + = " } "
return expression
@api.onchange ( ' model_object_field ' , ' sub_model_object_field ' , ' null_value ' )
def onchange_sub_model_object_value_field ( self ) :
if self . model_object_field :
if self . model_object_field . ttype in [ ' many2one ' , ' one2many ' , ' many2many ' ] :
model = self . env [ ' ir.model ' ] . _get ( self . model_object_field . relation )
if model :
self . sub_object = model . id
self . copyvalue = self . build_expression ( self . model_object_field . name , self . sub_model_object_field and self . sub_model_object_field . name or False , self . null_value or False )
else :
self . sub_object = False
self . sub_model_object_field = False
self . copyvalue = self . build_expression ( self . model_object_field . name , False , self . null_value or False )
else :
self . sub_object = False
self . copyvalue = False
self . sub_model_object_field = False
self . null_value = False
@api.multi
def unlink ( self ) :
self . unlink_action ( )
return super ( MailTemplate , self ) . unlink ( )
@api.multi
def copy ( self , default = None ) :
default = dict ( default or { } ,
name = _ ( " %s (copy) " ) % self . name )
return super ( MailTemplate , self ) . copy ( default = default )
@api.multi
def unlink_action ( self ) :
for template in self :
if template . ref_ir_act_window :
template . ref_ir_act_window . sudo ( ) . unlink ( )
return True
@api.multi
def create_action ( self ) :
ActWindowSudo = self . env [ ' ir.actions.act_window ' ] . sudo ( )
view = self . env . ref ( ' mail.email_compose_message_wizard_form ' )
for template in self :
button_name = _ ( ' Send Mail ( %s ) ' ) % template . name
action = ActWindowSudo . create ( {
' name ' : button_name ,
' type ' : ' ir.actions.act_window ' ,
' res_model ' : ' mail.compose.message ' ,
' src_model ' : template . model_id . model ,
' view_type ' : ' form ' ,
' context ' : " { ' default_composition_mode ' : ' mass_mail ' , ' default_template_id ' : %d , ' default_use_template ' : True} " % ( template . id ) ,
' view_mode ' : ' form,tree ' ,
' view_id ' : view . id ,
' target ' : ' new ' ,
' binding_model_id ' : template . model_id . id ,
} )
template . write ( { ' ref_ir_act_window ' : action . id } )
return True
# ----------------------------------------
# RENDERING
# ----------------------------------------
@api.model
def _replace_local_links ( self , html ) :
""" Post-processing of html content to replace local links to absolute
links , using web . base . url as base url . """
if not html :
return html
# form a tree
root = lxml . html . fromstring ( html )
if not len ( root ) and root . text is None and root . tail is None :
html = u ' <div> %s </div> ' % html
root = lxml . html . fromstring ( html , encoding = ' unicode ' )
base_url = self . env [ ' ir.config_parameter ' ] . sudo ( ) . get_param ( ' web.base.url ' )
base = urls . url_parse ( base_url )
def _process_link ( url ) :
new_url = urls . url_parse ( url )
2018-04-05 10:25:40 +02:00
if new_url . scheme and ( new_url . netloc or new_url . scheme == ' mailto ' ) :
2018-01-16 06:58:15 +01:00
return url
return new_url . replace ( scheme = base . scheme , netloc = base . netloc ) . to_url ( )
# check all nodes, replace :
# - img src -> check URL
# - a href -> check URL
for node in root . iter ( ) :
if node . tag == ' a ' and node . get ( ' href ' ) :
node . set ( ' href ' , _process_link ( node . get ( ' href ' ) ) )
elif node . tag == ' img ' and not node . get ( ' src ' , ' data ' ) . startswith ( u ' data ' ) :
node . set ( ' src ' , _process_link ( node . get ( ' src ' ) ) )
html = lxml . html . tostring ( root , pretty_print = False , method = ' html ' , encoding = ' unicode ' )
# this is ugly, but lxml/etree tostring want to put everything in a 'div' that breaks the editor -> remove that
if html . startswith ( u ' <div> ' ) and html . endswith ( u ' </div> ' ) :
html = html [ 5 : - 6 ]
return html
@api.model
def render_post_process ( self , html ) :
html = self . _replace_local_links ( html )
return html
@api.model
def render_template ( self , template_txt , model , res_ids , post_process = False ) :
""" Render the given template text, replace mako expressions ``$ {expr} ``
with the result of evaluating these expressions with an evaluation
context containing :
- ` ` user ` ` : Model of the current user
- ` ` object ` ` : record of the document record this mail is related to
- ` ` context ` ` : the context passed to the mail composition wizard
: param str template_txt : the template text to render
: param str model : model name of the document record this mail is related to .
: param int res_ids : list of ids of document records those mails are related to .
"""
multi_mode = True
if isinstance ( res_ids , pycompat . integer_types ) :
multi_mode = False
res_ids = [ res_ids ]
results = dict . fromkeys ( res_ids , u " " )
# try to load the template
try :
mako_env = mako_safe_template_env if self . env . context . get ( ' safe ' ) else mako_template_env
template = mako_env . from_string ( tools . ustr ( template_txt ) )
except Exception :
_logger . info ( " Failed to load template %r " , template_txt , exc_info = True )
return multi_mode and results or results [ res_ids [ 0 ] ]
# prepare template variables
records = self . env [ model ] . browse ( it for it in res_ids if it ) # filter to avoid browsing [None]
res_to_rec = dict . fromkeys ( res_ids , None )
for record in records :
res_to_rec [ record . id ] = record
variables = {
' format_date ' : lambda date , format = False , context = self . _context : format_date ( self . env , date , format ) ,
' format_tz ' : lambda dt , tz = False , format = False , context = self . _context : format_tz ( self . env , dt , tz , format ) ,
' format_amount ' : lambda amount , currency , context = self . _context : format_amount ( self . env , amount , currency ) ,
' user ' : self . env . user ,
' ctx ' : self . _context , # context kw would clash with mako internals
}
for res_id , record in res_to_rec . items ( ) :
variables [ ' object ' ] = record
try :
render_result = template . render ( variables )
except Exception :
_logger . info ( " Failed to render template %r using values %r " % ( template , variables ) , exc_info = True )
raise UserError ( _ ( " Failed to render template %r using values %r " ) % ( template , variables ) )
if render_result == u " False " :
render_result = u " "
results [ res_id ] = render_result
if post_process :
for res_id , result in results . items ( ) :
results [ res_id ] = self . render_post_process ( result )
return multi_mode and results or results [ res_ids [ 0 ] ]
@api.multi
def get_email_template ( self , res_ids ) :
multi_mode = True
if isinstance ( res_ids , pycompat . integer_types ) :
res_ids = [ res_ids ]
multi_mode = False
if res_ids is None :
res_ids = [ None ]
results = dict . fromkeys ( res_ids , False )
if not self . ids :
return results
self . ensure_one ( )
langs = self . render_template ( self . lang , self . model , res_ids )
for res_id , lang in langs . items ( ) :
if lang :
template = self . with_context ( lang = lang )
else :
template = self
results [ res_id ] = template
return multi_mode and results or results [ res_ids [ 0 ] ]
@api.multi
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 ( )
if self . use_default_to or self . _context . get ( ' tpl_force_default_to ' ) :
default_recipients = self . env [ ' mail.thread ' ] . message_get_default_recipients ( res_model = self . model , res_ids = res_ids )
for res_id , recipients in default_recipients . items ( ) :
results [ res_id ] . pop ( ' partner_to ' , None )
results [ res_id ] . update ( recipients )
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 ' , ' ' ) )
for mail in mails :
partner_id = self . env [ ' res.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
@api.multi
def generate_email ( self , res_ids , fields = None ) :
""" Generates an email from the template for given the given model based on
records given by res_ids .
: param template_id : id of the template to render .
: 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 .
"""
self . ensure_one ( )
multi_mode = True
if isinstance ( res_ids , pycompat . integer_types ) :
res_ids = [ res_ids ]
multi_mode = False
if fields is None :
fields = [ ' subject ' , ' body_html ' , ' email_from ' , ' email_to ' , ' partner_to ' , ' email_cc ' , ' reply_to ' , ' scheduled_date ' ]
res_ids_to_templates = self . get_email_template ( res_ids )
# templates: res_id -> template; template -> res_ids
templates_to_res_ids = { }
for res_id , template in res_ids_to_templates . items ( ) :
templates_to_res_ids . setdefault ( template , [ ] ) . append ( res_id )
results = dict ( )
for template , template_res_ids in templates_to_res_ids . items ( ) :
Template = self . env [ ' mail.template ' ]
# generate fields value for all res_ids linked to the current template
if template . lang :
Template = Template . with_context ( lang = template . _context . get ( ' lang ' ) )
for field in fields :
Template = Template . with_context ( safe = field in { ' subject ' } )
generated_field_values = Template . render_template (
getattr ( template , field ) , template . model , template_res_ids ,
post_process = ( field == ' body_html ' ) )
for res_id , field_value in generated_field_values . items ( ) :
results . setdefault ( res_id , dict ( ) ) [ field ] = field_value
# compute recipients
if any ( field in 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 ]
# body: add user signature, sanitize
if ' body_html ' in fields and template . user_signature :
signature = self . env . user . signature
if signature :
values [ ' body_html ' ] = tools . append_content_to_html ( values [ ' body_html ' ] , signature , plaintext = False )
if values . get ( ' body_html ' ) :
values [ ' body ' ] = tools . html_sanitize ( values [ ' body_html ' ] )
# technical settings
values . update (
mail_server_id = template . mail_server_id . id or False ,
auto_delete = template . auto_delete ,
2018-02-08 10:21:46 +01:00
keep_days = template . keep_days ,
2018-01-16 06:58:15 +01:00
model = template . model ,
res_id = res_id or False ,
attachment_ids = [ attach . id for attach in template . attachment_ids ] ,
)
# 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 = self . render_template ( template . report_name , template . model , res_id )
report = template . report_template
report_service = report . report_name
if report . report_type not in [ ' qweb-html ' , ' qweb-pdf ' ] :
raise UserError ( _ ( ' Unsupported report type %s found. ' ) % report . report_type )
result , format = report . render_qweb_pdf ( [ res_id ] )
# 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 = " . " + format
if not report_name . endswith ( ext ) :
report_name + = ext
attachments . append ( ( report_name , result ) )
results [ res_id ] [ ' attachments ' ] = attachments
return multi_mode and results or results [ res_ids [ 0 ] ]
@api.multi
def send_mail ( self , res_id , force_send = False , raise_exception = False , email_values = None ) :
""" Generates a new mail message for the given template and record,
and schedules it for delivery through the ` ` mail ` ` module ' s scheduler.
: param int res_id : id of the record to render the template with
( model is taken from the template )
: param bool force_send : if True , the generated mail . message is
immediately sent after being created , as if the scheduler
was executed for this message only .
: param dict email_values : if set , the generated mail . message is
updated with given values dict
: returns : id of the mail . message that was created
"""
self . ensure_one ( )
Mail = self . env [ ' mail.mail ' ]
Attachment = self . env [ ' ir.attachment ' ] # TDE FIXME: should remove dfeault_type from context
# create a mail_mail based on values, without attachments
values = self . generate_email ( res_id )
values [ ' recipient_ids ' ] = [ ( 4 , pid ) for pid in values . get ( ' partner_ids ' , list ( ) ) ]
values . update ( email_values or { } )
attachment_ids = values . pop ( ' attachment_ids ' , [ ] )
attachments = values . pop ( ' attachments ' , [ ] )
# add a protection against void email_from
if ' email_from ' in values and not values . get ( ' email_from ' ) :
values . pop ( ' email_from ' )
mail = Mail . create ( values )
# manage attachments
for attachment in attachments :
attachment_data = {
' name ' : attachment [ 0 ] ,
' datas_fname ' : attachment [ 0 ] ,
' datas ' : attachment [ 1 ] ,
' type ' : ' binary ' ,
' res_model ' : ' mail.message ' ,
' res_id ' : mail . mail_message_id . id ,
}
attachment_ids . append ( Attachment . create ( attachment_data ) . id )
if attachment_ids :
values [ ' attachment_ids ' ] = [ ( 6 , 0 , attachment_ids ) ]
mail . write ( { ' attachment_ids ' : [ ( 6 , 0 , attachment_ids ) ] } )
if force_send :
mail . send ( raise_exception = raise_exception )
return mail . id # TDE CLEANME: return mail + api.returns ?