Code

. fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
[roundup.git] / roundup / roundupdb.py
index 966f77db0cbc04c4df0a63fe9833fb74ab7a68ea..f870bb99a0e4b75e60accffc5d66b1dfdebaf2f5 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.28 2001-12-10 22:20:01 richard Exp $
+# $Id: roundupdb.py,v 1.52 2002-05-15 03:27:16 richard Exp $
 
 __doc__ = """
 Extending hyperdb with types specific to issue-tracking.
 """
 
-import re, os, smtplib, socket
-import mimetools, MimeWriter, cStringIO
-import base64, mimetypes
+import re, os, smtplib, socket, copy, time, random
+import MimeWriter, cStringIO
+import base64, quopri, mimetypes
 
 import hyperdb, date
 
+# set to indicate to roundup not to actually _send_ email
+# this var must contain a file to write the mail to
+SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
+
 class DesignatorError(ValueError):
     pass
 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
@@ -38,6 +42,23 @@ def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
     return m.group(1), m.group(2)
 
 
+def extractUserFromList(userClass, users):
+    '''Given a list of users, try to extract the first non-anonymous user
+       and return that user, otherwise return None
+    '''
+    if len(users) > 1:
+        # make sure we don't match the anonymous or admin user
+        for user in users:
+            if user == '1': continue
+            if userClass.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]
+    return None
+
 class Database:
     def getuid(self):
         """Return the id of the "user" node associated with the user
@@ -50,26 +71,31 @@ class Database:
             user is created if they don't exist in the db already
         '''
         (realname, address) = address
-        users = self.user.stringFind(address=address)
-        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)
+
+        # try a straight match of the 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},
+                [], [])
+            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,
+            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)
+        if create:
+            return self.user.create(username=address, address=address,
+                realname=realname)
+        else:
+            return 0
 
 _marker = []
 # XXX: added the 'creator' faked attribute
@@ -104,7 +130,16 @@ class Class(hyperdb.Class):
             raise KeyError, '"creation" and "activity" are reserved'
         for audit in self.auditors['set']:
             audit(self.db, self, nodeid, propvalues)
-        oldvalues = self.db.getnode(self.classname, nodeid)
+        # Take a copy of the node dict so that the subsequent set
+        # operation doesn't modify the oldvalues structure.
+        try:
+            # try not using the cache initially
+            oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
+                cache=0))
+        except IndexError:
+            # this will be needed if somone does a create() and set()
+            # 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)
@@ -119,7 +154,7 @@ class Class(hyperdb.Class):
         for react in self.reactors['retire']:
             react(self.db, self, nodeid, None)
 
-    def get(self, nodeid, propname, default=_marker):
+    def get(self, nodeid, propname, default=_marker, cache=1):
         """Attempts to get the "creation" or "activity" properties should
         do the right thing.
         """
@@ -145,9 +180,10 @@ class Class(hyperdb.Class):
                 return None
             return self.db.user.lookup(name)
         if default is not _marker:
-            return hyperdb.Class.get(self, nodeid, propname, default)
+            return hyperdb.Class.get(self, nodeid, propname, default,
+                cache=cache)
         else:
-            return hyperdb.Class.get(self, nodeid, propname)
+            return hyperdb.Class.get(self, nodeid, propname, cache=cache)
 
     def getprops(self, protected=1):
         """In addition to the actual properties on the node, these
@@ -168,12 +204,16 @@ class Class(hyperdb.Class):
     def audit(self, event, detector):
         """Register a detector
         """
-        self.auditors[event].append(detector)
+        l = self.auditors[event]
+        if detector not in l:
+            self.auditors[event].append(detector)
 
     def react(self, event, detector):
         """Register a detector
         """
-        self.reactors[event].append(detector)
+        l = self.reactors[event]
+        if detector not in l:
+            self.reactors[event].append(detector)
 
 
 class FileClass(Class):
@@ -183,32 +223,25 @@ class FileClass(Class):
         content = propvalues['content']
         del propvalues['content']
         newid = Class.create(self, **propvalues)
-        self.setcontent(self.classname, newid, content)
+        self.db.storefile(self.classname, newid, None, content)
         return newid
 
-    def filename(self, classname, nodeid):
-        # TODO: split into multiple files directories
-        return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
-
-    def setcontent(self, classname, nodeid, content):
-        ''' set the content file for this file
-        '''
-        open(self.filename(classname, nodeid), 'wb').write(content)
-
-    def getcontent(self, classname, nodeid):
-        ''' get the content file for this file
-        '''
-        return open(self.filename(classname, nodeid), 'rb').read()
-
-    def get(self, nodeid, propname, default=_marker):
+    def get(self, nodeid, propname, default=_marker, cache=1):
         ''' trap the content propname and get it from the file
         '''
+
+        poss_msg = 'Possibly a access right configuration problem.'
         if propname == 'content':
-            return self.getcontent(self.classname, nodeid)
+            try:
+                return self.db.getfile(self.classname, nodeid, None)
+            except IOError, (strerror):
+                # BUG: by catching this we donot see an error in the log.
+                return 'ERROR reading file: %s%s\n%s\n%s'%(
+                        self.classname, nodeid, poss_msg, strerror)
         if default is not _marker:
-            return Class.get(self, nodeid, propname, default)
+            return Class.get(self, nodeid, propname, default, cache=cache)
         else:
-            return Class.get(self, nodeid, propname)
+            return Class.get(self, nodeid, propname, cache=cache)
 
     def getprops(self, protected=1):
         ''' In addition to the actual properties on the node, these methods
@@ -229,10 +262,6 @@ class DetectorError(RuntimeError):
 
 # 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:
 
@@ -268,7 +297,7 @@ class IssueClass(Class):
         appended to the "messages" field of the specified issue.
         """
 
-    def sendmessage(self, nodeid, msgid):
+    def nosymessage(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
@@ -276,25 +305,27 @@ class IssueClass(Class):
         
         These users are then added to the message's "recipients" list.
         """
+        users = self.db.user
+        messages = self.db.msg
+
         # figure the recipient ids
-        recipients = self.db.msg.get(msgid, 'recipients')
+        sendto = []
         r = {}
-        for recipid in recipients:
+        recipients = messages.get(msgid, 'recipients')
+        for recipid in messages.get(msgid, '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')
+        authid = messages.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)
+        # possibly send the message to the author, as long as they aren't
+        # anonymous
+        if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
+                users.get(authid, 'username') != 'anonymous'):
+            sendto.append(authid)
         r[authid] = 1
 
         # now figure the nosy people who weren't recipients
@@ -302,26 +333,57 @@ class IssueClass(Class):
             # 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 users.get(nosyid, 'username') == 'anonymous':
+                continue
+            # make sure they haven't seen the message already
             if not r.has_key(nosyid):
+                # send it to them
+                sendto.append(nosyid)
                 recipients.append(nosyid)
 
-        # no new recipients
-        if rlen == len(recipients):
-            return
+        # 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, change_note, sendto)
 
-        # update the message's recipients list
-        self.db.msg.set(msgid, recipients=recipients)
+    # 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
+            messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
+                self.classname, nodeid, self.db.config.MAIL_DOMAIN)
+            messages.set(msgid, messageid=messageid)
 
         # 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')
+        authid = messages.get(msgid, 'author')
+        authname = users.get(authid, 'realname')
         if not authname:
-            authname = self.db.user.get(authid, 'username')
-        authaddr = self.db.user.get(authid, 'address')
+            authname = users.get(authid, 'username')
+        authaddr = users.get(authid, 'address')
         if authaddr:
             authaddr = ' <%s>'%authaddr
         else:
@@ -331,7 +393,7 @@ class IssueClass(Class):
         m = ['']
 
         # put in roundup's signature
-        if self.EMAIL_SIGNATURE_POSITION == 'top':
+        if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
             m.append(self.email_signature(nodeid, msgid))
 
         # add author information
@@ -342,36 +404,57 @@ class IssueClass(Class):
         m.append('')
 
         # add the content
-        m.append(self.db.msg.get(msgid, 'content'))
+        m.append(messages.get(msgid, 'content'))
+
+        # add the change note
+        if note:
+            m.append(note)
 
         # put in roundup's signature
-        if self.EMAIL_SIGNATURE_POSITION == 'bottom':
+        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
-        files = self.db.msg.get(msgid, 'files')
+        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>'%(self.INSTANCE_NAME,
-            self.ISSUE_TRACKER_EMAIL))
-        writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
-            self.ISSUE_TRACKER_EMAIL))
+        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('MIME-Version', '1.0')
+        if messageid:
+            writer.addheader('Message-Id', messageid)
+        if inreplyto:
+            writer.addheader('In-Reply-To', inreplyto)
+
+        # add a uniquely Roundup header to help filtering
+        writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
 
         # attach files
-        if files:
+        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))
-            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')
+            body.write(content_encoded)
+            for fileid in message_files:
+                name = files.get(fileid, 'name')
+                mime_type = files.get(fileid, 'type')
+                content = files.get(fileid, 'content')
                 part = writer.nextpart()
                 if mime_type == 'text/plain':
                     part.addheader('Content-Disposition',
@@ -393,29 +476,77 @@ 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
-        try:
-            smtp = smtplib.SMTP(self.MAILHOST)
-            smtp.sendmail(self.ISSUE_TRACKER_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
+        if SENDMAILDEBUG:
+            open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
+                self.db.config.ADMIN_EMAIL,
+                ', '.join(sendto),message.getvalue()))
+        else:
+            try:
+                # send the message as admin so bounces are sent there
+                # instead of to roundup
+                smtp = smtplib.SMTP(self.db.config.MAILHOST)
+                smtp.sendmail(self.db.config.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 + 'issue'+ nodeid
-        email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
+        web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid
+        email = '"%s" <%s>'%(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 generateChangeNote(self, nodeid, newvalues):
+    def generateCreateNote(self, nodeid):
+        """Generate a create note that lists initial property values
+        """
+        cn = self.classname
+        cl = self.db.classes[cn]
+        props = cl.getprops(protected=0)
+
+        # list the values
+        m = []
+        l = props.items()
+        l.sort()
+        for propname, prop in l:
+            value = cl.get(nodeid, propname, None)
+            # skip boring entries
+            if not value:
+                continue
+            if isinstance(prop, hyperdb.Link):
+                link = self.db.classes[prop.classname]
+                if value:
+                    key = link.labelprop(default_to_id=1)
+                    if key:
+                        value = link.get(value, key)
+                else:
+                    value = ''
+            elif isinstance(prop, hyperdb.Multilink):
+                if value is None: value = []
+                l = []
+                link = self.db.classes[prop.classname]
+                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, '----------')
+        m.insert(0, '')
+        return '\n'.join(m)
+
+    def generateChangeNote(self, nodeid, oldvalues):
         """Generate a change note that lists property changes
         """
         cn = self.classname
@@ -424,26 +555,27 @@ class IssueClass(Class):
         props = cl.getprops(protected=0)
 
         # determine what changed
-        for key in newvalues.keys():
+        for key in oldvalues.keys():
             if key in ['files','messages']: continue
-            new_value = newvalues[key]
+            new_value = cl.get(nodeid, key)
             # the old value might be non existent
             try:
-                old_value = cl.get(nodeid, key)
-                if type(old_value) is type([]):
-                    old_value.sort()
+                old_value = oldvalues[key]
+                if type(new_value) is type([]):
                     new_value.sort()
-                if old_value != new_value:
-                    changed[key] = new_value
+                    old_value.sort()
+                if new_value != old_value:
+                    changed[key] = old_value
             except:
                 changed[key] = new_value
 
         # list the changes
         m = []
-        for propname, value in changed.items():
-            prop = cl.properties[propname]
-            oldvalue = cl.get(nodeid, propname, None)
-            change = '%s -> %s'%(oldvalue, value)
+        l = changed.items()
+        l.sort()
+        for propname, oldvalue in l:
+            prop = props[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)
@@ -483,14 +615,132 @@ class IssueClass(Class):
                         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, '----------')
+            m.insert(0, '')
         return '\n'.join(m)
 
 #
 # $Log: not supported by cvs2svn $
+# 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  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.
+#
+# Revision 1.43  2002/02/14 22:33:15  richard
+#  . Added a uniquely Roundup header to email, "X-Roundup-Name"
+#
+# Revision 1.42  2002/01/21 09:55:14  rochecompaan
+# Properties in change note are now sorted
+#
+# Revision 1.41  2002/01/15 00:12:40  richard
+# #503340 ] creating issue with [asignedto=p.ohly]
+#
+# Revision 1.40  2002/01/14 22:21:38  richard
+# #503353 ] setting properties in initial email
+#
+# Revision 1.39  2002/01/14 02:20:15  richard
+#  . changed all config accesses so they access either the instance or the
+#    config attriubute on the db. This means that all config is obtained from
+#    instance_config instead of the mish-mash of classes. This will make
+#    switching to a ConfigParser setup easier too, I hope.
+#
+# At a minimum, this makes migration a _little_ easier (a lot easier in the
+# 0.5.0 switch, I hope!)
+#
+# Revision 1.38  2002/01/10 05:57:45  richard
+# namespace clobberation
+#
+# Revision 1.37  2002/01/08 04:12:05  richard
+# Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
+#
+# Revision 1.36  2002/01/02 02:31:38  richard
+# Sorry for the huge checkin message - I was only intending to implement #496356
+# but I found a number of places where things had been broken by transactions:
+#  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
+#    for _all_ roundup-generated smtp messages to be sent to.
+#  . the transaction cache had broken the roundupdb.Class set() reactors
+#  . newly-created author users in the mailgw weren't being committed to the db
+#
+# Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
+# on when I found that stuff :):
+#  . #496356 ] Use threading in messages
+#  . detectors were being registered multiple times
+#  . added tests for mailgw
+#  . much better attaching of erroneous messages in the mail gateway
+#
+# Revision 1.35  2001/12/20 15:43:01  rochecompaan
+# Features added:
+#  .  Multilink properties are now displayed as comma separated values in
+#     a textbox
+#  .  The add user link is now only visible to the admin user
+#  .  Modified the mail gateway to reject submissions from unknown
+#     addresses if ANONYMOUS_ACCESS is denied
+#
+# Revision 1.34  2001/12/17 03:52:48  richard
+# Implemented file store rollback. As a bonus, the hyperdb is now capable of
+# storing more than one file per node - if a property name is supplied,
+# the file is called designator.property.
+# I decided not to migrate the existing files stored over to the new naming
+# scheme - the FileClass just doesn't specify the property name.
+#
+# Revision 1.33  2001/12/16 10:53:37  richard
+# take a copy of the node dict so that the subsequent set
+# operation doesn't modify the oldvalues structure
+#
+# Revision 1.32  2001/12/15 23:48:35  richard
+# Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
+# actually sending mail :)
+#
+# Revision 1.31  2001/12/15 19:24:39  rochecompaan
+#  . Modified cgi interface to change properties only once all changes are
+#    collected, files created and messages generated.
+#  . Moved generation of change note to nosyreactors.
+#  . We now check for changes to "assignedto" to ensure it's added to the
+#    nosy list.
+#
+# 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
 #