[CMF-checkins] CVS: CMF/CMFCollector - CollectorIssue.py:1.7

Ken Manheimer klm@zope.com
Sun, 14 Oct 2001 22:57:22 -0400


Update of /cvs-repository/CMF/CMFCollector
In directory cvs.zope.org:/tmp/cvs-serv17185

Modified Files:
	CollectorIssue.py 
Log Message:
Email provisions; issue transcript view linkifies URLs and Upload names.
comment_number => action_number.

.__init__(): Use .do_action() rather than duplicating efforts.
(Crucial with addition of email, and good to normalize transaction
edit...)

.CookedBody(): Supplement transcript's Document.CookedBody() with ...

._cook_links(): Cook text so URLs and artifact references are hrefs,
or just for artifact references, depending on whether it's for web
display or email.

._send_update_notice(): Derive all the details for passing to
collector_issue_notice.dtml() skin method.

.do_action(): Include details in transcript about supporter assignment
and status changes, uploads, etc; and call ._send_update_notice().

._supporters_diff(): Helper to identify supporters roster changes, for
reporting.

.confidential(): Fetch workflow 'confidential' status.


=== CMF/CMFCollector/CollectorIssue.py 1.6 => 1.7 ===
 from CollectorPermissions import *
 
+urlchars  = (r'[A-Za-z0-9/:@_%~#=&\.\-\?]+')
+nonpuncurlchars  = (r'[A-Za-z0-9/:@_%~#=&\-]')
+url       = (r'["=]?((http|https|ftp|mailto|file|about):%s%s)'
+             % (urlchars, nonpuncurlchars))
+urlexp    = re.compile(url)
+UPLOAD_PREFIX = "Uploaded: "
+uploadexp = re.compile('(%s)([^<,\n]*)([<,\n])' % UPLOAD_PREFIX, re.MULTILINE)
+
 DEFAULT_TRANSCRIPT_FORMAT = 'stx'
 
 factory_type_information = (
@@ -45,7 +53,7 @@
      'allowed_content_types': ('Collector Issue Transcript', 'File', 'Image'), 
      'immediate_view': 'collector_edit_form',
      'actions': ({'id': 'view',
-                  'name': 'Transcript',
+                  'name': 'View Issue',
                   'action': 'collector_issue_contents',
                   'permissions': (ViewCollector,)},
                  {'id': 'followup',
@@ -80,7 +88,7 @@
 
     comment_delimiter = "<hr solid id=comment_delim>"
 
-    comment_number = 1
+    action_number = 0
 
     ACTIONS_ORDER = ['Accept', 'Resign', 'Assign',
                      'Resolve', 'Reject', 'Defer'] 
@@ -88,7 +96,7 @@
     def __init__(self,
                  id, container,
                  title='', description='',
-                 submitter_id=None, submitter_name=None, submitter_email=None,
+                 submitter_id=None, submitter_name=None,
                  kibitzers=None,
                  topic=None, classification=None,
                  security_related=0,
@@ -101,6 +109,7 @@
         """ """
 
         SkinnedFolder.__init__(self, id, title)
+
         # Take care of standard metadata:
         DefaultDublinCoreImpl.__init__(self,
                                        title=title, description=description,
@@ -110,12 +119,6 @@
             modification_date = self.creation_date
         self.modification_date = modification_date
 
-        # Acquisition-wrapped self so, eg, invokeFactory can find stuff.
-        contained = self.__of__(container)
-        attach_msg = contained._process_file(file, fileid, filetype,
-                                             description)
-        contained._create_transcript(description, attach_msg)
-
         user = getSecurityManager().getUser()
         if submitter_id is None:
             self.submitter_id = str(user)
@@ -128,9 +131,6 @@
             # XXX We're being cavalier about stashing the full_name.
             user.full_name = submitter_name
         self.submitter_name = submitter_name
-        if submitter_email is None and hasattr(user, 'email'):
-            submitter_email = user.email
-        self.submitter_email = submitter_email
 
         if kibitzers is None:
             kibitzers = ()
@@ -145,10 +145,35 @@
         self.reported_version = reported_version
         self.other_version_info = other_version_info
 
-        self.edited = 0
+        # Following is acquisition-wrapped so, eg, invokeFactory can work.
+        contained = self.__of__(container)
+        contained.do_action('Request', description, None,
+                            file, fileid, filetype)
 
         return self
 
+    security.declareProtected(CMFCorePermissions.View, 'CookedBody')
+    def CookedBody(self):
+        """Massage the transcript's cooked body to linkify obvious things."""
+        return self._cook_links(self.get_transcript().CookedBody(stx_level=3))
+
+    def _cook_links(self, text, email=0):
+        """Cook text so URLs and artifact references are hrefs.
+
+        If optional arg 'email' is true, then we just provide urls for uploads
+        (assuming the email client will take care of linkifying URLs)."""
+        if not email:
+            text = urlexp.sub(r'<a href=\1>\1</a>', text)
+            text = uploadexp.sub(r'\1<a href="%s/\2/view">\2</a>\3'
+                                 % self.absolute_url(),
+                                 text)
+        else:
+            text = uploadexp.sub(r'\1 "\2" (%s/\2/view)' % self.absolute_url(),
+                                 text)
+            text = string.replace(text, "<hr>", "-" * 62)
+        return text
+        
+
     security.declareProtected(EditCollectorIssue, 'edit')
     def edit(self, comment=None,
              text=None,
@@ -190,7 +215,6 @@
             self.reported_version = reported_version
         if other_version_info is not None:
             self.other_version_info = other_version_info
-        self.edited = 1
 
     security.declareProtected(CMFCorePermissions.View, 'get_transcript')
     def get_transcript(self):
@@ -201,9 +225,14 @@
                   assignees=None, file=None, fileid=None, filetype=None):
         """Execute an action, adding comment to the transcript."""
 
+        action_number = self.action_number = self.action_number + 1
         username = str(getSecurityManager().getUser())
 
-        if string.lower(action) != 'comment':
+        orig_supporters = self.assigned_to()
+        # Strip off '_confidential' from status, if any.
+        orig_status = string.split(self.status(), '_')[0]
+
+        if string.lower(action)  not in ['comment', 'request']:
             # Confirm against portal actions tool:
             if action not in self._valid_actions():
                 raise 'Unauthorized', "Invalid action '%s'" % action
@@ -214,29 +243,154 @@
                                              username=username,
                                              assignees=assignees)
 
+        new_status = string.split(self.status(), '_')[0]
+
+        if string.lower(action) == 'request':
+            self._create_transcript(comment)
         transcript = self.get_transcript()
-        attach_msg = self._process_file(file, fileid, filetype, comment)
-        self.comment_number = self.comment_number + 1
+
         entry_leader = self._entry_header(action, username) + "\n\n"
+        (uploadmsg, fileid) = self._process_file(file, fileid,
+                                                  filetype, comment)
+        additions, removals = self._supporters_diff(orig_supporters)
+        changes = []
+        if orig_status and (new_status != orig_status):
+            changes.append(" Status: %s => %s\n"
+                           % (orig_status, new_status))
+        if additions or removals:
+            if additions:
+                changes.append(" Supporters added: %s\n"
+                               % ", ".join(additions))
+            if removals:
+                changes.append(" Supporters removed: %s\n" %
+                               ", ".join(removals))
+        if changes:
+            changesstr = "\n".join(changes) + "\n"
+            if uploadmsg:
+                uploadmsg = " " + uploadmsg + "\n\n"
+            else:
+                changesstr = changesstr + "\n"
+        else:
+            changesstr = ''
         transcript._edit('stx',
                          entry_leader
-                         + attach_msg
+                         + changesstr
+                         + uploadmsg
                          + util.process_comment(string.strip(comment))
-                         + "\n<hr>\n"
+                         + ((action_number > 1) and "<hr>\n" or '')
                          + transcript.EditableBody())
+        self._send_update_notice(action, username, transcript.EditableBody(),
+                                 orig_status, additions, removals,
+                                 file=file, fileid=fileid)
+
+    def _supporters_diff(self, orig_supporters):
+        """Indicate supporter roster changes, relative to orig_supporters.
+
+        Return (list-of-added-supporters, list-of-removed-supporters)."""
+        plus, minus = self.assigned_to(), []
+        for supporter in orig_supporters:
+            if supporter in plus: plus.remove(supporter)
+            else: minus.append(supporter)
+        return (plus, minus)
+
+    def _send_update_notice(self, action, actor, comment,
+                            orig_status, additions, removals,
+                            file, fileid):
+        """Send email notification about issue event to relevant parties."""
+
+        action = string.capitalize(string.split(action, '_')[0])
+        new_status = string.split(self.status(), '_')[0]
+
+        recipients = []
+        didids = []; gotemails = []     # Duplicate prevention.
+
+        # Who to notify:
+        # We want to noodge only assigned supporters while it's being worked
+        # on, ie assigned supporters are corresponding about it, otherwise
+        # everyone gets updates:
+        # - Requester always
+        # - All supporters:
+        #   - When an issue is any state besides accepted
+        #   - When an issue is being accepted
+        #   - When an issue is accepted and moving to another state
+        # - Relevant supporters when an issue is accepted:
+        #   - those supporters assigned to the issue
+        #   - any supporters being removed from or added to an issue.
+        candidates = [self.submitter_id]
+        if not ('accepted' == string.lower(new_status) == 
+                string.lower(orig_status)):
+            candidates.extend(self.aq_parent.supporters)
+        else:
+            candidates.extend(self.assigned_to())
+            if removals:
+                # Notify supporters being removed from the issue (confirms 
+                # their action, if they're resigning, and informs them if
+                # manager is deassigning them).
+                candidates.extend(removals)
+
+        for userid in candidates:
+            if userid in didids:
+                continue
+            didids.append(userid)
+            name, email = util.get_email_fullname(self, userid)
+            if email:
+                if email in gotemails:
+                    continue
+                gotemails.append(email)
+                recipients.append((name, email))
+
+        if recipients:
+            to = ", ".join(["%s <%s>" % (name, email)
+                            for name, email in recipients])
+            title = self.aq_parent.title[:50]
+            if '.' in title or ',' in title:
+                title = '"%s"' % title
+            sender = self.aq_parent.email
+            mgrfrom = ("For %s by Collector Manager <%s>" % (actor, sender))
+            if self.abbrev:
+                subject = "[%s]" % self.abbrev
+            else: subject = "[Collector]"
+            subject = ('%s #%s/%s %s "%s"'
+                       % (subject, self.id, self.action_number,
+                          string.capitalize(action), self.title))
+
+            body = self._cook_links(self.get_transcript().text, email=1)
+            cin = self.collector_issue_notice
+            message = cin(sender=mgrfrom,
+                          recipients=to,
+                          subject=subject,
+                          issue_id=self.id,
+                          action=action,
+                          actor=actor,
+                          number=self.action_number,
+                          security_related=self.security_related,
+                          confidential=self.confidential(),
+                          title=self.title,
+                          submitter_name=self.submitter_name,
+                          status=new_status,
+                          klass=self.classification,
+                          topic=self.topic,
+                          importance=self.importance,
+                          severity=self.severity,
+                          issue_url=self.absolute_url(),
+                          body=body,
+                          candidates=candidates)
+            mh = self.MailHost
+            mh.send(message)
 
     def _process_file(self, file, fileid, filetype, comment):
-        """Attach file to issue if it is substantial (has a name).
+        """Upload file to issue if it is substantial (has a name).
 
         Return a message describing the file, for transcript inclusion."""
         if file and file.filename:
             if not fileid:
                 fileid = string.split(string.split(file.filename, '/')[-1],
                                       '\\')[-1]
-            attachment = self._add_artifact(fileid, filetype, comment, file)
-            return " - Attachment: %s\n\n" % fileid
+            upload = self._add_artifact(fileid, filetype, comment, file)
+            uploadmsg = "%s%s\n\n" % (UPLOAD_PREFIX, fileid)
+            return (uploadmsg, fileid)
         else:
-            return ''
+            return ('', '')
 
     def _add_artifact(self, id, type, description, file):
         """Add new artifact, and return object."""
@@ -262,22 +416,25 @@
     def status(self):
         """Return the current status according to workflow."""
         wftool = getToolByName(self, 'portal_workflow')
-        return wftool.getInfoFor(self, 'state', '??')
+        return wftool.getInfoFor(self, 'state', 'Pending')
+
+    security.declareProtected(CMFCorePermissions.View, 'confidential')
+    def confidential(self):
+        """True if workflow has the issue marked confidential.
+
+        (Security_related issues start confidential, and are made
+        unconfidential on any completion.)"""
+        wftool = getToolByName(self, 'portal_workflow')
+        return wftool.getInfoFor(self, 'state', 'confidential')
 
     def _create_transcript(self, description,
-                           text_format=DEFAULT_TRANSCRIPT_FORMAT,
-                           attachment_msg=''):
+                           text_format=DEFAULT_TRANSCRIPT_FORMAT):
         """Create events and comments transcript, with initial entry."""
 
         user = getSecurityManager().getUser()
         addDocument(self, TRANSCRIPT_NAME, description=description)
         it = self.get_transcript()
         it._setPortalTypeName('Collector Issue Transcript')
-        text = ("%s\n\n%s %s " %
-                (self._entry_header('Request', user),
-                 description,
-                 attachment_msg))
-        it._edit(text_format=text_format, text=text)
         it.title = self.title
 
     def _entry_header(self, type, user, prefix="= ", suffix=" ="):
@@ -285,8 +442,8 @@
         # Ideally this would be a skin method (probly python script), but i
         # don't know how to call it from the product, sigh.
         t = string.capitalize(type)
-        if self.comment_number:
-            lead = t + " - Entry #" + str(self.comment_number)
+        if self.action_number:
+            lead = t + " - Entry #" + str(self.action_number)
         else:
             lead = t
 
@@ -300,6 +457,7 @@
 
     def _valid_actions(self):
         """Return actions valid according to workflow and application logic."""
+
         pa = getToolByName(self, 'portal_actions', None)
         return [entry['name']
                 for entry in pa.listFilteredActionsFor(self)['issue_workflow']]
@@ -413,7 +571,6 @@
                       description='',
                       submitter_id=None,
                       submitter_name=None,
-                      submitter_email=None,
                       kibitzers=None,
                       topic=None,
                       classification=None,
@@ -434,7 +591,6 @@
                         description=description,
                         submitter_id=submitter_id,
                         submitter_name=submitter_name,
-                        submitter_email=submitter_email,
                         kibitzers=kibitzers,
                         topic=topic,
                         classification=classification,