Code

More informative error message
[roundup.git] / roundup / roundupdb.py
index 3821421a5d0fa32194bfe3247164d392d3e04580..3f5620b513513807154e04a650e428bc1fac6c84 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.46 2002-02-25 14:22:59 grubert Exp $
+# $Id: roundupdb.py,v 1.59 2002-06-18 03:55:25 dman13 Exp $
 
 __doc__ = """
 Extending hyperdb with types specific to issue-tracking.
 """
 
 import re, os, smtplib, socket, copy, time, random
-import mimetools, MimeWriter, cStringIO
-import base64, mimetypes
+import MimeWriter, cStringIO
+import base64, quopri, mimetypes
+# if available, use the 'email' module, otherwise fallback to 'rfc822'
+try :
+    from email.Utils import dump_address_pair as straddr
+except ImportError :
+    from rfc822 import dump_address_pair as straddr
 
 import hyperdb, date
 
@@ -42,7 +47,7 @@ def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
     return m.group(1), m.group(2)
 
 
-def extractUserFromList(users):
+def extractUserFromList(userClass, users):
     '''Given a list of users, try to extract the first non-anonymous user
        and return that user, otherwise return None
     '''
@@ -50,7 +55,7 @@ def extractUserFromList(users):
         # make sure we don't match the anonymous or admin user
         for user in users:
             if user == '1': continue
-            if self.user.get(user, 'username') == 'anonymous': continue
+            if userClass.get(user, 'username') == 'anonymous': continue
             # first valid match will do
             return user
         # well, I guess we have no choice
@@ -73,20 +78,22 @@ class Database:
         (realname, address) = address
 
         # try a straight match of the address
-        user = extractUserFromList(self.user.stringFind(address=address))
+        user = extractUserFromList(self.user,
+            self.user.stringFind(address=address))
         if user is not None: return user
 
         # try the user alternate addresses if possible
         props = self.user.getprops()
         if props.has_key('alternate_addresses'):
-            users = self.user.filter({'alternate_addresses': address},
+            users = self.user.filter(None, {'alternate_addresses': address},
                 [], [])
-            user = extractUserFromList(users)
+            user = extractUserFromList(self.user, users)
             if user is not None: return user
 
         # try to match the username to the address (for local
         # submissions where the address is empty)
-        user = extractUserFromList(self.user.stringFind(username=address))
+        user = extractUserFromList(self.user,
+            self.user.stringFind(username=address))
 
         # couldn't match address or username, so create a new user
         if create:
@@ -113,11 +120,9 @@ class Class(hyperdb.Class):
         """
         if propvalues.has_key('creation') or propvalues.has_key('activity'):
             raise KeyError, '"creation" and "activity" are reserved'
-        for audit in self.auditors['create']:
-            audit(self.db, self, None, propvalues)
+        self.fireAuditors('create', None, propvalues)
         nodeid = hyperdb.Class.create(self, **propvalues)
-        for react in self.reactors['create']:
-            react(self.db, self, nodeid, None)
+        self.fireReactors('create', nodeid, None)
         return nodeid
 
     def set(self, nodeid, **propvalues):
@@ -126,8 +131,7 @@ class Class(hyperdb.Class):
         """
         if propvalues.has_key('creation') or propvalues.has_key('activity'):
             raise KeyError, '"creation" and "activity" are reserved'
-        for audit in self.auditors['set']:
-            audit(self.db, self, nodeid, propvalues)
+        self.fireAuditors('set', nodeid, propvalues)
         # Take a copy of the node dict so that the subsequent set
         # operation doesn't modify the oldvalues structure.
         try:
@@ -139,18 +143,15 @@ class Class(hyperdb.Class):
             # with no intervening commit()
             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
         hyperdb.Class.set(self, nodeid, **propvalues)
-        for react in self.reactors['set']:
-            react(self.db, self, nodeid, oldvalues)
+        self.fireReactors('set', nodeid, oldvalues)
 
     def retire(self, nodeid):
         """These operations trigger detectors and can be vetoed.  Attempts
         to modify the "creation" or "activity" properties cause a KeyError.
         """
-        for audit in self.auditors['retire']:
-            audit(self.db, self, nodeid, None)
+        self.fireAuditors('retire', nodeid, None)
         hyperdb.Class.retire(self, nodeid)
-        for react in self.reactors['retire']:
-            react(self.db, self, nodeid, None)
+        self.fireReactors('retire', nodeid, None)
 
     def get(self, nodeid, propname, default=_marker, cache=1):
         """Attempts to get the "creation" or "activity" properties should
@@ -206,6 +207,12 @@ class Class(hyperdb.Class):
         if detector not in l:
             self.auditors[event].append(detector)
 
+    def fireAuditors(self, action, nodeid, newvalues):
+        """Fire all registered auditors.
+        """
+        for audit in self.auditors[action]:
+            audit(self.db, self, nodeid, newvalues)
+
     def react(self, event, detector):
         """Register a detector
         """
@@ -213,6 +220,11 @@ class Class(hyperdb.Class):
         if detector not in l:
             self.reactors[event].append(detector)
 
+    def fireReactors(self, action, nodeid, oldvalues):
+        """Fire all registered reactors.
+        """
+        for react in self.reactors[action]:
+            react(self.db, self, nodeid, oldvalues)
 
 class FileClass(Class):
     def create(self, **propvalues):
@@ -295,7 +307,7 @@ class IssueClass(Class):
         appended to the "messages" field of the specified issue.
         """
 
-    def sendmessage(self, nodeid, msgid, change_note):
+    def nosymessage(self, nodeid, msgid, oldvalues):
         """Send a message to the members of an issue's nosy list.
 
         The message is sent only to users on the nosy list who are not
@@ -305,7 +317,6 @@ class IssueClass(Class):
         """
         users = self.db.user
         messages = self.db.msg
-        files = self.db.file
 
         # figure the recipient ids
         sendto = []
@@ -317,9 +328,6 @@ class IssueClass(Class):
         # figure the author's id, and indicate they've received the message
         authid = messages.get(msgid, 'author')
 
-        # get the current nosy list, we'll need it
-        nosy = self.get(nodeid, 'nosy')
-
         # possibly send the message to the author, as long as they aren't
         # anonymous
         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
@@ -328,6 +336,7 @@ class IssueClass(Class):
         r[authid] = 1
 
         # now figure the nosy people who weren't recipients
+        nosy = self.get(nodeid, 'nosy')
         for nosyid in nosy:
             # Don't send nosy mail to the anonymous user (that user
             # shouldn't appear in the nosy list, but just in case they
@@ -340,13 +349,39 @@ class IssueClass(Class):
                 sendto.append(nosyid)
                 recipients.append(nosyid)
 
-        # no new recipients
-        if not sendto:
-            return
+        # generate a change note
+        if oldvalues:
+            note = self.generateChangeNote(nodeid, oldvalues)
+        else:
+            note = self.generateCreateNote(nodeid)
+
+        # we have new recipients
+        if sendto:
+            # map userids to addresses
+            sendto = [users.get(i, 'address') for i in sendto]
+
+            # update the message's recipients list
+            messages.set(msgid, recipients=recipients)
+
+            # send the message
+            self.send_message(nodeid, msgid, note, sendto)
+
+    # XXX backwards compatibility - don't remove
+    sendmessage = nosymessage
+
+    def send_message(self, nodeid, msgid, note, sendto):
+        '''Actually send the nominated message from this node to the sendto
+           recipients, with the note appended.
+        '''
+        users = self.db.user
+        messages = self.db.msg
+        files = self.db.file
 
         # determine the messageid and inreplyto of the message
         inreplyto = messages.get(msgid, 'inreplyto')
         messageid = messages.get(msgid, 'messageid')
+
+        # make up a messageid if there isn't one (web edit)
         if not messageid:
             # this is an old message that didn't get a messageid, so
             # create one
@@ -354,20 +389,17 @@ class IssueClass(Class):
                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
             messages.set(msgid, messageid=messageid)
 
-        # update the message's recipients list
-        messages.set(msgid, recipients=recipients)
-
         # send an email to the people who missed out
-        sendto = [users.get(i, 'address') for i in sendto]
         cn = self.classname
         title = self.get(nodeid, 'title') or '%s message copy'%cn
         # figure author information
+        authid = messages.get(msgid, 'author')
         authname = users.get(authid, 'realname')
         if not authname:
             authname = users.get(authid, 'username')
         authaddr = users.get(authid, 'address')
         if authaddr:
-            authaddr = ' <%s>'%authaddr
+            authaddr = " <%s>" % straddr( ('',authaddr) )
         else:
             authaddr = ''
 
@@ -389,25 +421,35 @@ class IssueClass(Class):
         m.append(messages.get(msgid, 'content'))
 
         # add the change note
-        if change_note:
-            m.append(change_note)
+        if note:
+            m.append(note)
 
         # put in roundup's signature
         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
             m.append(self.email_signature(nodeid, msgid))
 
+        # encode the content as quoted-printable
+        content = cStringIO.StringIO('\n'.join(m))
+        content_encoded = cStringIO.StringIO()
+        quopri.encode(content, content_encoded, 0)
+        content_encoded = content_encoded.getvalue()
+
         # get the files for this message
         message_files = messages.get(msgid, 'files')
 
+        # make sure the To line is always the same (for testing mostly)
+        sendto.sort()
+
         # create the message
         message = cStringIO.StringIO()
         writer = MimeWriter.MimeWriter(message)
         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
         writer.addheader('To', ', '.join(sendto))
-        writer.addheader('From', '%s <%s>'%(authname,
-            self.db.config.ISSUE_TRACKER_EMAIL))
-        writer.addheader('Reply-To', '%s <%s>'%(self.db.config.INSTANCE_NAME,
-            self.db.config.ISSUE_TRACKER_EMAIL))
+        writer.addheader('From', straddr(
+                              (authname, self.db.config.ISSUE_TRACKER_EMAIL) ) )
+        writer.addheader('Reply-To', straddr( 
+                                        (self.db.config.INSTANCE_NAME,
+                                         self.db.config.ISSUE_TRACKER_EMAIL) ) )
         writer.addheader('MIME-Version', '1.0')
         if messageid:
             writer.addheader('Message-Id', messageid)
@@ -421,8 +463,9 @@ class IssueClass(Class):
         if message_files:
             part = writer.startmultipartbody('mixed')
             part = writer.nextpart()
+            part.addheader('Content-Transfer-Encoding', 'quoted-printable')
             body = part.startbody('text/plain')
-            body.write('\n'.join(m))
+            body.write(content_encoded)
             for fileid in message_files:
                 name = files.get(fileid, 'name')
                 mime_type = files.get(fileid, 'type')
@@ -448,13 +491,15 @@ class IssueClass(Class):
                     body.write(base64.encodestring(content))
             writer.lastpart()
         else:
+            writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
             body = writer.startbody('text/plain')
-            body.write('\n'.join(m))
+            body.write(content_encoded)
 
         # now try to send the message
         if SENDMAILDEBUG:
             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
-                self.db.config.ADMIN_EMAIL,', '.join(sendto),message.getvalue()))
+                self.db.config.ADMIN_EMAIL,
+                ', '.join(sendto),message.getvalue()))
         else:
             try:
                 # send the message as admin so bounces are sent there
@@ -472,12 +517,24 @@ class IssueClass(Class):
     def email_signature(self, nodeid, msgid):
         ''' Add a signature to the e-mail with some useful information
         '''
-        web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid
-        email = '"%s" <%s>'%(self.db.config.INSTANCE_NAME,
-            self.db.config.ISSUE_TRACKER_EMAIL)
+
+        # simplistic check to see if the url is valid,
+        # then append a trailing slash if it is missing
+        base = self.db.config.ISSUE_TRACKER_WEB 
+        if not isinstance( base , type('') ) or not base.startswith( "http://" ) :
+            base = "Configuration Error: ISSUE_TRACKER_WEB isn't a fully-qualified URL"
+        elif base[-1] != '/' :
+            base += '/'
+        web = base + 'issue'+ nodeid
+
+        # ensure the email address is properly quoted
+        email = straddr( (self.db.config.INSTANCE_NAME ,
+                          self.db.config.ISSUE_TRACKER_EMAIL) )
+
         line = '_' * max(len(web), len(email))
         return '%s\n%s\n%s\n%s'%(line, email, web, line)
 
+
     def generateCreateNote(self, nodeid):
         """Generate a create note that lists initial property values
         """
@@ -509,6 +566,7 @@ class IssueClass(Class):
                 key = link.labelprop(default_to_id=1)
                 if key:
                     value = [link.get(entry, key) for entry in value]
+                value.sort()
                 value = ', '.join(value)
             m.append('%s: %s'%(propname, value))
         m.insert(0, '----------')
@@ -518,6 +576,13 @@ class IssueClass(Class):
     def generateChangeNote(self, nodeid, oldvalues):
         """Generate a change note that lists property changes
         """
+
+        if __debug__ :
+            if not isinstance( oldvalues , type({}) ) :
+                raise TypeError(
+                        "'oldvalues' must be dict-like, not %s."
+                        % str(type(oldvalues)) )
+
         cn = self.classname
         cl = self.db.classes[cn]
         changed = {}
@@ -543,7 +608,7 @@ class IssueClass(Class):
         l = changed.items()
         l.sort()
         for propname, oldvalue in l:
-            prop = cl.properties[propname]
+            prop = props[propname]
             value = cl.get(nodeid, propname, None)
             if isinstance(prop, hyperdb.Link):
                 link = self.db.classes[prop.classname]
@@ -594,6 +659,85 @@ class IssueClass(Class):
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.58  2002/06/16 01:05:15  dman13
+# Removed temporary workaround -- it seems it was a bug in the
+# nosyreaction detector in the 0.4.1 extended template and has already
+# been fixed in CVS.  We'll see.
+#
+# Revision 1.57  2002/06/15 15:49:29  dman13
+# Use 'email' instead of 'rfc822', if available.
+# Don't use isinstance() on a string (not allowed in python 2.1).
+# Return an error message instead of crashing if 'oldvalues' isn't a
+#     dict (in generateChangeNote).
+#
+# Revision 1.56  2002/06/14 03:54:21  dman13
+# #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it
+#
+# use the rfc822 module to ensure that every (oddball) email address and
+# real-name is properly quoted
+#
+# Revision 1.55  2002/06/11 04:58:07  richard
+# detabbing
+#
+# Revision 1.54  2002/05/29 01:16:17  richard
+# Sorry about this huge checkin! It's fixing a lot of related stuff in one go
+# though.
+#
+# . #541941 ] changing multilink properties by mail
+# . #526730 ] search for messages capability
+# . #505180 ] split MailGW.handle_Message
+#   - also changed cgi client since it was duplicating the functionality
+# . build htmlbase if tests are run using CVS checkout (removed note from
+#   installation.txt)
+# . don't create an empty message on email issue creation if the email is empty
+#
+# Revision 1.53  2002/05/25 07:16:24  rochecompaan
+# Merged search_indexing-branch with HEAD
+#
+# Revision 1.52  2002/05/15 03:27:16  richard
+#  . fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
+#    (thanks dman)
+#  . fixed some sorting issues that were breaking some unit tests under py2.2
+#  . mailgw test output dir was confusing the init test (but only on 2.2 *shrug*)
+#
+# fixed bug in the init unit test that meant only the bsddb test ran if it
+# could (it clobbered the anydbm test)
+#
+# Revision 1.51  2002/04/08 03:46:42  richard
+# make it work
+#
+# Revision 1.50  2002/04/08 03:40:31  richard
+#  . added a "detectors" directory for people to put their useful auditors and
+#    reactors in. Note - the roundupdb.IssueClass.sendmessage method has been
+#    split and renamed "nosymessage" specifically for things like the nosy
+#    reactor, and "send_message" which just sends the message.
+#
+# The initial detector is one that we'll be using here at ekit - it bounces new
+# issue messages to a team address.
+#
+# Revision 1.49.2.1  2002/04/19 19:54:42  rochecompaan
+# cgi_client.py
+#     removed search link for the time being
+#     moved rendering of matches to htmltemplate
+# hyperdb.py
+#     filtering of nodes on full text search incorporated in filter method
+# roundupdb.py
+#     added paramater to call of filter method
+# roundup_indexer.py
+#     added search method to RoundupIndexer class
+#
+# Revision 1.49  2002/03/19 06:41:49  richard
+# Faster, easier, less mess ;)
+#
+# Revision 1.48  2002/03/18 18:32:00  rochecompaan
+# All messages sent to the nosy list are now encoded as quoted-printable.
+#
+# Revision 1.47  2002/02/27 03:16:02  richard
+# Fixed a couple of dodgy bits found by pychekcer.
+#
+# Revision 1.46  2002/02/25 14:22:59  grubert
+#  . roundup db: catch only IOError in getfile.
+#
 # Revision 1.44  2002/02/15 07:08:44  richard
 #  . Alternate email addresses are now available for users. See the MIGRATION
 #    file for info on how to activate the feature.