Code

Improve an error message.
[roundup.git] / roundup / hyperdb.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: hyperdb.py,v 1.70 2002-06-27 12:06:20 gmcm Exp $
20 __doc__ = """
21 Hyperdatabase implementation, especially field types.
22 """
24 # standard python modules
25 import sys, re, string, weakref, os, time
27 # roundup modules
28 import date, password
30 # configure up the DEBUG and TRACE captures
31 class Sink:
32     def write(self, content):
33         pass
34 DEBUG = os.environ.get('HYPERDBDEBUG', '')
35 if DEBUG and __debug__:
36     if DEBUG == 'stdout':
37         DEBUG = sys.stdout
38     else:
39         DEBUG = open(DEBUG, 'a')
40 else:
41     DEBUG = Sink()
42 TRACE = os.environ.get('HYPERDBTRACE', '')
43 if TRACE and __debug__:
44     if TRACE == 'stdout':
45         TRACE = sys.stdout
46     else:
47         TRACE = open(TRACE, 'w')
48 else:
49     TRACE = Sink()
50 def traceMark():
51     print >>TRACE, '**MARK', time.ctime()
52 del Sink
54 #
55 # Types
56 #
57 class String:
58     """An object designating a String property."""
59     def __repr__(self):
60         ' more useful for dumps '
61         return '<%s>'%self.__class__
63 class Password:
64     """An object designating a Password property."""
65     def __repr__(self):
66         ' more useful for dumps '
67         return '<%s>'%self.__class__
69 class Date:
70     """An object designating a Date property."""
71     def __repr__(self):
72         ' more useful for dumps '
73         return '<%s>'%self.__class__
75 class Interval:
76     """An object designating an Interval property."""
77     def __repr__(self):
78         ' more useful for dumps '
79         return '<%s>'%self.__class__
81 class Link:
82     """An object designating a Link property that links to a
83        node in a specified class."""
84     def __init__(self, classname, do_journal='no'):
85         ''' Default is to not journal link and unlink events
86         '''
87         self.classname = classname
88         self.do_journal = do_journal == 'yes'
89     def __repr__(self):
90         ' more useful for dumps '
91         return '<%s to "%s">'%(self.__class__, self.classname)
93 class Multilink:
94     """An object designating a Multilink property that links
95        to nodes in a specified class.
97        "classname" indicates the class to link to
99        "do_journal" indicates whether the linked-to nodes should have
100                     'link' and 'unlink' events placed in their journal
101     """
102     def __init__(self, classname, do_journal='no'):
103         ''' Default is to not journal link and unlink events
104         '''
105         self.classname = classname
106         self.do_journal = do_journal == 'yes'
107     def __repr__(self):
108         ' more useful for dumps '
109         return '<%s to "%s">'%(self.__class__, self.classname)
111 class DatabaseError(ValueError):
112     '''Error to be raised when there is some problem in the database code
113     '''
114     pass
118 # the base Database class
120 class Database:
121     '''A database for storing records containing flexible data types.
123 This class defines a hyperdatabase storage layer, which the Classes use to
124 store their data.
127 Transactions
128 ------------
129 The Database should support transactions through the commit() and
130 rollback() methods. All other Database methods should be transaction-aware,
131 using data from the current transaction before looking up the database.
133 An implementation must provide an override for the get() method so that the
134 in-database value is returned in preference to the in-transaction value.
135 This is necessary to determine if any values have changed during a
136 transaction.
138 '''
140     # flag to set on retired entries
141     RETIRED_FLAG = '__hyperdb_retired'
143     # XXX deviates from spec: storagelocator is obtained from the config
144     def __init__(self, config, journaltag=None):
145         """Open a hyperdatabase given a specifier to some storage.
147         The 'storagelocator' is obtained from config.DATABASE.
148         The meaning of 'storagelocator' depends on the particular
149         implementation of the hyperdatabase.  It could be a file name,
150         a directory path, a socket descriptor for a connection to a
151         database over the network, etc.
153         The 'journaltag' is a token that will be attached to the journal
154         entries for any edits done on the database.  If 'journaltag' is
155         None, the database is opened in read-only mode: the Class.create(),
156         Class.set(), and Class.retire() methods are disabled.
157         """
158         raise NotImplementedError
160     def __getattr__(self, classname):
161         """A convenient way of calling self.getclass(classname)."""
162         raise NotImplementedError
164     def addclass(self, cl):
165         '''Add a Class to the hyperdatabase.
166         '''
167         raise NotImplementedError
169     def getclasses(self):
170         """Return a list of the names of all existing classes."""
171         raise NotImplementedError
173     def getclass(self, classname):
174         """Get the Class object representing a particular class.
176         If 'classname' is not a valid class name, a KeyError is raised.
177         """
178         raise NotImplementedError
180     def clear(self):
181         '''Delete all database contents.
182         '''
183         raise NotImplementedError
185     def getclassdb(self, classname, mode='r'):
186         '''Obtain a connection to the class db that will be used for
187            multiple actions.
188         '''
189         raise NotImplementedError
191     def addnode(self, classname, nodeid, node):
192         '''Add the specified node to its class's db.
193         '''
194         raise NotImplementedError
196     def serialise(self, classname, node):
197         '''Copy the node contents, converting non-marshallable data into
198            marshallable data.
199         '''
200         if __debug__:
201             print >>DEBUG, 'serialise', classname, node
202         properties = self.getclass(classname).getprops()
203         d = {}
204         for k, v in node.items():
205             # if the property doesn't exist, or is the "retired" flag then
206             # it won't be in the properties dict
207             if not properties.has_key(k):
208                 d[k] = v
209                 continue
211             # get the property spec
212             prop = properties[k]
214             if isinstance(prop, Password):
215                 d[k] = str(v)
216             elif isinstance(prop, Date) and v is not None:
217                 d[k] = v.get_tuple()
218             elif isinstance(prop, Interval) and v is not None:
219                 d[k] = v.get_tuple()
220             else:
221                 d[k] = v
222         return d
224     def setnode(self, classname, nodeid, node):
225         '''Change the specified node.
226         '''
227         raise NotImplementedError
229     def unserialise(self, classname, node):
230         '''Decode the marshalled node data
231         '''
232         if __debug__:
233             print >>DEBUG, 'unserialise', classname, node
234         properties = self.getclass(classname).getprops()
235         d = {}
236         for k, v in node.items():
237             # if the property doesn't exist, or is the "retired" flag then
238             # it won't be in the properties dict
239             if not properties.has_key(k):
240                 d[k] = v
241                 continue
243             # get the property spec
244             prop = properties[k]
246             if isinstance(prop, Date) and v is not None:
247                 d[k] = date.Date(v)
248             elif isinstance(prop, Interval) and v is not None:
249                 d[k] = date.Interval(v)
250             elif isinstance(prop, Password):
251                 p = password.Password()
252                 p.unpack(v)
253                 d[k] = p
254             else:
255                 d[k] = v
256         return d
258     def getnode(self, classname, nodeid, db=None, cache=1):
259         '''Get a node from the database.
260         '''
261         raise NotImplementedError
263     def hasnode(self, classname, nodeid, db=None):
264         '''Determine if the database has a given node.
265         '''
266         raise NotImplementedError
268     def countnodes(self, classname, db=None):
269         '''Count the number of nodes that exist for a particular Class.
270         '''
271         raise NotImplementedError
273     def getnodeids(self, classname, db=None):
274         '''Retrieve all the ids of the nodes for a particular Class.
275         '''
276         raise NotImplementedError
278     def storefile(self, classname, nodeid, property, content):
279         '''Store the content of the file in the database.
280         
281            The property may be None, in which case the filename does not
282            indicate which property is being saved.
283         '''
284         raise NotImplementedError
286     def getfile(self, classname, nodeid, property):
287         '''Store the content of the file in the database.
288         '''
289         raise NotImplementedError
291     def addjournal(self, classname, nodeid, action, params):
292         ''' Journal the Action
293         'action' may be:
295             'create' or 'set' -- 'params' is a dictionary of property values
296             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
297             'retire' -- 'params' is None
298         '''
299         raise NotImplementedError
301     def getjournal(self, classname, nodeid):
302         ''' get the journal for id
303         '''
304         raise NotImplementedError
306     def pack(self, pack_before):
307         ''' pack the database
308         '''
309         raise NotImplementedError
311     def commit(self):
312         ''' Commit the current transactions.
314         Save all data changed since the database was opened or since the
315         last commit() or rollback().
316         '''
317         raise NotImplementedError
319     def rollback(self):
320         ''' Reverse all actions from the current transaction.
322         Undo all the changes made since the database was opened or the last
323         commit() or rollback() was performed.
324         '''
325         raise NotImplementedError
327 _marker = []
329 # The base Class class
331 class Class:
332     """The handle to a particular class of nodes in a hyperdatabase."""
334     def __init__(self, db, classname, **properties):
335         """Create a new class with a given name and property specification.
337         'classname' must not collide with the name of an existing class,
338         or a ValueError is raised.  The keyword arguments in 'properties'
339         must map names to property objects, or a TypeError is raised.
340         """
341         self.classname = classname
342         self.properties = properties
343         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
344         self.key = ''
346         # do the db-related init stuff
347         db.addclass(self)
349     def __repr__(self):
350         '''Slightly more useful representation
351         '''
352         return '<hypderdb.Class "%s">'%self.classname
354     # Editing nodes:
356     def create(self, **propvalues):
357         """Create a new node of this class and return its id.
359         The keyword arguments in 'propvalues' map property names to values.
361         The values of arguments must be acceptable for the types of their
362         corresponding properties or a TypeError is raised.
363         
364         If this class has a key property, it must be present and its value
365         must not collide with other key strings or a ValueError is raised.
366         
367         Any other properties on this class that are missing from the
368         'propvalues' dictionary are set to None.
369         
370         If an id in a link or multilink property does not refer to a valid
371         node, an IndexError is raised.
372         """
373         if propvalues.has_key('id'):
374             raise KeyError, '"id" is reserved'
376         if self.db.journaltag is None:
377             raise DatabaseError, 'Database open read-only'
379         # new node's id
380         newid = self.db.newid(self.classname)
382         # validate propvalues
383         num_re = re.compile('^\d+$')
384         for key, value in propvalues.items():
385             if key == self.key:
386                 try:
387                     self.lookup(value)
388                 except KeyError:
389                     pass
390                 else:
391                     raise ValueError, 'node with key "%s" exists'%value
393             # try to handle this property
394             try:
395                 prop = self.properties[key]
396             except KeyError:
397                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
398                     key)
400             if isinstance(prop, Link):
401                 if type(value) != type(''):
402                     raise ValueError, 'link value must be String'
403                 link_class = self.properties[key].classname
404                 # if it isn't a number, it's a key
405                 if not num_re.match(value):
406                     try:
407                         value = self.db.classes[link_class].lookup(value)
408                     except (TypeError, KeyError):
409                         raise IndexError, 'new property "%s": %s not a %s'%(
410                             key, value, link_class)
411                 elif not self.db.hasnode(link_class, value):
412                     raise IndexError, '%s has no node %s'%(link_class, value)
414                 # save off the value
415                 propvalues[key] = value
417                 # register the link with the newly linked node
418                 if self.properties[key].do_journal:
419                     self.db.addjournal(link_class, value, 'link',
420                         (self.classname, newid, key))
422             elif isinstance(prop, Multilink):
423                 if type(value) != type([]):
424                     raise TypeError, 'new property "%s" not a list of ids'%key
426                 # clean up and validate the list of links
427                 link_class = self.properties[key].classname
428                 l = []
429                 for entry in value:
430                     if type(entry) != type(''):
431                         raise ValueError, '"%s" link value (%s) must be String' % (key, value)
432                     # if it isn't a number, it's a key
433                     if not num_re.match(entry):
434                         try:
435                             entry = self.db.classes[link_class].lookup(entry)
436                         except (TypeError, KeyError):
437                             raise IndexError, 'new property "%s": %s not a %s'%(
438                                 key, entry, self.properties[key].classname)
439                     l.append(entry)
440                 value = l
441                 propvalues[key] = value
443                 # handle additions
444                 for id in value:
445                     if not self.db.hasnode(link_class, id):
446                         raise IndexError, '%s has no node %s'%(link_class, id)
447                     # register the link with the newly linked node
448                     if self.properties[key].do_journal:
449                         self.db.addjournal(link_class, id, 'link',
450                             (self.classname, newid, key))
452             elif isinstance(prop, String):
453                 if type(value) != type(''):
454                     raise TypeError, 'new property "%s" not a string'%key
456             elif isinstance(prop, Password):
457                 if not isinstance(value, password.Password):
458                     raise TypeError, 'new property "%s" not a Password'%key
460             elif isinstance(prop, Date):
461                 if value is not None and not isinstance(value, date.Date):
462                     raise TypeError, 'new property "%s" not a Date'%key
464             elif isinstance(prop, Interval):
465                 if value is not None and not isinstance(value, date.Interval):
466                     raise TypeError, 'new property "%s" not an Interval'%key
468         # make sure there's data where there needs to be
469         for key, prop in self.properties.items():
470             if propvalues.has_key(key):
471                 continue
472             if key == self.key:
473                 raise ValueError, 'key property "%s" is required'%key
474             if isinstance(prop, Multilink):
475                 propvalues[key] = []
476             else:
477                 # TODO: None isn't right here, I think...
478                 propvalues[key] = None
480         # done
481         self.db.addnode(self.classname, newid, propvalues)
482         self.db.addjournal(self.classname, newid, 'create', propvalues)
483         return newid
485     def get(self, nodeid, propname, default=_marker, cache=1):
486         """Get the value of a property on an existing node of this class.
488         'nodeid' must be the id of an existing node of this class or an
489         IndexError is raised.  'propname' must be the name of a property
490         of this class or a KeyError is raised.
492         'cache' indicates whether the transaction cache should be queried
493         for the node. If the node has been modified and you need to
494         determine what its values prior to modification are, you need to
495         set cache=0.
496         """
497         if propname == 'id':
498             return nodeid
500         # get the property (raises KeyErorr if invalid)
501         prop = self.properties[propname]
503         # get the node's dict
504         d = self.db.getnode(self.classname, nodeid, cache=cache)
506         if not d.has_key(propname):
507             if default is _marker:
508                 if isinstance(prop, Multilink):
509                     return []
510                 else:
511                     # TODO: None isn't right here, I think...
512                     return None
513             else:
514                 return default
516         return d[propname]
518     # XXX not in spec
519     def getnode(self, nodeid, cache=1):
520         ''' Return a convenience wrapper for the node.
522         'nodeid' must be the id of an existing node of this class or an
523         IndexError is raised.
525         'cache' indicates whether the transaction cache should be queried
526         for the node. If the node has been modified and you need to
527         determine what its values prior to modification are, you need to
528         set cache=0.
529         '''
530         return Node(self, nodeid, cache=cache)
532     def set(self, nodeid, **propvalues):
533         """Modify a property on an existing node of this class.
534         
535         'nodeid' must be the id of an existing node of this class or an
536         IndexError is raised.
538         Each key in 'propvalues' must be the name of a property of this
539         class or a KeyError is raised.
541         All values in 'propvalues' must be acceptable types for their
542         corresponding properties or a TypeError is raised.
544         If the value of the key property is set, it must not collide with
545         other key strings or a ValueError is raised.
547         If the value of a Link or Multilink property contains an invalid
548         node id, a ValueError is raised.
549         """
550         if not propvalues:
551             return
553         if propvalues.has_key('id'):
554             raise KeyError, '"id" is reserved'
556         if self.db.journaltag is None:
557             raise DatabaseError, 'Database open read-only'
559         node = self.db.getnode(self.classname, nodeid)
560         if node.has_key(self.db.RETIRED_FLAG):
561             raise IndexError
562         num_re = re.compile('^\d+$')
563         for key, value in propvalues.items():
564             # check to make sure we're not duplicating an existing key
565             if key == self.key and node[key] != value:
566                 try:
567                     self.lookup(value)
568                 except KeyError:
569                     pass
570                 else:
571                     raise ValueError, 'node with key "%s" exists'%value
573             # this will raise the KeyError if the property isn't valid
574             # ... we don't use getprops() here because we only care about
575             # the writeable properties.
576             prop = self.properties[key]
578             # if the value's the same as the existing value, no sense in
579             # doing anything
580             if node.has_key(key) and value == node[key]:
581                 del propvalues[key]
582                 continue
584             # do stuff based on the prop type
585             if isinstance(prop, Link):
586                 link_class = self.properties[key].classname
587                 # if it isn't a number, it's a key
588                 if type(value) != type(''):
589                     raise ValueError, 'link value must be String'
590                 if not num_re.match(value):
591                     try:
592                         value = self.db.classes[link_class].lookup(value)
593                     except (TypeError, KeyError):
594                         raise IndexError, 'new property "%s": %s not a %s'%(
595                             key, value, self.properties[key].classname)
597                 if not self.db.hasnode(link_class, value):
598                     raise IndexError, '%s has no node %s'%(link_class, value)
600                 if self.properties[key].do_journal:
601                     # register the unlink with the old linked node
602                     if node[key] is not None:
603                         self.db.addjournal(link_class, node[key], 'unlink',
604                             (self.classname, nodeid, key))
606                     # register the link with the newly linked node
607                     if value is not None:
608                         self.db.addjournal(link_class, value, 'link',
609                             (self.classname, nodeid, key))
611             elif isinstance(prop, Multilink):
612                 if type(value) != type([]):
613                     raise TypeError, 'new property "%s" not a list of ids'%key
614                 link_class = self.properties[key].classname
615                 l = []
616                 for entry in value:
617                     # if it isn't a number, it's a key
618                     if type(entry) != type(''):
619                         raise ValueError, 'new property "%s" link value ' \
620                             'must be a string'%key
621                     if not num_re.match(entry):
622                         try:
623                             entry = self.db.classes[link_class].lookup(entry)
624                         except (TypeError, KeyError):
625                             raise IndexError, 'new property "%s": %s not a %s'%(
626                                 key, entry, self.properties[key].classname)
627                     l.append(entry)
628                 value = l
629                 propvalues[key] = value
631                 # handle removals
632                 if node.has_key(key):
633                     l = node[key]
634                 else:
635                     l = []
636                 for id in l[:]:
637                     if id in value:
638                         continue
639                     # register the unlink with the old linked node
640                     if self.properties[key].do_journal:
641                         self.db.addjournal(link_class, id, 'unlink',
642                             (self.classname, nodeid, key))
643                     l.remove(id)
645                 # handle additions
646                 for id in value:
647                     if not self.db.hasnode(link_class, id):
648                         raise IndexError, '%s has no node %s'%(
649                             link_class, id)
650                     if id in l:
651                         continue
652                     # register the link with the newly linked node
653                     if self.properties[key].do_journal:
654                         self.db.addjournal(link_class, id, 'link',
655                             (self.classname, nodeid, key))
656                     l.append(id)
658             elif isinstance(prop, String):
659                 if value is not None and type(value) != type(''):
660                     raise TypeError, 'new property "%s" not a string'%key
662             elif isinstance(prop, Password):
663                 if not isinstance(value, password.Password):
664                     raise TypeError, 'new property "%s" not a Password'% key
665                 propvalues[key] = value
667             elif value is not None and isinstance(prop, Date):
668                 if not isinstance(value, date.Date):
669                     raise TypeError, 'new property "%s" not a Date'% key
670                 propvalues[key] = value
672             elif value is not None and isinstance(prop, Interval):
673                 if not isinstance(value, date.Interval):
674                     raise TypeError, 'new property "%s" not an Interval'% key
675                 propvalues[key] = value
677             node[key] = value
679         # nothing to do?
680         if not propvalues:
681             return
683         # do the set, and journal it
684         self.db.setnode(self.classname, nodeid, node)
685         self.db.addjournal(self.classname, nodeid, 'set', propvalues)
687     def retire(self, nodeid):
688         """Retire a node.
689         
690         The properties on the node remain available from the get() method,
691         and the node's id is never reused.
692         
693         Retired nodes are not returned by the find(), list(), or lookup()
694         methods, and other nodes may reuse the values of their key properties.
695         """
696         if self.db.journaltag is None:
697             raise DatabaseError, 'Database open read-only'
698         node = self.db.getnode(self.classname, nodeid)
699         node[self.db.RETIRED_FLAG] = 1
700         self.db.setnode(self.classname, nodeid, node)
701         self.db.addjournal(self.classname, nodeid, 'retired', None)
703     def history(self, nodeid):
704         """Retrieve the journal of edits on a particular node.
706         'nodeid' must be the id of an existing node of this class or an
707         IndexError is raised.
709         The returned list contains tuples of the form
711             (date, tag, action, params)
713         'date' is a Timestamp object specifying the time of the change and
714         'tag' is the journaltag specified when the database was opened.
715         """
716         return self.db.getjournal(self.classname, nodeid)
718     # Locating nodes:
719     def hasnode(self, nodeid):
720         '''Determine if the given nodeid actually exists
721         '''
722         return self.db.hasnode(self.classname, nodeid)
724     def setkey(self, propname):
725         """Select a String property of this class to be the key property.
727         'propname' must be the name of a String property of this class or
728         None, or a TypeError is raised.  The values of the key property on
729         all existing nodes must be unique or a ValueError is raised.
730         """
731         # TODO: validate that the property is a String!
732         self.key = propname
734     def getkey(self):
735         """Return the name of the key property for this class or None."""
736         return self.key
738     def labelprop(self, default_to_id=0):
739         ''' Return the property name for a label for the given node.
741         This method attempts to generate a consistent label for the node.
742         It tries the following in order:
743             1. key property
744             2. "name" property
745             3. "title" property
746             4. first property from the sorted property name list
747         '''
748         k = self.getkey()
749         if  k:
750             return k
751         props = self.getprops()
752         if props.has_key('name'):
753             return 'name'
754         elif props.has_key('title'):
755             return 'title'
756         if default_to_id:
757             return 'id'
758         props = props.keys()
759         props.sort()
760         return props[0]
762     # TODO: set up a separate index db file for this? profile?
763     def lookup(self, keyvalue):
764         """Locate a particular node by its key property and return its id.
766         If this class has no key property, a TypeError is raised.  If the
767         'keyvalue' matches one of the values for the key property among
768         the nodes in this class, the matching node's id is returned;
769         otherwise a KeyError is raised.
770         """
771         cldb = self.db.getclassdb(self.classname)
772         for nodeid in self.db.getnodeids(self.classname, cldb):
773             node = self.db.getnode(self.classname, nodeid, cldb)
774             if node.has_key(self.db.RETIRED_FLAG):
775                 continue
776             if node[self.key] == keyvalue:
777                 return nodeid
778         raise KeyError, keyvalue
780     # XXX: change from spec - allows multiple props to match
781     def find(self, **propspec):
782         """Get the ids of nodes in this class which link to a given node.
784         'propspec' consists of keyword args propname=nodeid   
785           'propname' must be the name of a property in this class, or a
786             KeyError is raised.  That property must be a Link or Multilink
787             property, or a TypeError is raised.
789           'nodeid' must be the id of an existing node in the class linked
790             to by the given property, or an IndexError is raised.
791         """
792         propspec = propspec.items()
793         for propname, nodeid in propspec:
794             # check the prop is OK
795             prop = self.properties[propname]
796             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
797                 raise TypeError, "'%s' not a Link/Multilink property"%propname
798             if not self.db.hasnode(prop.classname, nodeid):
799                 raise ValueError, '%s has no node %s'%(prop.classname, nodeid)
801         # ok, now do the find
802         cldb = self.db.getclassdb(self.classname)
803         l = []
804         for id in self.db.getnodeids(self.classname, db=cldb):
805             node = self.db.getnode(self.classname, id, db=cldb)
806             if node.has_key(self.db.RETIRED_FLAG):
807                 continue
808             for propname, nodeid in propspec:
809                 # can't test if the node doesn't have this property
810                 if not node.has_key(propname):
811                     continue
812                 prop = self.properties[propname]
813                 property = node[propname]
814                 if isinstance(prop, Link) and nodeid == property:
815                     l.append(id)
816                 elif isinstance(prop, Multilink) and nodeid in property:
817                     l.append(id)
818         return l
820     def stringFind(self, **requirements):
821         """Locate a particular node by matching a set of its String
822         properties in a caseless search.
824         If the property is not a String property, a TypeError is raised.
825         
826         The return is a list of the id of all nodes that match.
827         """
828         for propname in requirements.keys():
829             prop = self.properties[propname]
830             if isinstance(not prop, String):
831                 raise TypeError, "'%s' not a String property"%propname
832             requirements[propname] = requirements[propname].lower()
833         l = []
834         cldb = self.db.getclassdb(self.classname)
835         for nodeid in self.db.getnodeids(self.classname, cldb):
836             node = self.db.getnode(self.classname, nodeid, cldb)
837             if node.has_key(self.db.RETIRED_FLAG):
838                 continue
839             for key, value in requirements.items():
840                 if node[key] and node[key].lower() != value:
841                     break
842             else:
843                 l.append(nodeid)
844         return l
846     def list(self):
847         """Return a list of the ids of the active nodes in this class."""
848         l = []
849         cn = self.classname
850         cldb = self.db.getclassdb(cn)
851         for nodeid in self.db.getnodeids(cn, cldb):
852             node = self.db.getnode(cn, nodeid, cldb)
853             if node.has_key(self.db.RETIRED_FLAG):
854                 continue
855             l.append(nodeid)
856         l.sort()
857         return l
859     # XXX not in spec
860     def filter(self, search_matches, filterspec, sort, group, 
861             num_re = re.compile('^\d+$')):
862         ''' Return a list of the ids of the active nodes in this class that
863             match the 'filter' spec, sorted by the group spec and then the
864             sort spec
865         '''
866         cn = self.classname
868         # optimise filterspec
869         l = []
870         props = self.getprops()
871         for k, v in filterspec.items():
872             propclass = props[k]
873             if isinstance(propclass, Link):
874                 if type(v) is not type([]):
875                     v = [v]
876                 # replace key values with node ids
877                 u = []
878                 link_class =  self.db.classes[propclass.classname]
879                 for entry in v:
880                     if entry == '-1': entry = None
881                     elif not num_re.match(entry):
882                         try:
883                             entry = link_class.lookup(entry)
884                         except (TypeError,KeyError):
885                             raise ValueError, 'property "%s": %s not a %s'%(
886                                 k, entry, self.properties[k].classname)
887                     u.append(entry)
889                 l.append((0, k, u))
890             elif isinstance(propclass, Multilink):
891                 if type(v) is not type([]):
892                     v = [v]
893                 # replace key values with node ids
894                 u = []
895                 link_class =  self.db.classes[propclass.classname]
896                 for entry in v:
897                     if not num_re.match(entry):
898                         try:
899                             entry = link_class.lookup(entry)
900                         except (TypeError,KeyError):
901                             raise ValueError, 'new property "%s": %s not a %s'%(
902                                 k, entry, self.properties[k].classname)
903                     u.append(entry)
904                 l.append((1, k, u))
905             elif isinstance(propclass, String):
906                 # simple glob searching
907                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
908                 v = v.replace('?', '.')
909                 v = v.replace('*', '.*?')
910                 l.append((2, k, re.compile(v, re.I)))
911             else:
912                 l.append((6, k, v))
913         filterspec = l
915         # now, find all the nodes that are active and pass filtering
916         l = []
917         cldb = self.db.getclassdb(cn)
918         for nodeid in self.db.getnodeids(cn, cldb):
919             node = self.db.getnode(cn, nodeid, cldb)
920             if node.has_key(self.db.RETIRED_FLAG):
921                 continue
922             # apply filter
923             for t, k, v in filterspec:
924                 # this node doesn't have this property, so reject it
925                 if not node.has_key(k): break
927                 if t == 0 and node[k] not in v:
928                     # link - if this node'd property doesn't appear in the
929                     # filterspec's nodeid list, skip it
930                     break
931                 elif t == 1:
932                     # multilink - if any of the nodeids required by the
933                     # filterspec aren't in this node's property, then skip
934                     # it
935                     for value in v:
936                         if value not in node[k]:
937                             break
938                     else:
939                         continue
940                     break
941                 elif t == 2 and (node[k] is None or not v.search(node[k])):
942                     # RE search
943                     break
944                 elif t == 6 and node[k] != v:
945                     # straight value comparison for the other types
946                     break
947             else:
948                 l.append((nodeid, node))
949         l.sort()
951         # filter based on full text search
952         if search_matches is not None:
953             k = []
954             l_debug = []
955             for v in l:
956                 l_debug.append(v[0])
957                 if search_matches.has_key(v[0]):
958                     k.append(v)
959             l = k
961         # optimise sort
962         m = []
963         for entry in sort:
964             if entry[0] != '-':
965                 m.append(('+', entry))
966             else:
967                 m.append((entry[0], entry[1:]))
968         sort = m
970         # optimise group
971         m = []
972         for entry in group:
973             if entry[0] != '-':
974                 m.append(('+', entry))
975             else:
976                 m.append((entry[0], entry[1:]))
977         group = m
978         # now, sort the result
979         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
980                 db = self.db, cl=self):
981             a_id, an = a
982             b_id, bn = b
983             # sort by group and then sort
984             for list in group, sort:
985                 for dir, prop in list:
986                     # sorting is class-specific
987                     propclass = properties[prop]
989                     # handle the properties that might be "faked"
990                     # also, handle possible missing properties
991                     try:
992                         if not an.has_key(prop):
993                             an[prop] = cl.get(a_id, prop)
994                         av = an[prop]
995                     except KeyError:
996                         # the node doesn't have a value for this property
997                         if isinstance(propclass, Multilink): av = []
998                         else: av = ''
999                     try:
1000                         if not bn.has_key(prop):
1001                             bn[prop] = cl.get(b_id, prop)
1002                         bv = bn[prop]
1003                     except KeyError:
1004                         # the node doesn't have a value for this property
1005                         if isinstance(propclass, Multilink): bv = []
1006                         else: bv = ''
1008                     # String and Date values are sorted in the natural way
1009                     if isinstance(propclass, String):
1010                         # clean up the strings
1011                         if av and av[0] in string.uppercase:
1012                             av = an[prop] = av.lower()
1013                         if bv and bv[0] in string.uppercase:
1014                             bv = bn[prop] = bv.lower()
1015                     if (isinstance(propclass, String) or
1016                             isinstance(propclass, Date)):
1017                         # it might be a string that's really an integer
1018                         try:
1019                             av = int(av)
1020                             bv = int(bv)
1021                         except:
1022                             pass
1023                         if dir == '+':
1024                             r = cmp(av, bv)
1025                             if r != 0: return r
1026                         elif dir == '-':
1027                             r = cmp(bv, av)
1028                             if r != 0: return r
1030                     # Link properties are sorted according to the value of
1031                     # the "order" property on the linked nodes if it is
1032                     # present; or otherwise on the key string of the linked
1033                     # nodes; or finally on  the node ids.
1034                     elif isinstance(propclass, Link):
1035                         link = db.classes[propclass.classname]
1036                         if av is None and bv is not None: return -1
1037                         if av is not None and bv is None: return 1
1038                         if av is None and bv is None: continue
1039                         if link.getprops().has_key('order'):
1040                             if dir == '+':
1041                                 r = cmp(link.get(av, 'order'),
1042                                     link.get(bv, 'order'))
1043                                 if r != 0: return r
1044                             elif dir == '-':
1045                                 r = cmp(link.get(bv, 'order'),
1046                                     link.get(av, 'order'))
1047                                 if r != 0: return r
1048                         elif link.getkey():
1049                             key = link.getkey()
1050                             if dir == '+':
1051                                 r = cmp(link.get(av, key), link.get(bv, key))
1052                                 if r != 0: return r
1053                             elif dir == '-':
1054                                 r = cmp(link.get(bv, key), link.get(av, key))
1055                                 if r != 0: return r
1056                         else:
1057                             if dir == '+':
1058                                 r = cmp(av, bv)
1059                                 if r != 0: return r
1060                             elif dir == '-':
1061                                 r = cmp(bv, av)
1062                                 if r != 0: return r
1064                     # Multilink properties are sorted according to how many
1065                     # links are present.
1066                     elif isinstance(propclass, Multilink):
1067                         if dir == '+':
1068                             r = cmp(len(av), len(bv))
1069                             if r != 0: return r
1070                         elif dir == '-':
1071                             r = cmp(len(bv), len(av))
1072                             if r != 0: return r
1073                 # end for dir, prop in list:
1074             # end for list in sort, group:
1075             # if all else fails, compare the ids
1076             return cmp(a[0], b[0])
1078         l.sort(sortfun)
1079         return [i[0] for i in l]
1081     def count(self):
1082         """Get the number of nodes in this class.
1084         If the returned integer is 'numnodes', the ids of all the nodes
1085         in this class run from 1 to numnodes, and numnodes+1 will be the
1086         id of the next node to be created in this class.
1087         """
1088         return self.db.countnodes(self.classname)
1090     # Manipulating properties:
1092     def getprops(self, protected=1):
1093         """Return a dictionary mapping property names to property objects.
1094            If the "protected" flag is true, we include protected properties -
1095            those which may not be modified."""
1096         d = self.properties.copy()
1097         if protected:
1098             d['id'] = String()
1099         return d
1101     def addprop(self, **properties):
1102         """Add properties to this class.
1104         The keyword arguments in 'properties' must map names to property
1105         objects, or a TypeError is raised.  None of the keys in 'properties'
1106         may collide with the names of existing properties, or a ValueError
1107         is raised before any properties have been added.
1108         """
1109         for key in properties.keys():
1110             if self.properties.has_key(key):
1111                 raise ValueError, key
1112         self.properties.update(properties)
1114 # XXX not in spec
1115 class Node:
1116     ''' A convenience wrapper for the given node
1117     '''
1118     def __init__(self, cl, nodeid, cache=1):
1119         self.__dict__['cl'] = cl
1120         self.__dict__['nodeid'] = nodeid
1121         self.__dict__['cache'] = cache
1122     def keys(self, protected=1):
1123         return self.cl.getprops(protected=protected).keys()
1124     def values(self, protected=1):
1125         l = []
1126         for name in self.cl.getprops(protected=protected).keys():
1127             l.append(self.cl.get(self.nodeid, name, cache=self.cache))
1128         return l
1129     def items(self, protected=1):
1130         l = []
1131         for name in self.cl.getprops(protected=protected).keys():
1132             l.append((name, self.cl.get(self.nodeid, name, cache=self.cache)))
1133         return l
1134     def has_key(self, name):
1135         return self.cl.getprops().has_key(name)
1136     def __getattr__(self, name):
1137         if self.__dict__.has_key(name):
1138             return self.__dict__[name]
1139         try:
1140             return self.cl.get(self.nodeid, name, cache=self.cache)
1141         except KeyError, value:
1142             # we trap this but re-raise it as AttributeError - all other
1143             # exceptions should pass through untrapped
1144             pass
1145         # nope, no such attribute
1146         raise AttributeError, str(value)
1147     def __getitem__(self, name):
1148         return self.cl.get(self.nodeid, name, cache=self.cache)
1149     def __setattr__(self, name, value):
1150         try:
1151             return self.cl.set(self.nodeid, **{name: value})
1152         except KeyError, value:
1153             raise AttributeError, str(value)
1154     def __setitem__(self, name, value):
1155         self.cl.set(self.nodeid, **{name: value})
1156     def history(self):
1157         return self.cl.history(self.nodeid)
1158     def retire(self):
1159         return self.cl.retire(self.nodeid)
1162 def Choice(name, db, *options):
1163     '''Quick helper to create a simple class with choices
1164     '''
1165     cl = Class(db, name, name=String(), order=String())
1166     for i in range(len(options)):
1167         cl.create(name=options[i], order=i)
1168     return hyperdb.Link(name)
1171 # $Log: not supported by cvs2svn $
1172 # Revision 1.69  2002/06/17 23:15:29  richard
1173 # Can debug to stdout now
1175 # Revision 1.68  2002/06/11 06:52:03  richard
1176 #  . #564271 ] find() and new properties
1178 # Revision 1.67  2002/06/11 05:02:37  richard
1179 #  . #565979 ] code error in hyperdb.Class.find
1181 # Revision 1.66  2002/05/25 07:16:24  rochecompaan
1182 # Merged search_indexing-branch with HEAD
1184 # Revision 1.65  2002/05/22 04:12:05  richard
1185 #  . applied patch #558876 ] cgi client customization
1186 #    ... with significant additions and modifications ;)
1187 #    - extended handling of ML assignedto to all places it's handled
1188 #    - added more NotFound info
1190 # Revision 1.64  2002/05/15 06:21:21  richard
1191 #  . node caching now works, and gives a small boost in performance
1193 # As a part of this, I cleaned up the DEBUG output and implemented TRACE
1194 # output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
1195 # CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
1196 # (using if __debug__ which is compiled out with -O)
1198 # Revision 1.63  2002/04/15 23:25:15  richard
1199 # . node ids are now generated from a lockable store - no more race conditions
1201 # We're using the portalocker code by Jonathan Feinberg that was contributed
1202 # to the ASPN Python cookbook. This gives us locking across Unix and Windows.
1204 # Revision 1.62  2002/04/03 07:05:50  richard
1205 # d'oh! killed retirement of nodes :(
1206 # all better now...
1208 # Revision 1.61  2002/04/03 06:11:51  richard
1209 # Fix for old databases that contain properties that don't exist any more.
1211 # Revision 1.60  2002/04/03 05:54:31  richard
1212 # Fixed serialisation problem by moving the serialisation step out of the
1213 # hyperdb.Class (get, set) into the hyperdb.Database.
1215 # Also fixed htmltemplate after the showid changes I made yesterday.
1217 # Unit tests for all of the above written.
1219 # Revision 1.59.2.2  2002/04/20 13:23:33  rochecompaan
1220 # We now have a separate search page for nodes.  Search links for
1221 # different classes can be customized in instance_config similar to
1222 # index links.
1224 # Revision 1.59.2.1  2002/04/19 19:54:42  rochecompaan
1225 # cgi_client.py
1226 #     removed search link for the time being
1227 #     moved rendering of matches to htmltemplate
1228 # hyperdb.py
1229 #     filtering of nodes on full text search incorporated in filter method
1230 # roundupdb.py
1231 #     added paramater to call of filter method
1232 # roundup_indexer.py
1233 #     added search method to RoundupIndexer class
1235 # Revision 1.59  2002/03/12 22:52:26  richard
1236 # more pychecker warnings removed
1238 # Revision 1.58  2002/02/27 03:23:16  richard
1239 # Ran it through pychecker, made fixes
1241 # Revision 1.57  2002/02/20 05:23:24  richard
1242 # Didn't accomodate new values for new properties
1244 # Revision 1.56  2002/02/20 05:05:28  richard
1245 #  . Added simple editing for classes that don't define a templated interface.
1246 #    - access using the admin "class list" interface
1247 #    - limited to admin-only
1248 #    - requires the csv module from object-craft (url given if it's missing)
1250 # Revision 1.55  2002/02/15 07:27:12  richard
1251 # Oops, precedences around the way w0rng.
1253 # Revision 1.54  2002/02/15 07:08:44  richard
1254 #  . Alternate email addresses are now available for users. See the MIGRATION
1255 #    file for info on how to activate the feature.
1257 # Revision 1.53  2002/01/22 07:21:13  richard
1258 # . fixed back_bsddb so it passed the journal tests
1260 # ... it didn't seem happy using the back_anydbm _open method, which is odd.
1261 # Yet another occurrance of whichdb not being able to recognise older bsddb
1262 # databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
1263 # process.
1265 # Revision 1.52  2002/01/21 16:33:19  rochecompaan
1266 # You can now use the roundup-admin tool to pack the database
1268 # Revision 1.51  2002/01/21 03:01:29  richard
1269 # brief docco on the do_journal argument
1271 # Revision 1.50  2002/01/19 13:16:04  rochecompaan
1272 # Journal entries for link and multilink properties can now be switched on
1273 # or off.
1275 # Revision 1.49  2002/01/16 07:02:57  richard
1276 #  . lots of date/interval related changes:
1277 #    - more relaxed date format for input
1279 # Revision 1.48  2002/01/14 06:32:34  richard
1280 #  . #502951 ] adding new properties to old database
1282 # Revision 1.47  2002/01/14 02:20:15  richard
1283 #  . changed all config accesses so they access either the instance or the
1284 #    config attriubute on the db. This means that all config is obtained from
1285 #    instance_config instead of the mish-mash of classes. This will make
1286 #    switching to a ConfigParser setup easier too, I hope.
1288 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1289 # 0.5.0 switch, I hope!)
1291 # Revision 1.46  2002/01/07 10:42:23  richard
1292 # oops
1294 # Revision 1.45  2002/01/02 04:18:17  richard
1295 # hyperdb docstrings
1297 # Revision 1.44  2002/01/02 02:31:38  richard
1298 # Sorry for the huge checkin message - I was only intending to implement #496356
1299 # but I found a number of places where things had been broken by transactions:
1300 #  . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
1301 #    for _all_ roundup-generated smtp messages to be sent to.
1302 #  . the transaction cache had broken the roundupdb.Class set() reactors
1303 #  . newly-created author users in the mailgw weren't being committed to the db
1305 # Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
1306 # on when I found that stuff :):
1307 #  . #496356 ] Use threading in messages
1308 #  . detectors were being registered multiple times
1309 #  . added tests for mailgw
1310 #  . much better attaching of erroneous messages in the mail gateway
1312 # Revision 1.43  2001/12/20 06:13:24  rochecompaan
1313 # Bugs fixed:
1314 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1315 #     lost somewhere
1316 #   . Internet Explorer submits full path for filename - we now strip away
1317 #     the path
1318 # Features added:
1319 #   . Link and multilink properties are now displayed sorted in the cgi
1320 #     interface
1322 # Revision 1.42  2001/12/16 10:53:37  richard
1323 # take a copy of the node dict so that the subsequent set
1324 # operation doesn't modify the oldvalues structure
1326 # Revision 1.41  2001/12/15 23:47:47  richard
1327 # Cleaned up some bare except statements
1329 # Revision 1.40  2001/12/14 23:42:57  richard
1330 # yuck, a gdbm instance tests false :(
1331 # I've left the debugging code in - it should be removed one day if we're ever
1332 # _really_ anal about performace :)
1334 # Revision 1.39  2001/12/02 05:06:16  richard
1335 # . We now use weakrefs in the Classes to keep the database reference, so
1336 #   the close() method on the database is no longer needed.
1337 #   I bumped the minimum python requirement up to 2.1 accordingly.
1338 # . #487480 ] roundup-server
1339 # . #487476 ] INSTALL.txt
1341 # I also cleaned up the change message / post-edit stuff in the cgi client.
1342 # There's now a clearly marked "TODO: append the change note" where I believe
1343 # the change note should be added there. The "changes" list will obviously
1344 # have to be modified to be a dict of the changes, or somesuch.
1346 # More testing needed.
1348 # Revision 1.38  2001/12/01 07:17:50  richard
1349 # . We now have basic transaction support! Information is only written to
1350 #   the database when the commit() method is called. Only the anydbm
1351 #   backend is modified in this way - neither of the bsddb backends have been.
1352 #   The mail, admin and cgi interfaces all use commit (except the admin tool
1353 #   doesn't have a commit command, so interactive users can't commit...)
1354 # . Fixed login/registration forwarding the user to the right page (or not,
1355 #   on a failure)
1357 # Revision 1.37  2001/11/28 21:55:35  richard
1358 #  . login_action and newuser_action return values were being ignored
1359 #  . Woohoo! Found that bloody re-login bug that was killing the mail
1360 #    gateway.
1361 #  (also a minor cleanup in hyperdb)
1363 # Revision 1.36  2001/11/27 03:16:09  richard
1364 # Another place that wasn't handling missing properties.
1366 # Revision 1.35  2001/11/22 15:46:42  jhermann
1367 # Added module docstrings to all modules.
1369 # Revision 1.34  2001/11/21 04:04:43  richard
1370 # *sigh* more missing value handling
1372 # Revision 1.33  2001/11/21 03:40:54  richard
1373 # more new property handling
1375 # Revision 1.32  2001/11/21 03:11:28  richard
1376 # Better handling of new properties.
1378 # Revision 1.31  2001/11/12 22:01:06  richard
1379 # Fixed issues with nosy reaction and author copies.
1381 # Revision 1.30  2001/11/09 10:11:08  richard
1382 #  . roundup-admin now handles all hyperdb exceptions
1384 # Revision 1.29  2001/10/27 00:17:41  richard
1385 # Made Class.stringFind() do caseless matching.
1387 # Revision 1.28  2001/10/21 04:44:50  richard
1388 # bug #473124: UI inconsistency with Link fields.
1389 #    This also prompted me to fix a fairly long-standing usability issue -
1390 #    that of being able to turn off certain filters.
1392 # Revision 1.27  2001/10/20 23:44:27  richard
1393 # Hyperdatabase sorts strings-that-look-like-numbers as numbers now.
1395 # Revision 1.26  2001/10/16 03:48:01  richard
1396 # admin tool now complains if a "find" is attempted with a non-link property.
1398 # Revision 1.25  2001/10/11 00:17:51  richard
1399 # Reverted a change in hyperdb so the default value for missing property
1400 # values in a create() is None and not '' (the empty string.) This obviously
1401 # breaks CSV import/export - the string 'None' will be created in an
1402 # export/import operation.
1404 # Revision 1.24  2001/10/10 03:54:57  richard
1405 # Added database importing and exporting through CSV files.
1406 # Uses the csv module from object-craft for exporting if it's available.
1407 # Requires the csv module for importing.
1409 # Revision 1.23  2001/10/09 23:58:10  richard
1410 # Moved the data stringification up into the hyperdb.Class class' get, set
1411 # and create methods. This means that the data is also stringified for the
1412 # journal call, and removes duplication of code from the backends. The
1413 # backend code now only sees strings.
1415 # Revision 1.22  2001/10/09 07:25:59  richard
1416 # Added the Password property type. See "pydoc roundup.password" for
1417 # implementation details. Have updated some of the documentation too.
1419 # Revision 1.21  2001/10/05 02:23:24  richard
1420 #  . roundup-admin create now prompts for property info if none is supplied
1421 #    on the command-line.
1422 #  . hyperdb Class getprops() method may now return only the mutable
1423 #    properties.
1424 #  . Login now uses cookies, which makes it a whole lot more flexible. We can
1425 #    now support anonymous user access (read-only, unless there's an
1426 #    "anonymous" user, in which case write access is permitted). Login
1427 #    handling has been moved into cgi_client.Client.main()
1428 #  . The "extended" schema is now the default in roundup init.
1429 #  . The schemas have had their page headings modified to cope with the new
1430 #    login handling. Existing installations should copy the interfaces.py
1431 #    file from the roundup lib directory to their instance home.
1432 #  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
1433 #    Ping - has been removed.
1434 #  . Fixed a whole bunch of places in the CGI interface where we should have
1435 #    been returning Not Found instead of throwing an exception.
1436 #  . Fixed a deviation from the spec: trying to modify the 'id' property of
1437 #    an item now throws an exception.
1439 # Revision 1.20  2001/10/04 02:12:42  richard
1440 # Added nicer command-line item adding: passing no arguments will enter an
1441 # interactive more which asks for each property in turn. While I was at it, I
1442 # fixed an implementation problem WRT the spec - I wasn't raising a
1443 # ValueError if the key property was missing from a create(). Also added a
1444 # protected=boolean argument to getprops() so we can list only the mutable
1445 # properties (defaults to yes, which lists the immutables).
1447 # Revision 1.19  2001/08/29 04:47:18  richard
1448 # Fixed CGI client change messages so they actually include the properties
1449 # changed (again).
1451 # Revision 1.18  2001/08/16 07:34:59  richard
1452 # better CGI text searching - but hidden filter fields are disappearing...
1454 # Revision 1.17  2001/08/16 06:59:58  richard
1455 # all searches use re now - and they're all case insensitive
1457 # Revision 1.16  2001/08/15 23:43:18  richard
1458 # Fixed some isFooTypes that I missed.
1459 # Refactored some code in the CGI code.
1461 # Revision 1.15  2001/08/12 06:32:36  richard
1462 # using isinstance(blah, Foo) now instead of isFooType
1464 # Revision 1.14  2001/08/07 00:24:42  richard
1465 # stupid typo
1467 # Revision 1.13  2001/08/07 00:15:51  richard
1468 # Added the copyright/license notice to (nearly) all files at request of
1469 # Bizar Software.
1471 # Revision 1.12  2001/08/02 06:38:17  richard
1472 # Roundupdb now appends "mailing list" information to its messages which
1473 # include the e-mail address and web interface address. Templates may
1474 # override this in their db classes to include specific information (support
1475 # instructions, etc).
1477 # Revision 1.11  2001/08/01 04:24:21  richard
1478 # mailgw was assuming certain properties existed on the issues being created.
1480 # Revision 1.10  2001/07/30 02:38:31  richard
1481 # get() now has a default arg - for migration only.
1483 # Revision 1.9  2001/07/29 09:28:23  richard
1484 # Fixed sorting by clicking on column headings.
1486 # Revision 1.8  2001/07/29 08:27:40  richard
1487 # Fixed handling of passed-in values in form elements (ie. during a
1488 # drill-down)
1490 # Revision 1.7  2001/07/29 07:01:39  richard
1491 # Added vim command to all source so that we don't get no steenkin' tabs :)
1493 # Revision 1.6  2001/07/29 05:36:14  richard
1494 # Cleanup of the link label generation.
1496 # Revision 1.5  2001/07/29 04:05:37  richard
1497 # Added the fabricated property "id".
1499 # Revision 1.4  2001/07/27 06:25:35  richard
1500 # Fixed some of the exceptions so they're the right type.
1501 # Removed the str()-ification of node ids so we don't mask oopsy errors any
1502 # more.
1504 # Revision 1.3  2001/07/27 05:17:14  richard
1505 # just some comments
1507 # Revision 1.2  2001/07/22 12:09:32  richard
1508 # Final commit of Grande Splite
1510 # Revision 1.1  2001/07/22 11:58:35  richard
1511 # More Grande Splite
1514 # vim: set filetype=python ts=4 sw=4 et si