appy.bin: adapted job.py for Appy >0.8; appy.gen: improved mail notification mechanism.

This commit is contained in:
Gaetan Delannay 2012-05-03 10:51:54 +02:00
parent 9b8064b0cd
commit 459a714b76
11 changed files with 168 additions and 147 deletions

View file

@ -6,12 +6,13 @@
<ZopeAdmin> is the userName of the Zope administrator for this instance. <ZopeAdmin> is the userName of the Zope administrator for this instance.
<PloneInstancePath> is the path, within Zope, to the Plone Site object (if <PloneInstancePath> is the path, within Zope, to the Plone Site object (if
not at the root of the Zope hierarchy, use '/' as not at the root of the Zope hierarchy, use '/' as
folder separator); folder separator); leave blank if using appy.gen > 0.8
<ApplicationName> is the name of the Appy application. If it begins with <ApplicationName> is the name of the Appy application. If it begins with
"path=", it does not represent an Appy application, but "path=", it does not represent an Appy application, but
the path, within <PloneInstancePath>, to any Zope object the path, within <PloneInstancePath>, to any Zope object
(use '/' as folder separator) (use '/' as folder separator); leave blank if using
appy.gen > 0.8;
<ToolMethodName> is the name of the method to call on the tool in this <ToolMethodName> is the name of the method to call on the tool in this
Appy application, or the method to call on the arbitrary Appy application, or the method to call on the arbitrary
@ -21,7 +22,7 @@
are supported). Several arguments must be separated by '*'. are supported). Several arguments must be separated by '*'.
Note that you can also specify several commands, separated with Note that you can also specify several commands, separated with
semicolons (";"). This scripts performes a single commit after all commands semicolons (";"). This scripts performs a single commit after all commands
have been executed. have been executed.
''' '''
@ -58,18 +59,21 @@ else:
if not hasattr(user, 'aq_base'): if not hasattr(user, 'aq_base'):
user = user.__of__(app.acl_users) user = user.__of__(app.acl_users)
newSecurityManager(None, user) newSecurityManager(None, user)
# Get the Plone site # Find the root object.
ploneSite = app # Initialised with the Zope root object. rootObject = app # Initialised with the Zope root object.
for elem in plonePath.split('/'): if plonePath:
ploneSite = getattr(ploneSite, elem) for elem in plonePath.split('/'):
rootObject = getattr(rootObject, elem)
# If we are in a Appy application, the object on which we will call the # If we are in a Appy application, the object on which we will call the
# method is the tool within this application. # method is the config object on this root object.
if not appName.startswith('path='): if not appName:
targetObject = rootObject.data.appy()
elif not appName.startswith('path='):
objectName = 'portal_%s' % appName.lower() objectName = 'portal_%s' % appName.lower()
targetObject = getattr(ploneSite, objectName).appy() targetObject = getattr(rootObject, objectName).appy()
else: else:
# It can be any object within the Plone site. # It can be any object.
targetObject = ploneSite targetObject = rootObject
for elem in appName[5:].split('/'): for elem in appName[5:].split('/'):
targetObject = getattr(targetObject, elem) targetObject = getattr(targetObject, elem)
# Execute the method on the target object # Execute the method on the target object

View file

@ -6,6 +6,7 @@ from appy import Object
from appy.gen.layout import Table from appy.gen.layout import Table
from appy.gen.layout import defaultFieldLayouts from appy.gen.layout import defaultFieldLayouts
from appy.gen.po import PoMessage from appy.gen.po import PoMessage
from appy.gen.mail import sendNotification
from appy.gen.utils import GroupDescr, Keywords, getClassName, SomeObjects from appy.gen.utils import GroupDescr, Keywords, getClassName, SomeObjects
import appy.pod import appy.pod
from appy.pod.renderer import Renderer from appy.pod.renderer import Renderer
@ -1770,7 +1771,7 @@ class Ref(Type):
if not res: return res if not res: return res
# We add here specific Ref rules for preventing to show the field under # We add here specific Ref rules for preventing to show the field under
# some inappropriate circumstances. # some inappropriate circumstances.
if (layoutType == 'edit') and self.add: return False if (layoutType == 'edit') and (self.add or not self.link): return False
if self.isBack: if self.isBack:
if layoutType == 'edit': return False if layoutType == 'edit': return False
else: return getattr(obj.aq_base, self.name, None) else: return getattr(obj.aq_base, self.name, None)
@ -1873,7 +1874,15 @@ class Ref(Type):
# Insert p_value into it. # Insert p_value into it.
uid = value.o.UID() uid = value.o.UID()
if uid not in refs: if uid not in refs:
refs.append(uid) # Where must we insert the object? At the start? At the end?
if callable(self.add):
add = self.callMethod(obj, self.add)
else:
add = self.add
if add == 'start':
refs.insert(0, uid)
else:
refs.append(uid)
# Update the back reference # Update the back reference
if not back: self.back.linkObject(value, obj, back=True) if not back: self.back.linkObject(value, obj, back=True)
@ -2125,7 +2134,7 @@ class Pod(Type):
'contact the system administrator.' 'contact the system administrator.'
DELETE_TEMP_DOC_ERROR = 'A temporary document could not be removed. %s.' DELETE_TEMP_DOC_ERROR = 'A temporary document could not be removed. %s.'
def __init__(self, validator=None, index=None, default=None, def __init__(self, validator=None, index=None, default=None,
optional=False, editDefault=False, show='view', optional=False, editDefault=False, show=('view', 'result'),
page='main', group=None, layouts=None, move=0, indexed=False, page='main', group=None, layouts=None, move=0, indexed=False,
searchable=False, specificReadPermission=False, searchable=False, specificReadPermission=False,
specificWritePermission=False, width=None, height=None, specificWritePermission=False, width=None, height=None,
@ -2634,7 +2643,7 @@ class Transition:
performed before calling this method). If p_doAction is False, the performed before calling this method). If p_doAction is False, the
action that must normally be executed after the transition has been action that must normally be executed after the transition has been
triggered will not be executed. If p_doNotify is False, the triggered will not be executed. If p_doNotify is False, the
notifications (email,...) that must normally be launched after the email notifications that must normally be launched after the
transition has been triggered will not be launched. If p_doHistory is transition has been triggered will not be launched. If p_doHistory is
False, there will be no trace from this transition triggering in the False, there will be no trace from this transition triggering in the
workflow history. If p_doSay is False, we consider the transition is workflow history. If p_doSay is False, we consider the transition is
@ -2674,8 +2683,8 @@ class Transition:
msg = '' msg = ''
if doAction and self.action: msg = self.executeAction(obj, wf) if doAction and self.action: msg = self.executeAction(obj, wf)
# Send notifications if needed # Send notifications if needed
if doNotify and self.notify and obj.getTool(True).enableNotifications: if doNotify and self.notify and obj.getTool(True).mailEnabled:
notifier.sendMail(obj.appy(), self, transitionName, wf) sendNotification(obj.appy(), self, transitionName, wf)
# Return a message to the user if needed # Return a message to the user if needed
if not doSay or (transitionName == '_init_'): return if not doSay or (transitionName == '_init_'): return
if not msg: msg = 'Changes saved.' # XXX Translate if not msg: msg = 'Changes saved.' # XXX Translate

117
gen/mail.py Normal file
View file

@ -0,0 +1,117 @@
'''This package contains functions for sending email notifications.'''
import smtplib
from email.MIMEMultipart import MIMEMultipart
from email.MIMEBase import MIMEBase
from email.MIMEText import MIMEText
from email import Encoders
from email.Header import Header
from appy.shared.utils import sequenceTypes
# ------------------------------------------------------------------------------
def sendMail(tool, to, subject, body, attachments=None):
'''Sends a mail, via p_tool.mailHost, to p_to (a single email address or a
list of email addresses).'''
# Just log things if mail is disabled
fromAddress = tool.mailFrom
if not tool.mailEnabled:
tool.log('Mail disabled: should send mail from %s to %s.' % \
(fromAddress, str(to)))
tool.log('Subject: %s' % subject)
tool.log('Body: %s' % body)
if attachments:
tool.log('%d attachment(s).' % len(attachments))
return
tool.log('Sending mail from %s to %s (subject: %s).' % \
(fromAddress, str(to), subject))
# Create the base MIME message
body = MIMEText(body, 'plain', 'utf-8')
if attachments:
msg = MIMEMultipart()
msg.attach( body )
else:
msg = body
# Add the header values
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = fromAddress
if isinstance(to, basestring):
msg['To'] = to
else:
if len(to) == 1:
msg['To'] = to[0]
else:
msg['To'] = fromAddress
msg['Bcc'] = ', '.join(to)
to = fromAddress
# Add attachments
if attachments:
for fileName, fileContent in attachments:
part = MIMEBase('application', 'octet-stream')
if hasattr(fileContent, 'data'):
# It is a File instance coming from the database
data = fileContent.data
if isinstance(data, basestring):
payLoad = data
else:
payLoad = ''
while data is not None:
payLoad += data.data
data = data.next
else:
payLoad = fileContent
part.set_payload(payLoad)
Encoders.encode_base64(part)
part.add_header('Content-Disposition',
'attachment; filename="%s"' % fileName)
msg.attach(part)
# Send the email
try:
mh = smtplib.SMTP(tool.mailHost)
mh.sendmail(fromAddress, [to], msg.as_string())
mh.quit()
except smtplib.SMTPException, e:
tool.log('Mail sending failed: %s' % str(e))
# ------------------------------------------------------------------------------
def sendNotification(obj, transition, transitionName, workflow):
'''Sends mail about p_transition named p_transitionName, that has been
triggered on p_obj that is controlled by p_workflow.'''
from appy.gen.descriptors import WorkflowDescriptor
wfName = WorkflowDescriptor.getWorkflowName(workflow.__class__)
zopeObj = obj.o
tool = zopeObj.getTool()
mailInfo = transition.notify(workflow, obj)
if not mailInfo[0]: return # Send a mail to nobody.
# mailInfo may be one of the following:
# (to,)
# (to, cc)
# (to, mailSubject, mailBody)
# (to, cc, mailSubject, mailBody)
# "to" and "cc" maybe simple strings (one simple string = one email
# address or one role) or sequences of strings.
# Determine mail subject and body.
if len(mailInfo) <= 2:
# The user didn't mention mail body and subject. We will use those
# defined from i18n labels.
wfHistory = zopeObj.getHistory()
labelPrefix = '%s_%s' % (wfName, transitionName)
tName = obj.translate(labelPrefix)
keys = {'siteUrl': tool.getPath('/').absolute_url(),
'siteTitle': tool.getAppName(),
'objectUrl': zopeObj.absolute_url(),
'objectTitle': zopeObj.Title(),
'transitionName': tName,
'transitionComment': wfHistory[0]['comments']}
mailSubject = obj.translate(labelPrefix + '_mail_subject', keys)
mailBody = obj.translate(labelPrefix + '_mail_body', keys)
else:
mailSubject = mailInfo[-1]
mailBody = mailInfo[-2]
# Determine "to" and "cc".
to = mailInfo[0]
cc = []
if (len(mailInfo) in (2,4)) and mailInfo[1]: cc = mailInfo[1]
if type(to) not in sequenceTypes: to = [to]
if type(cc) not in sequenceTypes: cc = [cc]
# Send the mail
sendMail(tool.appy(), to, mailSubject, mailBody)
# ------------------------------------------------------------------------------

View file

@ -1356,6 +1356,8 @@ class BaseMixin:
elif format == 'js': elif format == 'js':
res = text.replace('\r\n', '').replace('\n', '') res = text.replace('\r\n', '').replace('\n', '')
res = res.replace("'", "\\'") res = res.replace("'", "\\'")
elif format == 'text':
res = text.replace('<br/>', '\n')
else: else:
res = text res = text
return res return res

View file

@ -209,10 +209,10 @@ toolFieldPrefixes = ('defaultValue', 'podTemplate', 'formats', 'resultColumns',
'enableAdvancedSearch', 'numberOfSearchColumns', 'enableAdvancedSearch', 'numberOfSearchColumns',
'searchFields', 'optionalFields', 'showWorkflow', 'searchFields', 'optionalFields', 'showWorkflow',
'showAllStatesInPhase') 'showAllStatesInPhase')
defaultToolFields = ('title', 'users', 'groups', 'translations', 'pages', defaultToolFields = ('title', 'unoEnabledPython','openOfficePort',
'enableNotifications', 'unoEnabledPython','openOfficePort', 'numberOfResultsPerPage', 'mailHost', 'mailEnabled',
'numberOfResultsPerPage', 'listBoxesMaximumWidth', 'mailFrom', 'appyVersion', 'users', 'groups',
'appyVersion') 'translations', 'pages')
class Tool(ModelClass): class Tool(ModelClass):
# In a ModelClass we need to declare attributes in the following list. # In a ModelClass we need to declare attributes in the following list.
@ -222,11 +222,12 @@ class Tool(ModelClass):
# Tool attributes # Tool attributes
title = gen.String(show=False, page=gen.Page('main', show=False)) title = gen.String(show=False, page=gen.Page('main', show=False))
def validPythonWithUno(self, value): pass # Real method in the wrapper def validPythonWithUno(self, value): pass # Real method in the wrapper
unoEnabledPython = gen.String(group="connectionToOpenOffice", unoEnabledPython = gen.String(validator=validPythonWithUno)
validator=validPythonWithUno) openOfficePort = gen.Integer(default=2002)
openOfficePort = gen.Integer(default=2002, group="connectionToOpenOffice")
numberOfResultsPerPage = gen.Integer(default=30) numberOfResultsPerPage = gen.Integer(default=30)
listBoxesMaximumWidth = gen.Integer(default=100) mailHost = gen.String(default='localhost:25')
mailEnabled = gen.Boolean(default=False)
mailFrom = gen.String(default='info@appyframework.org')
appyVersion = gen.String(show=False, layouts='f') appyVersion = gen.String(show=False, layouts='f')
# Ref(User) will maybe be transformed into Ref(CustomUserClass). # Ref(User) will maybe be transformed into Ref(CustomUserClass).
users = gen.Ref(User, multiplicity=(0,None), add=True, link=False, users = gen.Ref(User, multiplicity=(0,None), add=True, link=False,
@ -246,8 +247,6 @@ class Tool(ModelClass):
pages = gen.Ref(Page, multiplicity=(0,None), add=True, link=False, pages = gen.Ref(Page, multiplicity=(0,None), add=True, link=False,
show='view', back=gen.Ref(attribute='toTool3', show=False), show='view', back=gen.Ref(attribute='toTool3', show=False),
page=gen.Page('pages', show='view')) page=gen.Page('pages', show='view'))
enableNotifications = gen.Boolean(default=True,
page=gen.Page('notifications', show=False))
@classmethod @classmethod
def _appy_clean(klass): def _appy_clean(klass):

View file

@ -1,105 +0,0 @@
'''This package contains functions for sending email notifications.'''
# ------------------------------------------------------------------------------
def getEmailAddress(name, email, encoding='utf-8'):
'''Creates a full email address from a p_name and p_email.'''
res = email
if name: res = name.decode(encoding) + ' <%s>' % email
return res
def convertRolesToEmails(users, portal):
'''p_users is a list of emails and/or roles. This function returns the same
list, where all roles have been expanded to emails of users having this
role (more precisely, users belonging to the group Appy created for the
given role).'''
res = []
for mailOrRole in users:
if mailOrRole.find('@') != -1:
# It is an email. Append it directly to the result.
res.append(mailOrRole)
else:
# It is a role. Find the corresponding group (Appy creates
# one group for every role defined in the application).
groupId = mailOrRole + '_group'
group = portal.acl_users.getGroupById(groupId)
if group:
for user in group.getAllGroupMembers():
userMail = user.getProperty('email')
if userMail and (userMail not in res):
res.append(userMail)
return res
# ------------------------------------------------------------------------------
SENDMAIL_ERROR = 'Error while sending mail: %s.'
ENCODING_ERROR = 'Encoding error while sending mail: %s.'
import socket
from appy.shared.utils import sequenceTypes
from appy.gen.descriptors import WorkflowDescriptor
def sendMail(obj, transition, transitionName, workflow):
'''Sends mail about p_transition that has been triggered on p_obj that is
controlled by p_workflow.'''
wfName = WorkflowDescriptor.getWorkflowName(workflow.__class__)
zopeObj = obj.o
tool = zopeObj.getTool()
mailInfo = transition.notify(workflow, obj)
if not mailInfo[0]: return # Send a mail to nobody.
# mailInfo may be one of the following:
# (to,)
# (to, cc)
# (to, mailSubject, mailBody)
# (to, cc, mailSubject, mailBody)
# "to" and "cc" maybe simple strings (one simple string = one email
# address or one role) or sequences of strings.
# Determine mail subject and body.
if len(mailInfo) <= 2:
# The user didn't mention mail body and subject. We will use those
# defined from i18n labels.
wfHistory = zopeObj.getHistory()
labelPrefix = '%s_%s' % (wfName, transitionName)
tName = obj.translate(labelPrefix)
keys = {'siteUrl': tool.getPath('/').absolute_url(),
'siteTitle': tool.getAppName(),
'objectUrl': zopeObj.absolute_url(),
'objectTitle': zopeObj.Title(),
'transitionName': tName,
'transitionComment': wfHistory[0]['comments']}
mailSubject = obj.translate(labelPrefix + '_mail_subject', keys)
mailBody = obj.translate(labelPrefix + '_mail_body', keys)
else:
mailSubject = mailInfo[-1]
mailBody = mailInfo[-2]
# Determine "to" and "cc".
to = mailInfo[0]
cc = []
if (len(mailInfo) in (2,4)) and mailInfo[1]: cc = mailInfo[1]
if type(to) not in sequenceTypes: to = [to]
if type(cc) not in sequenceTypes: cc = [cc]
# Among "to" and "cc", convert all roles to concrete email addresses
to = convertRolesToEmails(to, portal)
cc = convertRolesToEmails(cc, portal)
# Determine "from" address
enc= portal.portal_properties.site_properties.getProperty('default_charset')
fromAddress = getEmailAddress(
portal.getProperty('email_from_name'),
portal.getProperty('email_from_address'), enc)
# Send the mail
i = 0
for recipient in to:
i += 1
try:
if i != 1: cc = []
portal.MailHost.secureSend(mailBody.encode(enc),
recipient.encode(enc), fromAddress.encode(enc),
mailSubject.encode(enc), mcc=cc, charset='utf-8')
except socket.error, sg:
obj.log(SENDMAIL_ERROR % str(sg), type='warning')
break
except UnicodeDecodeError, ue:
obj.log(ENCODING_ERROR % str(ue), type='warning')
break
except Exception, e:
obj.log(SENDMAIL_ERROR % str(e), type='warning')
break
# ------------------------------------------------------------------------------

View file

@ -60,7 +60,8 @@ img {border: 0}
.portletSep { border-top: 1px solid #5F7983; margin-top: 2px;} .portletSep { border-top: 1px solid #5F7983; margin-top: 2px;}
.portletPage { font-style: italic; } .portletPage { font-style: italic; }
.portletGroup { font-variant: small-caps; font-weight: bold; font-style: normal; .portletGroup { font-variant: small-caps; font-weight: bold; font-style: normal;
margin: 0 0 0.2em 0; } margin-top: 0.1em }
.portletSearch { font-size: 90%; font-style: italic; padding-left: 1em}
.phase { border-style: dashed; border-width: thin; padding: 4px 0.6em 5px 1em;} .phase { border-style: dashed; border-width: thin; padding: 4px 0.6em 5px 1em;}
.phaseSelected { background-color: #F4F5F6; } .phaseSelected { background-color: #F4F5F6; }
.content { padding: 14px 14px 9px 15px;} .content { padding: 14px 14px 9px 15px;}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -64,20 +64,20 @@
<tal:expanded define="group searchOrGroup; <tal:expanded define="group searchOrGroup;
expanded python: request.get(group['labelId'], 'collapsed') == 'expanded'"> expanded python: request.get(group['labelId'], 'collapsed') == 'expanded'">
<tal:comment replace="nothing">Group name</tal:comment> <tal:comment replace="nothing">Group name</tal:comment>
<dt class="portletAppyItem portletGroup"> <dt class="portletGroup">
<img align="left" style="cursor:pointer" <img align="left" style="cursor:pointer; margin-right: 3px"
tal:attributes="id python: '%s_img' % group['labelId']; tal:attributes="id python: '%s_img' % group['labelId'];
src python:test(expanded, 'ui/collapse.gif', 'ui/expand.gif'); src python:test(expanded, 'ui/collapse.gif', 'ui/expand.gif');
onClick python:'toggleCookie(\'%s\')' % group['labelId']"/>&nbsp; onClick python:'toggleCookie(\'%s\')' % group['labelId']"/>
<span tal:replace="group/label"/> <span tal:replace="group/label"/>
</dt> </dt>
<tal:comment replace="nothing">Group searches</tal:comment> <tal:comment replace="nothing">Group searches</tal:comment>
<span tal:attributes="id group/labelId; <span tal:attributes="id group/labelId;
style python:test(expanded, 'display:block', 'display:none')"> style python:test(expanded, 'display:block', 'display:none')">
<dt class="portletAppyItem portletSearch portletGroupItem" tal:repeat="search group/searches"> <dt class="portletSearch" tal:repeat="search group/searches">
<a tal:attributes="href python: '%s?className=%s&search=%s' % (queryUrl, rootClass, search['name']); <a tal:attributes="href python: '%s?className=%s&search=%s' % (queryUrl, rootClass, search['name']);
title search/descr; title search/descr;
class python: test(search['name'] == currentSearch, 'portletCurrent', '');" class python: test(search['name'] == currentSearch, 'portletCurrent', '');"
tal:content="structure search/label"></a> tal:content="structure search/label"></a>
</dt> </dt>
</span> </span>

View file

@ -129,7 +129,6 @@
canWrite python: not appyType['isBack'] and contextObj.allows(appyType['writePermission']); canWrite python: not appyType['isBack'] and contextObj.allows(appyType['writePermission']);
showPlusIcon python: contextObj.mayAddReference(fieldName, folder); showPlusIcon python: contextObj.mayAddReference(fieldName, folder);
atMostOneRef python: (appyType['multiplicity'][1] == 1) and (len(objs)&lt;=1); atMostOneRef python: (appyType['multiplicity'][1] == 1) and (len(objs)&lt;=1);
label python: contextObj.translate('label', field=appyType);
addConfirmMsg python: appyType['addConfirm'] and _('%s_addConfirm' % appyType['labelId']) or ''; addConfirmMsg python: appyType['addConfirm'] and _('%s_addConfirm' % appyType['labelId']) or '';
navBaseCall python: 'askRefField(\'%s\',\'%s\',\'%s\',\'%s\',**v**)' % (ajaxHookId, contextObj.absolute_url(), fieldName, innerRef)"> navBaseCall python: 'askRefField(\'%s\',\'%s\',\'%s\',\'%s\',**v**)' % (ajaxHookId, contextObj.absolute_url(), fieldName, innerRef)">
@ -142,9 +141,6 @@
<tal:comment replace="nothing">Display a simplified widget if maximum number of <tal:comment replace="nothing">Display a simplified widget if maximum number of
referenced objects is 1.</tal:comment> referenced objects is 1.</tal:comment>
<table><tr valign="top"> <table><tr valign="top">
<td><span class="appyLabel" tal:condition="python: not innerRef and not appyType['link']"
tal:content="structure label"></span></td>
<tal:comment replace="nothing">If there is no object...</tal:comment> <tal:comment replace="nothing">If there is no object...</tal:comment>
<tal:noObject condition="not:objs"> <tal:noObject condition="not:objs">
<td tal:content="python: _('no_ref')"></td> <td tal:content="python: _('no_ref')"></td>

View file

@ -46,9 +46,7 @@ class AbstractWrapper(object):
elif name == 'url': return self.o.absolute_url() elif name == 'url': return self.o.absolute_url()
elif name == 'state': return self.o.State() elif name == 'state': return self.o.State()
elif name == 'stateLabel': elif name == 'stateLabel':
o = self.o return self.o.translate(self.o.getWorkflowLabel())
appName = o.getProductConfig().PROJECTNAME
return o.translate(o.getWorkflowLabel(), domain=appName)
elif name == 'history': elif name == 'history':
o = self.o o = self.o
key = o.workflow_history.keys()[0] key = o.workflow_history.keys()[0]