Code

Did some old TODOs
[roundup.git] / roundup / roundupdb.py
index f870bb99a0e4b75e60accffc5d66b1dfdebaf2f5..57e678da6ed9cbc74b4a2d7b412913e93d0c21e8 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.52 2002-05-15 03:27:16 richard Exp $
+# $Id: roundupdb.py,v 1.62 2002-07-14 02:05:53 richard Exp $
 
 __doc__ = """
 Extending hyperdb with types specific to issue-tracking.
 """
 
-import re, os, smtplib, socket, copy, time, random
+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 dump_address_pair as straddr
+except ImportError :
+    from rfc822 import dump_address_pair as straddr
 
-import hyperdb, date
+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 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)
-
 
 def extractUserFromList(userClass, users):
     '''Given a list of users, try to extract the first non-anonymous user
@@ -80,7 +75,7 @@ class Database:
         # 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(self.user, users)
             if user is not None: return user
@@ -97,163 +92,6 @@ class Database:
         else:
             return 0
 
-_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)
-        # 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)
-
-    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, cache=1):
-        """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,
-                cache=cache)
-        else:
-            return hyperdb.Class.get(self, nodeid, propname, cache=cache)
-
-    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
-        """
-        l = self.auditors[event]
-        if detector not in l:
-            self.auditors[event].append(detector)
-
-    def react(self, event, detector):
-        """Register a detector
-        """
-        l = self.reactors[event]
-        if detector not in l:
-            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.db.storefile(self.classname, newid, None, content)
-        return newid
-
-    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':
-            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, cache=cache)
-        else:
-            return Class.get(self, nodeid, propname, cache=cache)
-
-    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
-
 class MessageSendError(RuntimeError):
     pass
 
@@ -261,29 +99,19 @@ class DetectorError(RuntimeError):
     pass
 
 # XXX deviation from spec - was called ItemClass
-class IssueClass(Class):
-
-    # 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'):
+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.
 
@@ -297,7 +125,7 @@ class IssueClass(Class):
         appended to the "messages" field of the specified issue.
         """
 
-    def nosymessage(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
@@ -318,9 +146,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
@@ -329,6 +154,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
@@ -341,16 +167,22 @@ class IssueClass(Class):
                 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
+            # 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)
+            self.send_message(nodeid, msgid, note, sendto)
 
     # XXX backwards compatibility - don't remove
     sendmessage = nosymessage
@@ -385,7 +217,7 @@ class IssueClass(Class):
             authname = users.get(authid, 'username')
         authaddr = users.get(authid, 'address')
         if authaddr:
-            authaddr = ' <%s>'%authaddr
+            authaddr = " <%s>" % straddr( ('',authaddr) )
         else:
             authaddr = ''
 
@@ -431,10 +263,11 @@ class IssueClass(Class):
         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)
@@ -502,12 +335,25 @@ 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
         """
@@ -549,6 +395,11 @@ 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."%
+                    type(oldvalues))
+
         cn = self.classname
         cl = self.db.classes[cn]
         changed = {}
@@ -625,6 +476,75 @@ class IssueClass(Class):
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.61  2002/07/09 04:19:09  richard
+# Added reindex command to roundup-admin.
+# Fixed reindex on first access.
+# Also fixed reindexing of entries that change.
+#
+# Revision 1.60  2002/07/09 03:02:52  richard
+# More indexer work:
+# - all String properties may now be indexed too. Currently there's a bit of
+#   "issue" specific code in the actual searching which needs to be
+#   addressed. In a nutshell:
+#   + pass 'indexme="yes"' as a String() property initialisation arg, eg:
+#         file = FileClass(db, "file", name=String(), type=String(),
+#             comment=String(indexme="yes"))
+#   + the comment will then be indexed and be searchable, with the results
+#     related back to the issue that the file is linked to
+# - as a result of this work, the FileClass has a default MIME type that may
+#   be overridden in a subclass, or by the use of a "type" property as is
+#   done in the default templates.
+# - the regeneration of the indexes (if necessary) is done once the schema is
+#   set up in the dbinit.
+#
+# Revision 1.59  2002/06/18 03:55:25  dman13
+# Fixed name/address display problem introduced by an earlier change.
+# (instead of "name<addr>" display "name <addr>")
+#
+# 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
 #
@@ -637,6 +557,17 @@ class IssueClass(Class):
 # 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 ;)
 #