Code

Added the "actor" property. Metakit backend not done (still not confident
[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.137 2004-03-15 05:50:20 richard Exp $
19 '''This module defines a backend that saves the hyperdatabase in a
20 database chosen by anydbm. It is guaranteed to always be available in python
21 versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
22 serious bugs, and is not available)
23 '''
24 __docformat__ = 'restructuredtext'
26 try:
27     import anydbm, sys
28     # dumbdbm only works in python 2.1.2+
29     if sys.version_info < (2,1,2):
30         import dumbdbm
31         assert anydbm._defaultmod != dumbdbm
32         del dumbdbm
33 except AssertionError:
34     print "WARNING: you should upgrade to python 2.1.3"
36 import whichdb, os, marshal, re, weakref, string, copy
37 from roundup import hyperdb, date, password, roundupdb, security
38 from blobfiles import FileStorage
39 from sessions import Sessions, OneTimeKeys
40 from roundup.indexer import Indexer
41 from roundup.backends import locking
42 from roundup.hyperdb import String, Password, Date, Interval, Link, \
43     Multilink, DatabaseError, Boolean, Number, Node
44 from roundup.date import Range
46 #
47 # Now the database
48 #
49 class Database(FileStorage, hyperdb.Database, roundupdb.Database):
50     '''A database for storing records containing flexible data types.
52     Transaction stuff TODO:
53     
54     - check the timestamp of the class file and nuke the cache if it's
55       modified. Do some sort of conflict checking on the dirty stuff.
56     - perhaps detect write collisions (related to above)?
57     '''
58     def __init__(self, config, journaltag=None):
59         '''Open a hyperdatabase given a specifier to some storage.
61         The 'storagelocator' is obtained from config.DATABASE.
62         The meaning of 'storagelocator' depends on the particular
63         implementation of the hyperdatabase.  It could be a file name,
64         a directory path, a socket descriptor for a connection to a
65         database over the network, etc.
67         The 'journaltag' is a token that will be attached to the journal
68         entries for any edits done on the database.  If 'journaltag' is
69         None, the database is opened in read-only mode: the Class.create(),
70         Class.set(), Class.retire(), and Class.restore() methods are
71         disabled.  
72         '''        
73         self.config, self.journaltag = config, journaltag
74         self.dir = config.DATABASE
75         self.classes = {}
76         self.cache = {}         # cache of nodes loaded or created
77         self.dirtynodes = {}    # keep track of the dirty nodes by class
78         self.newnodes = {}      # keep track of the new nodes by class
79         self.destroyednodes = {}# keep track of the destroyed nodes by class
80         self.transactions = []
81         self.indexer = Indexer(self.dir)
82         self.sessions = Sessions(self.config)
83         self.otks = OneTimeKeys(self.config)
84         self.security = security.Security(self)
85         # ensure files are group readable and writable
86         os.umask(0002)
88         # lock it
89         lockfilenm = os.path.join(self.dir, 'lock')
90         self.lockfile = locking.acquire_lock(lockfilenm)
91         self.lockfile.write(str(os.getpid()))
92         self.lockfile.flush()
94     def post_init(self):
95         '''Called once the schema initialisation has finished.
96         '''
97         # reindex the db if necessary
98         if self.indexer.should_reindex():
99             self.reindex()
101     def refresh_database(self):
102         """Rebuild the database
103         """
104         self.reindex()
106     def reindex(self):
107         for klass in self.classes.values():
108             for nodeid in klass.list():
109                 klass.index(nodeid)
110         self.indexer.save_index()
112     def __repr__(self):
113         return '<back_anydbm instance at %x>'%id(self) 
115     #
116     # Classes
117     #
118     def __getattr__(self, classname):
119         '''A convenient way of calling self.getclass(classname).'''
120         if self.classes.has_key(classname):
121             if __debug__:
122                 print >>hyperdb.DEBUG, '__getattr__', (self, classname)
123             return self.classes[classname]
124         raise AttributeError, classname
126     def addclass(self, cl):
127         if __debug__:
128             print >>hyperdb.DEBUG, 'addclass', (self, cl)
129         cn = cl.classname
130         if self.classes.has_key(cn):
131             raise ValueError, cn
132         self.classes[cn] = cl
134         # add default Edit and View permissions
135         self.security.addPermission(name="Edit", klass=cn,
136             description="User is allowed to edit "+cn)
137         self.security.addPermission(name="View", klass=cn,
138             description="User is allowed to access "+cn)
140     def getclasses(self):
141         '''Return a list of the names of all existing classes.'''
142         if __debug__:
143             print >>hyperdb.DEBUG, 'getclasses', (self,)
144         l = self.classes.keys()
145         l.sort()
146         return l
148     def getclass(self, classname):
149         '''Get the Class object representing a particular class.
151         If 'classname' is not a valid class name, a KeyError is raised.
152         '''
153         if __debug__:
154             print >>hyperdb.DEBUG, 'getclass', (self, classname)
155         try:
156             return self.classes[classname]
157         except KeyError:
158             raise KeyError, 'There is no class called "%s"'%classname
160     #
161     # Class DBs
162     #
163     def clear(self):
164         '''Delete all database contents
165         '''
166         if __debug__:
167             print >>hyperdb.DEBUG, 'clear', (self,)
168         for cn in self.classes.keys():
169             for dummy in 'nodes', 'journals':
170                 path = os.path.join(self.dir, 'journals.%s'%cn)
171                 if os.path.exists(path):
172                     os.remove(path)
173                 elif os.path.exists(path+'.db'):    # dbm appends .db
174                     os.remove(path+'.db')
176     def getclassdb(self, classname, mode='r'):
177         ''' grab a connection to the class db that will be used for
178             multiple actions
179         '''
180         if __debug__:
181             print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
182         return self.opendb('nodes.%s'%classname, mode)
184     def determine_db_type(self, path):
185         ''' determine which DB wrote the class file
186         '''
187         db_type = ''
188         if os.path.exists(path):
189             db_type = whichdb.whichdb(path)
190             if not db_type:
191                 raise DatabaseError, "Couldn't identify database type"
192         elif os.path.exists(path+'.db'):
193             # if the path ends in '.db', it's a dbm database, whether
194             # anydbm says it's dbhash or not!
195             db_type = 'dbm'
196         return db_type
198     def opendb(self, name, mode):
199         '''Low-level database opener that gets around anydbm/dbm
200            eccentricities.
201         '''
202         if __debug__:
203             print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
205         # figure the class db type
206         path = os.path.join(os.getcwd(), self.dir, name)
207         db_type = self.determine_db_type(path)
209         # new database? let anydbm pick the best dbm
210         if not db_type:
211             if __debug__:
212                 print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
213             return anydbm.open(path, 'c')
215         # open the database with the correct module
216         try:
217             dbm = __import__(db_type)
218         except ImportError:
219             raise DatabaseError, \
220                 "Couldn't open database - the required module '%s'"\
221                 " is not available"%db_type
222         if __debug__:
223             print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
224                 mode)
225         return dbm.open(path, mode)
227     #
228     # Node IDs
229     #
230     def newid(self, classname):
231         ''' Generate a new id for the given class
232         '''
233         # open the ids DB - create if if doesn't exist
234         db = self.opendb('_ids', 'c')
235         if db.has_key(classname):
236             newid = db[classname] = str(int(db[classname]) + 1)
237         else:
238             # the count() bit is transitional - older dbs won't start at 1
239             newid = str(self.getclass(classname).count()+1)
240             db[classname] = newid
241         db.close()
242         return newid
244     def setid(self, classname, setid):
245         ''' Set the id counter: used during import of database
246         '''
247         # open the ids DB - create if if doesn't exist
248         db = self.opendb('_ids', 'c')
249         db[classname] = str(setid)
250         db.close()
252     #
253     # Nodes
254     #
255     def addnode(self, classname, nodeid, node):
256         ''' add the specified node to its class's db
257         '''
258         if __debug__:
259             print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
261         # we'll be supplied these props if we're doing an import
262         if not node.has_key('creator'):
263             # add in the "calculated" properties (dupe so we don't affect
264             # calling code's node assumptions)
265             node = node.copy()
266             node['creator'] = self.getuid()
267             node['actor'] = self.getuid()
268             node['creation'] = node['activity'] = date.Date()
270         self.newnodes.setdefault(classname, {})[nodeid] = 1
271         self.cache.setdefault(classname, {})[nodeid] = node
272         self.savenode(classname, nodeid, node)
274     def setnode(self, classname, nodeid, node):
275         ''' change the specified node
276         '''
277         if __debug__:
278             print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
279         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
281         # update the activity time (dupe so we don't affect
282         # calling code's node assumptions)
283         node = node.copy()
284         node['activity'] = date.Date()
285         node['actor'] = self.getuid()
287         # can't set without having already loaded the node
288         self.cache[classname][nodeid] = node
289         self.savenode(classname, nodeid, node)
291     def savenode(self, classname, nodeid, node):
292         ''' perform the saving of data specified by the set/addnode
293         '''
294         if __debug__:
295             print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
296         self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
298     def getnode(self, classname, nodeid, db=None, cache=1):
299         ''' get a node from the database
301             Note the "cache" parameter is not used, and exists purely for
302             backward compatibility!
303         '''
304         if __debug__:
305             print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
307         # try the cache
308         cache_dict = self.cache.setdefault(classname, {})
309         if cache_dict.has_key(nodeid):
310             if __debug__:
311                 print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
312                     nodeid)
313             return cache_dict[nodeid]
315         if __debug__:
316             print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
318         # get from the database and save in the cache
319         if db is None:
320             db = self.getclassdb(classname)
321         if not db.has_key(nodeid):
322             raise IndexError, "no such %s %s"%(classname, nodeid)
324         # check the uncommitted, destroyed nodes
325         if (self.destroyednodes.has_key(classname) and
326                 self.destroyednodes[classname].has_key(nodeid)):
327             raise IndexError, "no such %s %s"%(classname, nodeid)
329         # decode
330         res = marshal.loads(db[nodeid])
332         # reverse the serialisation
333         res = self.unserialise(classname, res)
335         # store off in the cache dict
336         if cache:
337             cache_dict[nodeid] = res
339         return res
341     def destroynode(self, classname, nodeid):
342         '''Remove a node from the database. Called exclusively by the
343            destroy() method on Class.
344         '''
345         if __debug__:
346             print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
348         # remove from cache and newnodes if it's there
349         if (self.cache.has_key(classname) and
350                 self.cache[classname].has_key(nodeid)):
351             del self.cache[classname][nodeid]
352         if (self.newnodes.has_key(classname) and
353                 self.newnodes[classname].has_key(nodeid)):
354             del self.newnodes[classname][nodeid]
356         # see if there's any obvious commit actions that we should get rid of
357         for entry in self.transactions[:]:
358             if entry[1][:2] == (classname, nodeid):
359                 self.transactions.remove(entry)
361         # add to the destroyednodes map
362         self.destroyednodes.setdefault(classname, {})[nodeid] = 1
364         # add the destroy commit action
365         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
367     def serialise(self, classname, node):
368         '''Copy the node contents, converting non-marshallable data into
369            marshallable data.
370         '''
371         if __debug__:
372             print >>hyperdb.DEBUG, 'serialise', classname, node
373         properties = self.getclass(classname).getprops()
374         d = {}
375         for k, v in node.items():
376             # if the property doesn't exist, or is the "retired" flag then
377             # it won't be in the properties dict
378             if not properties.has_key(k):
379                 d[k] = v
380                 continue
382             # get the property spec
383             prop = properties[k]
385             if isinstance(prop, Password) and v is not None:
386                 d[k] = str(v)
387             elif isinstance(prop, Date) and v is not None:
388                 d[k] = v.serialise()
389             elif isinstance(prop, Interval) and v is not None:
390                 d[k] = v.serialise()
391             else:
392                 d[k] = v
393         return d
395     def unserialise(self, classname, node):
396         '''Decode the marshalled node data
397         '''
398         if __debug__:
399             print >>hyperdb.DEBUG, 'unserialise', classname, node
400         properties = self.getclass(classname).getprops()
401         d = {}
402         for k, v in node.items():
403             # if the property doesn't exist, or is the "retired" flag then
404             # it won't be in the properties dict
405             if not properties.has_key(k):
406                 d[k] = v
407                 continue
409             # get the property spec
410             prop = properties[k]
412             if isinstance(prop, Date) and v is not None:
413                 d[k] = date.Date(v)
414             elif isinstance(prop, Interval) and v is not None:
415                 d[k] = date.Interval(v)
416             elif isinstance(prop, Password) and v is not None:
417                 p = password.Password()
418                 p.unpack(v)
419                 d[k] = p
420             else:
421                 d[k] = v
422         return d
424     def hasnode(self, classname, nodeid, db=None):
425         ''' determine if the database has a given node
426         '''
427         if __debug__:
428             print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
430         # try the cache
431         cache = self.cache.setdefault(classname, {})
432         if cache.has_key(nodeid):
433             if __debug__:
434                 print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
435             return 1
436         if __debug__:
437             print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
439         # not in the cache - check the database
440         if db is None:
441             db = self.getclassdb(classname)
442         res = db.has_key(nodeid)
443         return res
445     def countnodes(self, classname, db=None):
446         if __debug__:
447             print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
449         count = 0
451         # include the uncommitted nodes
452         if self.newnodes.has_key(classname):
453             count += len(self.newnodes[classname])
454         if self.destroyednodes.has_key(classname):
455             count -= len(self.destroyednodes[classname])
457         # and count those in the DB
458         if db is None:
459             db = self.getclassdb(classname)
460         count = count + len(db.keys())
461         return count
464     #
465     # Files - special node properties
466     # inherited from FileStorage
468     #
469     # Journal
470     #
471     def addjournal(self, classname, nodeid, action, params, creator=None,
472             creation=None):
473         ''' Journal the Action
474         'action' may be:
476             'create' or 'set' -- 'params' is a dictionary of property values
477             'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
478             'retire' -- 'params' is None
479         '''
480         if __debug__:
481             print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
482                 action, params, creator, creation)
483         self.transactions.append((self.doSaveJournal, (classname, nodeid,
484             action, params, creator, creation)))
486     def getjournal(self, classname, nodeid):
487         ''' get the journal for id
489             Raise IndexError if the node doesn't exist (as per history()'s
490             API)
491         '''
492         if __debug__:
493             print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
495         # our journal result
496         res = []
498         # add any journal entries for transactions not committed to the
499         # database
500         for method, args in self.transactions:
501             if method != self.doSaveJournal:
502                 continue
503             (cache_classname, cache_nodeid, cache_action, cache_params,
504                 cache_creator, cache_creation) = args
505             if cache_classname == classname and cache_nodeid == nodeid:
506                 if not cache_creator:
507                     cache_creator = self.getuid()
508                 if not cache_creation:
509                     cache_creation = date.Date()
510                 res.append((cache_nodeid, cache_creation, cache_creator,
511                     cache_action, cache_params))
513         # attempt to open the journal - in some rare cases, the journal may
514         # not exist
515         try:
516             db = self.opendb('journals.%s'%classname, 'r')
517         except anydbm.error, error:
518             if str(error) == "need 'c' or 'n' flag to open new db":
519                 raise IndexError, 'no such %s %s'%(classname, nodeid)
520             elif error.args[0] != 2:
521                 # this isn't a "not found" error, be alarmed!
522                 raise
523             if res:
524                 # we have unsaved journal entries, return them
525                 return res
526             raise IndexError, 'no such %s %s'%(classname, nodeid)
527         try:
528             journal = marshal.loads(db[nodeid])
529         except KeyError:
530             db.close()
531             if res:
532                 # we have some unsaved journal entries, be happy!
533                 return res
534             raise IndexError, 'no such %s %s'%(classname, nodeid)
535         db.close()
537         # add all the saved journal entries for this node
538         for nodeid, date_stamp, user, action, params in journal:
539             res.append((nodeid, date.Date(date_stamp), user, action, params))
540         return res
542     def pack(self, pack_before):
543         ''' Delete all journal entries except "create" before 'pack_before'.
544         '''
545         if __debug__:
546             print >>hyperdb.DEBUG, 'packjournal', (self, pack_before)
548         pack_before = pack_before.serialise()
549         for classname in self.getclasses():
550             # get the journal db
551             db_name = 'journals.%s'%classname
552             path = os.path.join(os.getcwd(), self.dir, classname)
553             db_type = self.determine_db_type(path)
554             db = self.opendb(db_name, 'w')
556             for key in db.keys():
557                 # get the journal for this db entry
558                 journal = marshal.loads(db[key])
559                 l = []
560                 last_set_entry = None
561                 for entry in journal:
562                     # unpack the entry
563                     (nodeid, date_stamp, self.journaltag, action, 
564                         params) = entry
565                     # if the entry is after the pack date, _or_ the initial
566                     # create entry, then it stays
567                     if date_stamp > pack_before or action == 'create':
568                         l.append(entry)
569                 db[key] = marshal.dumps(l)
570             if db_type == 'gdbm':
571                 db.reorganize()
572             db.close()
573             
575     #
576     # Basic transaction support
577     #
578     def commit(self):
579         ''' Commit the current transactions.
580         '''
581         if __debug__:
582             print >>hyperdb.DEBUG, 'commit', (self,)
584         # keep a handle to all the database files opened
585         self.databases = {}
587         try:
588             # now, do all the transactions
589             reindex = {}
590             for method, args in self.transactions:
591                 reindex[method(*args)] = 1
592         finally:
593             # make sure we close all the database files
594             for db in self.databases.values():
595                 db.close()
596             del self.databases
598         # reindex the nodes that request it
599         for classname, nodeid in filter(None, reindex.keys()):
600             print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
601             self.getclass(classname).index(nodeid)
603         # save the indexer state
604         self.indexer.save_index()
606         self.clearCache()
608     def clearCache(self):
609         # all transactions committed, back to normal
610         self.cache = {}
611         self.dirtynodes = {}
612         self.newnodes = {}
613         self.destroyednodes = {}
614         self.transactions = []
616     def getCachedClassDB(self, classname):
617         ''' get the class db, looking in our cache of databases for commit
618         '''
619         # get the database handle
620         db_name = 'nodes.%s'%classname
621         if not self.databases.has_key(db_name):
622             self.databases[db_name] = self.getclassdb(classname, 'c')
623         return self.databases[db_name]
625     def doSaveNode(self, classname, nodeid, node):
626         if __debug__:
627             print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
628                 node)
630         db = self.getCachedClassDB(classname)
632         # now save the marshalled data
633         db[nodeid] = marshal.dumps(self.serialise(classname, node))
635         # return the classname, nodeid so we reindex this content
636         return (classname, nodeid)
638     def getCachedJournalDB(self, classname):
639         ''' get the journal db, looking in our cache of databases for commit
640         '''
641         # get the database handle
642         db_name = 'journals.%s'%classname
643         if not self.databases.has_key(db_name):
644             self.databases[db_name] = self.opendb(db_name, 'c')
645         return self.databases[db_name]
647     def doSaveJournal(self, classname, nodeid, action, params, creator,
648             creation):
649         # serialise the parameters now if necessary
650         if isinstance(params, type({})):
651             if action in ('set', 'create'):
652                 params = self.serialise(classname, params)
654         # handle supply of the special journalling parameters (usually
655         # supplied on importing an existing database)
656         if creator:
657             journaltag = creator
658         else:
659             journaltag = self.getuid()
660         if creation:
661             journaldate = creation.serialise()
662         else:
663             journaldate = date.Date().serialise()
665         # create the journal entry
666         entry = (nodeid, journaldate, journaltag, action, params)
668         if __debug__:
669             print >>hyperdb.DEBUG, 'doSaveJournal', entry
671         db = self.getCachedJournalDB(classname)
673         # now insert the journal entry
674         if db.has_key(nodeid):
675             # append to existing
676             s = db[nodeid]
677             l = marshal.loads(s)
678             l.append(entry)
679         else:
680             l = [entry]
682         db[nodeid] = marshal.dumps(l)
684     def doDestroyNode(self, classname, nodeid):
685         if __debug__:
686             print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
688         # delete from the class database
689         db = self.getCachedClassDB(classname)
690         if db.has_key(nodeid):
691             del db[nodeid]
693         # delete from the database
694         db = self.getCachedJournalDB(classname)
695         if db.has_key(nodeid):
696             del db[nodeid]
698         # return the classname, nodeid so we reindex this content
699         return (classname, nodeid)
701     def rollback(self):
702         ''' Reverse all actions from the current transaction.
703         '''
704         if __debug__:
705             print >>hyperdb.DEBUG, 'rollback', (self, )
706         for method, args in self.transactions:
707             # delete temporary files
708             if method == self.doStoreFile:
709                 self.rollbackStoreFile(*args)
710         self.cache = {}
711         self.dirtynodes = {}
712         self.newnodes = {}
713         self.destroyednodes = {}
714         self.transactions = []
716     def close(self):
717         ''' Nothing to do
718         '''
719         if self.lockfile is not None:
720             locking.release_lock(self.lockfile)
721         if self.lockfile is not None:
722             self.lockfile.close()
723             self.lockfile = None
725 _marker = []
726 class Class(hyperdb.Class):
727     '''The handle to a particular class of nodes in a hyperdatabase.'''
729     def __init__(self, db, classname, **properties):
730         '''Create a new class with a given name and property specification.
732         'classname' must not collide with the name of an existing class,
733         or a ValueError is raised.  The keyword arguments in 'properties'
734         must map names to property objects, or a TypeError is raised.
735         '''
736         for name in 'creation activity creator actor'.split():
737             if properties.has_key(name):
738                 raise ValueError, '"creation", "activity", "creator" and '\
739                     '"actor" are reserved'
741         self.classname = classname
742         self.properties = properties
743         self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
744         self.key = ''
746         # should we journal changes (default yes)
747         self.do_journal = 1
749         # do the db-related init stuff
750         db.addclass(self)
752         self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
753         self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
755     def enableJournalling(self):
756         '''Turn journalling on for this class
757         '''
758         self.do_journal = 1
760     def disableJournalling(self):
761         '''Turn journalling off for this class
762         '''
763         self.do_journal = 0
765     # Editing nodes:
767     def create(self, **propvalues):
768         '''Create a new node of this class and return its id.
770         The keyword arguments in 'propvalues' map property names to values.
772         The values of arguments must be acceptable for the types of their
773         corresponding properties or a TypeError is raised.
774         
775         If this class has a key property, it must be present and its value
776         must not collide with other key strings or a ValueError is raised.
777         
778         Any other properties on this class that are missing from the
779         'propvalues' dictionary are set to None.
780         
781         If an id in a link or multilink property does not refer to a valid
782         node, an IndexError is raised.
784         These operations trigger detectors and can be vetoed.  Attempts
785         to modify the "creation" or "activity" properties cause a KeyError.
786         '''
787         self.fireAuditors('create', None, propvalues)
788         newid = self.create_inner(**propvalues)
789         self.fireReactors('create', newid, None)
790         return newid
792     def create_inner(self, **propvalues):
793         ''' Called by create, in-between the audit and react calls.
794         '''
795         if propvalues.has_key('id'):
796             raise KeyError, '"id" is reserved'
798         if self.db.journaltag is None:
799             raise DatabaseError, 'Database open read-only'
801         if propvalues.has_key('creation') or propvalues.has_key('activity'):
802             raise KeyError, '"creation" and "activity" are reserved'
803         # new node's id
804         newid = self.db.newid(self.classname)
806         # validate propvalues
807         num_re = re.compile('^\d+$')
808         for key, value in propvalues.items():
809             if key == self.key:
810                 try:
811                     self.lookup(value)
812                 except KeyError:
813                     pass
814                 else:
815                     raise ValueError, 'node with key "%s" exists'%value
817             # try to handle this property
818             try:
819                 prop = self.properties[key]
820             except KeyError:
821                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
822                     key)
824             if value is not None and isinstance(prop, Link):
825                 if type(value) != type(''):
826                     raise ValueError, 'link value must be String'
827                 link_class = self.properties[key].classname
828                 # if it isn't a number, it's a key
829                 if not num_re.match(value):
830                     try:
831                         value = self.db.classes[link_class].lookup(value)
832                     except (TypeError, KeyError):
833                         raise IndexError, 'new property "%s": %s not a %s'%(
834                             key, value, link_class)
835                 elif not self.db.getclass(link_class).hasnode(value):
836                     raise IndexError, '%s has no node %s'%(link_class, value)
838                 # save off the value
839                 propvalues[key] = value
841                 # register the link with the newly linked node
842                 if self.do_journal and self.properties[key].do_journal:
843                     self.db.addjournal(link_class, value, 'link',
844                         (self.classname, newid, key))
846             elif isinstance(prop, Multilink):
847                 if type(value) != type([]):
848                     raise TypeError, 'new property "%s" not a list of ids'%key
850                 # clean up and validate the list of links
851                 link_class = self.properties[key].classname
852                 l = []
853                 for entry in value:
854                     if type(entry) != type(''):
855                         raise ValueError, '"%s" multilink value (%r) '\
856                             'must contain Strings'%(key, value)
857                     # if it isn't a number, it's a key
858                     if not num_re.match(entry):
859                         try:
860                             entry = self.db.classes[link_class].lookup(entry)
861                         except (TypeError, KeyError):
862                             raise IndexError, 'new property "%s": %s not a %s'%(
863                                 key, entry, self.properties[key].classname)
864                     l.append(entry)
865                 value = l
866                 propvalues[key] = value
868                 # handle additions
869                 for nodeid in value:
870                     if not self.db.getclass(link_class).hasnode(nodeid):
871                         raise IndexError, '%s has no node %s'%(link_class,
872                             nodeid)
873                     # register the link with the newly linked node
874                     if self.do_journal and self.properties[key].do_journal:
875                         self.db.addjournal(link_class, nodeid, 'link',
876                             (self.classname, newid, key))
878             elif isinstance(prop, String):
879                 if type(value) != type('') and type(value) != type(u''):
880                     raise TypeError, 'new property "%s" not a string'%key
882             elif isinstance(prop, Password):
883                 if not isinstance(value, password.Password):
884                     raise TypeError, 'new property "%s" not a Password'%key
886             elif isinstance(prop, Date):
887                 if value is not None and not isinstance(value, date.Date):
888                     raise TypeError, 'new property "%s" not a Date'%key
890             elif isinstance(prop, Interval):
891                 if value is not None and not isinstance(value, date.Interval):
892                     raise TypeError, 'new property "%s" not an Interval'%key
894             elif value is not None and isinstance(prop, Number):
895                 try:
896                     float(value)
897                 except ValueError:
898                     raise TypeError, 'new property "%s" not numeric'%key
900             elif value is not None and isinstance(prop, Boolean):
901                 try:
902                     int(value)
903                 except ValueError:
904                     raise TypeError, 'new property "%s" not boolean'%key
906         # make sure there's data where there needs to be
907         for key, prop in self.properties.items():
908             if propvalues.has_key(key):
909                 continue
910             if key == self.key:
911                 raise ValueError, 'key property "%s" is required'%key
912             if isinstance(prop, Multilink):
913                 propvalues[key] = []
914             else:
915                 propvalues[key] = None
917         # done
918         self.db.addnode(self.classname, newid, propvalues)
919         if self.do_journal:
920             self.db.addjournal(self.classname, newid, 'create', {})
922         return newid
924     def export_list(self, propnames, nodeid):
925         ''' Export a node - generate a list of CSV-able data in the order
926             specified by propnames for the given node.
927         '''
928         properties = self.getprops()
929         l = []
930         for prop in propnames:
931             proptype = properties[prop]
932             value = self.get(nodeid, prop)
933             # "marshal" data where needed
934             if value is None:
935                 pass
936             elif isinstance(proptype, hyperdb.Date):
937                 value = value.get_tuple()
938             elif isinstance(proptype, hyperdb.Interval):
939                 value = value.get_tuple()
940             elif isinstance(proptype, hyperdb.Password):
941                 value = str(value)
942             l.append(repr(value))
944         # append retired flag
945         l.append(repr(self.is_retired(nodeid)))
947         return l
949     def import_list(self, propnames, proplist):
950         ''' Import a node - all information including "id" is present and
951             should not be sanity checked. Triggers are not triggered. The
952             journal should be initialised using the "creator" and "created"
953             information.
955             Return the nodeid of the node imported.
956         '''
957         if self.db.journaltag is None:
958             raise DatabaseError, 'Database open read-only'
959         properties = self.getprops()
961         # make the new node's property map
962         d = {}
963         newid = None
964         for i in range(len(propnames)):
965             # Figure the property for this column
966             propname = propnames[i]
968             # Use eval to reverse the repr() used to output the CSV
969             value = eval(proplist[i])
971             # "unmarshal" where necessary
972             if propname == 'id':
973                 newid = value
974                 continue
975             elif propname == 'is retired':
976                 # is the item retired?
977                 if int(value):
978                     d[self.db.RETIRED_FLAG] = 1
979                 continue
980             elif value is None:
981                 d[propname] = None
982                 continue
984             prop = properties[propname]
985             if isinstance(prop, hyperdb.Date):
986                 value = date.Date(value)
987             elif isinstance(prop, hyperdb.Interval):
988                 value = date.Interval(value)
989             elif isinstance(prop, hyperdb.Password):
990                 pwd = password.Password()
991                 pwd.unpack(value)
992                 value = pwd
993             d[propname] = value
995         # get a new id if necessary
996         if newid is None:
997             newid = self.db.newid(self.classname)
999         # add the node and journal
1000         self.db.addnode(self.classname, newid, d)
1002         # extract the journalling stuff and nuke it
1003         if d.has_key('creator'):
1004             creator = d['creator']
1005             del d['creator']
1006         else:
1007             creator = None
1008         if d.has_key('creation'):
1009             creation = d['creation']
1010             del d['creation']
1011         else:
1012             creation = None
1013         if d.has_key('activity'):
1014             del d['activity']
1015         if d.has_key('actor'):
1016             del d['actor']
1017         self.db.addjournal(self.classname, newid, 'create', {}, creator,
1018             creation)
1019         return newid
1021     def get(self, nodeid, propname, default=_marker, cache=1):
1022         '''Get the value of a property on an existing node of this class.
1024         'nodeid' must be the id of an existing node of this class or an
1025         IndexError is raised.  'propname' must be the name of a property
1026         of this class or a KeyError is raised.
1028         'cache' exists for backward compatibility, and is not used.
1030         Attempts to get the "creation" or "activity" properties should
1031         do the right thing.
1032         '''
1033         if propname == 'id':
1034             return nodeid
1036         # get the node's dict
1037         d = self.db.getnode(self.classname, nodeid)
1039         # check for one of the special props
1040         if propname == 'creation':
1041             if d.has_key('creation'):
1042                 return d['creation']
1043             if not self.do_journal:
1044                 raise ValueError, 'Journalling is disabled for this class'
1045             journal = self.db.getjournal(self.classname, nodeid)
1046             if journal:
1047                 return self.db.getjournal(self.classname, nodeid)[0][1]
1048             else:
1049                 # on the strange chance that there's no journal
1050                 return date.Date()
1051         if propname == 'activity':
1052             if d.has_key('activity'):
1053                 return d['activity']
1054             if not self.do_journal:
1055                 raise ValueError, 'Journalling is disabled for this class'
1056             journal = self.db.getjournal(self.classname, nodeid)
1057             if journal:
1058                 return self.db.getjournal(self.classname, nodeid)[-1][1]
1059             else:
1060                 # on the strange chance that there's no journal
1061                 return date.Date()
1062         if propname == 'creator':
1063             if d.has_key('creator'):
1064                 return d['creator']
1065             if not self.do_journal:
1066                 raise ValueError, 'Journalling is disabled for this class'
1067             journal = self.db.getjournal(self.classname, nodeid)
1068             if journal:
1069                 num_re = re.compile('^\d+$')
1070                 value = journal[0][2]
1071                 if num_re.match(value):
1072                     return value
1073                 else:
1074                     # old-style "username" journal tag
1075                     try:
1076                         return self.db.user.lookup(value)
1077                     except KeyError:
1078                         # user's been retired, return admin
1079                         return '1'
1080             else:
1081                 return self.db.getuid()
1082         if propname == 'actor':
1083             if d.has_key('actor'):
1084                 return d['actor']
1085             if not self.do_journal:
1086                 raise ValueError, 'Journalling is disabled for this class'
1087             journal = self.db.getjournal(self.classname, nodeid)
1088             if journal:
1089                 num_re = re.compile('^\d+$')
1090                 value = journal[-1][2]
1091                 if num_re.match(value):
1092                     return value
1093                 else:
1094                     # old-style "username" journal tag
1095                     try:
1096                         return self.db.user.lookup(value)
1097                     except KeyError:
1098                         # user's been retired, return admin
1099                         return '1'
1100             else:
1101                 return self.db.getuid()
1103         # get the property (raises KeyErorr if invalid)
1104         prop = self.properties[propname]
1106         if not d.has_key(propname):
1107             if default is _marker:
1108                 if isinstance(prop, Multilink):
1109                     return []
1110                 else:
1111                     return None
1112             else:
1113                 return default
1115         # return a dupe of the list so code doesn't get confused
1116         if isinstance(prop, Multilink):
1117             return d[propname][:]
1119         return d[propname]
1121     def set(self, nodeid, **propvalues):
1122         '''Modify a property on an existing node of this class.
1123         
1124         'nodeid' must be the id of an existing node of this class or an
1125         IndexError is raised.
1127         Each key in 'propvalues' must be the name of a property of this
1128         class or a KeyError is raised.
1130         All values in 'propvalues' must be acceptable types for their
1131         corresponding properties or a TypeError is raised.
1133         If the value of the key property is set, it must not collide with
1134         other key strings or a ValueError is raised.
1136         If the value of a Link or Multilink property contains an invalid
1137         node id, a ValueError is raised.
1139         These operations trigger detectors and can be vetoed.  Attempts
1140         to modify the "creation" or "activity" properties cause a KeyError.
1141         '''
1142         if not propvalues:
1143             return propvalues
1145         if propvalues.has_key('creation') or propvalues.has_key('activity'):
1146             raise KeyError, '"creation" and "activity" are reserved'
1148         if propvalues.has_key('id'):
1149             raise KeyError, '"id" is reserved'
1151         if self.db.journaltag is None:
1152             raise DatabaseError, 'Database open read-only'
1154         self.fireAuditors('set', nodeid, propvalues)
1155         # Take a copy of the node dict so that the subsequent set
1156         # operation doesn't modify the oldvalues structure.
1157         oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
1159         node = self.db.getnode(self.classname, nodeid)
1160         if node.has_key(self.db.RETIRED_FLAG):
1161             raise IndexError
1162         num_re = re.compile('^\d+$')
1164         # if the journal value is to be different, store it in here
1165         journalvalues = {}
1167         for propname, value in propvalues.items():
1168             # check to make sure we're not duplicating an existing key
1169             if propname == self.key and node[propname] != value:
1170                 try:
1171                     self.lookup(value)
1172                 except KeyError:
1173                     pass
1174                 else:
1175                     raise ValueError, 'node with key "%s" exists'%value
1177             # this will raise the KeyError if the property isn't valid
1178             # ... we don't use getprops() here because we only care about
1179             # the writeable properties.
1180             try:
1181                 prop = self.properties[propname]
1182             except KeyError:
1183                 raise KeyError, '"%s" has no property named "%s"'%(
1184                     self.classname, propname)
1186             # if the value's the same as the existing value, no sense in
1187             # doing anything
1188             current = node.get(propname, None)
1189             if value == current:
1190                 del propvalues[propname]
1191                 continue
1192             journalvalues[propname] = current
1194             # do stuff based on the prop type
1195             if isinstance(prop, Link):
1196                 link_class = prop.classname
1197                 # if it isn't a number, it's a key
1198                 if value is not None and not isinstance(value, type('')):
1199                     raise ValueError, 'property "%s" link value be a string'%(
1200                         propname)
1201                 if isinstance(value, type('')) and not num_re.match(value):
1202                     try:
1203                         value = self.db.classes[link_class].lookup(value)
1204                     except (TypeError, KeyError):
1205                         raise IndexError, 'new property "%s": %s not a %s'%(
1206                             propname, value, prop.classname)
1208                 if (value is not None and
1209                         not self.db.getclass(link_class).hasnode(value)):
1210                     raise IndexError, '%s has no node %s'%(link_class, value)
1212                 if self.do_journal and prop.do_journal:
1213                     # register the unlink with the old linked node
1214                     if node.has_key(propname) and node[propname] is not None:
1215                         self.db.addjournal(link_class, node[propname], 'unlink',
1216                             (self.classname, nodeid, propname))
1218                     # register the link with the newly linked node
1219                     if value is not None:
1220                         self.db.addjournal(link_class, value, 'link',
1221                             (self.classname, nodeid, propname))
1223             elif isinstance(prop, Multilink):
1224                 if type(value) != type([]):
1225                     raise TypeError, 'new property "%s" not a list of'\
1226                         ' ids'%propname
1227                 link_class = self.properties[propname].classname
1228                 l = []
1229                 for entry in value:
1230                     # if it isn't a number, it's a key
1231                     if type(entry) != type(''):
1232                         raise ValueError, 'new property "%s" link value ' \
1233                             'must be a string'%propname
1234                     if not num_re.match(entry):
1235                         try:
1236                             entry = self.db.classes[link_class].lookup(entry)
1237                         except (TypeError, KeyError):
1238                             raise IndexError, 'new property "%s": %s not a %s'%(
1239                                 propname, entry,
1240                                 self.properties[propname].classname)
1241                     l.append(entry)
1242                 value = l
1243                 propvalues[propname] = value
1245                 # figure the journal entry for this property
1246                 add = []
1247                 remove = []
1249                 # handle removals
1250                 if node.has_key(propname):
1251                     l = node[propname]
1252                 else:
1253                     l = []
1254                 for id in l[:]:
1255                     if id in value:
1256                         continue
1257                     # register the unlink with the old linked node
1258                     if self.do_journal and self.properties[propname].do_journal:
1259                         self.db.addjournal(link_class, id, 'unlink',
1260                             (self.classname, nodeid, propname))
1261                     l.remove(id)
1262                     remove.append(id)
1264                 # handle additions
1265                 for id in value:
1266                     if not self.db.getclass(link_class).hasnode(id):
1267                         raise IndexError, '%s has no node %s'%(link_class, id)
1268                     if id in l:
1269                         continue
1270                     # register the link with the newly linked node
1271                     if self.do_journal and self.properties[propname].do_journal:
1272                         self.db.addjournal(link_class, id, 'link',
1273                             (self.classname, nodeid, propname))
1274                     l.append(id)
1275                     add.append(id)
1277                 # figure the journal entry
1278                 l = []
1279                 if add:
1280                     l.append(('+', add))
1281                 if remove:
1282                     l.append(('-', remove))
1283                 if l:
1284                     journalvalues[propname] = tuple(l)
1286             elif isinstance(prop, String):
1287                 if value is not None and type(value) != type('') and type(value) != type(u''):
1288                     raise TypeError, 'new property "%s" not a string'%propname
1290             elif isinstance(prop, Password):
1291                 if not isinstance(value, password.Password):
1292                     raise TypeError, 'new property "%s" not a Password'%propname
1293                 propvalues[propname] = value
1295             elif value is not None and isinstance(prop, Date):
1296                 if not isinstance(value, date.Date):
1297                     raise TypeError, 'new property "%s" not a Date'% propname
1298                 propvalues[propname] = value
1300             elif value is not None and isinstance(prop, Interval):
1301                 if not isinstance(value, date.Interval):
1302                     raise TypeError, 'new property "%s" not an '\
1303                         'Interval'%propname
1304                 propvalues[propname] = value
1306             elif value is not None and isinstance(prop, Number):
1307                 try:
1308                     float(value)
1309                 except ValueError:
1310                     raise TypeError, 'new property "%s" not numeric'%propname
1312             elif value is not None and isinstance(prop, Boolean):
1313                 try:
1314                     int(value)
1315                 except ValueError:
1316                     raise TypeError, 'new property "%s" not boolean'%propname
1318             node[propname] = value
1320         # nothing to do?
1321         if not propvalues:
1322             return propvalues
1324         # do the set, and journal it
1325         self.db.setnode(self.classname, nodeid, node)
1327         if self.do_journal:
1328             self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
1330         self.fireReactors('set', nodeid, oldvalues)
1332         return propvalues        
1334     def retire(self, nodeid):
1335         '''Retire a node.
1336         
1337         The properties on the node remain available from the get() method,
1338         and the node's id is never reused.
1339         
1340         Retired nodes are not returned by the find(), list(), or lookup()
1341         methods, and other nodes may reuse the values of their key properties.
1343         These operations trigger detectors and can be vetoed.  Attempts
1344         to modify the "creation" or "activity" properties cause a KeyError.
1345         '''
1346         if self.db.journaltag is None:
1347             raise DatabaseError, 'Database open read-only'
1349         self.fireAuditors('retire', nodeid, None)
1351         node = self.db.getnode(self.classname, nodeid)
1352         node[self.db.RETIRED_FLAG] = 1
1353         self.db.setnode(self.classname, nodeid, node)
1354         if self.do_journal:
1355             self.db.addjournal(self.classname, nodeid, 'retired', None)
1357         self.fireReactors('retire', nodeid, None)
1359     def restore(self, nodeid):
1360         '''Restpre a retired node.
1362         Make node available for all operations like it was before retirement.
1363         '''
1364         if self.db.journaltag is None:
1365             raise DatabaseError, 'Database open read-only'
1367         node = self.db.getnode(self.classname, nodeid)
1368         # check if key property was overrided
1369         key = self.getkey()
1370         try:
1371             id = self.lookup(node[key])
1372         except KeyError:
1373             pass
1374         else:
1375             raise KeyError, "Key property (%s) of retired node clashes with \
1376                 existing one (%s)" % (key, node[key])
1377         # Now we can safely restore node
1378         self.fireAuditors('restore', nodeid, None)
1379         del node[self.db.RETIRED_FLAG]
1380         self.db.setnode(self.classname, nodeid, node)
1381         if self.do_journal:
1382             self.db.addjournal(self.classname, nodeid, 'restored', None)
1384         self.fireReactors('restore', nodeid, None)
1386     def is_retired(self, nodeid, cldb=None):
1387         '''Return true if the node is retired.
1388         '''
1389         node = self.db.getnode(self.classname, nodeid, cldb)
1390         if node.has_key(self.db.RETIRED_FLAG):
1391             return 1
1392         return 0
1394     def destroy(self, nodeid):
1395         '''Destroy a node.
1397         WARNING: this method should never be used except in extremely rare
1398                  situations where there could never be links to the node being
1399                  deleted
1401         WARNING: use retire() instead
1403         WARNING: the properties of this node will not be available ever again
1405         WARNING: really, use retire() instead
1407         Well, I think that's enough warnings. This method exists mostly to
1408         support the session storage of the cgi interface.
1409         '''
1410         if self.db.journaltag is None:
1411             raise DatabaseError, 'Database open read-only'
1412         self.db.destroynode(self.classname, nodeid)
1414     def history(self, nodeid):
1415         '''Retrieve the journal of edits on a particular node.
1417         'nodeid' must be the id of an existing node of this class or an
1418         IndexError is raised.
1420         The returned list contains tuples of the form
1422             (nodeid, date, tag, action, params)
1424         'date' is a Timestamp object specifying the time of the change and
1425         'tag' is the journaltag specified when the database was opened.
1426         '''
1427         if not self.do_journal:
1428             raise ValueError, 'Journalling is disabled for this class'
1429         return self.db.getjournal(self.classname, nodeid)
1431     # Locating nodes:
1432     def hasnode(self, nodeid):
1433         '''Determine if the given nodeid actually exists
1434         '''
1435         return self.db.hasnode(self.classname, nodeid)
1437     def setkey(self, propname):
1438         '''Select a String property of this class to be the key property.
1440         'propname' must be the name of a String property of this class or
1441         None, or a TypeError is raised.  The values of the key property on
1442         all existing nodes must be unique or a ValueError is raised. If the
1443         property doesn't exist, KeyError is raised.
1444         '''
1445         prop = self.getprops()[propname]
1446         if not isinstance(prop, String):
1447             raise TypeError, 'key properties must be String'
1448         self.key = propname
1450     def getkey(self):
1451         '''Return the name of the key property for this class or None.'''
1452         return self.key
1454     def labelprop(self, default_to_id=0):
1455         '''Return the property name for a label for the given node.
1457         This method attempts to generate a consistent label for the node.
1458         It tries the following in order:
1460         1. key property
1461         2. "name" property
1462         3. "title" property
1463         4. first property from the sorted property name list
1464         '''
1465         k = self.getkey()
1466         if  k:
1467             return k
1468         props = self.getprops()
1469         if props.has_key('name'):
1470             return 'name'
1471         elif props.has_key('title'):
1472             return 'title'
1473         if default_to_id:
1474             return 'id'
1475         props = props.keys()
1476         props.sort()
1477         return props[0]
1479     # TODO: set up a separate index db file for this? profile?
1480     def lookup(self, keyvalue):
1481         '''Locate a particular node by its key property and return its id.
1483         If this class has no key property, a TypeError is raised.  If the
1484         'keyvalue' matches one of the values for the key property among
1485         the nodes in this class, the matching node's id is returned;
1486         otherwise a KeyError is raised.
1487         '''
1488         if not self.key:
1489             raise TypeError, 'No key property set for class %s'%self.classname
1490         cldb = self.db.getclassdb(self.classname)
1491         try:
1492             for nodeid in self.getnodeids(cldb):
1493                 node = self.db.getnode(self.classname, nodeid, cldb)
1494                 if node.has_key(self.db.RETIRED_FLAG):
1495                     continue
1496                 if node[self.key] == keyvalue:
1497                     return nodeid
1498         finally:
1499             cldb.close()
1500         raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
1501             keyvalue, self.classname)
1503     # change from spec - allows multiple props to match
1504     def find(self, **propspec):
1505         '''Get the ids of items in this class which link to the given items.
1507         'propspec' consists of keyword args propname=itemid or
1508                    propname={itemid:1, }
1509         'propname' must be the name of a property in this class, or a
1510                    KeyError is raised.  That property must be a Link or
1511                    Multilink property, or a TypeError is raised.
1513         Any item in this class whose 'propname' property links to any of the
1514         itemids will be returned. Used by the full text indexing, which knows
1515         that "foo" occurs in msg1, msg3 and file7, so we have hits on these
1516         issues:
1518             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
1519         '''
1520         propspec = propspec.items()
1521         for propname, itemids in propspec:
1522             # check the prop is OK
1523             prop = self.properties[propname]
1524             if not isinstance(prop, Link) and not isinstance(prop, Multilink):
1525                 raise TypeError, "'%s' not a Link/Multilink property"%propname
1527         # ok, now do the find
1528         cldb = self.db.getclassdb(self.classname)
1529         l = []
1530         try:
1531             for id in self.getnodeids(db=cldb):
1532                 item = self.db.getnode(self.classname, id, db=cldb)
1533                 if item.has_key(self.db.RETIRED_FLAG):
1534                     continue
1535                 for propname, itemids in propspec:
1536                     # can't test if the item doesn't have this property
1537                     if not item.has_key(propname):
1538                         continue
1539                     if type(itemids) is not type({}):
1540                         itemids = {itemids:1}
1542                     # grab the property definition and its value on this item
1543                     prop = self.properties[propname]
1544                     value = item[propname]
1545                     if isinstance(prop, Link) and itemids.has_key(value):
1546                         l.append(id)
1547                         break
1548                     elif isinstance(prop, Multilink):
1549                         hit = 0
1550                         for v in value:
1551                             if itemids.has_key(v):
1552                                 l.append(id)
1553                                 hit = 1
1554                                 break
1555                         if hit:
1556                             break
1557         finally:
1558             cldb.close()
1559         return l
1561     def stringFind(self, **requirements):
1562         '''Locate a particular node by matching a set of its String
1563         properties in a caseless search.
1565         If the property is not a String property, a TypeError is raised.
1566         
1567         The return is a list of the id of all nodes that match.
1568         '''
1569         for propname in requirements.keys():
1570             prop = self.properties[propname]
1571             if not isinstance(prop, String):
1572                 raise TypeError, "'%s' not a String property"%propname
1573             requirements[propname] = requirements[propname].lower()
1574         l = []
1575         cldb = self.db.getclassdb(self.classname)
1576         try:
1577             for nodeid in self.getnodeids(cldb):
1578                 node = self.db.getnode(self.classname, nodeid, cldb)
1579                 if node.has_key(self.db.RETIRED_FLAG):
1580                     continue
1581                 for key, value in requirements.items():
1582                     if not node.has_key(key):
1583                         break
1584                     if node[key] is None or node[key].lower() != value:
1585                         break
1586                 else:
1587                     l.append(nodeid)
1588         finally:
1589             cldb.close()
1590         return l
1592     def list(self):
1593         ''' Return a list of the ids of the active nodes in this class.
1594         '''
1595         l = []
1596         cn = self.classname
1597         cldb = self.db.getclassdb(cn)
1598         try:
1599             for nodeid in self.getnodeids(cldb):
1600                 node = self.db.getnode(cn, nodeid, cldb)
1601                 if node.has_key(self.db.RETIRED_FLAG):
1602                     continue
1603                 l.append(nodeid)
1604         finally:
1605             cldb.close()
1606         l.sort()
1607         return l
1609     def getnodeids(self, db=None):
1610         ''' Return a list of ALL nodeids
1611         '''
1612         if __debug__:
1613             print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
1615         res = []
1617         # start off with the new nodes
1618         if self.db.newnodes.has_key(self.classname):
1619             res += self.db.newnodes[self.classname].keys()
1621         if db is None:
1622             db = self.db.getclassdb(self.classname)
1623         res = res + db.keys()
1625         # remove the uncommitted, destroyed nodes
1626         if self.db.destroyednodes.has_key(self.classname):
1627             for nodeid in self.db.destroyednodes[self.classname].keys():
1628                 if db.has_key(nodeid):
1629                     res.remove(nodeid)
1631         return res
1633     def filter(self, search_matches, filterspec, sort=(None,None),
1634             group=(None,None), num_re = re.compile('^\d+$')):
1635         """Return a list of the ids of the active nodes in this class that
1636         match the 'filter' spec, sorted by the group spec and then the
1637         sort spec.
1639         "filterspec" is {propname: value(s)}
1641         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
1642         and prop is a prop name or None
1644         "search_matches" is {nodeid: marker}
1646         The filter must match all properties specificed - but if the
1647         property value to match is a list, any one of the values in the
1648         list may match for that property to match. Unless the property
1649         is a Multilink, in which case the item's property list must
1650         match the filterspec list.
1651         """
1652         cn = self.classname
1654         # optimise filterspec
1655         l = []
1656         props = self.getprops()
1657         LINK = 0
1658         MULTILINK = 1
1659         STRING = 2
1660         DATE = 3
1661         INTERVAL = 4
1662         OTHER = 6
1663         
1664         timezone = self.db.getUserTimezone()
1665         for k, v in filterspec.items():
1666             propclass = props[k]
1667             if isinstance(propclass, Link):
1668                 if type(v) is not type([]):
1669                     v = [v]
1670                 u = []
1671                 for entry in v:
1672                     # the value -1 is a special "not set" sentinel
1673                     if entry == '-1':
1674                         entry = None
1675                     u.append(entry)
1676                 l.append((LINK, k, u))
1677             elif isinstance(propclass, Multilink):
1678                 # the value -1 is a special "not set" sentinel
1679                 if v in ('-1', ['-1']):
1680                     v = []
1681                 elif type(v) is not type([]):
1682                     v = [v]
1683                 l.append((MULTILINK, k, v))
1684             elif isinstance(propclass, String) and k != 'id':
1685                 if type(v) is not type([]):
1686                     v = [v]
1687                 m = []
1688                 for v in v:
1689                     # simple glob searching
1690                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
1691                     v = v.replace('?', '.')
1692                     v = v.replace('*', '.*?')
1693                     m.append(v)
1694                 m = re.compile('(%s)'%('|'.join(m)), re.I)
1695                 l.append((STRING, k, m))
1696             elif isinstance(propclass, Date):
1697                 try:
1698                     date_rng = Range(v, date.Date, offset=timezone)
1699                     l.append((DATE, k, date_rng))
1700                 except ValueError:
1701                     # If range creation fails - ignore that search parameter
1702                     pass
1703             elif isinstance(propclass, Interval):
1704                 try:
1705                     intv_rng = Range(v, date.Interval)
1706                     l.append((INTERVAL, k, intv_rng))
1707                 except ValueError:
1708                     # If range creation fails - ignore that search parameter
1709                     pass
1710                 
1711             elif isinstance(propclass, Boolean):
1712                 if type(v) is type(''):
1713                     bv = v.lower() in ('yes', 'true', 'on', '1')
1714                 else:
1715                     bv = v
1716                 l.append((OTHER, k, bv))
1717             elif isinstance(propclass, Number):
1718                 l.append((OTHER, k, int(v)))
1719             else:
1720                 l.append((OTHER, k, v))
1721         filterspec = l
1723         # now, find all the nodes that are active and pass filtering
1724         l = []
1725         cldb = self.db.getclassdb(cn)
1726         try:
1727             # TODO: only full-scan once (use items())
1728             for nodeid in self.getnodeids(cldb):
1729                 node = self.db.getnode(cn, nodeid, cldb)
1730                 if node.has_key(self.db.RETIRED_FLAG):
1731                     continue
1732                 # apply filter
1733                 for t, k, v in filterspec:
1734                     # handle the id prop
1735                     if k == 'id' and v == nodeid:
1736                         continue
1738                     # make sure the node has the property
1739                     if not node.has_key(k):
1740                         # this node doesn't have this property, so reject it
1741                         break
1743                     # now apply the property filter
1744                     if t == LINK:
1745                         # link - if this node's property doesn't appear in the
1746                         # filterspec's nodeid list, skip it
1747                         if node[k] not in v:
1748                             break
1749                     elif t == MULTILINK:
1750                         # multilink - if any of the nodeids required by the
1751                         # filterspec aren't in this node's property, then skip
1752                         # it
1753                         have = node[k]
1754                         # check for matching the absence of multilink values
1755                         if not v and have:
1756                             break
1758                         # othewise, make sure this node has each of the
1759                         # required values
1760                         for want in v:
1761                             if want not in have:
1762                                 break
1763                         else:
1764                             continue
1765                         break
1766                     elif t == STRING:
1767                         if node[k] is None:
1768                             break
1769                         # RE search
1770                         if not v.search(node[k]):
1771                             break
1772                     elif t == DATE or t == INTERVAL:
1773                         if node[k] is None:
1774                             break
1775                         if v.to_value:
1776                             if not (v.from_value <= node[k] and v.to_value >= node[k]):
1777                                 break
1778                         else:
1779                             if not (v.from_value <= node[k]):
1780                                 break
1781                     elif t == OTHER:
1782                         # straight value comparison for the other types
1783                         if node[k] != v:
1784                             break
1785                 else:
1786                     l.append((nodeid, node))
1787         finally:
1788             cldb.close()
1789         l.sort()
1791         # filter based on full text search
1792         if search_matches is not None:
1793             k = []
1794             for v in l:
1795                 if search_matches.has_key(v[0]):
1796                     k.append(v)
1797             l = k
1799         # now, sort the result
1800         def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
1801                 db = self.db, cl=self):
1802             a_id, an = a
1803             b_id, bn = b
1804             # sort by group and then sort
1805             for dir, prop in group, sort:
1806                 if dir is None or prop is None: continue
1808                 # sorting is class-specific
1809                 propclass = properties[prop]
1811                 # handle the properties that might be "faked"
1812                 # also, handle possible missing properties
1813                 try:
1814                     if not an.has_key(prop):
1815                         an[prop] = cl.get(a_id, prop)
1816                     av = an[prop]
1817                 except KeyError:
1818                     # the node doesn't have a value for this property
1819                     if isinstance(propclass, Multilink): av = []
1820                     else: av = ''
1821                 try:
1822                     if not bn.has_key(prop):
1823                         bn[prop] = cl.get(b_id, prop)
1824                     bv = bn[prop]
1825                 except KeyError:
1826                     # the node doesn't have a value for this property
1827                     if isinstance(propclass, Multilink): bv = []
1828                     else: bv = ''
1830                 # String and Date values are sorted in the natural way
1831                 if isinstance(propclass, String):
1832                     # clean up the strings
1833                     if av and av[0] in string.uppercase:
1834                         av = av.lower()
1835                     if bv and bv[0] in string.uppercase:
1836                         bv = bv.lower()
1837                 if (isinstance(propclass, String) or
1838                         isinstance(propclass, Date)):
1839                     # it might be a string that's really an integer
1840                     try:
1841                         av = int(av)
1842                         bv = int(bv)
1843                     except:
1844                         pass
1845                     if dir == '+':
1846                         r = cmp(av, bv)
1847                         if r != 0: return r
1848                     elif dir == '-':
1849                         r = cmp(bv, av)
1850                         if r != 0: return r
1852                 # Link properties are sorted according to the value of
1853                 # the "order" property on the linked nodes if it is
1854                 # present; or otherwise on the key string of the linked
1855                 # nodes; or finally on  the node ids.
1856                 elif isinstance(propclass, Link):
1857                     link = db.classes[propclass.classname]
1858                     if av is None and bv is not None: return -1
1859                     if av is not None and bv is None: return 1
1860                     if av is None and bv is None: continue
1861                     if link.getprops().has_key('order'):
1862                         if dir == '+':
1863                             r = cmp(link.get(av, 'order'),
1864                                 link.get(bv, 'order'))
1865                             if r != 0: return r
1866                         elif dir == '-':
1867                             r = cmp(link.get(bv, 'order'),
1868                                 link.get(av, 'order'))
1869                             if r != 0: return r
1870                     elif link.getkey():
1871                         key = link.getkey()
1872                         if dir == '+':
1873                             r = cmp(link.get(av, key), link.get(bv, key))
1874                             if r != 0: return r
1875                         elif dir == '-':
1876                             r = cmp(link.get(bv, key), link.get(av, key))
1877                             if r != 0: return r
1878                     else:
1879                         if dir == '+':
1880                             r = cmp(av, bv)
1881                             if r != 0: return r
1882                         elif dir == '-':
1883                             r = cmp(bv, av)
1884                             if r != 0: return r
1886                 else:
1887                     # all other types just compare
1888                     if dir == '+':
1889                         r = cmp(av, bv)
1890                     elif dir == '-':
1891                         r = cmp(bv, av)
1892                     if r != 0: return r
1893                     
1894             # end for dir, prop in sort, group:
1895             # if all else fails, compare the ids
1896             return cmp(a[0], b[0])
1898         l.sort(sortfun)
1899         return [i[0] for i in l]
1901     def count(self):
1902         '''Get the number of nodes in this class.
1904         If the returned integer is 'numnodes', the ids of all the nodes
1905         in this class run from 1 to numnodes, and numnodes+1 will be the
1906         id of the next node to be created in this class.
1907         '''
1908         return self.db.countnodes(self.classname)
1910     # Manipulating properties:
1912     def getprops(self, protected=1):
1913         '''Return a dictionary mapping property names to property objects.
1914            If the "protected" flag is true, we include protected properties -
1915            those which may not be modified.
1917            In addition to the actual properties on the node, these
1918            methods provide the "creation" and "activity" properties. If the
1919            "protected" flag is true, we include protected properties - those
1920            which may not be modified.
1921         '''
1922         d = self.properties.copy()
1923         if protected:
1924             d['id'] = String()
1925             d['creation'] = hyperdb.Date()
1926             d['activity'] = hyperdb.Date()
1927             d['creator'] = hyperdb.Link('user')
1928             d['actor'] = hyperdb.Link('user')
1929         return d
1931     def addprop(self, **properties):
1932         '''Add properties to this class.
1934         The keyword arguments in 'properties' must map names to property
1935         objects, or a TypeError is raised.  None of the keys in 'properties'
1936         may collide with the names of existing properties, or a ValueError
1937         is raised before any properties have been added.
1938         '''
1939         for key in properties.keys():
1940             if self.properties.has_key(key):
1941                 raise ValueError, key
1942         self.properties.update(properties)
1944     def index(self, nodeid):
1945         '''Add (or refresh) the node to search indexes
1946         '''
1947         # find all the String properties that have indexme
1948         for prop, propclass in self.getprops().items():
1949             if isinstance(propclass, String) and propclass.indexme:
1950                 try:
1951                     value = str(self.get(nodeid, prop))
1952                 except IndexError:
1953                     # node no longer exists - entry should be removed
1954                     self.db.indexer.purge_entry((self.classname, nodeid, prop))
1955                 else:
1956                     # and index them under (classname, nodeid, property)
1957                     self.db.indexer.add_text((self.classname, nodeid, prop),
1958                         value)
1960     #
1961     # Detector interface
1962     #
1963     def audit(self, event, detector):
1964         '''Register a detector
1965         '''
1966         l = self.auditors[event]
1967         if detector not in l:
1968             self.auditors[event].append(detector)
1970     def fireAuditors(self, action, nodeid, newvalues):
1971         '''Fire all registered auditors.
1972         '''
1973         for audit in self.auditors[action]:
1974             audit(self.db, self, nodeid, newvalues)
1976     def react(self, event, detector):
1977         '''Register a detector
1978         '''
1979         l = self.reactors[event]
1980         if detector not in l:
1981             self.reactors[event].append(detector)
1983     def fireReactors(self, action, nodeid, oldvalues):
1984         '''Fire all registered reactors.
1985         '''
1986         for react in self.reactors[action]:
1987             react(self.db, self, nodeid, oldvalues)
1989 class FileClass(Class, hyperdb.FileClass):
1990     '''This class defines a large chunk of data. To support this, it has a
1991        mandatory String property "content" which is typically saved off
1992        externally to the hyperdb.
1994        The default MIME type of this data is defined by the
1995        "default_mime_type" class attribute, which may be overridden by each
1996        node if the class defines a "type" String property.
1997     '''
1998     default_mime_type = 'text/plain'
2000     def create(self, **propvalues):
2001         ''' Snarf the "content" propvalue and store in a file
2002         '''
2003         # we need to fire the auditors now, or the content property won't
2004         # be in propvalues for the auditors to play with
2005         self.fireAuditors('create', None, propvalues)
2007         # now remove the content property so it's not stored in the db
2008         content = propvalues['content']
2009         del propvalues['content']
2011         # do the database create
2012         newid = Class.create_inner(self, **propvalues)
2014         # fire reactors
2015         self.fireReactors('create', newid, None)
2017         # store off the content as a file
2018         self.db.storefile(self.classname, newid, None, content)
2019         return newid
2021     def import_list(self, propnames, proplist):
2022         ''' Trap the "content" property...
2023         '''
2024         # dupe this list so we don't affect others
2025         propnames = propnames[:]
2027         # extract the "content" property from the proplist
2028         i = propnames.index('content')
2029         content = eval(proplist[i])
2030         del propnames[i]
2031         del proplist[i]
2033         # do the normal import
2034         newid = Class.import_list(self, propnames, proplist)
2036         # save off the "content" file
2037         self.db.storefile(self.classname, newid, None, content)
2038         return newid
2040     def get(self, nodeid, propname, default=_marker, cache=1):
2041         ''' Trap the content propname and get it from the file
2043         'cache' exists for backwards compatibility, and is not used.
2044         '''
2045         poss_msg = 'Possibly an access right configuration problem.'
2046         if propname == 'content':
2047             try:
2048                 return self.db.getfile(self.classname, nodeid, None)
2049             except IOError, (strerror):
2050                 # XXX by catching this we donot see an error in the log.
2051                 return 'ERROR reading file: %s%s\n%s\n%s'%(
2052                         self.classname, nodeid, poss_msg, strerror)
2053         if default is not _marker:
2054             return Class.get(self, nodeid, propname, default)
2055         else:
2056             return Class.get(self, nodeid, propname)
2058     def getprops(self, protected=1):
2059         ''' In addition to the actual properties on the node, these methods
2060             provide the "content" property. If the "protected" flag is true,
2061             we include protected properties - those which may not be
2062             modified.
2063         '''
2064         d = Class.getprops(self, protected=protected).copy()
2065         d['content'] = hyperdb.String()
2066         return d
2068     def index(self, nodeid):
2069         ''' Index the node in the search index.
2071             We want to index the content in addition to the normal String
2072             property indexing.
2073         '''
2074         # perform normal indexing
2075         Class.index(self, nodeid)
2077         # get the content to index
2078         content = self.get(nodeid, 'content')
2080         # figure the mime type
2081         if self.properties.has_key('type'):
2082             mime_type = self.get(nodeid, 'type')
2083         else:
2084             mime_type = self.default_mime_type
2086         # and index!
2087         self.db.indexer.add_text((self.classname, nodeid, 'content'), content,
2088             mime_type)
2090 # deviation from spec - was called ItemClass
2091 class IssueClass(Class, roundupdb.IssueClass):
2092     # Overridden methods:
2093     def __init__(self, db, classname, **properties):
2094         '''The newly-created class automatically includes the "messages",
2095         "files", "nosy", and "superseder" properties.  If the 'properties'
2096         dictionary attempts to specify any of these properties or a
2097         "creation" or "activity" property, a ValueError is raised.
2098         '''
2099         if not properties.has_key('title'):
2100             properties['title'] = hyperdb.String(indexme='yes')
2101         if not properties.has_key('messages'):
2102             properties['messages'] = hyperdb.Multilink("msg")
2103         if not properties.has_key('files'):
2104             properties['files'] = hyperdb.Multilink("file")
2105         if not properties.has_key('nosy'):
2106             # note: journalling is turned off as it really just wastes
2107             # space. this behaviour may be overridden in an instance
2108             properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
2109         if not properties.has_key('superseder'):
2110             properties['superseder'] = hyperdb.Multilink(classname)
2111         Class.__init__(self, db, classname, **properties)