diff --git a/bin/job.py b/bin/job.py index e740428..07c29dc 100644 --- a/bin/job.py +++ b/bin/job.py @@ -6,12 +6,13 @@ is the userName of the Zope administrator for this instance. 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 is the name of the Appy application. If it begins with "path=", it does not represent an Appy application, but the path, within , to any Zope object - (use '/' as folder separator) + (use '/' as folder separator); leave blank if using + appy.gen > 0.8; 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 diff --git a/gen/__init__.py b/gen/__init__.py index 401a43a..477253e 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -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 diff --git a/gen/mail.py b/gen/mail.py new file mode 100644 index 0000000..fc48838 --- /dev/null +++ b/gen/mail.py @@ -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) +# ------------------------------------------------------------------------------ diff --git a/gen/mixins/__init__.py b/gen/mixins/__init__.py index 05e9b2d..3a1cac7 100644 --- a/gen/mixins/__init__.py +++ b/gen/mixins/__init__.py @@ -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('
', '\n') else: res = text return res diff --git a/gen/model.py b/gen/model.py index d3d45ea..d360cf2 100644 --- a/gen/model.py +++ b/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): diff --git a/gen/notifier.py b/gen/notifier.py deleted file mode 100644 index 749725b..0000000 --- a/gen/notifier.py +++ /dev/null @@ -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 -# ------------------------------------------------------------------------------ diff --git a/gen/ui/appy.css b/gen/ui/appy.css index d402dff..726ff74 100644 --- a/gen/ui/appy.css +++ b/gen/ui/appy.css @@ -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;} diff --git a/gen/ui/banner.jpg b/gen/ui/banner.jpg index d6af136..0b5db91 100644 Binary files a/gen/ui/banner.jpg and b/gen/ui/banner.jpg differ diff --git a/gen/ui/portlet.pt b/gen/ui/portlet.pt index f95fa4c..a58f76a 100644 --- a/gen/ui/portlet.pt +++ b/gen/ui/portlet.pt @@ -64,20 +64,20 @@ Group name -
- +   + onClick python:'toggleCookie(\'%s\')' % group['labelId']"/>
Group searches -
+
diff --git a/gen/ui/widgets/ref.pt b/gen/ui/widgets/ref.pt index 013a311..c9e053b 100644 --- a/gen/ui/widgets/ref.pt +++ b/gen/ui/widgets/ref.pt @@ -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 @@ Display a simplified widget if maximum number of referenced objects is 1. - - If there is no object... diff --git a/gen/wrappers/__init__.py b/gen/wrappers/__init__.py index c318345..6e89b45 100644 --- a/gen/wrappers/__init__.py +++ b/gen/wrappers/__init__.py @@ -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]