appy.bin: adapted job.py for Appy >0.8; appy.gen: improved mail notification mechanism.
This commit is contained in:
parent
9b8064b0cd
commit
459a714b76
28
bin/job.py
28
bin/job.py
|
@ -6,12 +6,13 @@
|
|||
<ZopeAdmin> is the userName of the Zope administrator for this instance.
|
||||
<PloneInstancePath> is the path, within Zope, to the Plone Site object (if
|
||||
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
|
||||
"path=", it does not represent an Appy application, but
|
||||
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
|
||||
Appy application, or the method to call on the arbitrary
|
||||
|
@ -21,7 +22,7 @@
|
|||
are supported). Several arguments must be separated by '*'.
|
||||
|
||||
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.
|
||||
'''
|
||||
|
||||
|
@ -58,18 +59,21 @@ else:
|
|||
if not hasattr(user, 'aq_base'):
|
||||
user = user.__of__(app.acl_users)
|
||||
newSecurityManager(None, user)
|
||||
# Get the Plone site
|
||||
ploneSite = app # Initialised with the Zope root object.
|
||||
for elem in plonePath.split('/'):
|
||||
ploneSite = getattr(ploneSite, elem)
|
||||
# Find the root object.
|
||||
rootObject = app # Initialised with the Zope root object.
|
||||
if plonePath:
|
||||
for elem in plonePath.split('/'):
|
||||
rootObject = getattr(rootObject, elem)
|
||||
# If we are in a Appy application, the object on which we will call the
|
||||
# method is the tool within this application.
|
||||
if not appName.startswith('path='):
|
||||
# method is the config object on this root object.
|
||||
if not appName:
|
||||
targetObject = rootObject.data.appy()
|
||||
elif not appName.startswith('path='):
|
||||
objectName = 'portal_%s' % appName.lower()
|
||||
targetObject = getattr(ploneSite, objectName).appy()
|
||||
targetObject = getattr(rootObject, objectName).appy()
|
||||
else:
|
||||
# It can be any object within the Plone site.
|
||||
targetObject = ploneSite
|
||||
# It can be any object.
|
||||
targetObject = rootObject
|
||||
for elem in appName[5:].split('/'):
|
||||
targetObject = getattr(targetObject, elem)
|
||||
# Execute the method on the target object
|
||||
|
|
|
@ -6,6 +6,7 @@ from appy import Object
|
|||
from appy.gen.layout import Table
|
||||
from appy.gen.layout import defaultFieldLayouts
|
||||
from appy.gen.po import PoMessage
|
||||
from appy.gen.mail import sendNotification
|
||||
from appy.gen.utils import GroupDescr, Keywords, getClassName, SomeObjects
|
||||
import appy.pod
|
||||
from appy.pod.renderer import Renderer
|
||||
|
@ -1770,7 +1771,7 @@ class Ref(Type):
|
|||
if not res: return res
|
||||
# We add here specific Ref rules for preventing to show the field under
|
||||
# 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 layoutType == 'edit': return False
|
||||
else: return getattr(obj.aq_base, self.name, None)
|
||||
|
@ -1873,7 +1874,15 @@ class Ref(Type):
|
|||
# Insert p_value into it.
|
||||
uid = value.o.UID()
|
||||
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
|
||||
if not back: self.back.linkObject(value, obj, back=True)
|
||||
|
||||
|
@ -2125,7 +2134,7 @@ class Pod(Type):
|
|||
'contact the system administrator.'
|
||||
DELETE_TEMP_DOC_ERROR = 'A temporary document could not be removed. %s.'
|
||||
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,
|
||||
searchable=False, specificReadPermission=False,
|
||||
specificWritePermission=False, width=None, height=None,
|
||||
|
@ -2634,7 +2643,7 @@ class Transition:
|
|||
performed before calling this method). If p_doAction is False, the
|
||||
action that must normally be executed after the transition has been
|
||||
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
|
||||
False, there will be no trace from this transition triggering in the
|
||||
workflow history. If p_doSay is False, we consider the transition is
|
||||
|
@ -2674,8 +2683,8 @@ class Transition:
|
|||
msg = ''
|
||||
if doAction and self.action: msg = self.executeAction(obj, wf)
|
||||
# Send notifications if needed
|
||||
if doNotify and self.notify and obj.getTool(True).enableNotifications:
|
||||
notifier.sendMail(obj.appy(), self, transitionName, wf)
|
||||
if doNotify and self.notify and obj.getTool(True).mailEnabled:
|
||||
sendNotification(obj.appy(), self, transitionName, wf)
|
||||
# Return a message to the user if needed
|
||||
if not doSay or (transitionName == '_init_'): return
|
||||
if not msg: msg = 'Changes saved.' # XXX Translate
|
||||
|
|
117
gen/mail.py
Normal file
117
gen/mail.py
Normal 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)
|
||||
# ------------------------------------------------------------------------------
|
|
@ -1356,6 +1356,8 @@ class BaseMixin:
|
|||
elif format == 'js':
|
||||
res = text.replace('\r\n', '').replace('\n', '')
|
||||
res = res.replace("'", "\\'")
|
||||
elif format == 'text':
|
||||
res = text.replace('<br/>', '\n')
|
||||
else:
|
||||
res = text
|
||||
return res
|
||||
|
|
19
gen/model.py
19
gen/model.py
|
@ -209,10 +209,10 @@ toolFieldPrefixes = ('defaultValue', 'podTemplate', 'formats', 'resultColumns',
|
|||
'enableAdvancedSearch', 'numberOfSearchColumns',
|
||||
'searchFields', 'optionalFields', 'showWorkflow',
|
||||
'showAllStatesInPhase')
|
||||
defaultToolFields = ('title', 'users', 'groups', 'translations', 'pages',
|
||||
'enableNotifications', 'unoEnabledPython','openOfficePort',
|
||||
'numberOfResultsPerPage', 'listBoxesMaximumWidth',
|
||||
'appyVersion')
|
||||
defaultToolFields = ('title', 'unoEnabledPython','openOfficePort',
|
||||
'numberOfResultsPerPage', 'mailHost', 'mailEnabled',
|
||||
'mailFrom', 'appyVersion', 'users', 'groups',
|
||||
'translations', 'pages')
|
||||
|
||||
class Tool(ModelClass):
|
||||
# In a ModelClass we need to declare attributes in the following list.
|
||||
|
@ -222,11 +222,12 @@ class Tool(ModelClass):
|
|||
# Tool attributes
|
||||
title = gen.String(show=False, page=gen.Page('main', show=False))
|
||||
def validPythonWithUno(self, value): pass # Real method in the wrapper
|
||||
unoEnabledPython = gen.String(group="connectionToOpenOffice",
|
||||
validator=validPythonWithUno)
|
||||
openOfficePort = gen.Integer(default=2002, group="connectionToOpenOffice")
|
||||
unoEnabledPython = gen.String(validator=validPythonWithUno)
|
||||
openOfficePort = gen.Integer(default=2002)
|
||||
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')
|
||||
# Ref(User) will maybe be transformed into Ref(CustomUserClass).
|
||||
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,
|
||||
show='view', back=gen.Ref(attribute='toTool3', show=False),
|
||||
page=gen.Page('pages', show='view'))
|
||||
enableNotifications = gen.Boolean(default=True,
|
||||
page=gen.Page('notifications', show=False))
|
||||
|
||||
@classmethod
|
||||
def _appy_clean(klass):
|
||||
|
|
105
gen/notifier.py
105
gen/notifier.py
|
@ -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
|
||||
# ------------------------------------------------------------------------------
|
|
@ -60,7 +60,8 @@ img {border: 0}
|
|||
.portletSep { border-top: 1px solid #5F7983; margin-top: 2px;}
|
||||
.portletPage { font-style: italic; }
|
||||
.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;}
|
||||
.phaseSelected { background-color: #F4F5F6; }
|
||||
.content { padding: 14px 14px 9px 15px;}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
@ -64,20 +64,20 @@
|
|||
<tal:expanded define="group searchOrGroup;
|
||||
expanded python: request.get(group['labelId'], 'collapsed') == 'expanded'">
|
||||
<tal:comment replace="nothing">Group name</tal:comment>
|
||||
<dt class="portletAppyItem portletGroup">
|
||||
<img align="left" style="cursor:pointer"
|
||||
<dt class="portletGroup">
|
||||
<img align="left" style="cursor:pointer; margin-right: 3px"
|
||||
tal:attributes="id python: '%s_img' % group['labelId'];
|
||||
src python:test(expanded, 'ui/collapse.gif', 'ui/expand.gif');
|
||||
onClick python:'toggleCookie(\'%s\')' % group['labelId']"/>
|
||||
onClick python:'toggleCookie(\'%s\')' % group['labelId']"/>
|
||||
<span tal:replace="group/label"/>
|
||||
</dt>
|
||||
<tal:comment replace="nothing">Group searches</tal:comment>
|
||||
<span tal:attributes="id group/labelId;
|
||||
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']);
|
||||
title search/descr;
|
||||
class python: test(search['name'] == currentSearch, 'portletCurrent', '');"
|
||||
title search/descr;
|
||||
class python: test(search['name'] == currentSearch, 'portletCurrent', '');"
|
||||
tal:content="structure search/label"></a>
|
||||
</dt>
|
||||
</span>
|
||||
|
|
|
@ -129,7 +129,6 @@
|
|||
canWrite python: not appyType['isBack'] and contextObj.allows(appyType['writePermission']);
|
||||
showPlusIcon python: contextObj.mayAddReference(fieldName, folder);
|
||||
atMostOneRef python: (appyType['multiplicity'][1] == 1) and (len(objs)<=1);
|
||||
label python: contextObj.translate('label', field=appyType);
|
||||
addConfirmMsg python: appyType['addConfirm'] and _('%s_addConfirm' % appyType['labelId']) or '';
|
||||
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
|
||||
referenced objects is 1.</tal:comment>
|
||||
<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:noObject condition="not:objs">
|
||||
<td tal:content="python: _('no_ref')"></td>
|
||||
|
|
|
@ -46,9 +46,7 @@ class AbstractWrapper(object):
|
|||
elif name == 'url': return self.o.absolute_url()
|
||||
elif name == 'state': return self.o.State()
|
||||
elif name == 'stateLabel':
|
||||
o = self.o
|
||||
appName = o.getProductConfig().PROJECTNAME
|
||||
return o.translate(o.getWorkflowLabel(), domain=appName)
|
||||
return self.o.translate(self.o.getWorkflowLabel())
|
||||
elif name == 'history':
|
||||
o = self.o
|
||||
key = o.workflow_history.keys()[0]
|
||||
|
|
Loading…
Reference in a new issue