Code

. Modified cgi interface to change properties only once all changes are
[roundup.git] / roundup / roundupdb.py
index 1840147d725efa3c023ea3ba563196035b34c173..6cb939a2b988665f5234c97c2eb1b29e47e0d70e 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.11 2001-10-04 02:12:42 richard Exp $
+# $Id: roundupdb.py,v 1.31 2001-12-15 19:24:39 rochecompaan Exp $
+
+__doc__ = """
+Extending hyperdb with types specific to issue-tracking.
+"""
 
 import re, os, smtplib, socket
+import mimetools, MimeWriter, cStringIO
+import base64, mimetypes
 
 import hyperdb, date
 
+class DesignatorError(ValueError):
+    pass
 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
     ''' Take a foo123 and return ('foo', 123)
     '''
     m = dre.match(designator)
+    if m is None:
+        raise DesignatorError, '"%s" not a node designator'%designator
     return m.group(1), m.group(2)
 
+
 class Database:
     def getuid(self):
         """Return the id of the "user" node associated with the user
@@ -40,7 +51,23 @@ class Database:
         '''
         (realname, address) = address
         users = self.user.stringFind(address=address)
-        if users: return users[0]
+        for dummy in range(2):
+            if len(users) > 1:
+                # 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
+                    # first valid match will do
+                    return user
+                # well, I guess we have no choice
+                return user[0]
+            elif users:
+                return users[0]
+            # try to match the username to the address (for local
+            # submissions where the address is empty)
+            users = self.user.stringFind(username=address)
+
+        # couldn't match address or username, so create a new user
         return self.user.create(username=address, address=address,
             realname=realname)
 
@@ -49,6 +76,9 @@ _marker = []
 class Class(hyperdb.Class):
     # Overridden methods:
     def __init__(self, db, classname, **properties):
+        if (properties.has_key('creation') or properties.has_key('activity')
+                or properties.has_key('creator')):
+            raise ValueError, '"creation", "activity" and "creator" are reserved'
         hyperdb.Class.__init__(self, db, classname, **properties)
         self.auditors = {'create': [], 'set': [], 'retire': []}
         self.reactors = {'create': [], 'set': [], 'retire': []}
@@ -125,7 +155,7 @@ class Class(hyperdb.Class):
         "protected" flag is true, we include protected properties - those
         which may not be modified.
         """
-        d = hyperdb.Class.getprops(self).copy()
+        d = hyperdb.Class.getprops(self, protected=protected).copy()
         if protected:
             d['creation'] = hyperdb.Date()
             d['activity'] = hyperdb.Date()
@@ -186,13 +216,24 @@ class FileClass(Class):
             we include protected properties - those which may not be
             modified.
         '''
-        d = Class.getprops(self).copy()
+        d = Class.getprops(self, protected=protected).copy()
         if protected:
             d['content'] = hyperdb.String()
         return d
 
+class MessageSendError(RuntimeError):
+    pass
+
+class DetectorError(RuntimeError):
+    pass
+
 # XXX deviation from spec - was called ItemClass
 class IssueClass(Class):
+    # configuration
+    MESSAGES_TO_AUTHOR = 'no'
+    INSTANCE_NAME = 'Roundup issue tracker'
+    EMAIL_SIGNATURE_POSITION = 'bottom'
+
     # Overridden methods:
 
     def __init__(self, db, classname, **properties):
@@ -210,9 +251,6 @@ class IssueClass(Class):
             properties['nosy'] = hyperdb.Multilink("user")
         if not properties.has_key('superseder'):
             properties['superseder'] = hyperdb.Multilink(classname)
-        if (properties.has_key('creation') or properties.has_key('activity')
-                or properties.has_key('creator')):
-            raise ValueError, '"creation", "activity" and "creator" are reserved'
         Class.__init__(self, db, classname, **properties)
 
     # New methods:
@@ -230,7 +268,7 @@ class IssueClass(Class):
         appended to the "messages" field of the specified issue.
         """
 
-    def sendmessage(self, nodeid, msgid):
+    def sendmessage(self, nodeid, msgid, change_note):
         """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
@@ -243,52 +281,315 @@ class IssueClass(Class):
         r = {}
         for recipid in recipients:
             r[recipid] = 1
+        rlen = len(recipients)
+
+        # figure the author's id, and indicate they've received the message
         authid = self.db.msg.get(msgid, 'author')
+
+        # get the current nosy list, we'll need it
+        nosy = self.get(nodeid, 'nosy')
+
+        # ... but duplicate the message to the author as long as it's not
+        # the anonymous user
+        if (self.MESSAGES_TO_AUTHOR == 'yes' and
+                self.db.user.get(authid, 'username') != 'anonymous'):
+            if not r.has_key(authid):
+                recipients.append(authid)
         r[authid] = 1
 
         # now figure the nosy people who weren't recipients
-        sendto = []
-        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
+            # do...)
+            if self.db.user.get(nosyid, 'username') == 'anonymous': continue
             if not r.has_key(nosyid):
-                sendto.append(nosyid)
                 recipients.append(nosyid)
 
-        if sendto:
-            # update the message's recipients list
-            self.db.msg.set(msgid, recipients=recipients)
-
-            # send an email to the people who missed out
-            sendto = [self.db.user.get(i, 'address') for i in recipients]
-            cn = self.classname
-            title = self.get(nodeid, 'title') or '%s message copy'%cn
-            m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
-            m.append('To: %s'%', '.join(sendto))
-            m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
-            m.append('')
-            m.append(self.db.msg.get(msgid, 'content'))
-            m.append(self.email_footer(nodeid, msgid))
-            # TODO attachments
-            try:
-                smtp = smtplib.SMTP(self.MAILHOST)
-                smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
-            except socket.error, value:
-                return "Couldn't send confirmation email: mailhost %s"%value
-            except smtplib.SMTPException, value:
-                return "Couldn't send confirmation email: %s"%value
-
-    def email_footer(self, nodeid, msgid):
-        ''' Add a footer to the e-mail with some useful information
+        # no new recipients
+        if rlen == len(recipients):
+            return
+
+        # update the message's recipients list
+        self.db.msg.set(msgid, recipients=recipients)
+
+        # send an email to the people who missed out
+        sendto = [self.db.user.get(i, 'address') for i in recipients]
+        cn = self.classname
+        title = self.get(nodeid, 'title') or '%s message copy'%cn
+        # figure author information
+        authname = self.db.user.get(authid, 'realname')
+        if not authname:
+            authname = self.db.user.get(authid, 'username')
+        authaddr = self.db.user.get(authid, 'address')
+        if authaddr:
+            authaddr = ' <%s>'%authaddr
+        else:
+            authaddr = ''
+
+        # make the message body
+        m = ['']
+
+        # put in roundup's signature
+        if self.EMAIL_SIGNATURE_POSITION == 'top':
+            m.append(self.email_signature(nodeid, msgid))
+
+        # add author information
+        if len(self.get(nodeid,'messages')) == 1:
+            m.append("New submission from %s%s:"%(authname, authaddr))
+        else:
+            m.append("%s%s added the comment:"%(authname, authaddr))
+        m.append('')
+
+        # add the content
+        m.append(self.db.msg.get(msgid, 'content'))
+
+        # add the change note
+        if change_note:
+            m.append(change_note)
+
+        # put in roundup's signature
+        if self.EMAIL_SIGNATURE_POSITION == 'bottom':
+            m.append(self.email_signature(nodeid, msgid))
+
+        # get the files for this message
+        files = self.db.msg.get(msgid, 'files')
+
+        # 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.ISSUE_TRACKER_EMAIL))
+        writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
+            self.ISSUE_TRACKER_EMAIL))
+        writer.addheader('MIME-Version', '1.0')
+
+        # attach files
+        if files:
+            part = writer.startmultipartbody('mixed')
+            part = writer.nextpart()
+            body = part.startbody('text/plain')
+            body.write('\n'.join(m))
+            for fileid in files:
+                name = self.db.file.get(fileid, 'name')
+                mime_type = self.db.file.get(fileid, 'type')
+                content = self.db.file.get(fileid, 'content')
+                part = writer.nextpart()
+                if mime_type == 'text/plain':
+                    part.addheader('Content-Disposition',
+                        'attachment;\n filename="%s"'%name)
+                    part.addheader('Content-Transfer-Encoding', '7bit')
+                    body = part.startbody('text/plain')
+                    body.write(content)
+                else:
+                    # some other type, so encode it
+                    if not mime_type:
+                        # this should have been done when the file was saved
+                        mime_type = mimetypes.guess_type(name)[0]
+                    if mime_type is None:
+                        mime_type = 'application/octet-stream'
+                    part.addheader('Content-Disposition',
+                        'attachment;\n filename="%s"'%name)
+                    part.addheader('Content-Transfer-Encoding', 'base64')
+                    body = part.startbody(mime_type)
+                    body.write(base64.encodestring(content))
+            writer.lastpart()
+        else:
+            body = writer.startbody('text/plain')
+            body.write('\n'.join(m))
+
+        # now try to send the message
+        try:
+            smtp = smtplib.SMTP(self.MAILHOST)
+            # send the message as admin so bounces are sent there instead
+            # of to roundup
+            smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
+        except socket.error, value:
+            raise MessageSendError, \
+                "Couldn't send confirmation email: mailhost %s"%value
+        except smtplib.SMTPException, value:
+            raise MessageSendError, \
+                "Couldn't send confirmation email: %s"%value
+
+    def email_signature(self, nodeid, msgid):
+        ''' Add a signature to the e-mail with some useful information
         '''
-        web = self.ISSUE_TRACKER_WEB
-        return '''%s
-Roundup issue tracker
-%s
-%s
-'''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
+        web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
+        email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
+        line = '_' * max(len(web), len(email))
+        return '%s\n%s\n%s\n%s'%(line, email, web, line)
+
+    def generateChangeNote(self, nodeid, oldvalues):
+        """Generate a change note that lists property changes
+        """
+        cn = self.classname
+        cl = self.db.classes[cn]
+        changed = {}
+        props = cl.getprops(protected=0)
+
+        # determine what changed
+        for key in oldvalues.keys():
+            if key in ['files','messages']: continue
+            new_value = cl.get(nodeid, key)
+            # the old value might be non existent
+            try:
+                old_value = oldvalues[key]
+                if type(new_value) is type([]):
+                    new_value.sort()
+                    old_value.sort()
+                if new_value != old_value:
+                    changed[key] = old_value
+            except:
+                changed[key] = new_value
+
+        # list the changes
+        m = []
+        for propname, oldvalue in changed.items():
+            prop = cl.properties[propname]
+            value = cl.get(nodeid, propname, None)
+            if isinstance(prop, hyperdb.Link):
+                link = self.db.classes[prop.classname]
+                key = link.labelprop(default_to_id=1)
+                if key:
+                    if value:
+                        value = link.get(value, key)
+                    else:
+                        value = ''
+                    if oldvalue:
+                        oldvalue = link.get(oldvalue, key)
+                    else:
+                        oldvalue = ''
+                change = '%s -> %s'%(oldvalue, value)
+            elif isinstance(prop, hyperdb.Multilink):
+                change = ''
+                if value is None: value = []
+                if oldvalue is None: oldvalue = []
+                l = []
+                link = self.db.classes[prop.classname]
+                key = link.labelprop(default_to_id=1)
+                # check for additions
+                for entry in value:
+                    if entry in oldvalue: continue
+                    if key:
+                        l.append(link.get(entry, key))
+                    else:
+                        l.append(entry)
+                if l:
+                    change = '+%s'%(', '.join(l))
+                    l = []
+                # check for removals
+                for entry in oldvalue:
+                    if entry in value: continue
+                    if key:
+                        l.append(link.get(entry, key))
+                    else:
+                        l.append(entry)
+                if l:
+                    change += ' -%s'%(', '.join(l))
+            else:
+                change = '%s -> %s'%(oldvalue, value)
+            m.append('%s: %s'%(propname, change))
+        if m:
+            m.insert(0, '----------')
+            m.insert(0, '')
+        return '\n'.join(m)
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.30  2001/12/12 21:47:45  richard
+#  . Message author's name appears in From: instead of roundup instance name
+#    (which still appears in the Reply-To:)
+#  . envelope-from is now set to the roundup-admin and not roundup itself so
+#    delivery reports aren't sent to roundup (thanks Patrick Ohly)
+#
+# Revision 1.29  2001/12/11 04:50:49  richard
+# fixed the order of the blank line and '-------' line
+#
+# Revision 1.28  2001/12/10 22:20:01  richard
+# Enabled transaction support in the bsddb backend. It uses the anydbm code
+# where possible, only replacing methods where the db is opened (it uses the
+# btree opener specifically.)
+# Also cleaned up some change note generation.
+# Made the backends package work with pydoc too.
+#
+# Revision 1.27  2001/12/10 21:02:53  richard
+# only insert the -------- change note marker if there is a change note
+#
+# Revision 1.26  2001/12/05 14:26:44  rochecompaan
+# Removed generation of change note from "sendmessage" in roundupdb.py.
+# The change note is now generated when the message is created.
+#
+# Revision 1.25  2001/11/30 20:28:10  rochecompaan
+# Property changes are now completely traceable, whether changes are
+# made through the web or by email
+#
+# Revision 1.24  2001/11/30 11:29:04  rochecompaan
+# Property changes are now listed in emails generated by Roundup
+#
+# Revision 1.23  2001/11/27 03:17:13  richard
+# oops
+#
+# Revision 1.22  2001/11/27 03:00:50  richard
+# couple of bugfixes from latest patch integration
+#
+# Revision 1.21  2001/11/26 22:55:56  richard
+# Feature:
+#  . Added INSTANCE_NAME to configuration - used in web and email to identify
+#    the instance.
+#  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
+#    signature info in e-mails.
+#  . Some more flexibility in the mail gateway and more error handling.
+#  . Login now takes you to the page you back to the were denied access to.
+#
+# Fixed:
+#  . Lots of bugs, thanks Roché and others on the devel mailing list!
+#
+# Revision 1.20  2001/11/25 10:11:14  jhermann
+# Typo fix
+#
+# Revision 1.19  2001/11/22 15:46:42  jhermann
+# Added module docstrings to all modules.
+#
+# Revision 1.18  2001/11/15 10:36:17  richard
+#  . incorporated patch from Roch'e Compaan implementing attachments in nosy
+#     e-mail
+#
+# Revision 1.17  2001/11/12 22:01:06  richard
+# Fixed issues with nosy reaction and author copies.
+#
+# Revision 1.16  2001/10/30 00:54:45  richard
+# Features:
+#  . #467129 ] Lossage when username=e-mail-address
+#  . #473123 ] Change message generation for author
+#  . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
+#
+# Revision 1.15  2001/10/23 01:00:18  richard
+# Re-enabled login and registration access after lopping them off via
+# disabling access for anonymous users.
+# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
+# a couple of bugs while I was there. Probably introduced a couple, but
+# things seem to work OK at the moment.
+#
+# Revision 1.14  2001/10/21 07:26:35  richard
+# feature #473127: Filenames. I modified the file.index and htmltemplate
+#  source so that the filename is used in the link and the creation
+#  information is displayed.
+#
+# Revision 1.13  2001/10/21 00:45:15  richard
+# Added author identification to e-mail messages from roundup.
+#
+# Revision 1.12  2001/10/04 02:16:15  richard
+# Forgot to pass the protected flag down *sigh*.
+#
+# Revision 1.11  2001/10/04 02:12:42  richard
+# Added nicer command-line item adding: passing no arguments will enter an
+# interactive more which asks for each property in turn. While I was at it, I
+# fixed an implementation problem WRT the spec - I wasn't raising a
+# ValueError if the key property was missing from a create(). Also added a
+# protected=boolean argument to getprops() so we can list only the mutable
+# properties (defaults to yes, which lists the immutables).
+#
 # Revision 1.10  2001/08/07 00:24:42  richard
 # stupid typo
 #