Code

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