Code

Added metakit backend to the db tests and fixed the more easily fixable test
[roundup.git] / roundup / backends / back_metakit.py
1 from roundup import hyperdb, date, password, roundupdb
2 import metakit
3 import re, marshal, os, sys, weakref, time, calendar
4 from roundup.indexer import Indexer
6 _instances = weakref.WeakValueDictionary()
8 def Database(config, journaltag=None):
9     if _instances.has_key(id(config)):
10         db = _instances[id(config)]
11         old = db.journaltag
12         db.journaltag = journaltag
13         try:
14             delattr(db, 'curuserid')
15         except AttributeError:
16             pass
17         return db
18     else:
19         db = _Database(config, journaltag)
20         _instances[id(config)] = db
21         return db
23 class _Database(hyperdb.Database):
24     def __init__(self, config, journaltag=None):
25         self.config = config
26         self.journaltag = journaltag
27         self.classes = {}
28         self._classes = []
29         self.dirty = 0
30         self.__RW = 0
31         self._db = self.__open()
32         self.indexer = Indexer(self.config.DATABASE)
33         os.umask(0002)
34     def post_init(self):
35         if self.indexer.should_reindex():
36             self.reindex()
38     def reindex(self):
39         for klass in self.classes.values():
40             for nodeid in klass.list():
41                 klass.index(nodeid)
42         self.indexer.save_index()
43         
44             
45     # --- defined in ping's spec
46     def __getattr__(self, classname):
47         if classname == 'curuserid':
48             try:
49                 self.curuserid = x = int(self.classes['user'].lookup(self.journaltag))
50             except KeyError:
51                 x = 0
52             return x
53         return self.getclass(classname)
54     def getclass(self, classname):
55         return self.classes[classname]
56     def getclasses(self):
57         return self.classes.keys()
58     # --- end of ping's spec 
59     # --- exposed methods
60     def commit(self):
61         if self.dirty:
62             if self.__RW:
63                 self._db.commit()
64                 for cl in self.classes.values():
65                     cl._commit()
66                 self.indexer.save_index()
67             else:
68                 raise RuntimeError, "metakit is open RO"
69         self.dirty = 0
70     def rollback(self):
71         if self.dirty:
72             for cl in self.classes.values():
73                 cl._rollback()
74             self._db.rollback()
75         self.dirty = 0
76     def clear(self):
77         for cl in self.classes.values():
78             cl._clear()
79     def hasnode(self, classname, nodeid):
80         return self.getclass(clasname).hasnode(nodeid)
81     def pack(self, pack_before):
82         pass
83     def addclass(self, cl):
84         self.classes[cl.classname] = cl
85     def addjournal(self, tablenm, nodeid, action, params):
86         tblid = self.tables.find(name=tablenm)
87         if tblid == -1:
88             tblid = self.tables.append(name=tablenm)
89         # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
90         self.hist.append(tableid=tblid,
91                          nodeid=int(nodeid),
92                          date=int(time.time()),
93                          action=action,
94                          user = self.curuserid,
95                          params = marshal.dumps(params))
96     def gethistory(self, tablenm, nodeid):
97         rslt = []
98         tblid = self.tables.find(name=tablenm)
99         if tblid == -1:
100             return rslt
101         q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
102         i = 0
103         userclass = self.getclass('user')
104         for row in q:
105             try:
106                 params = marshal.loads(row.params)
107             except ValueError:
108                 print "history couldn't unmarshal %r" % row.params
109                 params = {}
110             usernm = userclass.get(str(row.user), 'username')
111             dt = date.Date(time.gmtime(row.date))
112             rslt.append((i, dt, usernm, _actionnames[row.action], params))
113             i += 1
114         return rslt
115             
116     def close(self):
117         import time
118         now = time.time
119         start = now()
120         for cl in self.classes.values():
121             cl.db = None
122         #self._db.rollback()
123         #print "pre-close cleanup of DB(%d) took %2.2f secs" % (self.__RW, now()-start)
124         self._db = None
125         #print "close of DB(%d) took %2.2f secs" % (self.__RW, now()-start)
126         self.classes = {}
127         try:
128             del _instances[id(self.config)]
129         except KeyError:
130             pass
131         self.__RW = 0
132         
133     # --- internal
134     def __open(self):
135         self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
136         self.fastopen = 0
137         if os.path.exists(db):
138             dbtm = os.path.getmtime(db)
139             pkgnm = self.config.__name__.split('.')[0]
140             schemamod = sys.modules.get(pkgnm+'.dbinit', None)
141             if schemamod:
142                 if os.path.exists(schemamod.__file__):
143                     schematm = os.path.getmtime(schemamod.__file__)
144                     if schematm < dbtm:
145                         # found schema mod - it's older than the db
146                         self.fastopen = 1
147                 else:
148                      # can't find schemamod - must be frozen
149                     self.fastopen = 1
150         else:
151             self.__RW = 1
152         if not self.fastopen:
153             self.__RW = 1
154         db = metakit.storage(db, self.__RW)
155         hist = db.view('history')
156         tables = db.view('tables')
157         if not self.fastopen:
158             if not hist.structure():
159                 hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
160             if not tables.structure():
161                 tables = db.getas('tables[name:S]')
162         self.tables = tables
163         self.hist = hist
164         return db
165     def isReadOnly(self):
166         return self.__RW == 0
167     def getWriteAccess(self):
168         if self.journaltag is not None and self.__RW == 0:
169             now = time.time
170             start = now()
171             self._db = None
172             #print "closing the file took %2.2f secs" % (now()-start)
173             start = now()
174             self._db = metakit.storage(self.dbnm, 1)
175             self.__RW = 1
176             self.hist = self._db.view('history')
177             self.tables = self._db.view('tables')
178             #print "getting RW access took %2.2f secs" % (now()-start)
179     
180 _STRINGTYPE = type('')
181 _LISTTYPE = type([])
182 _CREATE, _SET, _RETIRE, _LINK, _UNLINK = range(5)
184 _actionnames = {
185     _CREATE : 'create',
186     _SET : 'set',
187     _RETIRE : 'retire',
188     _LINK : 'link',
189     _UNLINK : 'unlink',
192 _marker = []
194 _ALLOWSETTINGPRIVATEPROPS = 0
196 class Class:    # no, I'm not going to subclass the existing!
197     privateprops = None
198     def __init__(self, db, classname, **properties):
199         self.db = weakref.proxy(db)
200         self.classname = classname
201         self.keyname = None
202         self.ruprops = properties
203         self.privateprops = { 'id' : hyperdb.String(),
204                               'activity' : hyperdb.Date(),
205                               'creation' : hyperdb.Date(),
206                               'creator'  : hyperdb.Link('user') }
207         self.auditors = {'create': [], 'set': [], 'retire': []} # event -> list of callables
208         self.reactors = {'create': [], 'set': [], 'retire': []} # ditto
209         view = self.__getview()
210         self.maxid = 1
211         if view:
212             self.maxid = view[-1].id + 1
213         self.uncommitted = {}
214         self.rbactions = []
215         # people reach inside!!
216         self.properties = self.ruprops
217         self.db.addclass(self)
218         self.idcache = {}
219         
220     # --- the roundup.Class methods
221     def audit(self, event, detector):
222         l = self.auditors[event]
223         if detector not in l:
224             self.auditors[event].append(detector)
225     def fireAuditors(self, action, nodeid, newvalues):
226         for audit in self.auditors[action]:
227             audit(self.db, self, nodeid, newvalues)
228     def fireReactors(self, action, nodeid, oldvalues):
229         for react in self.reactors[action]:
230             react(self.db, self, nodeid, oldvalues)
231     def react(self, event, detector):
232         l = self.reactors[event]
233         if detector not in l:
234             self.reactors[event].append(detector)
235     # --- the hyperdb.Class methods
236     def create(self, **propvalues):
237         rowdict = {}
238         rowdict['id'] = newid = self.maxid
239         self.maxid += 1
240         ndx = self.getview(1).append(rowdict)
241         propvalues['#ISNEW'] = 1
242         try:
243             self.set(str(newid), **propvalues)
244         except Exception:
245             self.maxid -= 1
246             raise
247         return str(newid)
248     
249     def get(self, nodeid, propname, default=_marker, cache=1):
250         # default and cache aren't in the spec
251         # cache=0 means "original value"
253         view = self.getview()        
254         id = int(nodeid)
255         if cache == 0:
256             oldnode = self.uncommitted.get(id, None)
257             if oldnode and oldnode.has_key(propname):
258                 return oldnode[propname]
259         ndx = self.idcache.get(id, None)
260         if ndx is None:
261             ndx = view.find(id=id)
262             if ndx < 0:
263                 raise IndexError, "%s has no node %s" % (self.classname, nodeid)
264             self.idcache[id] = ndx
265         try:
266             raw = getattr(view[ndx], propname)
267         except AttributeError:
268             raise KeyError, propname
269         rutyp = self.ruprops.get(propname, None)
270         if rutyp is None:
271             rutyp = self.privateprops[propname]
272         converter = _converters.get(rutyp.__class__, None)
273         if converter:
274             raw = converter(raw)
275         return raw
276         
277     def set(self, nodeid, **propvalues):
278         isnew = 0
279         if propvalues.has_key('#ISNEW'):
280             isnew = 1
281             del propvalues['#ISNEW']
282         if not propvalues:
283             return
284         if propvalues.has_key('id'):
285             raise KeyError, '"id" is reserved'
286         if self.db.journaltag is None:
287             raise DatabaseError, 'Database open read-only'
288         view = self.getview(1)
289         # node must exist & not be retired
290         id = int(nodeid)
291         ndx = view.find(id=id)
292         if ndx < 0:
293             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
294         row = view[ndx]
295         if row._isdel:
296             raise IndexError, "%s has no node %s" % (self.classname, nodeid)
297         oldnode = self.uncommitted.setdefault(id, {})
298         changes = {}
299         
300         for key, value in propvalues.items():
301             # this will raise the KeyError if the property isn't valid
302             # ... we don't use getprops() here because we only care about
303             # the writeable properties.
304             if _ALLOWSETTINGPRIVATEPROPS:
305                 prop = self.ruprops.get(key, None)
306                 if not prop:
307                     prop = self.privateprops[key]
308             else:
309                 prop = self.ruprops[key]
310             converter = _converters.get(prop.__class__, lambda v: v)
311             # if the value's the same as the existing value, no sense in
312             # doing anything
313             oldvalue = converter(getattr(row, key))
314             if  value == oldvalue:
315                 del propvalues[key]
316                 continue
317             
318             # check to make sure we're not duplicating an existing key
319             if key == self.keyname:
320                 iv = self.getindexview(1)
321                 ndx = iv.find(k=value)
322                 if ndx == -1:
323                     iv.append(k=value, i=row.id)
324                     if not isnew:
325                         ndx = iv.find(k=oldvalue)
326                         if ndx > -1:
327                             iv.delete(ndx)
328                 else:
329                     raise ValueError, 'node with key "%s" exists'%value
331             # do stuff based on the prop type
332             if isinstance(prop, hyperdb.Link):
333                 link_class = prop.classname
334                 # if it isn't a number, it's a key
335                 if type(value) != _STRINGTYPE:
336                     raise ValueError, 'link value must be String'
337                 try:
338                     int(value)
339                 except ValueError:
340                     try:
341                         value = self.db.getclass(link_class).lookup(value)
342                     except (TypeError, KeyError):
343                         raise IndexError, 'new property "%s": %s not a %s'%(
344                             key, value, prop.classname)
346                 if not self.db.getclass(link_class).hasnode(value):
347                     raise IndexError, '%s has no node %s'%(link_class, value)
349                 setattr(row, key, int(value))
350                 changes[key] = oldvalue
351                 
352                 if prop.do_journal:
353                     # register the unlink with the old linked node
354                     if oldvalue:
355                         self.db.addjournal(link_class, value, _UNLINK, (self.classname, str(row.id), key))
357                     # register the link with the newly linked node
358                     if value:
359                         self.db.addjournal(link_class, value, _LINK, (self.classname, str(row.id), key))
361             elif isinstance(prop, hyperdb.Multilink):
362                 if type(value) != _LISTTYPE:
363                     raise TypeError, 'new property "%s" not a list of ids'%key
364                 link_class = prop.classname
365                 l = []
366                 for entry in value:
367                     if type(entry) != _STRINGTYPE:
368                         raise ValueError, 'new property "%s" link value ' \
369                             'must be a string'%key
370                     # if it isn't a number, it's a key
371                     try:
372                         int(entry)
373                     except ValueError:
374                         try:
375                             entry = self.db.getclass(link_class).lookup(entry)
376                         except (TypeError, KeyError):
377                             raise IndexError, 'new property "%s": %s not a %s'%(
378                                 key, entry, prop.classname)
379                     l.append(entry)
380                 propvalues[key] = value = l
382                 # handle removals
383                 rmvd = []
384                 for id in oldvalue:
385                     if id not in value:
386                         rmvd.append(id)
387                         # register the unlink with the old linked node
388                         if prop.do_journal:
389                             self.db.addjournal(link_class, id, _UNLINK, (self.classname, str(row.id), key))
391                 # handle additions
392                 adds = []
393                 for id in value:
394                     if id not in oldvalue:
395                         if not self.db.getclass(link_class).hasnode(id):
396                             raise IndexError, '%s has no node %s'%(
397                                 link_class, id)
398                         adds.append(id)
399                         # register the link with the newly linked node
400                         if prop.do_journal:
401                             self.db.addjournal(link_class, id, _LINK, (self.classname, str(row.id), key))
402                             
403                 sv = getattr(row, key)
404                 i = 0
405                 while i < len(sv):
406                     if str(sv[i].fid) in rmvd:
407                         sv.delete(i)
408                     else:
409                         i += 1
410                 for id in adds:
411                     sv.append(fid=int(id))
412                 changes[key] = oldvalue
413                     
415             elif isinstance(prop, hyperdb.String):
416                 if value is not None and type(value) != _STRINGTYPE:
417                     raise TypeError, 'new property "%s" not a string'%key
418                 setattr(row, key, value)
419                 changes[key] = oldvalue
420                 if hasattr(prop, 'isfilename') and prop.isfilename:
421                     propvalues[key] = os.path.basename(value)
422                 if prop.indexme:
423                     self.db.indexer.add_text((self.classname, nodeid, key), value, 'text/plain')
425             elif isinstance(prop, hyperdb.Password):
426                 if not isinstance(value, password.Password):
427                     raise TypeError, 'new property "%s" not a Password'% key
428                 setattr(row, key, str(value))
429                 changes[key] = str(oldvalue)
430                 propvalues[key] = str(value)
432             elif value is not None and isinstance(prop, hyperdb.Date):
433                 if not isinstance(value, date.Date):
434                     raise TypeError, 'new property "%s" not a Date'% key
435                 setattr(row, key, int(calendar.timegm(value.get_tuple())))
436                 changes[key] = str(oldvalue)
437                 propvalues[key] = str(value)
439             elif value is not None and isinstance(prop, hyperdb.Interval):
440                 if not isinstance(value, date.Interval):
441                     raise TypeError, 'new property "%s" not an Interval'% key
442                 setattr(row, key, str(value))
443                 changes[key] = str(oldvalue)
444                 propvalues[key] = str(value)
446             oldnode[key] = oldvalue
448         # nothing to do?
449         if not propvalues:
450             return
451         if not row.activity:
452             row.activity = int(time.time())
453         if isnew:
454             if not row.creation:
455                 row.creation = int(time.time())
456             if not row.creator:
457                 row.creator = self.db.curuserid
458             
459         self.db.dirty = 1
460         if isnew:
461             self.db.addjournal(self.classname, nodeid, _CREATE, {})
462         else:
463             self.db.addjournal(self.classname, nodeid, _SET, changes)
465     def retire(self, nodeid):
466         view = self.getview(1)
467         ndx = view.find(id=int(nodeid))
468         if ndx < 0:
469             raise KeyError, "nodeid %s not found" % nodeid
470         row = view[ndx]
471         oldvalues = self.uncommitted.setdefault(row.id, {})
472         oldval = oldvalues['_isdel'] = row._isdel
473         row._isdel = 1
474         self.db.addjournal(self.classname, nodeid, _RETIRE, {})
475         iv = self.getindexview(1)
476         ndx = iv.find(k=getattr(row, self.keyname),i=row.id)
477         if ndx > -1:
478             iv.delete(ndx)
479         self.db.dirty = 1
480     def history(self, nodeid):
481         return self.db.gethistory(self.classname, nodeid)
482     def setkey(self, propname):
483         if self.keyname:
484             if propname == self.keyname:
485                 return
486             raise ValueError, "%s already indexed on %s" % (self.classname, self.keyname)
487         # first setkey for this run
488         self.keyname = propname
489         iv = self.db._db.view('_%s' % self.classname)
490         if self.db.fastopen or iv.structure():
491             return
492         # very first setkey ever
493         iv = self.db._db.getas('_%s[k:S,i:I]' % self.classname)
494         iv = iv.ordered(1)
495         #XXX
496 #        print "setkey building index"
497         for row in self.getview():
498             iv.append(k=getattr(row, propname), i=row.id)
499     def getkey(self):
500         return self.keyname
501     def lookup(self, keyvalue):
502         if type(keyvalue) is not _STRINGTYPE:
503             raise TypeError, "%r is not a string" % keyvalue
504         iv = self.getindexview()
505         if iv:
506             ndx = iv.find(k=keyvalue)
507             if ndx > -1:
508                 return str(iv[ndx].i)
509         else:
510             view = self.getview()
511             ndx = view.find({self.keyname:keyvalue, '_isdel':0})
512             if ndx > -1:
513                 return str(view[ndx].id)
514         raise KeyError, keyvalue
515     def find(self, **propspec):
516         """Get the ids of nodes in this class which link to the given nodes.
518         'propspec' consists of keyword args propname={nodeid:1,}   
519         'propname' must be the name of a property in this class, or a
520                    KeyError is raised.  That property must be a Link or
521                    Multilink property, or a TypeError is raised.
523         Any node in this class whose propname property links to any of the
524         nodeids will be returned. Used by the full text indexing, which knows
525         that "foo" occurs in msg1, msg3 and file7; so we have hits on these
526         issues:
528             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
530         """
531         propspec = propspec.items()
532         for propname, nodeid in propspec:
533             # check the prop is OK
534             prop = self.ruprops[propname]
535             if (not isinstance(prop, hyperdb.Link) and
536                     not isinstance(prop, hyperdb.Multilink)):
537                 raise TypeError, "'%s' not a Link/Multilink property"%propname
539         vws = []
540         for propname, ids in propspec:
541             if type(ids) is _STRINGTYPE:
542                 ids = {ids:1}
543             prop = self.ruprops[propname]
544             view = self.getview()
545             if isinstance(prop, hyperdb.Multilink):
546                 view = view.flatten(getattr(view, propname))
547                 def ff(row, nm=propname, ids=ids):
548                     return ids.has_key(str(row.fid))
549             else:
550                 def ff(row, nm=propname, ids=ids):
551                     return ids.has_key(str(getattr(row, nm)))
552             ndxview = view.filter(ff)
553             vws.append(ndxview.unique())
555         # handle the empty match case
556         if not vws:
557             return []
559         ndxview = vws[0]
560         for v in vws[1:]:
561             ndxview = ndxview.union(v)
562         view = view.remapwith(ndxview)
563         rslt = []
564         for row in view:
565             rslt.append(str(row.id))
566         return rslt
567             
569     def list(self):
570         l = []
571         for row in self.getview().select(_isdel=0):
572             l.append(str(row.id))
573         return l
574     def count(self):
575         return len(self.getview())
576     def getprops(self, protected=1):
577         # protected is not in ping's spec
578         allprops = self.ruprops.copy()
579         if protected and self.privateprops is not None:
580             allprops.update(self.privateprops)
581         return allprops
582     def addprop(self, **properties):
583         for key in properties.keys():
584             if self.ruprops.has_key(key):
585                 raise ValueError, "%s is already a property of %s" % (key, self.classname)
586         self.ruprops.update(properties)
587         view = self.__getview()
588     # ---- end of ping's spec
589     def filter(self, search_matches, filterspec, sort, group):
590         # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
591         # filterspec is a dict {propname:value}
592         # sort and group are lists of propnames
593         
594         where = {'_isdel':0}
595         mlcriteria = {}
596         regexes = {}
597         orcriteria = {}
598         for propname, value in filterspec.items():
599             prop = self.ruprops.get(propname, None)
600             if prop is None:
601                 prop = self.privateprops[propname]
602             if isinstance(prop, hyperdb.Multilink):
603                 if type(value) is not _LISTTYPE:
604                     value = [value]
605                 # transform keys to ids
606                 u = []
607                 for item in value:
608                     try:
609                         item = int(item)
610                     except (TypeError, ValueError):
611                         item = int(self.db.getclass(prop.classname).lookup(item))
612                     if item == -1:
613                         item = 0
614                     u.append(item)
615                 mlcriteria[propname] = u
616             elif isinstance(prop, hyperdb.Link):
617                 if type(value) is not _LISTTYPE:
618                     value = [value]
619                 # transform keys to ids
620                 u = []
621                 for item in value:
622                     try:
623                         item = int(item)
624                     except (TypeError, ValueError):
625                         item = int(self.db.getclass(prop.classname).lookup(item))
626                     if item == -1:
627                         item = 0
628                     u.append(item)
629                 if len(u) == 1:
630                     where[propname] = u[0]
631                 else:
632                     orcriteria[propname] = u
633             elif isinstance(prop, hyperdb.String):
634                 # simple glob searching
635                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', value)
636                 v = v.replace('?', '.')
637                 v = v.replace('*', '.*?')
638                 regexes[propname] = re.compile(v, re.I)
639             elif propname == 'id':
640                 where[propname] = int(value)
641             else:
642                 where[propname] = str(value)
643         v = self.getview()
644         #print "filter start at  %s" % time.time() 
645         if where:
646             v = v.select(where)
647         #print "filter where at  %s" % time.time() 
648             
649         if mlcriteria:
650                     # multilink - if any of the nodeids required by the
651                     # filterspec aren't in this node's property, then skip
652                     # it
653             def ff(row, ml=mlcriteria):
654                 for propname, values in ml.items():
655                     sv = getattr(row, propname)
656                     for id in values:
657                         if sv.find(fid=id) == -1:
658                             return 0
659                 return 1
660             iv = v.filter(ff)
661             v = v.remapwith(iv)
663         #print "filter mlcrit at %s" % time.time() 
664         
665         if orcriteria:
666             def ff(row, crit=orcriteria):
667                 for propname, allowed in crit.items():
668                     val = getattr(row, propname)
669                     if val not in allowed:
670                         return 0
671                 return 1
672             
673             iv = v.filter(ff)
674             v = v.remapwith(iv)
675         
676         #print "filter orcrit at %s" % time.time() 
677         if regexes:
678             def ff(row, r=regexes):
679                 for propname, regex in r.items():
680                     val = getattr(row, propname)
681                     if not regex.search(val):
682                         return 0
683                 return 1
684             
685             iv = v.filter(ff)
686             v = v.remapwith(iv)
687         #print "filter regexs at %s" % time.time() 
688         
689         if sort or group:
690             sortspec = []
691             rev = []
692             for propname in group + sort:
693                 isreversed = 0
694                 if propname[0] == '-':
695                     propname = propname[1:]
696                     isreversed = 1
697                 try:
698                     prop = getattr(v, propname)
699                 except AttributeError:
700                     # I can't sort on 'activity', cause it's psuedo!!
701                     continue
702                 if isreversed:
703                     rev.append(prop)
704                 sortspec.append(prop)
705             v = v.sortrev(sortspec, rev)[:] #XXX Aaaabh
706         #print "filter sort   at %s" % time.time() 
707             
708         rslt = []
709         for row in v:
710             id = str(row.id)
711             if search_matches is not None:
712                 if search_matches.has_key(id):
713                     rslt.append(id)
714             else:
715                 rslt.append(id)
716         return rslt
717     
718     def hasnode(self, nodeid):
719         return int(nodeid) < self.maxid
720     
721     def labelprop(self, default_to_id=0):
722         ''' Return the property name for a label for the given node.
724         This method attempts to generate a consistent label for the node.
725         It tries the following in order:
726             1. key property
727             2. "name" property
728             3. "title" property
729             4. first property from the sorted property name list
730         '''
731         k = self.getkey()
732         if  k:
733             return k
734         props = self.getprops()
735         if props.has_key('name'):
736             return 'name'
737         elif props.has_key('title'):
738             return 'title'
739         if default_to_id:
740             return 'id'
741         props = props.keys()
742         props.sort()
743         return props[0]
744     def stringFind(self, **requirements):
745         """Locate a particular node by matching a set of its String
746         properties in a caseless search.
748         If the property is not a String property, a TypeError is raised.
749         
750         The return is a list of the id of all nodes that match.
751         """
752         for propname in requirements.keys():
753             prop = self.properties[propname]
754             if isinstance(not prop, hyperdb.String):
755                 raise TypeError, "'%s' not a String property"%propname
756             requirements[propname] = requirements[propname].lower()
757         requirements['_isdel'] = 0
758         
759         l = []
760         for row in self.getview().select(requirements):
761             l.append(str(row.id))
762         return l
764     def addjournal(self, nodeid, action, params):
765         self.db.addjournal(self.classname, nodeid, action, params)
767     def index(self, nodeid):
768         ''' Add (or refresh) the node to search indexes '''
769         # find all the String properties that have indexme
770         for prop, propclass in self.getprops().items():
771             if isinstance(propclass, hyperdb.String) and propclass.indexme:
772                 # index them under (classname, nodeid, property)
773                 self.db.indexer.add_text((self.classname, nodeid, prop),
774                                 str(self.get(nodeid, prop)))
776     # --- used by Database
777     def _commit(self):
778         """ called post commit of the DB.
779             interested subclasses may override """
780         self.uncommitted = {}
781         self.rbactions = []
782         self.idcache = {}
783     def _rollback(self):  
784         """ called pre rollback of the DB.
785             interested subclasses may override """
786         for action in self.rbactions:
787             action()
788         self.rbactions = []
789         self.uncommitted = {}
790         self.idcache = {}
791     def _clear(self):
792         view = self.getview(1)
793         if len(view):
794             view[:] = []
795             self.db.dirty = 1
796         iv = self.getindexview(1)
797         if iv:
798             iv[:] = []
799     def rollbackaction(self, action):
800         """ call this to register a callback called on rollback
801             callback is removed on end of transaction """
802         self.rbactions.append(action)
803     # --- internal
804     def __getview(self):
805         db = self.db._db
806         view = db.view(self.classname)
807         if self.db.fastopen:
808             return view.ordered(1)
809         # is the definition the same?
810         mkprops = view.structure()
811         for nm, rutyp in self.ruprops.items():
812             for mkprop in mkprops:
813                 if mkprop.name == nm:
814                     break
815             else:
816                 mkprop = None
817             if mkprop is None:
818                 #print "%s missing prop %s (%s)" % (self.classname, nm, rutyp.__class__.__name__)
819                 break
820             if _typmap[rutyp.__class__] != mkprop.type:
821                 #print "%s - prop %s (%s) has wrong mktyp (%s)" % (self.classname, nm, rutyp.__class__.__name__, mkprop.type)
822                 break
823         else:
824             return view.ordered(1)
825         # need to create or restructure the mk view
826         # id comes first, so MK will order it for us
827         self.db.dirty = 1
828         s = ["%s[id:I" % self.classname]
829         for nm, rutyp in self.ruprops.items():
830             mktyp = _typmap[rutyp.__class__]
831             s.append('%s:%s' % (nm, mktyp))
832             if mktyp == 'V':
833                 s[-1] += ('[fid:I]')
834         s.append('_isdel:I,activity:I,creation:I,creator:I]')
835         v = db.getas(','.join(s))
836         return v.ordered(1)
837     def getview(self, RW=0):
838         if RW and self.db.isReadOnly():
839             self.db.getWriteAccess()
840         return self.db._db.view(self.classname).ordered(1)
841     def getindexview(self, RW=0):
842         if RW and self.db.isReadOnly():
843             self.db.getWriteAccess()
844         return self.db._db.view("_%s" % self.classname).ordered(1)
845     
846 def _fetchML(sv):
847     l = []
848     for row in sv:
849         if row.fid:
850             l.append(str(row.fid))
851     return l
853 def _fetchPW(s):
854     p = password.Password()
855     p.unpack(s)
856     return p
858 def _fetchLink(n):
859     return n and str(n) or None
861 def _fetchDate(n):
862     return date.Date(time.gmtime(n))
864 _converters = {
865     hyperdb.Date   : _fetchDate,
866     hyperdb.Link   : _fetchLink,
867     hyperdb.Multilink : _fetchML,
868     hyperdb.Interval  : date.Interval,
869     hyperdb.Password  : _fetchPW,
870 }                
872 class FileName(hyperdb.String):
873     isfilename = 1            
875 _typmap = {
876     FileName : 'S',
877     hyperdb.String : 'S',
878     hyperdb.Date   : 'I',
879     hyperdb.Link   : 'I',
880     hyperdb.Multilink : 'V',
881     hyperdb.Interval  : 'S',
882     hyperdb.Password  : 'S',
884 class FileClass(Class):
885     ' like Class but with a content property '
886     default_mime_type = 'text/plain'
887     def __init__(self, db, classname, **properties):
888         properties['content'] = FileName()
889         if not properties.has_key('type'):
890             properties['type'] = hyperdb.String()
891         Class.__init__(self, db, classname, **properties)
892     def get(self, nodeid, propname, default=_marker, cache=1):
893         x = Class.get(self, nodeid, propname, default, cache)
894         if propname == 'content':
895             if x.startswith('file:'):
896                 fnm = x[5:]
897                 try:
898                     x = open(fnm, 'rb').read()
899                 except Exception, e:
900                     x = repr(e)
901         return x
902     def create(self, **propvalues):
903         content = propvalues['content']
904         del propvalues['content']
905         newid = Class.create(self, **propvalues)
906         if not content:
907             return newid
908         if content.startswith('/tracker/download.php?'):
909             self.set(newid, content='http://sourceforge.net'+content)
910             return newid
911         nm = bnm = '%s%s' % (self.classname, newid)
912         sd = str(int(int(newid) / 1000))
913         d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
914         if not os.path.exists(d):
915             os.makedirs(d)
916         nm = os.path.join(d, nm)
917         open(nm, 'wb').write(content)
918         self.set(newid, content = 'file:'+nm)
919         mimetype = propvalues.get('type', self.default_mime_type)
920         self.db.indexer.add_text((self.classname, newid, 'content'), content, mimetype)
921         def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer):
922             remove(fnm)
923         self.rollbackaction(undo)
924         return newid
925     def index(self, nodeid):
926         Class.index(self, nodeid)
927         mimetype = self.get(nodeid, 'type')
928         if not mimetype:
929             mimetype = self.default_mime_type
930         self.db.indexer.add_text((self.classname, nodeid, 'content'),
931                     self.get(nodeid, 'content'), mimetype)
932  
933 # Yuck - c&p to avoid getting hyperdb.Class
934 class IssueClass(Class):
936     # Overridden methods:
938     def __init__(self, db, classname, **properties):
939         """The newly-created class automatically includes the "messages",
940         "files", "nosy", and "superseder" properties.  If the 'properties'
941         dictionary attempts to specify any of these properties or a
942         "creation" or "activity" property, a ValueError is raised."""
943         if not properties.has_key('title'):
944             properties['title'] = hyperdb.String(indexme='yes')
945         if not properties.has_key('messages'):
946             properties['messages'] = hyperdb.Multilink("msg")
947         if not properties.has_key('files'):
948             properties['files'] = hyperdb.Multilink("file")
949         if not properties.has_key('nosy'):
950             properties['nosy'] = hyperdb.Multilink("user")
951         if not properties.has_key('superseder'):
952             properties['superseder'] = hyperdb.Multilink(classname)
953         Class.__init__(self, db, classname, **properties)
955     # New methods:
957     def addmessage(self, nodeid, summary, text):
958         """Add a message to an issue's mail spool.
960         A new "msg" node is constructed using the current date, the user that
961         owns the database connection as the author, and the specified summary
962         text.
964         The "files" and "recipients" fields are left empty.
966         The given text is saved as the body of the message and the node is
967         appended to the "messages" field of the specified issue.
968         """
970     def nosymessage(self, nodeid, msgid, oldvalues):
971         """Send a message to the members of an issue's nosy list.
973         The message is sent only to users on the nosy list who are not
974         already on the "recipients" list for the message.
975         
976         These users are then added to the message's "recipients" list.
977         """
978         users = self.db.user
979         messages = self.db.msg
981         # figure the recipient ids
982         sendto = []
983         r = {}
984         recipients = messages.get(msgid, 'recipients')
985         for recipid in messages.get(msgid, 'recipients'):
986             r[recipid] = 1
988         # figure the author's id, and indicate they've received the message
989         authid = messages.get(msgid, 'author')
991         # possibly send the message to the author, as long as they aren't
992         # anonymous
993         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
994                 users.get(authid, 'username') != 'anonymous'):
995             sendto.append(authid)
996         r[authid] = 1
998         # now figure the nosy people who weren't recipients
999         nosy = self.get(nodeid, 'nosy')
1000         for nosyid in nosy:
1001             # Don't send nosy mail to the anonymous user (that user
1002             # shouldn't appear in the nosy list, but just in case they
1003             # do...)
1004             if users.get(nosyid, 'username') == 'anonymous':
1005                 continue
1006             # make sure they haven't seen the message already
1007             if not r.has_key(nosyid):
1008                 # send it to them
1009                 sendto.append(nosyid)
1010                 recipients.append(nosyid)
1012         # generate a change note
1013         if oldvalues:
1014             note = self.generateChangeNote(nodeid, oldvalues)
1015         else:
1016             note = self.generateCreateNote(nodeid)
1018         # we have new recipients
1019         if sendto:
1020             # map userids to addresses
1021             sendto = [users.get(i, 'address') for i in sendto]
1023             # update the message's recipients list
1024             messages.set(msgid, recipients=recipients)
1026             # send the message
1027             self.send_message(nodeid, msgid, note, sendto)
1029     # XXX backwards compatibility - don't remove
1030     sendmessage = nosymessage
1032     def send_message(self, nodeid, msgid, note, sendto):
1033         '''Actually send the nominated message from this node to the sendto
1034            recipients, with the note appended.
1035         '''
1036         users = self.db.user
1037         messages = self.db.msg
1038         files = self.db.file
1040         # determine the messageid and inreplyto of the message
1041         inreplyto = messages.get(msgid, 'inreplyto')
1042         messageid = messages.get(msgid, 'messageid')
1044         # make up a messageid if there isn't one (web edit)
1045         if not messageid:
1046             # this is an old message that didn't get a messageid, so
1047             # create one
1048             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
1049                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
1050             messages.set(msgid, messageid=messageid)
1052         # send an email to the people who missed out
1053         cn = self.classname
1054         title = self.get(nodeid, 'title') or '%s message copy'%cn
1055         # figure author information
1056         authid = messages.get(msgid, 'author')
1057         authname = users.get(authid, 'realname')
1058         if not authname:
1059             authname = users.get(authid, 'username')
1060         authaddr = users.get(authid, 'address')
1061         if authaddr:
1062             authaddr = ' <%s>'%authaddr
1063         else:
1064             authaddr = ''
1066         # make the message body
1067         m = ['']
1069         # put in roundup's signature
1070         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
1071             m.append(self.email_signature(nodeid, msgid))
1073         # add author information
1074         if len(self.get(nodeid,'messages')) == 1:
1075             m.append("New submission from %s%s:"%(authname, authaddr))
1076         else:
1077             m.append("%s%s added the comment:"%(authname, authaddr))
1078         m.append('')
1080         # add the content
1081         m.append(messages.get(msgid, 'content'))
1083         # add the change note
1084         if note:
1085             m.append(note)
1087         # put in roundup's signature
1088         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
1089             m.append(self.email_signature(nodeid, msgid))
1091         # encode the content as quoted-printable
1092         content = cStringIO.StringIO('\n'.join(m))
1093         content_encoded = cStringIO.StringIO()
1094         quopri.encode(content, content_encoded, 0)
1095         content_encoded = content_encoded.getvalue()
1097         # get the files for this message
1098         message_files = messages.get(msgid, 'files')
1100         # make sure the To line is always the same (for testing mostly)
1101         sendto.sort()
1103         # create the message
1104         message = cStringIO.StringIO()
1105         writer = MimeWriter.MimeWriter(message)
1106         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
1107         writer.addheader('To', ', '.join(sendto))
1108         writer.addheader('From', '%s <%s>'%(authname,
1109             self.db.config.ISSUE_TRACKER_EMAIL))
1110         writer.addheader('Reply-To', '%s <%s>'%(self.db.config.INSTANCE_NAME,
1111             self.db.config.ISSUE_TRACKER_EMAIL))
1112         writer.addheader('MIME-Version', '1.0')
1113         if messageid:
1114             writer.addheader('Message-Id', messageid)
1115         if inreplyto:
1116             writer.addheader('In-Reply-To', inreplyto)
1118         # add a uniquely Roundup header to help filtering
1119         writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
1121         # attach files
1122         if message_files:
1123             part = writer.startmultipartbody('mixed')
1124             part = writer.nextpart()
1125             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
1126             body = part.startbody('text/plain')
1127             body.write(content_encoded)
1128             for fileid in message_files:
1129                 name = files.get(fileid, 'name')
1130                 mime_type = files.get(fileid, 'type')
1131                 content = files.get(fileid, 'content')
1132                 part = writer.nextpart()
1133                 if mime_type == 'text/plain':
1134                     part.addheader('Content-Disposition',
1135                         'attachment;\n filename="%s"'%name)
1136                     part.addheader('Content-Transfer-Encoding', '7bit')
1137                     body = part.startbody('text/plain')
1138                     body.write(content)
1139                 else:
1140                     # some other type, so encode it
1141                     if not mime_type:
1142                         # this should have been done when the file was saved
1143                         mime_type = mimetypes.guess_type(name)[0]
1144                     if mime_type is None:
1145                         mime_type = 'application/octet-stream'
1146                     part.addheader('Content-Disposition',
1147                         'attachment;\n filename="%s"'%name)
1148                     part.addheader('Content-Transfer-Encoding', 'base64')
1149                     body = part.startbody(mime_type)
1150                     body.write(base64.encodestring(content))
1151             writer.lastpart()
1152         else:
1153             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
1154             body = writer.startbody('text/plain')
1155             body.write(content_encoded)
1157         # now try to send the message
1158         if SENDMAILDEBUG:
1159             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
1160                 self.db.config.ADMIN_EMAIL,
1161                 ', '.join(sendto),message.getvalue()))
1162         else:
1163             try:
1164                 # send the message as admin so bounces are sent there
1165                 # instead of to roundup
1166                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
1167                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
1168                     message.getvalue())
1169             except socket.error, value:
1170                 raise MessageSendError, \
1171                     "Couldn't send confirmation email: mailhost %s"%value
1172             except smtplib.SMTPException, value:
1173                 raise MessageSendError, \
1174                     "Couldn't send confirmation email: %s"%value
1176     def email_signature(self, nodeid, msgid):
1177         ''' Add a signature to the e-mail with some useful information
1178         '''
1179         web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid
1180         email = '"%s" <%s>'%(self.db.config.INSTANCE_NAME,
1181             self.db.config.ISSUE_TRACKER_EMAIL)
1182         line = '_' * max(len(web), len(email))
1183         return '%s\n%s\n%s\n%s'%(line, email, web, line)
1185     def generateCreateNote(self, nodeid):
1186         """Generate a create note that lists initial property values
1187         """
1188         cn = self.classname
1189         cl = self.db.classes[cn]
1190         props = cl.getprops(protected=0)
1192         # list the values
1193         m = []
1194         l = props.items()
1195         l.sort()
1196         for propname, prop in l:
1197             value = cl.get(nodeid, propname, None)
1198             # skip boring entries
1199             if not value:
1200                 continue
1201             if isinstance(prop, hyperdb.Link):
1202                 link = self.db.classes[prop.classname]
1203                 if value:
1204                     key = link.labelprop(default_to_id=1)
1205                     if key:
1206                         value = link.get(value, key)
1207                 else:
1208                     value = ''
1209             elif isinstance(prop, hyperdb.Multilink):
1210                 if value is None: value = []
1211                 l = []
1212                 link = self.db.classes[prop.classname]
1213                 key = link.labelprop(default_to_id=1)
1214                 if key:
1215                     value = [link.get(entry, key) for entry in value]
1216                 value.sort()
1217                 value = ', '.join(value)
1218             m.append('%s: %s'%(propname, value))
1219         m.insert(0, '----------')
1220         m.insert(0, '')
1221         return '\n'.join(m)
1223     def generateChangeNote(self, nodeid, oldvalues):
1224         """Generate a change note that lists property changes
1225         """
1226         cn = self.classname
1227         cl = self.db.classes[cn]
1228         changed = {}
1229         props = cl.getprops(protected=0)
1231         # determine what changed
1232         for key in oldvalues.keys():
1233             if key in ['files','messages']: continue
1234             new_value = cl.get(nodeid, key)
1235             # the old value might be non existent
1236             try:
1237                 old_value = oldvalues[key]
1238                 if type(new_value) is type([]):
1239                     new_value.sort()
1240                     old_value.sort()
1241                 if new_value != old_value:
1242                     changed[key] = old_value
1243             except:
1244                 changed[key] = new_value
1246         # list the changes
1247         m = []
1248         l = changed.items()
1249         l.sort()
1250         for propname, oldvalue in l:
1251             prop = props[propname]
1252             value = cl.get(nodeid, propname, None)
1253             if isinstance(prop, hyperdb.Link):
1254                 link = self.db.classes[prop.classname]
1255                 key = link.labelprop(default_to_id=1)
1256                 if key:
1257                     if value:
1258                         value = link.get(value, key)
1259                     else:
1260                         value = ''
1261                     if oldvalue:
1262                         oldvalue = link.get(oldvalue, key)
1263                     else:
1264                         oldvalue = ''
1265                 change = '%s -> %s'%(oldvalue, value)
1266             elif isinstance(prop, hyperdb.Multilink):
1267                 change = ''
1268                 if value is None: value = []
1269                 if oldvalue is None: oldvalue = []
1270                 l = []
1271                 link = self.db.classes[prop.classname]
1272                 key = link.labelprop(default_to_id=1)
1273                 # check for additions
1274                 for entry in value:
1275                     if entry in oldvalue: continue
1276                     if key:
1277                         l.append(link.get(entry, key))
1278                     else:
1279                         l.append(entry)
1280                 if l:
1281                     change = '+%s'%(', '.join(l))
1282                     l = []
1283                 # check for removals
1284                 for entry in oldvalue:
1285                     if entry in value: continue
1286                     if key:
1287                         l.append(link.get(entry, key))
1288                     else:
1289                         l.append(entry)
1290                 if l:
1291                     change += ' -%s'%(', '.join(l))
1292             else:
1293                 change = '%s -> %s'%(oldvalue, value)
1294             m.append('%s: %s'%(propname, change))
1295         if m:
1296             m.insert(0, '----------')
1297             m.insert(0, '')
1298         return '\n'.join(m)