X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Froundupdb.py;h=8fd9b287bf552ad1920678dca470a16adfc62d94;hb=715f6d151e0a42146aeb9c64af875cae6bb946e6;hp=bce531513554842af812c949173a08c2421fe94e;hpb=4d5eb5a8fdaec99dd02d6edfbff8b2c29af16104;p=roundup.git diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py index bce5315..8fd9b28 100644 --- a/roundup/roundupdb.py +++ b/roundup/roundupdb.py @@ -15,275 +15,65 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: roundupdb.py,v 1.48 2002-03-18 18:32:00 rochecompaan Exp $ +# $Id: roundupdb.py,v 1.73 2002-11-05 22:59:46 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 - -import hyperdb, date +# 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 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 - 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 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, - 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 - 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 - ''' - - 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 class DetectorError(RuntimeError): + ''' Raised by detectors that want to indicate that something's amiss + ''' 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'): +# 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. @@ -297,7 +87,7 @@ class IssueClass(Class): 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 @@ -307,7 +97,6 @@ class IssueClass(Class): """ users = self.db.user messages = self.db.msg - files = self.db.file # figure the recipient ids sendto = [] @@ -319,9 +108,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 @@ -330,6 +116,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 @@ -342,13 +129,39 @@ class IssueClass(Class): 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) + + # 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 @@ -356,20 +169,17 @@ class IssueClass(Class): 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 = '' @@ -391,8 +201,8 @@ class IssueClass(Class): 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': @@ -402,21 +212,24 @@ class IssueClass(Class): content = cStringIO.StringIO('\n'.join(m)) content_encoded = cStringIO.StringIO() quopri.encode(content, content_encoded, 0) - content_encoded.seek(0) - content_encoded = content_encoded.read() + 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.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) @@ -424,7 +237,7 @@ class IssueClass(Class): writer.addheader('In-Reply-To', inreplyto) # add a uniquely Roundup header to help filtering - writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME) + writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME) # attach files if message_files: @@ -465,7 +278,8 @@ class IssueClass(Class): # 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 @@ -483,12 +297,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.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 """ @@ -520,6 +347,7 @@ class IssueClass(Class): 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, '----------') @@ -529,6 +357,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 = {} @@ -536,7 +369,10 @@ class IssueClass(Class): # determine what changed for key in oldvalues.keys(): - if key in ['files','messages']: continue + 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: @@ -584,6 +420,7 @@ class IssueClass(Class): else: l.append(entry) if l: + l.sort() change = '+%s'%(', '.join(l)) l = [] # check for removals @@ -594,6 +431,7 @@ class IssueClass(Class): else: l.append(entry) if l: + l.sort() change += ' -%s'%(', '.join(l)) else: change = '%s -> %s'%(oldvalue, value) @@ -603,217 +441,4 @@ class IssueClass(Class): m.insert(0, '') return '\n'.join(m) -# -# $Log: not supported by cvs2svn $ -# 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 -# -# 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