From 9639606db9eee8cc7e58ba5422848fe3a1a01a6d Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 22 Aug 2002 07:56:51 +0000 Subject: [PATCH] Whee! It's not finished yet, but I can create a new instance and play with it a little bit :) git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@985 57a73879-2fb5-44c3-a270-3262357dd7e2 --- roundup/backends/__init__.py | 15 +- roundup/backends/back_gadfly.py | 1623 +++++++++++++++++++++++++++++++ 2 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 roundup/backends/back_gadfly.py diff --git a/roundup/backends/__init__.py b/roundup/backends/__init__.py index 1e0348b..8ec7fba 100644 --- a/roundup/backends/__init__.py +++ b/roundup/backends/__init__.py @@ -15,7 +15,7 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: __init__.py,v 1.13 2002-07-11 01:11:03 richard Exp $ +# $Id: __init__.py,v 1.14 2002-08-22 07:56:51 richard Exp $ __all__ = [] @@ -36,6 +36,15 @@ else: anydbm = back_anydbm __all__.append('anydbm') +try: + import gadfly +except ImportError, message: + if str(message) != 'No module named gadfly': raise +else: + import back_gadfly + bsddb = back_gadfly + __all__.append('gadfly') + try: import bsddb except ImportError, message: @@ -65,6 +74,10 @@ else: # # $Log: not supported by cvs2svn $ +# Revision 1.13 2002/07/11 01:11:03 richard +# Added metakit backend to the db tests and fixed the more easily fixable test +# failures. +# # Revision 1.12 2002/05/22 00:32:33 richard # . changed the default message list in issues to display the message body # . made backends.__init__ be more specific about which ImportErrors it really diff --git a/roundup/backends/back_gadfly.py b/roundup/backends/back_gadfly.py new file mode 100644 index 0000000..ec62ba0 --- /dev/null +++ b/roundup/backends/back_gadfly.py @@ -0,0 +1,1623 @@ +# $Id: back_gadfly.py,v 1.1 2002-08-22 07:56:51 richard Exp $ +__doc__ = ''' +About Gadfly +============ + +Gadfly is a collection of python modules that provides relational +database functionality entirely implemented in Python. It supports a +subset of the intergalactic standard RDBMS Structured Query Language +SQL. + + +Basic Structure +=============== + +We map roundup classes to relational tables. Automatically detect schema +changes and modify the gadfly table schemas appropriately. Multilinks +(which represent a many-to-many relationship) are handled through +intermediate tables. + +Journals are stored adjunct to the per-class tables. + +Table columns for properties have "_" prepended so the names can't +clash with restricted names (like "order"). Retirement is determined by the +__retired__ column being true. + +All columns are defined as VARCHAR, since it really doesn't matter what +type they're defined as. We stuff all kinds of data in there ;) [as long as +it's marshallable, gadfly doesn't care] + + +Additional Instance Requirements +================================ + +The instance configuration must specify where the database is. It does this +with GADFLY_DATABASE, which is used as the arguments to the gadfly.gadfly() +method: + +Using an on-disk database directly (not a good idea): + GADFLY_DATABASE = (database name, directory) + +Using a network database (much better idea): + GADFLY_DATABASE = (policy, password, address, port) + +Because multiple accesses directly to a gadfly database aren't handled, but +multiple network accesses are, it's strongly advised that the latter setup be +used. + +''' + +# standard python modules +import sys, os, time, re, errno, weakref + +# roundup modules +from roundup import hyperdb, date, password, roundupdb, security +from roundup.hyperdb import String, Password, Date, Interval, Link, \ + Multilink, DatabaseError, Boolean, Number + +# the all-important gadfly :) +import gadfly +from gadfly import client + +# support +from blobfiles import FileStorage +from roundup.indexer import Indexer +from sessions import Sessions + +class Database(FileStorage, hyperdb.Database, roundupdb.Database): + # flag to set on retired entries + RETIRED_FLAG = '__hyperdb_retired' + + def __init__(self, config, journaltag=None): + ''' Open the database and load the schema from it. + ''' + self.config, self.journaltag = config, journaltag + self.dir = config.DATABASE + self.classes = {} + self.indexer = Indexer(self.dir) + self.sessions = Sessions(self.config) + self.security = security.Security(self) + + db = config.GADFLY_DATABASE + if len(db) == 2: + # ensure files are group readable and writable + os.umask(0002) + try: + self.conn = gadfly.gadfly(*db) + except IOError, error: + if error.errno != errno.ENOENT: + raise + self.database_schema = {} + self.conn = gadfly.gadfly() + self.conn.startup(*db) + cursor = self.conn.cursor() + cursor.execute('create table schema (schema varchar)') + cursor.execute('create table ids (name varchar, num integer)') + else: + cursor = self.conn.cursor() + cursor.execute('select schema from schema') + self.database_schema = cursor.fetchone()[0] + else: + self.conn = client.gfclient(*db) + cursor = self.conn.cursor() + cursor.execute('select schema from schema') + self.database_schema = cursor.fetchone()[0] + + def post_init(self): + ''' Called once the schema initialisation has finished. + + We should now confirm that the schema defined by our "classes" + attribute actually matches the schema in the database. + ''' + for classname, spec in self.classes.items(): + if self.database_schema.has_key(classname): + dbspec = self.database_schema[classname] + self.update_class(spec.schema(), dbspec) + self.database_schema[classname] = dbspec + else: + self.create_class(spec) + self.database_schema[classname] = spec.schema() + + for classname in self.database_schema.keys(): + if not self.classes.has_key(classname): + self.drop_class(classname) + + # commit any changes + cursor = self.conn.cursor() + cursor.execute('delete from schema') + cursor.execute('insert into schema values (?)', (self.database_schema,)) + self.conn.commit() + + def determine_columns(self, spec): + ''' Figure the column names and multilink properties from the spec + ''' + cols = [] + mls = [] + # add the multilinks separately + for col, prop in spec.properties.items(): + if isinstance(prop, Multilink): + mls.append(col) + else: + cols.append('_'+col) + cols.sort() + return cols, mls + + def update_class(self, spec, dbspec): + ''' Determine the differences between the current spec and the + database version of the spec, and update where necessary + ''' + if spec == dbspec: + return + raise NotImplementedError + + def create_class(self, spec): + ''' Create a database table according to the given spec. + ''' + cols, mls = self.determine_columns(spec) + + # add on our special columns + cols.append('id') + cols.append('__retired__') + + cursor = self.conn.cursor() + + # create the base table + cols = ','.join(['%s varchar'%x for x in cols]) + sql = 'create table %s (%s)'%(spec.classname, cols) + if __debug__: + print >>hyperdb.DEBUG, 'create_class', (self, sql) + cursor.execute(sql) + + # journal table + cols = ','.join(['%s varchar'%x + for x in 'nodeid date tag action params'.split()]) + sql = 'create table %s__journal (%s)'%(spec.classname, cols) + if __debug__: + print >>hyperdb.DEBUG, 'create_class', (self, sql) + cursor.execute(sql) + + # now create the multilink tables + for ml in mls: + sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%( + spec.classname, ml) + if __debug__: + print >>hyperdb.DEBUG, 'create_class', (self, sql) + cursor.execute(sql) + + # ID counter + sql = 'insert into ids (name, num) values (?,?)' + vals = (spec.classname, 1) + if __debug__: + print >>hyperdb.DEBUG, 'create_class', (self, sql, vals) + cursor.execute(sql, vals) + + def drop_class(self, spec): + ''' Drop the given table from the database. + + Drop the journal and multilink tables too. + ''' + # figure the multilinks + mls = [] + for col, prop in spec.properties.items(): + if isinstance(prop, Multilink): + mls.append(col) + cursor = self.conn.cursor() + + sql = 'drop table %s'%spec.classname + if __debug__: + print >>hyperdb.DEBUG, 'drop_class', (self, sql) + cursor.execute(sql) + + sql = 'drop table %s__journal'%spec.classname + if __debug__: + print >>hyperdb.DEBUG, 'drop_class', (self, sql) + cursor.execute(sql) + + for ml in mls: + sql = 'drop table %s_%s'%(spec.classname, ml) + if __debug__: + print >>hyperdb.DEBUG, 'drop_class', (self, sql) + cursor.execute(sql) + + # + # Classes + # + def __getattr__(self, classname): + ''' A convenient way of calling self.getclass(classname). + ''' + if self.classes.has_key(classname): + if __debug__: + print >>hyperdb.DEBUG, '__getattr__', (self, classname) + return self.classes[classname] + raise AttributeError, classname + + def addclass(self, cl): + ''' Add a Class to the hyperdatabase. + ''' + if __debug__: + print >>hyperdb.DEBUG, 'addclass', (self, cl) + cn = cl.classname + if self.classes.has_key(cn): + raise ValueError, cn + self.classes[cn] = cl + + def getclasses(self): + ''' Return a list of the names of all existing classes. + ''' + if __debug__: + print >>hyperdb.DEBUG, 'getclasses', (self,) + l = self.classes.keys() + l.sort() + return l + + def getclass(self, classname): + '''Get the Class object representing a particular class. + + If 'classname' is not a valid class name, a KeyError is raised. + ''' + if __debug__: + print >>hyperdb.DEBUG, 'getclass', (self, classname) + return self.classes[classname] + + def clear(self): + ''' Delete all database contents. + + Note: I don't commit here, which is different behaviour to the + "nuke from orbit" behaviour in the *dbms. + ''' + if __debug__: + print >>hyperdb.DEBUG, 'clear', (self,) + cursor = self.conn.cursor() + for cn in self.classes.keys(): + sql = 'delete from %s'%cn + if __debug__: + print >>hyperdb.DEBUG, 'clear', (self, sql) + cursor.execute(sql) + + # + # Node IDs + # + def newid(self, classname): + ''' Generate a new id for the given class + ''' + # get the next ID + cursor = self.conn.cursor() + sql = 'select num from ids where name=?' + if __debug__: + print >>hyperdb.DEBUG, 'newid', (self, sql, classname) + cursor.execute(sql, (classname, )) + newid = cursor.fetchone()[0] + + # update the counter + sql = 'update ids set num=? where name=?' + vals = (newid+1, classname) + if __debug__: + print >>hyperdb.DEBUG, 'newid', (self, sql, vals) + cursor.execute(sql, vals) + + # return as string + return str(newid) + + def setid(self, classname, setid): + ''' Set the id counter: used during import of database + ''' + cursor = self.conn.cursor() + sql = 'update ids set num=? where name=?' + vals = (setid, spec.classname) + if __debug__: + print >>hyperdb.DEBUG, 'setid', (self, sql, vals) + cursor.execute(sql, vals) + + # + # Nodes + # + + def addnode(self, classname, nodeid, node): + ''' Add the specified node to its class's db. + ''' + # gadfly requires values for all non-multilink columns + cl = self.classes[classname] + cols, mls = self.determine_columns(cl) + + # default the non-multilink columns + for col, prop in cl.properties.items(): + if not isinstance(col, Multilink): + if not node.has_key(col): + node[col] = None + + node = self.serialise(classname, node) + + # make sure the ordering is correct for column name -> column value + vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0) + s = ','.join(['?' for x in cols]) + ',?,?' + cols = ','.join(cols) + ',id,__retired__' + + # perform the inserts + cursor = self.conn.cursor() + sql = 'insert into %s (%s) values (%s)'%(classname, cols, s) + if __debug__: + print >>hyperdb.DEBUG, 'addnode', (self, sql, vals) + cursor.execute(sql, vals) + + # insert the multilink rows + for col in mls: + t = '%s_%s'%(classname, col) + for entry in node[col]: + sql = 'insert into %s (linkid, nodeid) values (?,?)'%t + vals = (entry, nodeid) + if __debug__: + print >>hyperdb.DEBUG, 'addnode', (self, sql, vals) + cursor.execute(sql, vals) + + def setnode(self, classname, nodeid, node): + ''' Change the specified node. + ''' + node = self.serialise(classname, node) + + cl = self.classes[classname] + cols = [] + mls = [] + # add the multilinks separately + for col in node.keys(): + prop = cl.properties[col] + if isinstance(prop, Multilink): + mls.append(col) + else: + cols.append('_'+col) + cols.sort() + + # make sure the ordering is correct for column name -> column value + vals = tuple([node[col[1:]] for col in cols]) + s = ','.join(['?' for x in cols]) + cols = ','.join(cols) + + # perform the update + cursor = self.conn.cursor() + sql = 'update %s (%s) values (%s)'%(classname, cols, s) + if __debug__: + print >>hyperdb.DEBUG, 'setnode', (self, sql, vals) + cursor.execute(sql, vals) + + # now the fun bit, updating the multilinks ;) + # XXX TODO XXX + + def getnode(self, classname, nodeid): + ''' Get a node from the database. + ''' + # figure the columns we're fetching + cl = self.classes[classname] + cols, mls = self.determine_columns(cl) + cols = ','.join(cols) + + # perform the basic property fetch + cursor = self.conn.cursor() + sql = 'select %s from %s where id=?'%(cols, classname) + if __debug__: + print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + values = cursor.fetchone() + + # make up the node + node = {} + for col in range(len(cols)): + node[col] = values[col] + + # now the multilinks + for col in mls: + # get the link ids + sql = 'select linkid from %s_%s where nodeid=?'%(classname, col) + if __debug__: + print >>hyperdb.DEBUG, 'getnode', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + # extract the first column from the result + node[col] = [x[0] for x in cursor.fetchall()] + + return self.unserialise(classname, node) + + def serialise(self, classname, node): + '''Copy the node contents, converting non-marshallable data into + marshallable data. + ''' + if __debug__: + print >>hyperdb.DEBUG, 'serialise', classname, node + properties = self.getclass(classname).getprops() + d = {} + for k, v in node.items(): + # if the property doesn't exist, or is the "retired" flag then + # it won't be in the properties dict + if not properties.has_key(k): + d[k] = v + continue + + # get the property spec + prop = properties[k] + + if isinstance(prop, Password): + d[k] = str(v) + elif isinstance(prop, Date) and v is not None: + d[k] = v.serialise() + elif isinstance(prop, Interval) and v is not None: + d[k] = v.serialise() + else: + d[k] = v + return d + + def unserialise(self, classname, node): + '''Decode the marshalled node data + ''' + if __debug__: + print >>hyperdb.DEBUG, 'unserialise', classname, node + properties = self.getclass(classname).getprops() + d = {} + for k, v in node.items(): + # if the property doesn't exist, or is the "retired" flag then + # it won't be in the properties dict + if not properties.has_key(k): + d[k] = v + continue + + # get the property spec + prop = properties[k] + + if isinstance(prop, Date) and v is not None: + d[k] = date.Date(v) + elif isinstance(prop, Interval) and v is not None: + d[k] = date.Interval(v) + elif isinstance(prop, Password): + p = password.Password() + p.unpack(v) + d[k] = p + else: + d[k] = v + return d + + def hasnode(self, classname, nodeid): + ''' Determine if the database has a given node. + ''' + cursor = self.conn.cursor() + sql = 'select count(*) from %s where nodeid=?'%classname + if __debug__: + print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + return cursor.fetchone()[0] + + def countnodes(self, classname): + ''' Count the number of nodes that exist for a particular Class. + ''' + cursor = self.conn.cursor() + sql = 'select count(*) from %s'%classname + if __debug__: + print >>hyperdb.DEBUG, 'countnodes', (self, sql) + cursor.execute(sql) + return cursor.fetchone()[0] + + def getnodeids(self, classname): + ''' Retrieve all the ids of the nodes for a particular Class. + ''' + cursor = self.conn.cursor() + sql = 'select id from %s'%classname + if __debug__: + print >>hyperdb.DEBUG, 'getnodeids', (self, sql) + cursor.execute(sql) + return [x[0] for x in cursor.fetchall()] + + def addjournal(self, classname, nodeid, action, params): + ''' Journal the Action + 'action' may be: + + 'create' or 'set' -- 'params' is a dictionary of property values + 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) + 'retire' -- 'params' is None + ''' + if isinstance(params, type({})): + if params.has_key('creator'): + journaltag = self.user.get(params['creator'], 'username') + del params['creator'] + else: + journaltag = self.journaltag + if params.has_key('created'): + journaldate = params['created'].serialise() + del params['created'] + else: + journaldate = date.Date().serialise() + if params.has_key('activity'): + del params['activity'] + + # serialise the parameters now + if action in ('set', 'create'): + params = self.serialise(classname, params) + else: + journaltag = self.journaltag + journaldate = date.Date().serialise() + + # create the journal entry + cols = ','.join('nodeid date tag action params'.split()) + entry = (nodeid, journaldate, journaltag, action, params) + + if __debug__: + print >>hyperdb.DEBUG, 'doSaveJournal', entry + + # do the insert + cursor = self.conn.cursor() + sql = 'insert into %s__journal (%s) values (?,?,?,?,?)'%(classname, + cols) + if __debug__: + print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry) + cursor.execute(sql, entry) + + def getjournal(self, classname, nodeid): + ''' get the journal for id + ''' + cols = ','.join('nodeid date tag action params'.split()) + cursor = self.conn.cursor() + sql = 'select %s from %s__journal where nodeid=?'%(cols, classname) + if __debug__: + print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + res = [] + for nodeid, date_stamp, user, action, params in cursor.fetchall(): + res.append((nodeid, date.Date(date_stamp), user, action, params)) + return res + + def pack(self, pack_before): + ''' Pack the database, removing all journal entries before the + "pack_before" date. + ''' + # get a 'yyyymmddhhmmss' version of the date + date_stamp = pack_before.serialise() + + # do the delete + cursor = self.conn.cursor() + for classname in self.classes.keys(): + sql = 'delete from %s__journal where date>hyperdb.DEBUG, 'pack', (self, sql, date_stamp) + cursor.execute(sql, (date_stamp,)) + + def commit(self): + ''' Commit the current transactions. + + Save all data changed since the database was opened or since the + last commit() or rollback(). + ''' + self.conn.commit() + + def rollback(self): + ''' Reverse all actions from the current transaction. + + Undo all the changes made since the database was opened or the last + commit() or rollback() was performed. + ''' + self.conn.rollback() + +# +# The base Class class +# +class Class(hyperdb.Class): + ''' The handle to a particular class of nodes in a hyperdatabase. + + All methods except __repr__ and getnode must be implemented by a + concrete backend Class. + ''' + + def __init__(self, db, classname, **properties): + '''Create a new class with a given name and property specification. + + 'classname' must not collide with the name of an existing class, + or a ValueError is raised. The keyword arguments in 'properties' + must map names to property objects, or a TypeError is raised. + ''' + if (properties.has_key('creation') or properties.has_key('activity') + or properties.has_key('creator')): + raise ValueError, '"creation", "activity" and "creator" are '\ + 'reserved' + + self.classname = classname + self.properties = properties + self.db = weakref.proxy(db) # use a weak ref to avoid circularity + self.key = '' + + # should we journal changes (default yes) + self.do_journal = 1 + + # do the db-related init stuff + db.addclass(self) + + self.auditors = {'create': [], 'set': [], 'retire': []} + self.reactors = {'create': [], 'set': [], 'retire': []} + + def schema(self): + ''' A dumpable version of the schema that we can store in the + database + ''' + return (self.key, [(x, repr(y)) for x,y in self.properties.items()]) + + def enableJournalling(self): + '''Turn journalling on for this class + ''' + self.do_journal = 1 + + def disableJournalling(self): + '''Turn journalling off for this class + ''' + self.do_journal = 0 + + # Editing nodes: + def create(self, **propvalues): + ''' Create a new node of this class and return its id. + + The keyword arguments in 'propvalues' map property names to values. + + The values of arguments must be acceptable for the types of their + corresponding properties or a TypeError is raised. + + If this class has a key property, it must be present and its value + must not collide with other key strings or a ValueError is raised. + + Any other properties on this class that are missing from the + 'propvalues' dictionary are set to None. + + If an id in a link or multilink property does not refer to a valid + node, an IndexError is raised. + ''' + if propvalues.has_key('id'): + raise KeyError, '"id" is reserved' + + if self.db.journaltag is None: + raise DatabaseError, 'Database open read-only' + + if propvalues.has_key('creation') or propvalues.has_key('activity'): + raise KeyError, '"creation" and "activity" are reserved' + + self.fireAuditors('create', None, propvalues) + + # new node's id + newid = self.db.newid(self.classname) + + # validate propvalues + num_re = re.compile('^\d+$') + for key, value in propvalues.items(): + if key == self.key: + try: + self.lookup(value) + except KeyError: + pass + else: + raise ValueError, 'node with key "%s" exists'%value + + # try to handle this property + try: + prop = self.properties[key] + except KeyError: + raise KeyError, '"%s" has no property "%s"'%(self.classname, + key) + + if value is not None and isinstance(prop, Link): + if type(value) != type(''): + raise ValueError, 'link value must be String' + link_class = self.properties[key].classname + # if it isn't a number, it's a key + if not num_re.match(value): + try: + value = self.db.classes[link_class].lookup(value) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + key, value, link_class) + elif not self.db.getclass(link_class).hasnode(value): + raise IndexError, '%s has no node %s'%(link_class, value) + + # save off the value + propvalues[key] = value + + # register the link with the newly linked node + if self.do_journal and self.properties[key].do_journal: + self.db.addjournal(link_class, value, 'link', + (self.classname, newid, key)) + + elif isinstance(prop, Multilink): + if type(value) != type([]): + raise TypeError, 'new property "%s" not a list of ids'%key + + # clean up and validate the list of links + link_class = self.properties[key].classname + l = [] + for entry in value: + if type(entry) != type(''): + raise ValueError, '"%s" link value (%s) must be '\ + 'String'%(key, value) + # if it isn't a number, it's a key + if not num_re.match(entry): + try: + entry = self.db.classes[link_class].lookup(entry) + except (TypeError, KeyError): + raise IndexError, 'new property "%s": %s not a %s'%( + key, entry, self.properties[key].classname) + l.append(entry) + value = l + propvalues[key] = value + + # handle additions + for nodeid in value: + if not self.db.getclass(link_class).hasnode(nodeid): + raise IndexError, '%s has no node %s'%(link_class, + nodeid) + # register the link with the newly linked node + if self.do_journal and self.properties[key].do_journal: + self.db.addjournal(link_class, nodeid, 'link', + (self.classname, newid, key)) + + elif isinstance(prop, String): + if type(value) != type(''): + raise TypeError, 'new property "%s" not a string'%key + + elif isinstance(prop, Password): + if not isinstance(value, password.Password): + raise TypeError, 'new property "%s" not a Password'%key + + elif isinstance(prop, Date): + if value is not None and not isinstance(value, date.Date): + raise TypeError, 'new property "%s" not a Date'%key + + elif isinstance(prop, Interval): + if value is not None and not isinstance(value, date.Interval): + raise TypeError, 'new property "%s" not an Interval'%key + + elif value is not None and isinstance(prop, Number): + try: + float(value) + except ValueError: + raise TypeError, 'new property "%s" not numeric'%key + + elif value is not None and isinstance(prop, Boolean): + try: + int(value) + except ValueError: + raise TypeError, 'new property "%s" not boolean'%key + + # make sure there's data where there needs to be + for key, prop in self.properties.items(): + if propvalues.has_key(key): + continue + if key == self.key: + raise ValueError, 'key property "%s" is required'%key + if isinstance(prop, Multilink): + propvalues[key] = [] + else: + propvalues[key] = None + + # done + self.db.addnode(self.classname, newid, propvalues) + if self.do_journal: + self.db.addjournal(self.classname, newid, 'create', propvalues) + + self.fireReactors('create', newid, None) + + return newid + + _marker = [] + def get(self, nodeid, propname, default=_marker, cache=1): + '''Get the value of a property on an existing node of this class. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. 'propname' must be the name of a property + of this class or a KeyError is raised. + + 'cache' indicates whether the transaction cache should be queried + for the node. If the node has been modified and you need to + determine what its values prior to modification are, you need to + set cache=0. + ''' + if propname == 'id': + return nodeid + + if propname == 'creation': + if not self.do_journal: + raise ValueError, 'Journalling is disabled for this class' + 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': + if not self.do_journal: + raise ValueError, 'Journalling is disabled for this class' + 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': + if not self.do_journal: + raise ValueError, 'Journalling is disabled for this class' + 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) + + # get the property (raises KeyErorr if invalid) + prop = self.properties[propname] + + # get the node's dict + d = self.db.getnode(self.classname, nodeid) #, cache=cache) + + if not d.has_key(propname): + if default is _marker: + if isinstance(prop, Multilink): + return [] + else: + return None + else: + return default + + return d[propname] + + def getnode(self, nodeid, cache=1): + ''' Return a convenience wrapper for the node. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. + + 'cache' indicates whether the transaction cache should be queried + for the node. If the node has been modified and you need to + determine what its values prior to modification are, you need to + set cache=0. + ''' + return Node(self, nodeid, cache=cache) + + def set(self, nodeid, **propvalues): + '''Modify a property on an existing node of this class. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. + + Each key in 'propvalues' must be the name of a property of this + class or a KeyError is raised. + + All values in 'propvalues' must be acceptable types for their + corresponding properties or a TypeError is raised. + + If the value of the key property is set, it must not collide with + other key strings or a ValueError is raised. + + If the value of a Link or Multilink property contains an invalid + node id, a ValueError is raised. + ''' + raise NotImplementedError + + def retire(self, nodeid): + '''Retire a node. + + The properties on the node remain available from the get() method, + and the node's id is never reused. + + Retired nodes are not returned by the find(), list(), or lookup() + methods, and other nodes may reuse the values of their key properties. + ''' + cursor = self.db.conn.cursor() + sql = 'update %s set __retired__=1 where id=?'%self.classname + if __debug__: + print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + + def is_retired(self, nodeid): + '''Return true if the node is rerired + ''' + cursor = self.db.conn.cursor() + sql = 'select __retired__ from %s where id=?'%self.classname + if __debug__: + print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid) + cursor.execute(sql, (nodeid,)) + return cursor.fetchone()[0] + + def destroy(self, nodeid): + '''Destroy a node. + + WARNING: this method should never be used except in extremely rare + situations where there could never be links to the node being + deleted + WARNING: use retire() instead + WARNING: the properties of this node will not be available ever again + WARNING: really, use retire() instead + + Well, I think that's enough warnings. This method exists mostly to + support the session storage of the cgi interface. + + The node is completely removed from the hyperdb, including all journal + entries. It will no longer be available, and will generally break code + if there are any references to the node. + ''' + raise NotImplementedError + + def history(self, nodeid): + '''Retrieve the journal of edits on a particular node. + + 'nodeid' must be the id of an existing node of this class or an + IndexError is raised. + + The returned list contains tuples of the form + + (date, tag, action, params) + + 'date' is a Timestamp object specifying the time of the change and + 'tag' is the journaltag specified when the database was opened. + ''' + raise NotImplementedError + + # Locating nodes: + def hasnode(self, nodeid): + '''Determine if the given nodeid actually exists + ''' + return self.db.hasnode(self.classname, nodeid) + + def setkey(self, propname): + '''Select a String property of this class to be the key property. + + 'propname' must be the name of a String property of this class or + None, or a TypeError is raised. The values of the key property on + all existing nodes must be unique or a ValueError is raised. + ''' + # XXX create an index on the key prop column + prop = self.getprops()[propname] + if not isinstance(prop, String): + raise TypeError, 'key properties must be String' + self.key = propname + + def getkey(self): + '''Return the name of the key property for this class or None.''' + return self.key + + def labelprop(self, default_to_id=0): + ''' Return the property name for a label for the given node. + + This method attempts to generate a consistent label for the node. + It tries the following in order: + 1. key property + 2. "name" property + 3. "title" property + 4. first property from the sorted property name list + ''' + raise NotImplementedError + + def lookup(self, keyvalue): + '''Locate a particular node by its key property and return its id. + + If this class has no key property, a TypeError is raised. If the + 'keyvalue' matches one of the values for the key property among + the nodes in this class, the matching node's id is returned; + otherwise a KeyError is raised. + ''' + if not self.key: + raise TypeError, 'No key property set' + + cursor = self.db.conn.cursor() + sql = 'select id from %s where _%s=?'%(self.classname, self.key) + if __debug__: + print >>hyperdb.DEBUG, 'lookup', (self, sql, keyvalue) + cursor.execute(sql, (keyvalue,)) + + # see if there was a result + l = cursor.fetchall() + if not l: + raise KeyError, keyvalue + + # return the id + return l[0][0] + + def find(self, **propspec): + '''Get the ids of nodes in this class which link to the given nodes. + + 'propspec' consists of keyword args propname={nodeid:1,} + 'propname' must be the name of a property in this class, or a + KeyError is raised. That property must be a Link or Multilink + property, or a TypeError is raised. + + Any node in this class whose 'propname' property links to any of the + nodeids will be returned. Used by the full text indexing, which knows + that "foo" occurs in msg1, msg3 and file7, so we have hits on these + issues: + + db.issue.find(messages={'1':1,'3':1}, files={'7':1}) + ''' + raise NotImplementedError + + def filter(self, search_matches, filterspec, sort, group, + num_re = re.compile('^\d+$')): + ''' Return a list of the ids of the active nodes in this class that + match the 'filter' spec, sorted by the group spec and then the + sort spec + ''' + raise NotImplementedError + + def count(self): + '''Get the number of nodes in this class. + + If the returned integer is 'numnodes', the ids of all the nodes + in this class run from 1 to numnodes, and numnodes+1 will be the + id of the next node to be created in this class. + ''' + return self.db.countnodes(self.classname) + + # Manipulating properties: + def getprops(self, protected=1): + '''Return a dictionary mapping property names to property objects. + If the "protected" flag is true, we include protected properties - + those which may not be modified. + ''' + d = self.properties.copy() + if protected: + d['id'] = String() + d['creation'] = hyperdb.Date() + d['activity'] = hyperdb.Date() + d['creator'] = hyperdb.Link("user") + return d + + def addprop(self, **properties): + '''Add properties to this class. + + The keyword arguments in 'properties' must map names to property + objects, or a TypeError is raised. None of the keys in 'properties' + may collide with the names of existing properties, or a ValueError + is raised before any properties have been added. + ''' + for key in properties.keys(): + if self.properties.has_key(key): + raise ValueError, key + self.properties.update(properties) + + def index(self, nodeid): + '''Add (or refresh) the node to search indexes + ''' + # find all the String properties that have indexme + for prop, propclass in self.getprops().items(): + if isinstance(propclass, String) and propclass.indexme: + try: + value = str(self.get(nodeid, prop)) + except IndexError: + # node no longer exists - entry should be removed + self.db.indexer.purge_entry((self.classname, nodeid, prop)) + else: + # and index them under (classname, nodeid, property) + self.db.indexer.add_text((self.classname, nodeid, prop), + value) + + + # + # 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 fireAuditors(self, action, nodeid, newvalues): + '''Fire all registered auditors. + ''' + for audit in self.auditors[action]: + audit(self.db, self, nodeid, newvalues) + + def react(self, event, detector): + '''Register a detector + ''' + l = self.reactors[event] + if detector not in l: + self.reactors[event].append(detector) + + def fireReactors(self, action, nodeid, oldvalues): + '''Fire all registered reactors. + ''' + for react in self.reactors[action]: + react(self.db, self, nodeid, oldvalues) + +class FileClass(Class): + '''This class defines a large chunk of data. To support this, it has a + mandatory String property "content" which is typically saved off + externally to the hyperdb. + + The default MIME type of this data is defined by the + "default_mime_type" class attribute, which may be overridden by each + node if the class defines a "type" String property. + ''' + default_mime_type = 'text/plain' + + 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 import_list(self, propnames, proplist): + ''' Trap the "content" property... + ''' + # dupe this list so we don't affect others + propnames = propnames[:] + + # extract the "content" property from the proplist + i = propnames.index('content') + content = proplist[i] + del propnames[i] + del proplist[i] + + # do the normal import + newid = Class.import_list(self, propnames, proplist) + + # save off the "content" file + self.db.storefile(self.classname, newid, None, content) + return newid + + _marker = [] + def get(self, nodeid, propname, default=_marker, cache=1): + ''' trap the content propname and get it from the file + ''' + + poss_msg = 'Possibly a access right configuration problem.' + if propname == 'content': + 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 + + def index(self, nodeid): + ''' Index the node in the search index. + + We want to index the content in addition to the normal String + property indexing. + ''' + # perform normal indexing + Class.index(self, nodeid) + + # get the content to index + content = self.get(nodeid, 'content') + + # figure the mime type + if self.properties.has_key('type'): + mime_type = self.get(nodeid, 'type') + else: + mime_type = self.default_mime_type + + # and index! + self.db.indexer.add_text((self.classname, nodeid, 'content'), content, + mime_type) + +# XXX deviation from spec - was called ItemClass +class IssueClass(Class, roundupdb.IssueClass): + # 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(indexme='yes') + if not properties.has_key('messages'): + 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) + +# +# $Log: not supported by cvs2svn $ +# Revision 1.80 2002/08/16 04:28:13 richard +# added is_retired query to Class +# +# Revision 1.79 2002/07/29 23:30:14 richard +# documentation reorg post-new-security +# +# Revision 1.78 2002/07/21 03:26:37 richard +# Gordon, does this help? +# +# Revision 1.77 2002/07/18 11:27:47 richard +# ws +# +# Revision 1.76 2002/07/18 11:17:30 gmcm +# Add Number and Boolean types to hyperdb. +# Add conversion cases to web, mail & admin interfaces. +# Add storage/serialization cases to back_anydbm & back_metakit. +# +# Revision 1.75 2002/07/14 02:05:53 richard +# . all storage-specific code (ie. backend) is now implemented by the backends +# +# Revision 1.74 2002/07/10 00:24:10 richard +# braino +# +# Revision 1.73 2002/07/10 00:19:48 richard +# Added explicit closing of backend database handles. +# +# Revision 1.72 2002/07/09 21:53:38 gmcm +# Optimize Class.find so that the propspec can contain a set of ids to match. +# This is used by indexer.search so it can do just one find for all the index matches. +# This was already confusing code, but for common terms (lots of index matches), +# it is enormously faster. +# +# Revision 1.71 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.70 2002/06/27 12:06:20 gmcm +# Improve an error message. +# +# Revision 1.69 2002/06/17 23:15:29 richard +# Can debug to stdout now +# +# Revision 1.68 2002/06/11 06:52:03 richard +# . #564271 ] find() and new properties +# +# Revision 1.67 2002/06/11 05:02:37 richard +# . #565979 ] code error in hyperdb.Class.find +# +# Revision 1.66 2002/05/25 07:16:24 rochecompaan +# Merged search_indexing-branch with HEAD +# +# Revision 1.65 2002/05/22 04:12:05 richard +# . applied patch #558876 ] cgi client customization +# ... with significant additions and modifications ;) +# - extended handling of ML assignedto to all places it's handled +# - added more NotFound info +# +# Revision 1.64 2002/05/15 06:21:21 richard +# . node caching now works, and gives a small boost in performance +# +# As a part of this, I cleaned up the DEBUG output and implemented TRACE +# output (HYPERDBTRACE='file to trace to') with checkpoints at the start of +# CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff +# (using if __debug__ which is compiled out with -O) +# +# Revision 1.63 2002/04/15 23:25:15 richard +# . node ids are now generated from a lockable store - no more race conditions +# +# We're using the portalocker code by Jonathan Feinberg that was contributed +# to the ASPN Python cookbook. This gives us locking across Unix and Windows. +# +# Revision 1.62 2002/04/03 07:05:50 richard +# d'oh! killed retirement of nodes :( +# all better now... +# +# Revision 1.61 2002/04/03 06:11:51 richard +# Fix for old databases that contain properties that don't exist any more. +# +# Revision 1.60 2002/04/03 05:54:31 richard +# Fixed serialisation problem by moving the serialisation step out of the +# hyperdb.Class (get, set) into the hyperdb.Database. +# +# Also fixed htmltemplate after the showid changes I made yesterday. +# +# Unit tests for all of the above written. +# +# Revision 1.59.2.2 2002/04/20 13:23:33 rochecompaan +# We now have a separate search page for nodes. Search links for +# different classes can be customized in instance_config similar to +# index links. +# +# Revision 1.59.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.59 2002/03/12 22:52:26 richard +# more pychecker warnings removed +# +# Revision 1.58 2002/02/27 03:23:16 richard +# Ran it through pychecker, made fixes +# +# Revision 1.57 2002/02/20 05:23:24 richard +# Didn't accomodate new values for new properties +# +# Revision 1.56 2002/02/20 05:05:28 richard +# . Added simple editing for classes that don't define a templated interface. +# - access using the admin "class list" interface +# - limited to admin-only +# - requires the csv module from object-craft (url given if it's missing) +# +# Revision 1.55 2002/02/15 07:27:12 richard +# Oops, precedences around the way w0rng. +# +# Revision 1.54 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.53 2002/01/22 07:21:13 richard +# . fixed back_bsddb so it passed the journal tests +# +# ... it didn't seem happy using the back_anydbm _open method, which is odd. +# Yet another occurrance of whichdb not being able to recognise older bsddb +# databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the +# process. +# +# Revision 1.52 2002/01/21 16:33:19 rochecompaan +# You can now use the roundup-admin tool to pack the database +# +# Revision 1.51 2002/01/21 03:01:29 richard +# brief docco on the do_journal argument +# +# Revision 1.50 2002/01/19 13:16:04 rochecompaan +# Journal entries for link and multilink properties can now be switched on +# or off. +# +# Revision 1.49 2002/01/16 07:02:57 richard +# . lots of date/interval related changes: +# - more relaxed date format for input +# +# Revision 1.48 2002/01/14 06:32:34 richard +# . #502951 ] adding new properties to old database +# +# Revision 1.47 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.46 2002/01/07 10:42:23 richard +# oops +# +# Revision 1.45 2002/01/02 04:18:17 richard +# hyperdb docstrings +# +# Revision 1.44 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.43 2001/12/20 06:13:24 rochecompaan +# Bugs fixed: +# . Exception handling in hyperdb for strings-that-look-like numbers got +# lost somewhere +# . Internet Explorer submits full path for filename - we now strip away +# the path +# Features added: +# . Link and multilink properties are now displayed sorted in the cgi +# interface +# +# Revision 1.42 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.41 2001/12/15 23:47:47 richard +# Cleaned up some bare except statements +# +# Revision 1.40 2001/12/14 23:42:57 richard +# yuck, a gdbm instance tests false :( +# I've left the debugging code in - it should be removed one day if we're ever +# _really_ anal about performace :) +# +# Revision 1.39 2001/12/02 05:06:16 richard +# . We now use weakrefs in the Classes to keep the database reference, so +# the close() method on the database is no longer needed. +# I bumped the minimum python requirement up to 2.1 accordingly. +# . #487480 ] roundup-server +# . #487476 ] INSTALL.txt +# +# I also cleaned up the change message / post-edit stuff in the cgi client. +# There's now a clearly marked "TODO: append the change note" where I believe +# the change note should be added there. The "changes" list will obviously +# have to be modified to be a dict of the changes, or somesuch. +# +# More testing needed. +# +# Revision 1.38 2001/12/01 07:17:50 richard +# . We now have basic transaction support! Information is only written to +# the database when the commit() method is called. Only the anydbm +# backend is modified in this way - neither of the bsddb backends have been. +# The mail, admin and cgi interfaces all use commit (except the admin tool +# doesn't have a commit command, so interactive users can't commit...) +# . Fixed login/registration forwarding the user to the right page (or not, +# on a failure) +# +# Revision 1.37 2001/11/28 21:55:35 richard +# . login_action and newuser_action return values were being ignored +# . Woohoo! Found that bloody re-login bug that was killing the mail +# gateway. +# (also a minor cleanup in hyperdb) +# +# Revision 1.36 2001/11/27 03:16:09 richard +# Another place that wasn't handling missing properties. +# +# Revision 1.35 2001/11/22 15:46:42 jhermann +# Added module docstrings to all modules. +# +# Revision 1.34 2001/11/21 04:04:43 richard +# *sigh* more missing value handling +# +# Revision 1.33 2001/11/21 03:40:54 richard +# more new property handling +# +# Revision 1.32 2001/11/21 03:11:28 richard +# Better handling of new properties. +# +# Revision 1.31 2001/11/12 22:01:06 richard +# Fixed issues with nosy reaction and author copies. +# +# Revision 1.30 2001/11/09 10:11:08 richard +# . roundup-admin now handles all hyperdb exceptions +# +# Revision 1.29 2001/10/27 00:17:41 richard +# Made Class.stringFind() do caseless matching. +# +# Revision 1.28 2001/10/21 04:44:50 richard +# bug #473124: UI inconsistency with Link fields. +# This also prompted me to fix a fairly long-standing usability issue - +# that of being able to turn off certain filters. +# +# Revision 1.27 2001/10/20 23:44:27 richard +# Hyperdatabase sorts strings-that-look-like-numbers as numbers now. +# +# Revision 1.26 2001/10/16 03:48:01 richard +# admin tool now complains if a "find" is attempted with a non-link property. +# +# Revision 1.25 2001/10/11 00:17:51 richard +# Reverted a change in hyperdb so the default value for missing property +# values in a create() is None and not '' (the empty string.) This obviously +# breaks CSV import/export - the string 'None' will be created in an +# export/import operation. +# +# Revision 1.24 2001/10/10 03:54:57 richard +# Added database importing and exporting through CSV files. +# Uses the csv module from object-craft for exporting if it's available. +# Requires the csv module for importing. +# +# Revision 1.23 2001/10/09 23:58:10 richard +# Moved the data stringification up into the hyperdb.Class class' get, set +# and create methods. This means that the data is also stringified for the +# journal call, and removes duplication of code from the backends. The +# backend code now only sees strings. +# +# Revision 1.22 2001/10/09 07:25:59 richard +# Added the Password property type. See "pydoc roundup.password" for +# implementation details. Have updated some of the documentation too. +# +# Revision 1.21 2001/10/05 02:23:24 richard +# . roundup-admin create now prompts for property info if none is supplied +# on the command-line. +# . hyperdb Class getprops() method may now return only the mutable +# properties. +# . Login now uses cookies, which makes it a whole lot more flexible. We can +# now support anonymous user access (read-only, unless there's an +# "anonymous" user, in which case write access is permitted). Login +# handling has been moved into cgi_client.Client.main() +# . The "extended" schema is now the default in roundup init. +# . The schemas have had their page headings modified to cope with the new +# login handling. Existing installations should copy the interfaces.py +# file from the roundup lib directory to their instance home. +# . Incorrectly had a Bizar Software copyright on the cgitb.py module from +# Ping - has been removed. +# . Fixed a whole bunch of places in the CGI interface where we should have +# been returning Not Found instead of throwing an exception. +# . Fixed a deviation from the spec: trying to modify the 'id' property of +# an item now throws an exception. +# +# Revision 1.20 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.19 2001/08/29 04:47:18 richard +# Fixed CGI client change messages so they actually include the properties +# changed (again). +# +# Revision 1.18 2001/08/16 07:34:59 richard +# better CGI text searching - but hidden filter fields are disappearing... +# +# Revision 1.17 2001/08/16 06:59:58 richard +# all searches use re now - and they're all case insensitive +# +# Revision 1.16 2001/08/15 23:43:18 richard +# Fixed some isFooTypes that I missed. +# Refactored some code in the CGI code. +# +# Revision 1.15 2001/08/12 06:32:36 richard +# using isinstance(blah, Foo) now instead of isFooType +# +# Revision 1.14 2001/08/07 00:24:42 richard +# stupid typo +# +# Revision 1.13 2001/08/07 00:15:51 richard +# Added the copyright/license notice to (nearly) all files at request of +# Bizar Software. +# +# Revision 1.12 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.11 2001/08/01 04:24:21 richard +# mailgw was assuming certain properties existed on the issues being created. +# +# Revision 1.10 2001/07/30 02:38:31 richard +# get() now has a default arg - for migration only. +# +# Revision 1.9 2001/07/29 09:28:23 richard +# Fixed sorting by clicking on column headings. +# +# Revision 1.8 2001/07/29 08:27:40 richard +# Fixed handling of passed-in values in form elements (ie. during a +# drill-down) +# +# Revision 1.7 2001/07/29 07:01:39 richard +# Added vim command to all source so that we don't get no steenkin' tabs :) +# +# Revision 1.6 2001/07/29 05:36:14 richard +# Cleanup of the link label generation. +# +# Revision 1.5 2001/07/29 04:05:37 richard +# Added the fabricated property "id". +# +# Revision 1.4 2001/07/27 06:25:35 richard +# Fixed some of the exceptions so they're the right type. +# Removed the str()-ification of node ids so we don't mask oopsy errors any +# more. +# +# Revision 1.3 2001/07/27 05:17:14 richard +# just some comments +# +# 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 -- 2.30.2