Code

- detect and break email loops (sf bug 640854)
[roundup.git] / roundup / roundupdb.py
index ab3e0f3de796c6aad5fc835f08c3656dff94e672..af624eba974697dfd9fd599eee7b98a0cc0ffb8e 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.16 2001-10-30 00:54:45 richard Exp $
-
-import re, os, smtplib, socket
-
-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)
+# $Id: roundupdb.py,v 1.74 2002-12-10 00:23:36 richard Exp $
+
+__doc__ = """
+Extending hyperdb with types specific to issue-tracking.
+"""
+
+import re, os, smtplib, socket, time, random
+import MimeWriter, cStringIO
+import base64, quopri, mimetypes
+# if available, use the 'email' module, otherwise fallback to 'rfc822'
+try :
+    from email.Utils import formataddr as straddr
+except ImportError :
+    # code taken from the email package 2.4.3
+    def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
+            escapesre = re.compile(r'[][\()"]')):
+        name, address = pair
+        if name:
+            quotes = ''
+            if specialsre.search(name):
+                quotes = '"'
+            name = escapesre.sub(r'\\\g<0>', name)
+            return '%s%s%s <%s>' % (quotes, name, quotes, address)
+        return address
+
+import hyperdb
+
+# 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 Database:
     def getuid(self):
@@ -37,209 +52,28 @@ class Database:
         that owns this connection to the hyperdatabase."""
         return self.user.lookup(self.journaltag)
 
-    def uidFromAddress(self, address, create=1):
-        ''' address is from the rfc822 module, and therefore is (name, addr)
-
-            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)
-
-        # couldn't match address or username, so create a new user
-        return self.user.create(username=address, address=address,
-            realname=realname)
-
-_marker = []
-# XXX: added the 'creator' faked attribute
-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': []}
-
-    def create(self, **propvalues):
-        """These operations trigger detectors and can be vetoed.  Attempts
-        to modify the "creation" or "activity" properties cause a KeyError.
-        """
-        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)
-        nodeid = hyperdb.Class.create(self, **propvalues)
-        for react in self.reactors['create']:
-            react(self.db, self, nodeid, None)
-        return nodeid
-
-    def set(self, nodeid, **propvalues):
-        """These operations trigger detectors and can be vetoed.  Attempts
-        to modify the "creation" or "activity" properties cause a KeyError.
-        """
-        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)
-        oldvalues = self.db.getnode(self.classname, nodeid)
-        hyperdb.Class.set(self, nodeid, **propvalues)
-        for react in self.reactors['set']:
-            react(self.db, self, 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)
-        hyperdb.Class.retire(self, nodeid)
-        for react in self.reactors['retire']:
-            react(self.db, self, nodeid, None)
-
-    def get(self, nodeid, propname, default=_marker):
-        """Attempts to get the "creation" or "activity" properties should
-        do the right thing.
-        """
-        if propname == 'creation':
-            journal = self.db.getjournal(self.classname, nodeid)
-            if journal:
-                return self.db.getjournal(self.classname, nodeid)[0][1]
-            else:
-                # on the strange chance that there's no journal
-                return date.Date()
-        if propname == 'activity':
-            journal = self.db.getjournal(self.classname, nodeid)
-            if journal:
-                return self.db.getjournal(self.classname, nodeid)[-1][1]
-            else:
-                # on the strange chance that there's no journal
-                return date.Date()
-        if propname == 'creator':
-            journal = self.db.getjournal(self.classname, nodeid)
-            if journal:
-                name = self.db.getjournal(self.classname, nodeid)[0][2]
-            else:
-                return None
-            return self.db.user.lookup(name)
-        if default is not _marker:
-            return hyperdb.Class.get(self, nodeid, propname, default)
-        else:
-            return hyperdb.Class.get(self, nodeid, propname)
-
-    def getprops(self, protected=1):
-        """In addition to the actual properties on the node, these
-        methods provide the "creation" and "activity" properties. If the
-        "protected" flag is true, we include protected properties - those
-        which may not be modified.
-        """
-        d = hyperdb.Class.getprops(self, protected=protected).copy()
-        if protected:
-            d['creation'] = hyperdb.Date()
-            d['activity'] = hyperdb.Date()
-            d['creator'] = hyperdb.Link("user")
-        return d
-
-    #
-    # Detector interface
-    #
-    def audit(self, event, detector):
-        """Register a detector
-        """
-        self.auditors[event].append(detector)
-
-    def react(self, event, detector):
-        """Register a detector
-        """
-        self.reactors[event].append(detector)
-
-
-class FileClass(Class):
-    def create(self, **propvalues):
-        ''' snaffle the file propvalue and store in a file
-        '''
-        content = propvalues['content']
-        del propvalues['content']
-        newid = Class.create(self, **propvalues)
-        self.setcontent(self.classname, newid, 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()
+class MessageSendError(RuntimeError):
+    pass
 
-    def get(self, nodeid, propname, default=_marker):
-        ''' trap the content propname and get it from the file
-        '''
-        if propname == 'content':
-            return self.getcontent(self.classname, nodeid)
-        if default is not _marker:
-            return Class.get(self, nodeid, propname, default)
-        else:
-            return Class.get(self, nodeid, propname)
+class DetectorError(RuntimeError):
+    ''' Raised by detectors that want to indicate that something's amiss
+    '''
+    pass
 
-    def getprops(self, protected=1):
-        ''' In addition to the actual properties on the node, these methods
-            provide the "content" property. If the "protected" flag is true,
-            we include protected properties - those which may not be
-            modified.
-        '''
-        d = Class.getprops(self, protected=protected).copy()
-        if protected:
-            d['content'] = hyperdb.String()
-        return d
-
-# XXX deviation from spec - was called ItemClass
-class IssueClass(Class):
-    # configuration
-    MESSAGES_TO_AUTHOR = 'no'
-
-    # Overridden methods:
-
-    def __init__(self, db, classname, **properties):
-        """The newly-created class automatically includes the "messages",
-        "files", "nosy", and "superseder" properties.  If the 'properties'
-        dictionary attempts to specify any of these properties or a
-        "creation" or "activity" property, a ValueError is raised."""
-        if not properties.has_key('title'):
-            properties['title'] = hyperdb.String()
-        if not properties.has_key('messages'):
+# deviation from spec - was called IssueClass
+class IssueClass:
+    """ This class is intended to be mixed-in with a hyperdb backend
+        implementation. The backend should provide a mechanism that
+        enforces the title, messages, files, nosy and superseder
+        properties:
+            properties['title'] = hyperdb.String(indexme='yes')
             properties['messages'] = hyperdb.Multilink("msg")
-        if not properties.has_key('files'):
             properties['files'] = hyperdb.Multilink("file")
-        if not properties.has_key('nosy'):
             properties['nosy'] = hyperdb.Multilink("user")
-        if not properties.has_key('superseder'):
             properties['superseder'] = hyperdb.Multilink(classname)
-        Class.__init__(self, db, classname, **properties)
+    """
 
     # New methods:
-
     def addmessage(self, nodeid, summary, text):
         """Add a message to an issue's mail spool.
 
@@ -253,7 +87,7 @@ class IssueClass(Class):
         appended to the "messages" field of the specified issue.
         """
 
-    def sendmessage(self, nodeid, msgid):
+    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
@@ -261,22 +95,25 @@ 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
 
         # figure the author's id, and indicate they've received the message
-        authid = self.db.msg.get(msgid, 'author')
-        r[authid] = 1
+        authid = messages.get(msgid, 'author')
 
-        sendto = []
-        # ... 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'):
+        # 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
         nosy = self.get(nodeid, 'nosy')
@@ -284,121 +121,327 @@ 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)
 
+        # 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
-            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 = ''
-            # TODO attachments
-            m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
-            m.append('To: %s'%', '.join(sendto))
-            m.append('From: %s'%self.ISSUE_TRACKER_EMAIL)
-            m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
-            m.append('')
-            # add author information
-            m.append("%s %sadded the comment:"%(authname, authaddr))
-            m.append('')
-            # add the content
-            m.append(self.db.msg.get(msgid, 'content'))
-            # "list information" footer
-            m.append(self.email_footer(nodeid, msgid))
+            messages.set(msgid, recipients=recipients)
+
+            # send the message
+            self.send_message(nodeid, msgid, note, sendto)
+
+    # 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
+        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>" % straddr( ('',authaddr) )
+        else:
+            authaddr = ''
+
+        # make the message body
+        m = ['']
+
+        # put in roundup's signature
+        if self.db.config.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(messages.get(msgid, 'content'))
+
+        # add the 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', straddr(
+                              (authname, self.db.config.TRACKER_EMAIL) ) )
+        writer.addheader('Reply-To', straddr( 
+                                        (self.db.config.TRACKER_NAME,
+                                         self.db.config.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.TRACKER_NAME)
+
+        # avoid email loops
+        writer.addheader('X-Roundup-Loop', 'hello')
+
+        # attach 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(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',
+                        '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:
+            writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
+            body = writer.startbody('text/plain')
+            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()))
+        else:
             try:
-                smtp = smtplib.SMTP(self.MAILHOST)
-                smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
+                # 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:
-                return "Couldn't send confirmation email: mailhost %s"%value
+                raise MessageSendError, \
+                    "Couldn't send confirmation email: mailhost %s"%value
             except smtplib.SMTPException, value:
-                return "Couldn't send confirmation email: %s"%value
+                raise MessageSendError, \
+                    "Couldn't send confirmation email: %s"%value
 
-    def email_footer(self, nodeid, msgid):
-        ''' Add a footer to the e-mail with some useful information
+    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)
+        # simplistic check to see if the url is valid,
+        # then append a trailing slash if it is missing
+        base = self.db.config.TRACKER_WEB 
+        if (not isinstance(base , type('')) or
+            not (base.startswith('http://') or base.startswith('https://'))):
+            base = "Configuration Error: TRACKER_WEB isn't a " \
+                "fully-qualified URL"
+        elif base[-1] != '/' :
+            base += '/'
+        web = base + self.classname + nodeid
+
+        # ensure the email address is properly quoted
+        email = straddr((self.db.config.TRACKER_NAME,
+            self.db.config.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
+        """
+        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
+        """
+        if __debug__ :
+            if not isinstance(oldvalues, type({})) :
+                raise TypeError("'oldvalues' must be dict-like, not %s."%
+                    type(oldvalues))
+
+        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
+            if key in ('activity', 'creator', 'creation'):
+                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 = []
+        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)
+                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:
+                    l.sort()
+                    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:
+                    l.sort()
+                    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.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
-#
-# Revision 1.9  2001/08/07 00:15:51  richard
-# Added the copyright/license notice to (nearly) all files at request of
-# Bizar Software.
-#
-# Revision 1.8  2001/08/02 06:38:17  richard
-# Roundupdb now appends "mailing list" information to its messages which
-# include the e-mail address and web interface address. Templates may
-# override this in their db classes to include specific information (support
-# instructions, etc).
-#
-# Revision 1.7  2001/07/30 02:38:31  richard
-# get() now has a default arg - for migration only.
-#
-# Revision 1.6  2001/07/30 00:05:54  richard
-# Fixed IssueClass so that superseders links to its classname rather than
-# hard-coded to "issue".
-#
-# Revision 1.5  2001/07/29 07:01:39  richard
-# Added vim command to all source so that we don't get no steenkin' tabs :)
-#
-# Revision 1.4  2001/07/29 04:05:37  richard
-# Added the fabricated property "id".
-#
-# Revision 1.3  2001/07/23 07:14:41  richard
-# Moved the database backends off into backends.
-#
-# Revision 1.2  2001/07/22 12:09:32  richard
-# Final commit of Grande Splite
-#
-# Revision 1.1  2001/07/22 11:58:35  richard
-# More Grande Splite
-#
-#
 # vim: set filetype=python ts=4 sw=4 et si