Code

use more robust date stamp comparisons in pack(), make journal smaller too
[roundup.git] / roundup / backends / back_anydbm.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: back_anydbm.py,v 1.63 2002-08-22 04:42:28 richard Exp $
19 '''
20 This module defines a backend that saves the hyperdatabase in a database
21 chosen by anydbm. It is guaranteed to always be available in python
22 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
23 serious bugs, and is not available)
24 '''
26 import whichdb, anydbm, os, marshal, re, weakref, string, copy
27 from roundup import hyperdb, date, password, roundupdb, security
28 from blobfiles import FileStorage
29 from sessions import Sessions
30 from roundup.indexer import Indexer
31 from locking import acquire_lock, release_lock
32 from roundup.hyperdb import String, Password, Date, Interval, Link, \
33     Multilink, DatabaseError, Boolean, Number
35 #
36 # Now the database
37 #
38 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
39     """A database for storing records containing flexible data types.
41     Transaction stuff TODO:
42         . check the timestamp of the class file and nuke the cache if it's
43           modified. Do some sort of conflict checking on the dirty stuff.
44         . perhaps detect write collisions (related to above)?
46     """
47     def __init__(self, config, journaltag=None):
48         """Open a hyperdatabase given a specifier to some storage.
50         The 'storagelocator' is obtained from config.DATABASE.
51         The meaning of 'storagelocator' depends on the particular
52         implementation of the hyperdatabase.  It could be a file name,
53         a directory path, a socket descriptor for a connection to a
54         database over the network, etc.
56         The 'journaltag' is a token that will be attached to the journal
57         entries for any edits done on the database.  If 'journaltag' is
58         None, the database is opened in read-only mode: the Class.create(),
59         Class.set(), and Class.retire() methods are disabled.
60         """
61         self.config, self.journaltag = config, journaltag
62         self.dir = config.DATABASE
63         self.classes = {}
64         self.cache = {}         # cache of nodes loaded or created
65         self.dirtynodes = {}    # keep track of the dirty nodes by class
66         self.newnodes = {}      # keep track of the new nodes by class
67         self.destroyednodes = {}# keep track of the destroyed nodes by class
68         self.transactions = []
69         self.indexer = Indexer(self.dir)
70         self.sessions = Sessions(self.config)
71         self.security = security.Security(self)
72         # ensure files are group readable and writable
73         os.umask(0002)
75     def post_init(self):
76         """Called once the schema initialisation has finished."""
77         # reindex the db if necessary
78         if self.indexer.should_reindex():
79             self.reindex()
81     def reindex(self):
82         for klass in self.classes.values():
83             for nodeid in klass.list():
84                 klass.index(nodeid)
85         self.indexer.save_index()
87     def __repr__(self):
88         return '<back_anydbm instance at %x>'%id(self) 
90     #
91     # Classes
92     #
93     def __getattr__(self, classname):
94         """A convenient way of calling self.getclass(classname)."""
95         if self.classes.has_key(classname):
96             if __debug__:
97                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
98             return self.classes[classname]
99         raise AttributeError, classname
101     def addclass(self, cl):
102         if __debug__:
103             print >>hyperdb.DEBUG, 'addclass', (self, cl)
104         cn = cl.classname
105         if self.classes.has_key(cn):
106             raise ValueError, cn
107         self.classes[cn] = cl
109     def getclasses(self):
110         """Return a list of the names of all existing classes."""
111         if __debug__:
112             print >>hyperdb.DEBUG, 'getclasses', (self,)
113         l = self.classes.keys()
114         l.sort()
115         return l
117     def getclass(self, classname):
118         """Get the Class object representing a particular class.
120         If 'classname' is not a valid class name, a KeyError is raised.
121         """
122         if __debug__:
123             print >>hyperdb.DEBUG, 'getclass', (self, classname)
124         return self.classes[classname]
126     #
127     # Class DBs
128     #
129     def clear(self):
130         '''Delete all database contents
131         '''
132         if __debug__:
133             print >>hyperdb.DEBUG, 'clear', (self,)
134         for cn in self.classes.keys():
135             for dummy in 'nodes', 'journals':
136                 path = os.path.join(self.dir, 'journals.%s'%cn)
137                 if os.path.exists(path):
138                     os.remove(path)
139                 elif os.path.exists(path+'.db'):    # dbm appends .db
140                     os.remove(path+'.db')
142     def getclassdb(self, classname, mode='r'):
143         ''' grab a connection to the class db that will be used for
144             multiple actions
145         '''
146         if __debug__:
147             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
148         return self.opendb('nodes.%s'%classname, mode)
150     def determine_db_type(self, path):
151         ''' determine which DB wrote the class file
152         '''
153         db_type = ''
154         if os.path.exists(path):
155             db_type = whichdb.whichdb(path)
156             if not db_type:
157                 raise hyperdb.DatabaseError, "Couldn't identify database type"
158         elif os.path.exists(path+'.db'):
159             # if the path ends in '.db', it's a dbm database, whether
160             # anydbm says it's dbhash or not!
161             db_type = 'dbm'
162         return db_type
164     def opendb(self, name, mode):
165         '''Low-level database opener that gets around anydbm/dbm
166            eccentricities.
167         '''
168         if __debug__:
169             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
171         # figure the class db type
172         path = os.path.join(os.getcwd(), self.dir, name)
173         db_type = self.determine_db_type(path)
175         # new database? let anydbm pick the best dbm
176         if not db_type:
177             if __debug__:
178                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'n')"%path
179             return anydbm.open(path, 'n')
181         # open the database with the correct module
182         try:
183             dbm = __import__(db_type)
184         except ImportError:
185             raise hyperdb.DatabaseError, \
186                 "Couldn't open database - the required module '%s'"\
187                 " is not available"%db_type
188         if __debug__:
189             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
190                 mode)
191         return dbm.open(path, mode)
193     def lockdb(self, name):
194         ''' Lock a database file
195         '''
196         path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name)
197         return acquire_lock(path)
199     #
200     # Node IDs
201     #
202     def newid(self, classname):
203         ''' Generate a new id for the given class
204         '''
205         # open the ids DB - create if if doesn't exist
206         lock = self.lockdb('_ids')
207         db = self.opendb('_ids', 'c')
208         if db.has_key(classname):
209             newid = db[classname] = str(int(db[classname]) + 1)
210         else:
211             # the count() bit is transitional - older dbs won't start at 1
212             newid = str(self.getclass(classname).count()+1)
213             db[classname] = newid
214         db.close()
215         release_lock(lock)
216         return newid
218     def setid(self, classname, setid):
219         ''' Set the id counter: used during import of database
220         '''
221         # open the ids DB - create if if doesn't exist
222         lock = self.lockdb('_ids')
223         db = self.opendb('_ids', 'c')
224         db[classname] = str(setid)
225         db.close()
226         release_lock(lock)
228     #
229     # Nodes
230     #
231     def addnode(self, classname, nodeid, node):
232         ''' add the specified node to its class's db
233         '''
234         if __debug__:
235             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
236         self.newnodes.setdefault(classname, {})[nodeid] = 1
237         self.cache.setdefault(classname, {})[nodeid] = node
238         self.savenode(classname, nodeid, node)
240     def setnode(self, classname, nodeid, node):
241         ''' change the specified node
242         '''
243         if __debug__:
244             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
245         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
247         # can't set without having already loaded the node
248         self.cache[classname][nodeid] = node
249         self.savenode(classname, nodeid, node)
251     def savenode(self, classname, nodeid, node):
252         ''' perform the saving of data specified by the set/addnode
253         '''
254         if __debug__:
255             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
256         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
258     def getnode(self, classname, nodeid, db=None, cache=1):
259         ''' get a node from the database
260         '''
261         if __debug__:
262             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
263         if cache:
264             # try the cache
265             cache_dict = self.cache.setdefault(classname, {})
266             if cache_dict.has_key(nodeid):
267                 if __debug__:
268                     print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
269                         nodeid)
270                 return cache_dict[nodeid]
272         if __debug__:
273             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
275         # get from the database and save in the cache
276         if db is None:
277             db = self.getclassdb(classname)
278         if not db.has_key(nodeid):
279             raise IndexError, "no such %s %s"%(classname, nodeid)
281         # check the uncommitted, destroyed nodes
282         if (self.destroyednodes.has_key(classname) and
283                 self.destroyednodes[classname].has_key(nodeid)):
284             raise IndexError, "no such %s %s"%(classname, nodeid)
286         # decode
287         res = marshal.loads(db[nodeid])
289         # reverse the serialisation
290         res = self.unserialise(classname, res)
292         # store off in the cache dict
293         if cache:
294             cache_dict[nodeid] = res
296         return res
298     def destroynode(self, classname, nodeid):
299         '''Remove a node from the database. Called exclusively by the
300            destroy() method on Class.
301         '''
302         if __debug__:
303             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
305         # remove from cache and newnodes if it's there
306         if (self.cache.has_key(classname) and
307                 self.cache[classname].has_key(nodeid)):
308             del self.cache[classname][nodeid]
309         if (self.newnodes.has_key(classname) and
310                 self.newnodes[classname].has_key(nodeid)):
311             del self.newnodes[classname][nodeid]
313         # see if there's any obvious commit actions that we should get rid of
314         for entry in self.transactions[:]:
315             if entry[1][:2] == (classname, nodeid):
316                 self.transactions.remove(entry)
318         # add to the destroyednodes map
319         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
321         # add the destroy commit action
322         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
324     def serialise(self, classname, node):
325         '''Copy the node contents, converting non-marshallable data into
326            marshallable data.
327         '''
328         if __debug__:
329             print >>hyperdb.DEBUG, 'serialise', classname, node
330         properties = self.getclass(classname).getprops()
331         d = {}
332         for k, v in node.items():
333             # if the property doesn't exist, or is the "retired" flag then
334             # it won't be in the properties dict
335             if not properties.has_key(k):
336                 d[k] = v
337                 continue
339             # get the property spec
340             prop = properties[k]
342             if isinstance(prop, Password):
343                 d[k] = str(v)
344             elif isinstance(prop, Date) and v is not None:
345                 d[k] = v.serialise()
346             elif isinstance(prop, Interval) and v is not None:
347                 d[k] = v.serialise()
348             else:
349                 d[k] = v
350         return d
352     def unserialise(self, classname, node):
353         '''Decode the marshalled node data
354         '''
355         if __debug__:
356             print >>hyperdb.DEBUG, 'unserialise', classname, node
357         properties = self.getclass(classname).getprops()
358         d = {}
359         for k, v in node.items():
360             # if the property doesn't exist, or is the "retired" flag then
361             # it won't be in the properties dict
362             if not properties.has_key(k):
363                 d[k] = v
364                 continue
366             # get the property spec
367             prop = properties[k]
369             if isinstance(prop, Date) and v is not None:
370                 d[k] = date.Date(v)
371             elif isinstance(prop, Interval) and v is not None:
372                 d[k] = date.Interval(v)
373             elif isinstance(prop, Password):
374                 p = password.Password()
375                 p.unpack(v)
376                 d[k] = p
377             else:
378                 d[k] = v
379         return d
381     def hasnode(self, classname, nodeid, db=None):
382         ''' determine if the database has a given node
383         '''
384         if __debug__:
385             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
387         # try the cache
388         cache = self.cache.setdefault(classname, {})
389         if cache.has_key(nodeid):
390             if __debug__:
391                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
392             return 1
393         if __debug__:
394             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
396         # not in the cache - check the database
397         if db is None:
398             db = self.getclassdb(classname)
399         res = db.has_key(nodeid)
400         return res
402     def countnodes(self, classname, db=None):
403         if __debug__:
404             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
406         count = 0
408         # include the uncommitted nodes
409         if self.newnodes.has_key(classname):
410             count += len(self.newnodes[classname])
411         if self.destroyednodes.has_key(classname):
412             count -= len(self.destroyednodes[classname])
414         # and count those in the DB
415         if db is None:
416             db = self.getclassdb(classname)
417         count = count + len(db.keys())
418         return count
420     def getnodeids(self, classname, db=None):
421         if __debug__:
422             print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db)
424         res = []
426         # start off with the new nodes
427         if self.newnodes.has_key(classname):
428             res += self.newnodes[classname].keys()
430         if db is None:
431             db = self.getclassdb(classname)
432         res = res + db.keys()
434         # remove the uncommitted, destroyed nodes
435         if self.destroyednodes.has_key(classname):
436             for nodeid in self.destroyednodes[classname].keys():
437                 if db.has_key(nodeid):
438                     res.remove(nodeid)
440         return res
443     #
444     # Files - special node properties
445     # inherited from FileStorage
447     #
448     # Journal
449     #
450     def addjournal(self, classname, nodeid, action, params):
451         ''' Journal the Action
452         'action' may be:
454             'create' or 'set' -- 'params' is a dictionary of property values
455             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
456             'retire' -- 'params' is None
457         '''
458         if __debug__:
459             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
460                 action, params)
461         self.transactions.append((self.doSaveJournal, (classname, nodeid,
462             action, params)))
464     def getjournal(self, classname, nodeid):
465         ''' get the journal for id
467             Raise IndexError if the node doesn't exist (as per history()'s
468             API)
469         '''
470         if __debug__:
471             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
472         # attempt to open the journal - in some rare cases, the journal may
473         # not exist
474         try:
475             db = self.opendb('journals.%s'%classname, 'r')
476         except anydbm.error, error:
477             if str(error) == "need 'c' or 'n' flag to open new db":
478                 raise IndexError, 'no such %s %s'%(classname, nodeid)
479             elif error.args[0] != 2:
480                 raise
481             raise IndexError, 'no such %s %s'%(classname, nodeid)
482         try:
483             journal = marshal.loads(db[nodeid])
484         except KeyError:
485             db.close()
486             raise IndexError, 'no such %s %s'%(classname, nodeid)
487         db.close()
488         res = []
489         for nodeid, date_stamp, user, action, params in journal:
490             res.append((nodeid, date.Date(date_stamp), user, action, params))
491         return res
493     def pack(self, pack_before):
494         ''' delete all journal entries before 'pack_before' '''
495         if __debug__:
496             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
498         classes = self.getclasses()
500         # figure the class db type
502         for classname in classes:
503             db_name = 'journals.%s'%classname
504             path = os.path.join(os.getcwd(), self.dir, classname)
505             db_type = self.determine_db_type(path)
506             db = self.opendb(db_name, 'w')
508             for key in db.keys():
509                 journal = marshal.loads(db[key])
510                 l = []
511                 last_set_entry = None
512                 for entry in journal:
513                     (nodeid, date_stamp, self.journaltag, action, 
514                         params) = entry
515                     date_stamp = date.Date(date_stamp)
516                     if date_stamp > pack_before or action == 'create':
517                         l.append(entry)
518                     elif action == 'set':
519                         # grab the last set entry to keep information on
520                         # activity
521                         last_set_entry = entry
522                 if last_set_entry:
523                     date_stamp = last_set_entry[1]
524                     # if the last set entry was made after the pack date
525                     # then it is already in the list
526                     if date_stamp < pack_before:
527                         l.append(last_set_entry)
528                 db[key] = marshal.dumps(l)
529             if db_type == 'gdbm':
530                 db.reorganize()
531             db.close()
532             
534     #
535     # Basic transaction support
536     #
537     def commit(self):
538         ''' Commit the current transactions.
539         '''
540         if __debug__:
541             print >>hyperdb.DEBUG, 'commit', (self,)
542         # TODO: lock the DB
544         # keep a handle to all the database files opened
545         self.databases = {}
547         # now, do all the transactions
548         reindex = {}
549         for method, args in self.transactions:
550             reindex[method(*args)] = 1
552         # now close all the database files
553         for db in self.databases.values():
554             db.close()
555         del self.databases
556         # TODO: unlock the DB
558         # reindex the nodes that request it
559         for classname, nodeid in filter(None, reindex.keys()):
560             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
561             self.getclass(classname).index(nodeid)
563         # save the indexer state
564         self.indexer.save_index()
566         # all transactions committed, back to normal
567         self.cache = {}
568         self.dirtynodes = {}
569         self.newnodes = {}
570         self.destroyednodes = {}
571         self.transactions = []
573     def getCachedClassDB(self, classname):
574         ''' get the class db, looking in our cache of databases for commit
575         '''
576         # get the database handle
577         db_name = 'nodes.%s'%classname
578         if not self.databases.has_key(db_name):
579             self.databases[db_name] = self.getclassdb(classname, 'c')
580         return self.databases[db_name]
582     def doSaveNode(self, classname, nodeid, node):
583         if __debug__:
584             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
585                 node)
587         db = self.getCachedClassDB(classname)
589         # now save the marshalled data
590         db[nodeid] = marshal.dumps(self.serialise(classname, node))
592         # return the classname, nodeid so we reindex this content
593         return (classname, nodeid)
595     def getCachedJournalDB(self, classname):
596         ''' get the journal db, looking in our cache of databases for commit
597         '''
598         # get the database handle
599         db_name = 'journals.%s'%classname
600         if not self.databases.has_key(db_name):
601             self.databases[db_name] = self.opendb(db_name, 'c')
602         return self.databases[db_name]
604     def doSaveJournal(self, classname, nodeid, action, params):
605         # handle supply of the special journalling parameters (usually
606         # supplied on importing an existing database)
607         if isinstance(params, type({})):
608             if params.has_key('creator'):
609                 journaltag = self.user.get(params['creator'], 'username')
610                 del params['creator']
611             else:
612                 journaltag = self.journaltag
613             if params.has_key('created'):
614                 journaldate = params['created'].serialise()
615                 del params['created']
616             else:
617                 journaldate = date.Date().serialise()
618             if params.has_key('activity'):
619                 del params['activity']
621             # serialise the parameters now
622             if action in ('set', 'create'):
623                 params = self.serialise(classname, params)
624         else:
625             journaltag = self.journaltag
626             journaldate = date.Date().serialise()
628         # create the journal entry
629         entry = (nodeid, journaldate, journaltag, action, params)
630         print 'doSaveJournal', entry
632         if __debug__:
633             print >>hyperdb.DEBUG, 'doSaveJournal', entry
635         db = self.getCachedJournalDB(classname)
637         # now insert the journal entry
638         if db.has_key(nodeid):
639             # append to existing
640             s = db[nodeid]
641             l = marshal.loads(s)
642             l.append(entry)
643         else:
644             l = [entry]
646         db[nodeid] = marshal.dumps(l)
648     def doDestroyNode(self, classname, nodeid):
649         if __debug__:
650             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
652         # delete from the class database
653         db = self.getCachedClassDB(classname)
654         if db.has_key(nodeid):
655             del db[nodeid]
657         # delete from the database
658         db = self.getCachedJournalDB(classname)
659         if db.has_key(nodeid):
660             del db[nodeid]
662         # return the classname, nodeid so we reindex this content
663         return (classname, nodeid)
665     def rollback(self):
666         ''' Reverse all actions from the current transaction.
667         '''
668         if __debug__:
669             print >>hyperdb.DEBUG, 'rollback', (self, )
670         for method, args in self.transactions:
671             # delete temporary files
672             if method == self.doStoreFile:
673                 self.rollbackStoreFile(*args)
674         self.cache = {}
675         self.dirtynodes = {}
676         self.newnodes = {}
677         self.destroyednodes = {}
678         self.transactions = []
680 _marker = []
681 class Class(hyperdb.Class):
682     """The handle to a particular class of nodes in a hyperdatabase."""
684     def __init__(self, db, classname, **properties):
685         """Create a new class with a given name and property specification.
687         'classname' must not collide with the name of an existing class,
688         or a ValueError is raised.  The keyword arguments in 'properties'
689         must map names to property objects, or a TypeError is raised.
690         """
691         if (properties.has_key('creation') or properties.has_key('activity')
692                 or properties.has_key('creator')):
693             raise ValueError, '"creation", "activity" and "creator" are '\
694                 'reserved'
696         self.classname = classname
697         self.properties = properties
698         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
699         self.key = ''
701         # should we journal changes (default yes)
702         self.do_journal = 1
704         # do the db-related init stuff
705         db.addclass(self)
707         self.auditors = {'create': [], 'set': [], 'retire': []}
708         self.reactors = {'create': [], 'set': [], 'retire': []}
710     def enableJournalling(self):
711         '''Turn journalling on for this class
712         '''
713         self.do_journal = 1
715     def disableJournalling(self):
716         '''Turn journalling off for this class
717         '''
718         self.do_journal = 0
720     # Editing nodes:
722     def create(self, **propvalues):
723         """Create a new node of this class and return its id.
725         The keyword arguments in 'propvalues' map property names to values.
727         The values of arguments must be acceptable for the types of their
728         corresponding properties or a TypeError is raised.
729         
730         If this class has a key property, it must be present and its value
731         must not collide with other key strings or a ValueError is raised.
732         
733         Any other properties on this class that are missing from the
734         'propvalues' dictionary are set to None.
735         
736         If an id in a link or multilink property does not refer to a valid
737         node, an IndexError is raised.
739         These operations trigger detectors and can be vetoed.  Attempts
740         to modify the "creation" or "activity" properties cause a KeyError.
741         """
742         if propvalues.has_key('id'):
743             raise KeyError, '"id" is reserved'
745         if self.db.journaltag is None:
746             raise DatabaseError, 'Database open read-only'
748         if propvalues.has_key('creation') or propvalues.has_key('activity'):
749             raise KeyError, '"creation" and "activity" are reserved'
751         self.fireAuditors('create', None, propvalues)
753         # new node's id
754         newid = self.db.newid(self.classname)
756         # validate propvalues
757         num_re = re.compile('^\d+$')
758         for key, value in propvalues.items():
759             if key == self.key:
760                 try:
761                     self.lookup(value)
762                 except KeyError:
763                     pass
764                 else:
765                     raise ValueError, 'node with key "%s" exists'%value
767             # try to handle this property
768             try:
769                 prop = self.properties[key]
770             except KeyError:
771                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
772                     key)
774             if value is not None and isinstance(prop, Link):
775                 if type(value) != type(''):
776                     raise ValueError, 'link value must be String'
777                 link_class = self.properties[key].classname
778                 # if it isn't a number, it's a key
779                 if not num_re.match(value):
780                     try:
781                         value = self.db.classes[link_class].lookup(value)
782                     except (TypeError, KeyError):
783                         raise IndexError, 'new property "%s": %s not a %s'%(
784                             key, value, link_class)
785                 elif not self.db.getclass(link_class).hasnode(value):
786                     raise IndexError, '%s has no node %s'%(link_class, value)
788                 # save off the value
789                 propvalues[key] = value
791                 # register the link with the newly linked node
792                 if self.do_journal and self.properties[key].do_journal:
793                     self.db.addjournal(link_class, value, 'link',
794                         (self.classname, newid, key))
796             elif isinstance(prop, Multilink):
797                 if type(value) != type([]):
798                     raise TypeError, 'new property "%s" not a list of ids'%key
800                 # clean up and validate the list of links
801                 link_class = self.properties[key].classname
802                 l = []
803                 for entry in value:
804                     if type(entry) != type(''):
805                         raise ValueError, '"%s" link value (%s) must be '\
806                             'String'%(key, value)
807                     # if it isn't a number, it's a key
808                     if not num_re.match(entry):
809                         try:
810                             entry = self.db.classes[link_class].lookup(entry)
811                         except (TypeError, KeyError):
812                             raise IndexError, 'new property "%s": %s not a %s'%(
813                                 key, entry, self.properties[key].classname)
814                     l.append(entry)
815                 value = l
816                 propvalues[key] = value
818                 # handle additions
819                 for nodeid in value:
820                     if not self.db.getclass(link_class).hasnode(nodeid):
821                         raise IndexError, '%s has no node %s'%(link_class,
822                             nodeid)
823                     # register the link with the newly linked node
824                     if self.do_journal and self.properties[key].do_journal:
825                         self.db.addjournal(link_class, nodeid, 'link',
826                             (self.classname, newid, key))
828             elif isinstance(prop, String):
829                 if type(value) != type(''):
830                     raise TypeError, 'new property "%s" not a string'%key
832             elif isinstance(prop, Password):
833                 if not isinstance(value, password.Password):
834                     raise TypeError, 'new property "%s" not a Password'%key
836             elif isinstance(prop, Date):
837                 if value is not None and not isinstance(value, date.Date):
838                     raise TypeError, 'new property "%s" not a Date'%key
840             elif isinstance(prop, Interval):
841                 if value is not None and not isinstance(value, date.Interval):
842                     raise TypeError, 'new property "%s" not an Interval'%key
844             elif value is not None and isinstance(prop, Number):
845                 try:
846                     float(value)
847                 except ValueError:
848                     raise TypeError, 'new property "%s" not numeric'%key
850             elif value is not None and isinstance(prop, Boolean):
851                 try:
852                     int(value)
853                 except ValueError:
854                     raise TypeError, 'new property "%s" not boolean'%key
856         # make sure there's data where there needs to be
857         for key, prop in self.properties.items():
858             if propvalues.has_key(key):
859                 continue
860             if key == self.key:
861                 raise ValueError, 'key property "%s" is required'%key
862             if isinstance(prop, Multilink):
863                 propvalues[key] = []
864             else:
865                 propvalues[key] = None
867         # done
868         self.db.addnode(self.classname, newid, propvalues)
869         if self.do_journal:
870             self.db.addjournal(self.classname, newid, 'create', propvalues)
872         self.fireReactors('create', newid, None)
874         return newid
876     def export_list(self, propnames, nodeid):
877         ''' Export a node - generate a list of CSV-able data in the order
878             specified by propnames for the given node.
879         '''
880         properties = self.getprops()
881         l = []
882         for prop in propnames:
883             proptype = properties[prop]
884             value = self.get(nodeid, prop)
885             # "marshal" data where needed
886             if isinstance(proptype, hyperdb.Date):
887                 value = value.get_tuple()
888             elif isinstance(proptype, hyperdb.Interval):
889                 value = value.get_tuple()
890             elif isinstance(proptype, hyperdb.Password):
891                 value = str(value)
892             l.append(repr(value))
893         return l
895     def import_list(self, propnames, proplist):
896         ''' Import a node - all information including "id" is present and
897             should not be sanity checked. Triggers are not triggered. The
898             journal should be initialised using the "creator" and "created"
899             information.
901             Return the nodeid of the node imported.
902         '''
903         if self.db.journaltag is None:
904             raise DatabaseError, 'Database open read-only'
905         properties = self.getprops()
907         # make the new node's property map
908         d = {}
909         for i in range(len(propnames)):
910             # Use eval to reverse the repr() used to output the CSV
911             value = eval(proplist[i])
913             # Figure the property for this column
914             propname = propnames[i]
915             prop = properties[propname]
917             # "unmarshal" where necessary
918             if propname == 'id':
919                 newid = value
920                 continue
921             elif isinstance(prop, hyperdb.Date):
922                 value = date.Date(value)
923             elif isinstance(prop, hyperdb.Interval):
924                 value = date.Interval(value)
925             elif isinstance(prop, hyperdb.Password):
926                 pwd = password.Password()
927                 pwd.unpack(value)
928                 value = pwd
929             if value is not None:
930                 d[propname] = value
932         # add
933         self.db.addnode(self.classname, newid, d)
934         self.db.addjournal(self.classname, newid, 'create', d)
935         return newid
937     def get(self, nodeid, propname, default=_marker, cache=1):
938         """Get the value of a property on an existing node of this class.
940         'nodeid' must be the id of an existing node of this class or an
941         IndexError is raised.  'propname' must be the name of a property
942         of this class or a KeyError is raised.
944         'cache' indicates whether the transaction cache should be queried
945         for the node. If the node has been modified and you need to
946         determine what its values prior to modification are, you need to
947         set cache=0.
949         Attempts to get the "creation" or "activity" properties should
950         do the right thing.
951         """
952         if propname == 'id':
953             return nodeid
955         if propname == 'creation':
956             if not self.do_journal:
957                 raise ValueError, 'Journalling is disabled for this class'
958             journal = self.db.getjournal(self.classname, nodeid)
959             if journal:
960                 return self.db.getjournal(self.classname, nodeid)[0][1]
961             else:
962                 # on the strange chance that there's no journal
963                 return date.Date()
964         if propname == 'activity':
965             if not self.do_journal:
966                 raise ValueError, 'Journalling is disabled for this class'
967             journal = self.db.getjournal(self.classname, nodeid)
968             if journal:
969                 return self.db.getjournal(self.classname, nodeid)[-1][1]
970             else:
971                 # on the strange chance that there's no journal
972                 return date.Date()
973         if propname == 'creator':
974             if not self.do_journal:
975                 raise ValueError, 'Journalling is disabled for this class'
976             journal = self.db.getjournal(self.classname, nodeid)
977             if journal:
978                 name = self.db.getjournal(self.classname, nodeid)[0][2]
979             else:
980                 return None
981             return self.db.user.lookup(name)
983         # get the property (raises KeyErorr if invalid)
984         prop = self.properties[propname]
986         # get the node's dict
987         d = self.db.getnode(self.classname, nodeid, cache=cache)
989         if not d.has_key(propname):
990             if default is _marker:
991                 if isinstance(prop, Multilink):
992                     return []
993                 else:
994                     return None
995             else:
996                 return default
998         return d[propname]
1000     # XXX not in spec
1001     def getnode(self, nodeid, cache=1):
1002         ''' Return a convenience wrapper for the node.
1004         'nodeid' must be the id of an existing node of this class or an
1005         IndexError is raised.
1007         'cache' indicates whether the transaction cache should be queried
1008         for the node. If the node has been modified and you need to
1009         determine what its values prior to modification are, you need to
1010         set cache=0.
1011         '''
1012         return Node(self, nodeid, cache=cache)
1014     def set(self, nodeid, **propvalues):
1015         """Modify a property on an existing node of this class.
1016         
1017         'nodeid' must be the id of an existing node of this class or an
1018         IndexError is raised.
1020         Each key in 'propvalues' must be the name of a property of this
1021         class or a KeyError is raised.
1023         All values in 'propvalues' must be acceptable types for their
1024         corresponding properties or a TypeError is raised.
1026         If the value of the key property is set, it must not collide with
1027         other key strings or a ValueError is raised.
1029         If the value of a Link or Multilink property contains an invalid
1030         node id, a ValueError is raised.
1032         These operations trigger detectors and can be vetoed.  Attempts
1033         to modify the "creation" or "activity" properties cause a KeyError.
1034         """
1035         if not propvalues:
1036             return propvalues
1038         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1039             raise KeyError, '"creation" and "activity" are reserved'
1041         if propvalues.has_key('id'):
1042             raise KeyError, '"id" is reserved'
1044         if self.db.journaltag is None:
1045             raise DatabaseError, 'Database open read-only'
1047         self.fireAuditors('set', nodeid, propvalues)
1048         # Take a copy of the node dict so that the subsequent set
1049         # operation doesn't modify the oldvalues structure.
1050         try:
1051             # try not using the cache initially
1052             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid,
1053                 cache=0))
1054         except IndexError:
1055             # this will be needed if somone does a create() and set()
1056             # with no intervening commit()
1057             oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1059         node = self.db.getnode(self.classname, nodeid)
1060         if node.has_key(self.db.RETIRED_FLAG):
1061             raise IndexError
1062         num_re = re.compile('^\d+$')
1064         # if the journal value is to be different, store it in here
1065         journalvalues = {}
1067         for propname, value in propvalues.items():
1068             # check to make sure we're not duplicating an existing key
1069             if propname == self.key and node[propname] != value:
1070                 try:
1071                     self.lookup(value)
1072                 except KeyError:
1073                     pass
1074                 else:
1075                     raise ValueError, 'node with key "%s" exists'%value
1077             # this will raise the KeyError if the property isn't valid
1078             # ... we don't use getprops() here because we only care about
1079             # the writeable properties.
1080             prop = self.properties[propname]
1082             # if the value's the same as the existing value, no sense in
1083             # doing anything
1084             if node.has_key(propname) and value == node[propname]:
1085                 del propvalues[propname]
1086                 continue
1088             # do stuff based on the prop type
1089             if isinstance(prop, Link):
1090                 link_class = prop.classname
1091                 # if it isn't a number, it's a key
1092                 if value is not None and not isinstance(value, type('')):
1093                     raise ValueError, 'property "%s" link value be a string'%(
1094                         propname)
1095                 if isinstance(value, type('')) and not num_re.match(value):
1096                     try:
1097                         value = self.db.classes[link_class].lookup(value)
1098                     except (TypeError, KeyError):
1099                         raise IndexError, 'new property "%s": %s not a %s'%(
1100                             propname, value, prop.classname)
1102                 if (value is not None and
1103                         not self.db.getclass(link_class).hasnode(value)):
1104                     raise IndexError, '%s has no node %s'%(link_class, value)
1106                 if self.do_journal and prop.do_journal:
1107                     # register the unlink with the old linked node
1108                     if node[propname] is not None:
1109                         self.db.addjournal(link_class, node[propname], 'unlink',
1110                             (self.classname, nodeid, propname))
1112                     # register the link with the newly linked node
1113                     if value is not None:
1114                         self.db.addjournal(link_class, value, 'link',
1115                             (self.classname, nodeid, propname))
1117             elif isinstance(prop, Multilink):
1118                 if type(value) != type([]):
1119                     raise TypeError, 'new property "%s" not a list of'\
1120                         ' ids'%propname
1121                 link_class = self.properties[propname].classname
1122                 l = []
1123                 for entry in value:
1124                     # if it isn't a number, it's a key
1125                     if type(entry) != type(''):
1126                         raise ValueError, 'new property "%s" link value ' \
1127                             'must be a string'%propname
1128                     if not num_re.match(entry):
1129                         try:
1130                             entry = self.db.classes[link_class].lookup(entry)
1131                         except (TypeError, KeyError):
1132                             raise IndexError, 'new property "%s": %s not a %s'%(
1133                                 propname, entry,
1134                                 self.properties[propname].classname)
1135                     l.append(entry)
1136                 value = l
1137                 propvalues[propname] = value
1139                 # figure the journal entry for this property
1140                 add = []
1141                 remove = []
1143                 # handle removals
1144                 if node.has_key(propname):
1145                     l = node[propname]
1146                 else:
1147                     l = []
1148                 for id in l[:]:
1149                     if id in value:
1150                         continue
1151                     # register the unlink with the old linked node
1152                     if self.do_journal and self.properties[propname].do_journal:
1153                         self.db.addjournal(link_class, id, 'unlink',
1154                             (self.classname, nodeid, propname))
1155                     l.remove(id)
1156                     remove.append(id)
1158                 # handle additions
1159                 for id in value:
1160                     if not self.db.getclass(link_class).hasnode(id):
1161                         raise IndexError, '%s has no node %s'%(link_class, id)
1162                     if id in l:
1163                         continue
1164                     # register the link with the newly linked node
1165                     if self.do_journal and self.properties[propname].do_journal:
1166                         self.db.addjournal(link_class, id, 'link',
1167                             (self.classname, nodeid, propname))
1168                     l.append(id)
1169                     add.append(id)
1171                 # figure the journal entry
1172                 l = []
1173                 if add:
1174                     l.append(('+', add))
1175                 if remove:
1176                     l.append(('-', remove))
1177                 if l:
1178                     journalvalues[propname] = tuple(l)
1180             elif isinstance(prop, String):
1181                 if value is not None and type(value) != type(''):
1182                     raise TypeError, 'new property "%s" not a string'%propname
1184             elif isinstance(prop, Password):
1185                 if not isinstance(value, password.Password):
1186                     raise TypeError, 'new property "%s" not a Password'%propname
1187                 propvalues[propname] = value
1189             elif value is not None and isinstance(prop, Date):
1190                 if not isinstance(value, date.Date):
1191                     raise TypeError, 'new property "%s" not a Date'% propname
1192                 propvalues[propname] = value
1194             elif value is not None and isinstance(prop, Interval):
1195                 if not isinstance(value, date.Interval):
1196                     raise TypeError, 'new property "%s" not an '\
1197                         'Interval'%propname
1198                 propvalues[propname] = value
1200             elif value is not None and isinstance(prop, Number):
1201                 try:
1202                     float(value)
1203                 except ValueError:
1204                     raise TypeError, 'new property "%s" not numeric'%propname
1206             elif value is not None and isinstance(prop, Boolean):
1207                 try:
1208                     int(value)
1209                 except ValueError:
1210                     raise TypeError, 'new property "%s" not boolean'%propname
1212             node[propname] = value
1214         # nothing to do?
1215         if not propvalues:
1216             return propvalues
1218         # do the set, and journal it
1219         self.db.setnode(self.classname, nodeid, node)
1221         if self.do_journal:
1222             propvalues.update(journalvalues)
1223             self.db.addjournal(self.classname, nodeid, 'set', propvalues)
1225         self.fireReactors('set', nodeid, oldvalues)
1227         return propvalues        
1229     def retire(self, nodeid):
1230         """Retire a node.
1231         
1232         The properties on the node remain available from the get() method,
1233         and the node's id is never reused.
1234         
1235         Retired nodes are not returned by the find(), list(), or lookup()
1236         methods, and other nodes may reuse the values of their key properties.
1238         These operations trigger detectors and can be vetoed.  Attempts
1239         to modify the "creation" or "activity" properties cause a KeyError.
1240         """
1241         if self.db.journaltag is None:
1242             raise DatabaseError, 'Database open read-only'
1244         self.fireAuditors('retire', nodeid, None)
1246         node = self.db.getnode(self.classname, nodeid)
1247         node[self.db.RETIRED_FLAG] = 1
1248         self.db.setnode(self.classname, nodeid, node)
1249         if self.do_journal:
1250             self.db.addjournal(self.classname, nodeid, 'retired', None)
1252         self.fireReactors('retire', nodeid, None)
1254     def is_retired(self, nodeid):
1255         '''Return true if the node is retired.
1256         '''
1257         node = self.db.getnode(cn, nodeid, cldb)
1258         if node.has_key(self.db.RETIRED_FLAG):
1259             return 1
1260         return 0
1262     def destroy(self, nodeid):
1263         """Destroy a node.
1264         
1265         WARNING: this method should never be used except in extremely rare
1266                  situations where there could never be links to the node being
1267                  deleted
1268         WARNING: use retire() instead
1269         WARNING: the properties of this node will not be available ever again
1270         WARNING: really, use retire() instead
1272         Well, I think that's enough warnings. This method exists mostly to
1273         support the session storage of the cgi interface.
1274         """
1275         if self.db.journaltag is None:
1276             raise DatabaseError, 'Database open read-only'
1277         self.db.destroynode(self.classname, nodeid)
1279     def history(self, nodeid):
1280         """Retrieve the journal of edits on a particular node.
1282         'nodeid' must be the id of an existing node of this class or an
1283         IndexError is raised.
1285         The returned list contains tuples of the form
1287             (date, tag, action, params)
1289         'date' is a Timestamp object specifying the time of the change and
1290         'tag' is the journaltag specified when the database was opened.
1291         """
1292         if not self.do_journal:
1293             raise ValueError, 'Journalling is disabled for this class'
1294         return self.db.getjournal(self.classname, nodeid)
1296     # Locating nodes:
1297     def hasnode(self, nodeid):
1298         '''Determine if the given nodeid actually exists
1299         '''
1300         return self.db.hasnode(self.classname, nodeid)
1302     def setkey(self, propname):
1303         """Select a String property of this class to be the key property.
1305         'propname' must be the name of a String property of this class or
1306         None, or a TypeError is raised.  The values of the key property on
1307         all existing nodes must be unique or a ValueError is raised. If the
1308         property doesn't exist, KeyError is raised.
1309         """
1310         prop = self.getprops()[propname]
1311         if not isinstance(prop, String):
1312             raise TypeError, 'key properties must be String'
1313         self.key = propname
1315     def getkey(self):
1316         """Return the name of the key property for this class or None."""
1317         return self.key
1319     def labelprop(self, default_to_id=0):
1320         ''' Return the property name for a label for the given node.
1322         This method attempts to generate a consistent label for the node.
1323         It tries the following in order:
1324             1. key property
1325             2. "name" property
1326             3. "title" property
1327             4. first property from the sorted property name list
1328         '''
1329         k = self.getkey()
1330         if  k:
1331             return k
1332         props = self.getprops()
1333         if props.has_key('name'):
1334             return 'name'
1335         elif props.has_key('title'):
1336             return 'title'
1337         if default_to_id:
1338             return 'id'
1339         props = props.keys()
1340         props.sort()
1341         return props[0]
1343     # TODO: set up a separate index db file for this? profile?
1344     def lookup(self, keyvalue):
1345         """Locate a particular node by its key property and return its id.
1347         If this class has no key property, a TypeError is raised.  If the
1348         'keyvalue' matches one of the values for the key property among
1349         the nodes in this class, the matching node's id is returned;
1350         otherwise a KeyError is raised.
1351         """
1352         cldb = self.db.getclassdb(self.classname)
1353         try:
1354             for nodeid in self.db.getnodeids(self.classname, cldb):
1355                 node = self.db.getnode(self.classname, nodeid, cldb)
1356                 if node.has_key(self.db.RETIRED_FLAG):
1357                     continue
1358                 if node[self.key] == keyvalue:
1359                     cldb.close()
1360                     return nodeid
1361         finally:
1362             cldb.close()
1363         raise KeyError, keyvalue
1365     # XXX: change from spec - allows multiple props to match
1366     def find(self, **propspec):
1367         """Get the ids of nodes in this class which link to the given nodes.
1369         'propspec' consists of keyword args propname={nodeid:1,}   
1370           'propname' must be the name of a property in this class, or a
1371             KeyError is raised.  That property must be a Link or Multilink
1372             property, or a TypeError is raised.
1374         Any node in this class whose 'propname' property links to any of the
1375         nodeids will be returned. Used by the full text indexing, which knows
1376         that "foo" occurs in msg1, msg3 and file7, so we have hits on these issues:
1377             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1378         """
1379         propspec = propspec.items()
1380         for propname, nodeids in propspec:
1381             # check the prop is OK
1382             prop = self.properties[propname]
1383             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1384                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1386         # ok, now do the find
1387         cldb = self.db.getclassdb(self.classname)
1388         l = []
1389         try:
1390             for id in self.db.getnodeids(self.classname, db=cldb):
1391                 node = self.db.getnode(self.classname, id, db=cldb)
1392                 if node.has_key(self.db.RETIRED_FLAG):
1393                     continue
1394                 for propname, nodeids in propspec:
1395                     # can't test if the node doesn't have this property
1396                     if not node.has_key(propname):
1397                         continue
1398                     if type(nodeids) is type(''):
1399                         nodeids = {nodeids:1}
1400                     prop = self.properties[propname]
1401                     value = node[propname]
1402                     if isinstance(prop, Link) and nodeids.has_key(value):
1403                         l.append(id)
1404                         break
1405                     elif isinstance(prop, Multilink):
1406                         hit = 0
1407                         for v in value:
1408                             if nodeids.has_key(v):
1409                                 l.append(id)
1410                                 hit = 1
1411                                 break
1412                         if hit:
1413                             break
1414         finally:
1415             cldb.close()
1416         return l
1418     def stringFind(self, **requirements):
1419         """Locate a particular node by matching a set of its String
1420         properties in a caseless search.
1422         If the property is not a String property, a TypeError is raised.
1423         
1424         The return is a list of the id of all nodes that match.
1425         """
1426         for propname in requirements.keys():
1427             prop = self.properties[propname]
1428             if isinstance(not prop, String):
1429                 raise TypeError, "'%s' not a String property"%propname
1430             requirements[propname] = requirements[propname].lower()
1431         l = []
1432         cldb = self.db.getclassdb(self.classname)
1433         try:
1434             for nodeid in self.db.getnodeids(self.classname, cldb):
1435                 node = self.db.getnode(self.classname, nodeid, cldb)
1436                 if node.has_key(self.db.RETIRED_FLAG):
1437                     continue
1438                 for key, value in requirements.items():
1439                     if node[key] is None or node[key].lower() != value:
1440                         break
1441                 else:
1442                     l.append(nodeid)
1443         finally:
1444             cldb.close()
1445         return l
1447     def list(self):
1448         """Return a list of the ids of the active nodes in this class."""
1449         l = []
1450         cn = self.classname
1451         cldb = self.db.getclassdb(cn)
1452         try:
1453             for nodeid in self.db.getnodeids(cn, cldb):
1454                 node = self.db.getnode(cn, nodeid, cldb)
1455                 if node.has_key(self.db.RETIRED_FLAG):
1456                     continue
1457                 l.append(nodeid)
1458         finally:
1459             cldb.close()
1460         l.sort()
1461         return l
1463     def filter(self, search_matches, filterspec, sort, group, 
1464             num_re = re.compile('^\d+$')):
1465         ''' Return a list of the ids of the active nodes in this class that
1466             match the 'filter' spec, sorted by the group spec and then the
1467             sort spec.
1469             "filterspec" is {propname: value(s)}
1470             "sort" is ['+propname', '-propname', 'propname', ...]
1471             "group is ['+propname', '-propname', 'propname', ...]
1472         '''
1473         cn = self.classname
1475         # optimise filterspec
1476         l = []
1477         props = self.getprops()
1478         LINK = 0
1479         MULTILINK = 1
1480         STRING = 2
1481         OTHER = 6
1482         for k, v in filterspec.items():
1483             propclass = props[k]
1484             if isinstance(propclass, Link):
1485                 if type(v) is not type([]):
1486                     v = [v]
1487                 # replace key values with node ids
1488                 u = []
1489                 link_class =  self.db.classes[propclass.classname]
1490                 for entry in v:
1491                     if entry == '-1': entry = None
1492                     elif not num_re.match(entry):
1493                         try:
1494                             entry = link_class.lookup(entry)
1495                         except (TypeError,KeyError):
1496                             raise ValueError, 'property "%s": %s not a %s'%(
1497                                 k, entry, self.properties[k].classname)
1498                     u.append(entry)
1500                 l.append((LINK, k, u))
1501             elif isinstance(propclass, Multilink):
1502                 if type(v) is not type([]):
1503                     v = [v]
1504                 # replace key values with node ids
1505                 u = []
1506                 link_class =  self.db.classes[propclass.classname]
1507                 for entry in v:
1508                     if not num_re.match(entry):
1509                         try:
1510                             entry = link_class.lookup(entry)
1511                         except (TypeError,KeyError):
1512                             raise ValueError, 'new property "%s": %s not a %s'%(
1513                                 k, entry, self.properties[k].classname)
1514                     u.append(entry)
1515                 l.append((MULTILINK, k, u))
1516             elif isinstance(propclass, String):
1517                 # simple glob searching
1518                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1519                 v = v.replace('?', '.')
1520                 v = v.replace('*', '.*?')
1521                 l.append((STRING, k, re.compile(v, re.I)))
1522             elif isinstance(propclass, Boolean):
1523                 if type(v) is type(''):
1524                     bv = v.lower() in ('yes', 'true', 'on', '1')
1525                 else:
1526                     bv = v
1527                 l.append((OTHER, k, bv))
1528             elif isinstance(propclass, Number):
1529                 l.append((OTHER, k, int(v)))
1530             else:
1531                 l.append((OTHER, k, v))
1532         filterspec = l
1534         # now, find all the nodes that are active and pass filtering
1535         l = []
1536         cldb = self.db.getclassdb(cn)
1537         try:
1538             # TODO: only full-scan once (use items())
1539             for nodeid in self.db.getnodeids(cn, cldb):
1540                 node = self.db.getnode(cn, nodeid, cldb)
1541                 if node.has_key(self.db.RETIRED_FLAG):
1542                     continue
1543                 # apply filter
1544                 for t, k, v in filterspec:
1545                     # make sure the node has the property
1546                     if not node.has_key(k):
1547                         # this node doesn't have this property, so reject it
1548                         break
1550                     # now apply the property filter
1551                     if t == LINK:
1552                         # link - if this node's property doesn't appear in the
1553                         # filterspec's nodeid list, skip it
1554                         if node[k] not in v:
1555                             break
1556                     elif t == MULTILINK:
1557                         # multilink - if any of the nodeids required by the
1558                         # filterspec aren't in this node's property, then skip
1559                         # it
1560                         have = node[k]
1561                         for want in v:
1562                             if want not in have:
1563                                 break
1564                         else:
1565                             continue
1566                         break
1567                     elif t == STRING:
1568                         # RE search
1569                         if node[k] is None or not v.search(node[k]):
1570                             break
1571                     elif t == OTHER:
1572                         # straight value comparison for the other types
1573                         if node[k] != v:
1574                             break
1575                 else:
1576                     l.append((nodeid, node))
1577         finally:
1578             cldb.close()
1579         l.sort()
1581         # filter based on full text search
1582         if search_matches is not None:
1583             k = []
1584             for v in l:
1585                 if search_matches.has_key(v[0]):
1586                     k.append(v)
1587             l = k
1589         # optimise sort
1590         m = []
1591         for entry in sort:
1592             if entry[0] != '-':
1593                 m.append(('+', entry))
1594             else:
1595                 m.append((entry[0], entry[1:]))
1596         sort = m
1598         # optimise group
1599         m = []
1600         for entry in group:
1601             if entry[0] != '-':
1602                 m.append(('+', entry))
1603             else:
1604                 m.append((entry[0], entry[1:]))
1605         group = m
1606         # now, sort the result
1607         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1608                 db = self.db, cl=self):
1609             a_id, an = a
1610             b_id, bn = b
1611             # sort by group and then sort
1612             for list in group, sort:
1613                 for dir, prop in list:
1614                     # sorting is class-specific
1615                     propclass = properties[prop]
1617                     # handle the properties that might be "faked"
1618                     # also, handle possible missing properties
1619                     try:
1620                         if not an.has_key(prop):
1621                             an[prop] = cl.get(a_id, prop)
1622                         av = an[prop]
1623                     except KeyError:
1624                         # the node doesn't have a value for this property
1625                         if isinstance(propclass, Multilink): av = []
1626                         else: av = ''
1627                     try:
1628                         if not bn.has_key(prop):
1629                             bn[prop] = cl.get(b_id, prop)
1630                         bv = bn[prop]
1631                     except KeyError:
1632                         # the node doesn't have a value for this property
1633                         if isinstance(propclass, Multilink): bv = []
1634                         else: bv = ''
1636                     # String and Date values are sorted in the natural way
1637                     if isinstance(propclass, String):
1638                         # clean up the strings
1639                         if av and av[0] in string.uppercase:
1640                             av = an[prop] = av.lower()
1641                         if bv and bv[0] in string.uppercase:
1642                             bv = bn[prop] = bv.lower()
1643                     if (isinstance(propclass, String) or
1644                             isinstance(propclass, Date)):
1645                         # it might be a string that's really an integer
1646                         try:
1647                             av = int(av)
1648                             bv = int(bv)
1649                         except:
1650                             pass
1651                         if dir == '+':
1652                             r = cmp(av, bv)
1653                             if r != 0: return r
1654                         elif dir == '-':
1655                             r = cmp(bv, av)
1656                             if r != 0: return r
1658                     # Link properties are sorted according to the value of
1659                     # the "order" property on the linked nodes if it is
1660                     # present; or otherwise on the key string of the linked
1661                     # nodes; or finally on  the node ids.
1662                     elif isinstance(propclass, Link):
1663                         link = db.classes[propclass.classname]
1664                         if av is None and bv is not None: return -1
1665                         if av is not None and bv is None: return 1
1666                         if av is None and bv is None: continue
1667                         if link.getprops().has_key('order'):
1668                             if dir == '+':
1669                                 r = cmp(link.get(av, 'order'),
1670                                     link.get(bv, 'order'))
1671                                 if r != 0: return r
1672                             elif dir == '-':
1673                                 r = cmp(link.get(bv, 'order'),
1674                                     link.get(av, 'order'))
1675                                 if r != 0: return r
1676                         elif link.getkey():
1677                             key = link.getkey()
1678                             if dir == '+':
1679                                 r = cmp(link.get(av, key), link.get(bv, key))
1680                                 if r != 0: return r
1681                             elif dir == '-':
1682                                 r = cmp(link.get(bv, key), link.get(av, key))
1683                                 if r != 0: return r
1684                         else:
1685                             if dir == '+':
1686                                 r = cmp(av, bv)
1687                                 if r != 0: return r
1688                             elif dir == '-':
1689                                 r = cmp(bv, av)
1690                                 if r != 0: return r
1692                     # Multilink properties are sorted according to how many
1693                     # links are present.
1694                     elif isinstance(propclass, Multilink):
1695                         if dir == '+':
1696                             r = cmp(len(av), len(bv))
1697                             if r != 0: return r
1698                         elif dir == '-':
1699                             r = cmp(len(bv), len(av))
1700                             if r != 0: return r
1701                     elif isinstance(propclass, Number) or isinstance(propclass, Boolean):
1702                         if dir == '+':
1703                             r = cmp(av, bv)
1704                         elif dir == '-':
1705                             r = cmp(bv, av)
1706                         
1707                 # end for dir, prop in list:
1708             # end for list in sort, group:
1709             # if all else fails, compare the ids
1710             return cmp(a[0], b[0])
1712         l.sort(sortfun)
1713         return [i[0] for i in l]
1715     def count(self):
1716         """Get the number of nodes in this class.
1718         If the returned integer is 'numnodes', the ids of all the nodes
1719         in this class run from 1 to numnodes, and numnodes+1 will be the
1720         id of the next node to be created in this class.
1721         """
1722         return self.db.countnodes(self.classname)
1724     # Manipulating properties:
1726     def getprops(self, protected=1):
1727         """Return a dictionary mapping property names to property objects.
1728            If the "protected" flag is true, we include protected properties -
1729            those which may not be modified.
1731            In addition to the actual properties on the node, these
1732            methods provide the "creation" and "activity" properties. If the
1733            "protected" flag is true, we include protected properties - those
1734            which may not be modified.
1735         """
1736         d = self.properties.copy()
1737         if protected:
1738             d['id'] = String()
1739             d['creation'] = hyperdb.Date()
1740             d['activity'] = hyperdb.Date()
1741             d['creator'] = hyperdb.Link("user")
1742         return d
1744     def addprop(self, **properties):
1745         """Add properties to this class.
1747         The keyword arguments in 'properties' must map names to property
1748         objects, or a TypeError is raised.  None of the keys in 'properties'
1749         may collide with the names of existing properties, or a ValueError
1750         is raised before any properties have been added.
1751         """
1752         for key in properties.keys():
1753             if self.properties.has_key(key):
1754                 raise ValueError, key
1755         self.properties.update(properties)
1757     def index(self, nodeid):
1758         '''Add (or refresh) the node to search indexes
1759         '''
1760         # find all the String properties that have indexme
1761         for prop, propclass in self.getprops().items():
1762             if isinstance(propclass, String) and propclass.indexme:
1763                 try:
1764                     value = str(self.get(nodeid, prop))
1765                 except IndexError:
1766                     # node no longer exists - entry should be removed
1767                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1768                 else:
1769                     # and index them under (classname, nodeid, property)
1770                     self.db.indexer.add_text((self.classname, nodeid, prop),
1771                         value)
1773     #
1774     # Detector interface
1775     #
1776     def audit(self, event, detector):
1777         """Register a detector
1778         """
1779         l = self.auditors[event]
1780         if detector not in l:
1781             self.auditors[event].append(detector)
1783     def fireAuditors(self, action, nodeid, newvalues):
1784         """Fire all registered auditors.
1785         """
1786         for audit in self.auditors[action]:
1787             audit(self.db, self, nodeid, newvalues)
1789     def react(self, event, detector):
1790         """Register a detector
1791         """
1792         l = self.reactors[event]
1793         if detector not in l:
1794             self.reactors[event].append(detector)
1796     def fireReactors(self, action, nodeid, oldvalues):
1797         """Fire all registered reactors.
1798         """
1799         for react in self.reactors[action]:
1800             react(self.db, self, nodeid, oldvalues)
1802 class FileClass(Class):
1803     '''This class defines a large chunk of data. To support this, it has a
1804        mandatory String property "content" which is typically saved off
1805        externally to the hyperdb.
1807        The default MIME type of this data is defined by the
1808        "default_mime_type" class attribute, which may be overridden by each
1809        node if the class defines a "type" String property.
1810     '''
1811     default_mime_type = 'text/plain'
1813     def create(self, **propvalues):
1814         ''' snaffle the file propvalue and store in a file
1815         '''
1816         content = propvalues['content']
1817         del propvalues['content']
1818         newid = Class.create(self, **propvalues)
1819         self.db.storefile(self.classname, newid, None, content)
1820         return newid
1822     def import_list(self, propnames, proplist):
1823         ''' Trap the "content" property...
1824         '''
1825         # dupe this list so we don't affect others
1826         propnames = propnames[:]
1828         # extract the "content" property from the proplist
1829         i = propnames.index('content')
1830         content = proplist[i]
1831         del propnames[i]
1832         del proplist[i]
1834         # do the normal import
1835         newid = Class.import_list(self, propnames, proplist)
1837         # save off the "content" file
1838         self.db.storefile(self.classname, newid, None, content)
1839         return newid
1841     def get(self, nodeid, propname, default=_marker, cache=1):
1842         ''' trap the content propname and get it from the file
1843         '''
1845         poss_msg = 'Possibly a access right configuration problem.'
1846         if propname == 'content':
1847             try:
1848                 return self.db.getfile(self.classname, nodeid, None)
1849             except IOError, (strerror):
1850                 # BUG: by catching this we donot see an error in the log.
1851                 return 'ERROR reading file: %s%s\n%s\n%s'%(
1852                         self.classname, nodeid, poss_msg, strerror)
1853         if default is not _marker:
1854             return Class.get(self, nodeid, propname, default, cache=cache)
1855         else:
1856             return Class.get(self, nodeid, propname, cache=cache)
1858     def getprops(self, protected=1):
1859         ''' In addition to the actual properties on the node, these methods
1860             provide the "content" property. If the "protected" flag is true,
1861             we include protected properties - those which may not be
1862             modified.
1863         '''
1864         d = Class.getprops(self, protected=protected).copy()
1865         if protected:
1866             d['content'] = hyperdb.String()
1867         return d
1869     def index(self, nodeid):
1870         ''' Index the node in the search index.
1872             We want to index the content in addition to the normal String
1873             property indexing.
1874         '''
1875         # perform normal indexing
1876         Class.index(self, nodeid)
1878         # get the content to index
1879         content = self.get(nodeid, 'content')
1881         # figure the mime type
1882         if self.properties.has_key('type'):
1883             mime_type = self.get(nodeid, 'type')
1884         else:
1885             mime_type = self.default_mime_type
1887         # and index!
1888         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
1889             mime_type)
1891 # XXX deviation from spec - was called ItemClass
1892 class IssueClass(Class, roundupdb.IssueClass):
1893     # Overridden methods:
1894     def __init__(self, db, classname, **properties):
1895         """The newly-created class automatically includes the "messages",
1896         "files", "nosy", and "superseder" properties.  If the 'properties'
1897         dictionary attempts to specify any of these properties or a
1898         "creation" or "activity" property, a ValueError is raised.
1899         """
1900         if not properties.has_key('title'):
1901             properties['title'] = hyperdb.String(indexme='yes')
1902         if not properties.has_key('messages'):
1903             properties['messages'] = hyperdb.Multilink("msg")
1904         if not properties.has_key('files'):
1905             properties['files'] = hyperdb.Multilink("file")
1906         if not properties.has_key('nosy'):
1907             properties['nosy'] = hyperdb.Multilink("user")
1908         if not properties.has_key('superseder'):
1909             properties['superseder'] = hyperdb.Multilink(classname)
1910         Class.__init__(self, db, classname, **properties)
1913 #$Log: not supported by cvs2svn $
1914 #Revision 1.62  2002/08/21 07:07:27  richard
1915 #In preparing to turn back on link/unlink journal events (by default these
1916 #are turned off) I've:
1917 #- fixed back_anydbm so it can journal those events again (had broken it
1918 #  with recent changes)
1919 #- changed the serialisation format for dates and intervals to use a
1920 #  numbers-only (and sign for Intervals) string instead of tuple-of-ints.
1921 #  Much smaller.
1923 #Revision 1.61  2002/08/19 02:53:27  richard
1924 #full database export and import is done
1926 #Revision 1.60  2002/08/19 00:23:19  richard
1927 #handle "unset" initial Link values (!)
1929 #Revision 1.59  2002/08/16 04:28:13  richard
1930 #added is_retired query to Class
1932 #Revision 1.58  2002/08/01 15:06:24  gmcm
1933 #Use same regex to split search terms as used to index text.
1934 #Fix to back_metakit for not changing journaltag on reopen.
1935 #Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
1936 #Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
1938 #Revision 1.57  2002/07/31 23:57:36  richard
1939 # . web forms may now unset Link values (like assignedto)
1941 #Revision 1.56  2002/07/31 22:04:33  richard
1942 #cleanup
1944 #Revision 1.55  2002/07/30 08:22:38  richard
1945 #Session storage in the hyperdb was horribly, horribly inefficient. We use
1946 #a simple anydbm wrapper now - which could be overridden by the metakit
1947 #backend or RDB backend if necessary.
1948 #Much, much better.
1950 #Revision 1.54  2002/07/26 08:26:59  richard
1951 #Very close now. The cgi and mailgw now use the new security API. The two
1952 #templates have been migrated to that setup. Lots of unit tests. Still some
1953 #issue in the web form for editing Roles assigned to users.
1955 #Revision 1.53  2002/07/25 07:14:06  richard
1956 #Bugger it. Here's the current shape of the new security implementation.
1957 #Still to do:
1958 # . call the security funcs from cgi and mailgw
1959 # . change shipped templates to include correct initialisation and remove
1960 #   the old config vars
1961 #... that seems like a lot. The bulk of the work has been done though. Honest :)
1963 #Revision 1.52  2002/07/19 03:36:34  richard
1964 #Implemented the destroy() method needed by the session database (and possibly
1965 #others). At the same time, I removed the leading underscores from the hyperdb
1966 #methods that Really Didn't Need Them.
1967 #The journal also raises IndexError now for all situations where there is a
1968 #request for the journal of a node that doesn't have one. It used to return
1969 #[] in _some_ situations, but not all. This _may_ break code, but the tests
1970 #pass...
1972 #Revision 1.51  2002/07/18 23:07:08  richard
1973 #Unit tests and a few fixes.
1975 #Revision 1.50  2002/07/18 11:50:58  richard
1976 #added tests for number type too
1978 #Revision 1.49  2002/07/18 11:41:10  richard
1979 #added tests for boolean type, and fixes to anydbm backend
1981 #Revision 1.48  2002/07/18 11:17:31  gmcm
1982 #Add Number and Boolean types to hyperdb.
1983 #Add conversion cases to web, mail & admin interfaces.
1984 #Add storage/serialization cases to back_anydbm & back_metakit.
1986 #Revision 1.47  2002/07/14 23:18:20  richard
1987 #. fixed the journal bloat from multilink changes - we just log the add or
1988 #  remove operations, not the whole list
1990 #Revision 1.46  2002/07/14 06:06:34  richard
1991 #Did some old TODOs
1993 #Revision 1.45  2002/07/14 04:03:14  richard
1994 #Implemented a switch to disable journalling for a Class. CGI session
1995 #database now uses it.
1997 #Revision 1.44  2002/07/14 02:05:53  richard
1998 #. all storage-specific code (ie. backend) is now implemented by the backends
2000 #Revision 1.43  2002/07/10 06:30:30  richard
2001 #...except of course it's nice to use valid Python syntax
2003 #Revision 1.42  2002/07/10 06:21:38  richard
2004 #Be extra safe
2006 #Revision 1.41  2002/07/10 00:21:45  richard
2007 #explicit database closing
2009 #Revision 1.40  2002/07/09 04:19:09  richard
2010 #Added reindex command to roundup-admin.
2011 #Fixed reindex on first access.
2012 #Also fixed reindexing of entries that change.
2014 #Revision 1.39  2002/07/09 03:02:52  richard
2015 #More indexer work:
2016 #- all String properties may now be indexed too. Currently there's a bit of
2017 #  "issue" specific code in the actual searching which needs to be
2018 #  addressed. In a nutshell:
2019 #  + pass 'indexme="yes"' as a String() property initialisation arg, eg:
2020 #        file = FileClass(db, "file", name=String(), type=String(),
2021 #            comment=String(indexme="yes"))
2022 #  + the comment will then be indexed and be searchable, with the results
2023 #    related back to the issue that the file is linked to
2024 #- as a result of this work, the FileClass has a default MIME type that may
2025 #  be overridden in a subclass, or by the use of a "type" property as is
2026 #  done in the default templates.
2027 #- the regeneration of the indexes (if necessary) is done once the schema is
2028 #  set up in the dbinit.
2030 #Revision 1.38  2002/07/08 06:58:15  richard
2031 #cleaned up the indexer code:
2032 # - it splits more words out (much simpler, faster splitter)
2033 # - removed code we'll never use (roundup.roundup_indexer has the full
2034 #   implementation, and replaces roundup.indexer)
2035 # - only index text/plain and rfc822/message (ideas for other text formats to
2036 #   index are welcome)
2037 # - added simple unit test for indexer. Needs more tests for regression.
2039 #Revision 1.37  2002/06/20 23:52:35  richard
2040 #More informative error message
2042 #Revision 1.36  2002/06/19 03:07:19  richard
2043 #Moved the file storage commit into blobfiles where it belongs.
2045 #Revision 1.35  2002/05/25 07:16:24  rochecompaan
2046 #Merged search_indexing-branch with HEAD
2048 #Revision 1.34  2002/05/15 06:21:21  richard
2049 # . node caching now works, and gives a small boost in performance
2051 #As a part of this, I cleaned up the DEBUG output and implemented TRACE
2052 #output (HYPERDBTRACE='file to trace to') with checkpoints at the start of
2053 #CGI requests. Run roundup with python -O to skip all the DEBUG/TRACE stuff
2054 #(using if __debug__ which is compiled out with -O)
2056 #Revision 1.33  2002/04/24 10:38:26  rochecompaan
2057 #All database files are now created group readable and writable.
2059 #Revision 1.32  2002/04/15 23:25:15  richard
2060 #. node ids are now generated from a lockable store - no more race conditions
2062 #We're using the portalocker code by Jonathan Feinberg that was contributed
2063 #to the ASPN Python cookbook. This gives us locking across Unix and Windows.
2065 #Revision 1.31  2002/04/03 05:54:31  richard
2066 #Fixed serialisation problem by moving the serialisation step out of the
2067 #hyperdb.Class (get, set) into the hyperdb.Database.
2069 #Also fixed htmltemplate after the showid changes I made yesterday.
2071 #Unit tests for all of the above written.
2073 #Revision 1.30.2.1  2002/04/03 11:55:57  rochecompaan
2074 # . Added feature #526730 - search for messages capability
2076 #Revision 1.30  2002/02/27 03:40:59  richard
2077 #Ran it through pychecker, made fixes
2079 #Revision 1.29  2002/02/25 14:34:31  grubert
2080 # . use blobfiles in back_anydbm which is used in back_bsddb.
2081 #   change test_db as dirlist does not work for subdirectories.
2082 #   ATTENTION: blobfiles now creates subdirectories for files.
2084 #Revision 1.28  2002/02/16 09:14:17  richard
2085 # . #514854 ] History: "User" is always ticket creator
2087 #Revision 1.27  2002/01/22 07:21:13  richard
2088 #. fixed back_bsddb so it passed the journal tests
2090 #... it didn't seem happy using the back_anydbm _open method, which is odd.
2091 #Yet another occurrance of whichdb not being able to recognise older bsddb
2092 #databases. Yadda yadda. Made the HYPERDBDEBUG stuff more sane in the
2093 #process.
2095 #Revision 1.26  2002/01/22 05:18:38  rochecompaan
2096 #last_set_entry was referenced before assignment
2098 #Revision 1.25  2002/01/22 05:06:08  rochecompaan
2099 #We need to keep the last 'set' entry in the journal to preserve
2100 #information on 'activity' for nodes.
2102 #Revision 1.24  2002/01/21 16:33:20  rochecompaan
2103 #You can now use the roundup-admin tool to pack the database
2105 #Revision 1.23  2002/01/18 04:32:04  richard
2106 #Rollback was breaking because a message hadn't actually been written to the file. Needs
2107 #more investigation.
2109 #Revision 1.22  2002/01/14 02:20:15  richard
2110 # . changed all config accesses so they access either the instance or the
2111 #   config attriubute on the db. This means that all config is obtained from
2112 #   instance_config instead of the mish-mash of classes. This will make
2113 #   switching to a ConfigParser setup easier too, I hope.
2115 #At a minimum, this makes migration a _little_ easier (a lot easier in the
2116 #0.5.0 switch, I hope!)
2118 #Revision 1.21  2002/01/02 02:31:38  richard
2119 #Sorry for the huge checkin message - I was only intending to implement #496356
2120 #but I found a number of places where things had been broken by transactions:
2121 # . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
2122 #   for _all_ roundup-generated smtp messages to be sent to.
2123 # . the transaction cache had broken the roundupdb.Class set() reactors
2124 # . newly-created author users in the mailgw weren't being committed to the db
2126 #Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
2127 #on when I found that stuff :):
2128 # . #496356 ] Use threading in messages
2129 # . detectors were being registered multiple times
2130 # . added tests for mailgw
2131 # . much better attaching of erroneous messages in the mail gateway
2133 #Revision 1.20  2001/12/18 15:30:34  rochecompaan
2134 #Fixed bugs:
2135 # .  Fixed file creation and retrieval in same transaction in anydbm
2136 #    backend
2137 # .  Cgi interface now renders new issue after issue creation
2138 # .  Could not set issue status to resolved through cgi interface
2139 # .  Mail gateway was changing status back to 'chatting' if status was
2140 #    omitted as an argument
2142 #Revision 1.19  2001/12/17 03:52:48  richard
2143 #Implemented file store rollback. As a bonus, the hyperdb is now capable of
2144 #storing more than one file per node - if a property name is supplied,
2145 #the file is called designator.property.
2146 #I decided not to migrate the existing files stored over to the new naming
2147 #scheme - the FileClass just doesn't specify the property name.
2149 #Revision 1.18  2001/12/16 10:53:38  richard
2150 #take a copy of the node dict so that the subsequent set
2151 #operation doesn't modify the oldvalues structure
2153 #Revision 1.17  2001/12/14 23:42:57  richard
2154 #yuck, a gdbm instance tests false :(
2155 #I've left the debugging code in - it should be removed one day if we're ever
2156 #_really_ anal about performace :)
2158 #Revision 1.16  2001/12/12 03:23:14  richard
2159 #Cor blimey this anydbm/whichdb stuff is yecchy. Turns out that whichdb
2160 #incorrectly identifies a dbm file as a dbhash file on my system. This has
2161 #been submitted to the python bug tracker as issue #491888:
2162 #https://sourceforge.net/tracker/index.php?func=detail&aid=491888&group_id=5470&atid=105470
2164 #Revision 1.15  2001/12/12 02:30:51  richard
2165 #I fixed the problems with people whose anydbm was using the dbm module at the
2166 #backend. It turns out the dbm module modifies the file name to append ".db"
2167 #and my check to determine if we're opening an existing or new db just
2168 #tested os.path.exists() on the filename. Well, no longer! We now perform a
2169 #much better check _and_ cope with the anydbm implementation module changing
2170 #too!
2171 #I also fixed the backends __init__ so only ImportError is squashed.
2173 #Revision 1.14  2001/12/10 22:20:01  richard
2174 #Enabled transaction support in the bsddb backend. It uses the anydbm code
2175 #where possible, only replacing methods where the db is opened (it uses the
2176 #btree opener specifically.)
2177 #Also cleaned up some change note generation.
2178 #Made the backends package work with pydoc too.
2180 #Revision 1.13  2001/12/02 05:06:16  richard
2181 #. We now use weakrefs in the Classes to keep the database reference, so
2182 #  the close() method on the database is no longer needed.
2183 #  I bumped the minimum python requirement up to 2.1 accordingly.
2184 #. #487480 ] roundup-server
2185 #. #487476 ] INSTALL.txt
2187 #I also cleaned up the change message / post-edit stuff in the cgi client.
2188 #There's now a clearly marked "TODO: append the change note" where I believe
2189 #the change note should be added there. The "changes" list will obviously
2190 #have to be modified to be a dict of the changes, or somesuch.
2192 #More testing needed.
2194 #Revision 1.12  2001/12/01 07:17:50  richard
2195 #. We now have basic transaction support! Information is only written to
2196 #  the database when the commit() method is called. Only the anydbm
2197 #  backend is modified in this way - neither of the bsddb backends have been.
2198 #  The mail, admin and cgi interfaces all use commit (except the admin tool
2199 #  doesn't have a commit command, so interactive users can't commit...)
2200 #. Fixed login/registration forwarding the user to the right page (or not,
2201 #  on a failure)
2203 #Revision 1.11  2001/11/21 02:34:18  richard
2204 #Added a target version field to the extended issue schema
2206 #Revision 1.10  2001/10/09 23:58:10  richard
2207 #Moved the data stringification up into the hyperdb.Class class' get, set
2208 #and create methods. This means that the data is also stringified for the
2209 #journal call, and removes duplication of code from the backends. The
2210 #backend code now only sees strings.
2212 #Revision 1.9  2001/10/09 07:25:59  richard
2213 #Added the Password property type. See "pydoc roundup.password" for
2214 #implementation details. Have updated some of the documentation too.
2216 #Revision 1.8  2001/09/29 13:27:00  richard
2217 #CGI interfaces now spit up a top-level index of all the instances they can
2218 #serve.
2220 #Revision 1.7  2001/08/12 06:32:36  richard
2221 #using isinstance(blah, Foo) now instead of isFooType
2223 #Revision 1.6  2001/08/07 00:24:42  richard
2224 #stupid typo
2226 #Revision 1.5  2001/08/07 00:15:51  richard
2227 #Added the copyright/license notice to (nearly) all files at request of
2228 #Bizar Software.
2230 #Revision 1.4  2001/07/30 01:41:36  richard
2231 #Makes schema changes mucho easier.
2233 #Revision 1.3  2001/07/25 01:23:07  richard
2234 #Added the Roundup spec to the new documentation directory.
2236 #Revision 1.2  2001/07/23 08:20:44  richard
2237 #Moved over to using marshal in the bsddb and anydbm backends.
2238 #roundup-admin now has a "freshen" command that'll load/save all nodes (not
2239 # retired - mod hyperdb.Class.list() so it lists retired nodes)