diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py
index c77a3411bf27b7f093dd9f3742b8da2d98353ce2..8cbf92bee02dbfbf1815bf89ff9b5ae919be26c4 100644 (file)
--- a/roundup/roundupdb.py
+++ b/roundup/roundupdb.py
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
-# $Id: roundupdb.py,v 1.44 2002-02-15 07:08:44 richard Exp $
+# $Id: roundupdb.py,v 1.64 2002-09-10 00:18:20 richard Exp $
__doc__ = """
Extending hyperdb with types specific to issue-tracking.
"""
-import re, os, smtplib, socket, copy, time, random
-import mimetools, MimeWriter, cStringIO
-import base64, mimetypes
+import 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(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 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]
- return None
-
class Database:
def getuid(self):
"""Return the id of the "user" node associated with the user
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
-
- # try a straight match of the address
- user = extractUserFromList(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(users)
- if user is not None: return user
-
- # try to match the username to the address (for local
- # submissions where the address is empty)
- user = extractUserFromList(self.user.stringFind(username=address))
-
- # couldn't match address or username, so create a new user
- if create:
- return self.user.create(username=address, address=address,
- realname=realname)
- 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
- '''
- if propname == 'content':
- return self.db.getfile(self.classname, nodeid, None)
- 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
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.
appended to the "messages" field of the specified issue.
"""
- def sendmessage(self, nodeid, msgid, change_note):
+ def nosymessage(self, nodeid, msgid, oldvalues):
"""Send a message to the members of an issue's nosy list.
The message is sent only to users on the nosy list who are not
"""
users = self.db.user
messages = self.db.msg
- files = self.db.file
# figure the recipient ids
sendto = []
# 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
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
sendto.append(nosyid)
recipients.append(nosyid)
- # no new recipients
- if not sendto:
- return
+ # generate a change note
+ if oldvalues:
+ note = self.generateChangeNote(nodeid, oldvalues)
+ else:
+ note = self.generateCreateNote(nodeid)
+
+ # we have new recipients
+ if sendto:
+ # map userids to addresses
+ sendto = [users.get(i, 'address') for i in sendto]
+
+ # update the message's recipients list
+ messages.set(msgid, recipients=recipients)
+
+ # send the message
+ self.send_message(nodeid, msgid, note, sendto)
+
+ # XXX backwards compatibility - don't remove
+ sendmessage = nosymessage
+
+ def send_message(self, nodeid, msgid, note, sendto):
+ '''Actually send the nominated message from this node to the sendto
+ recipients, with the note appended.
+ '''
+ users = self.db.user
+ messages = self.db.msg
+ files = self.db.file
# determine the messageid and inreplyto of the message
inreplyto = messages.get(msgid, 'inreplyto')
messageid = messages.get(msgid, 'messageid')
+
+ # make up a messageid if there isn't one (web edit)
if not messageid:
# this is an old message that didn't get a messageid, so
# create one
self.classname, nodeid, self.db.config.MAIL_DOMAIN)
messages.set(msgid, messageid=messageid)
- # update the message's recipients list
- messages.set(msgid, recipients=recipients)
-
# send an email to the people who missed out
- sendto = [users.get(i, 'address') for i in sendto]
cn = self.classname
title = self.get(nodeid, 'title') or '%s message copy'%cn
# figure author information
+ authid = messages.get(msgid, 'author')
authname = users.get(authid, 'realname')
if not authname:
authname = users.get(authid, 'username')
authaddr = users.get(authid, 'address')
if authaddr:
- authaddr = ' <%s>'%authaddr
+ authaddr = " <%s>" % straddr( ('',authaddr) )
else:
authaddr = ''
m.append(messages.get(msgid, 'content'))
# add the change note
- if change_note:
- m.append(change_note)
+ if note:
+ m.append(note)
# put in roundup's signature
if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
m.append(self.email_signature(nodeid, msgid))
+ # encode the content as quoted-printable
+ content = cStringIO.StringIO('\n'.join(m))
+ content_encoded = cStringIO.StringIO()
+ quopri.encode(content, content_encoded, 0)
+ content_encoded = content_encoded.getvalue()
+
# get the files for this message
message_files = messages.get(msgid, 'files')
+ # make sure the To line is always the same (for testing mostly)
+ sendto.sort()
+
# create the message
message = cStringIO.StringIO()
writer = MimeWriter.MimeWriter(message)
writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
writer.addheader('To', ', '.join(sendto))
- writer.addheader('From', '%s <%s>'%(authname,
- self.db.config.ISSUE_TRACKER_EMAIL))
- writer.addheader('Reply-To', '%s <%s>'%(self.db.config.INSTANCE_NAME,
- self.db.config.ISSUE_TRACKER_EMAIL))
+ writer.addheader('From', straddr(
+ (authname, self.db.config.ISSUE_TRACKER_EMAIL) ) )
+ writer.addheader('Reply-To', straddr(
+ (self.db.config.INSTANCE_NAME,
+ self.db.config.ISSUE_TRACKER_EMAIL) ) )
writer.addheader('MIME-Version', '1.0')
if messageid:
writer.addheader('Message-Id', messageid)
if message_files:
part = writer.startmultipartbody('mixed')
part = writer.nextpart()
+ part.addheader('Content-Transfer-Encoding', 'quoted-printable')
body = part.startbody('text/plain')
- body.write('\n'.join(m))
+ body.write(content_encoded)
for fileid in message_files:
name = files.get(fileid, 'name')
mime_type = files.get(fileid, 'type')
body.write(base64.encodestring(content))
writer.lastpart()
else:
+ writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
body = writer.startbody('text/plain')
- body.write('\n'.join(m))
+ body.write(content_encoded)
# now try to send the message
if SENDMAILDEBUG:
open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
- self.db.config.ADMIN_EMAIL,', '.join(sendto),message.getvalue()))
+ self.db.config.ADMIN_EMAIL,
+ ', '.join(sendto),message.getvalue()))
else:
try:
# send the message as admin so bounces are sent there
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
"""
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, '----------')
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 = {}
l = changed.items()
l.sort()
for propname, oldvalue in l:
- prop = cl.properties[propname]
+ prop = props[propname]
value = cl.get(nodeid, propname, None)
if isinstance(prop, hyperdb.Link):
link = self.db.classes[prop.classname]
m.insert(0, '')
return '\n'.join(m)
-#
-# $Log: not supported by cvs2svn $
-# 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
-#
-# 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
-#
-# 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