Code

...except of course it's nice to use valid Python syntax
[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         self.db.dirty = 1
456         if isnew:
457             self.db.addjournal(self.classname, nodeid, _CREATE, {})
458         else:
459             self.db.addjournal(self.classname, nodeid, _SET, changes)
461     def retire(self, nodeid):
462         view = self.getview(1)
463         ndx = view.find(id=int(nodeid))
464         if ndx < 0:
465             raise KeyError, "nodeid %s not found" % nodeid
466         row = view[ndx]
467         oldvalues = self.uncommitted.setdefault(row.id, {})
468         oldval = oldvalues['_isdel'] = row._isdel
469         row._isdel = 1
470         self.db.addjournal(self.classname, nodeid, _RETIRE, {})
471         iv = self.getindexview(1)
472         ndx = iv.find(k=getattr(row, self.keyname),i=row.id)
473         if ndx > -1:
474             iv.delete(ndx)
475         self.db.dirty = 1
476     def history(self, nodeid):
477         return self.db.gethistory(self.classname, nodeid)
478     def setkey(self, propname):
479         if self.keyname:
480             if propname == self.keyname:
481                 return
482             raise ValueError, "%s already indexed on %s" % (self.classname, self.keyname)
483         # first setkey for this run
484         self.keyname = propname
485         iv = self.db._db.view('_%s' % self.classname)
486         if self.db.fastopen or iv.structure():
487             return
488         # very first setkey ever
489         iv = self.db._db.getas('_%s[k:S,i:I]' % self.classname)
490         iv = iv.ordered(1)
491         #XXX
492         print "setkey building index"
493         for row in self.getview():
494             iv.append(k=getattr(row, propname), i=row.id)
495     def getkey(self):
496         return self.keyname
497     def lookup(self, keyvalue):
498         if type(keyvalue) is not _STRINGTYPE:
499             raise TypeError, "%r is not a string" % keyvalue
500         iv = self.getindexview()
501         if iv:
502             ndx = iv.find(k=keyvalue)
503             if ndx > -1:
504                 return str(iv[ndx].i)
505         else:
506             view = self.getview()
507             ndx = view.find({self.keyname:keyvalue, '_isdel':0})
508             if ndx > -1:
509                 return str(view[ndx].id)
510         raise KeyError, keyvalue
511     def find(self, **propspec):
512         """Get the ids of nodes in this class which link to the given nodes.
514         'propspec' consists of keyword args propname={nodeid:1,}   
515           'propname' must be the name of a property in this class, or a
516             KeyError is raised.  That property must be a Link or Multilink
517             property, or a TypeError is raised.
518         Any node in this class whose propname property links to any of the
519         nodeids will be returned. Used by the full text indexing, which knows
520         that "foo" occurs in msg1, msg3 and file7; so we have hits on these issues:
521             db.issue.find(messages={'1':1,'3':1}, files={'7':1})
522         """
523         propspec = propspec.items()
524         for propname, nodeid in propspec:
525             # check the prop is OK
526             prop = self.ruprops[propname]
527             if not isinstance(prop, hyperdb.Link) and not isinstance(prop, hyperdb.Multilink):
528                 raise TypeError, "'%s' not a Link/Multilink property"%propname
530         vws = []
531         for propname, ids in propspec:
532             if type(ids) is _STRINGTYPE:
533                 ids = {ids:1}
534             prop = self.ruprops[propname]
535             view = self.getview()
536             if isinstance(prop, hyperdb.Multilink):
537                 view = view.flatten(getattr(view, propname))
538                 def ff(row, nm=propname, ids=ids):
539                     return ids.has_key(str(row.fid))
540             else:
541                 def ff(row, nm=propname, ids=ids):
542                     return ids.has_key(str(getattr(row, nm)))
543             ndxview = view.filter(ff)
544             vws.append(ndxview.unique())
545         ndxview = vws[0]
546         for v in vws[1:]:
547             ndxview = ndxview.union(v)
548         view = view.remapwith(ndxview)
549         rslt = []
550         for row in view:
551             rslt.append(str(row.id))
552         return rslt
553             
555     def list(self):
556         l = []
557         for row in self.getview().select(_isdel=0):
558             l.append(str(row.id))
559         return l
560     def count(self):
561         return len(self.getview())
562     def getprops(self, protected=1):
563         # protected is not in ping's spec
564         allprops = self.ruprops.copy()
565         if protected and self.privateprops is not None:
566             allprops.update(self.privateprops)
567         return allprops
568     def addprop(self, **properties):
569         for key in properties.keys():
570             if self.ruprops.has_key(key):
571                 raise ValueError, "%s is already a property of %s" % (key, self.classname)
572         self.ruprops.update(properties)
573         view = self.__getview()
574     # ---- end of ping's spec
575     def filter(self, search_matches, filterspec, sort, group):
576         # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
577         # filterspec is a dict {propname:value}
578         # sort and group are lists of propnames
579         
580         where = {'_isdel':0}
581         mlcriteria = {}
582         regexes = {}
583         orcriteria = {}
584         for propname, value in filterspec.items():
585             prop = self.ruprops.get(propname, None)
586             if prop is None:
587                 prop = self.privateprops[propname]
588             if isinstance(prop, hyperdb.Multilink):
589                 if type(value) is not _LISTTYPE:
590                     value = [value]
591                 # transform keys to ids
592                 u = []
593                 for item in value:
594                     try:
595                         item = int(item)
596                     except (TypeError, ValueError):
597                         item = int(self.db.getclass(prop.classname).lookup(item))
598                     if item == -1:
599                         item = 0
600                     u.append(item)
601                 mlcriteria[propname] = u
602             elif isinstance(prop, hyperdb.Link):
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                 if len(u) == 1:
616                     where[propname] = u[0]
617                 else:
618                     orcriteria[propname] = u
619             elif isinstance(prop, hyperdb.String):
620                 # simple glob searching
621                 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', value)
622                 v = v.replace('?', '.')
623                 v = v.replace('*', '.*?')
624                 regexes[propname] = re.compile(v, re.I)
625             elif propname == 'id':
626                 where[propname] = int(value)
627             else:
628                 where[propname] = str(value)
629         v = self.getview()
630         #print "filter start at  %s" % time.time() 
631         if where:
632             v = v.select(where)
633         #print "filter where at  %s" % time.time() 
634             
635         if mlcriteria:
636                     # multilink - if any of the nodeids required by the
637                     # filterspec aren't in this node's property, then skip
638                     # it
639             def ff(row, ml=mlcriteria):
640                 for propname, values in ml.items():
641                     sv = getattr(row, propname)
642                     for id in values:
643                         if sv.find(fid=id) == -1:
644                             return 0
645                 return 1
646             iv = v.filter(ff)
647             v = v.remapwith(iv)
649         #print "filter mlcrit at %s" % time.time() 
650         
651         if orcriteria:
652             def ff(row, crit=orcriteria):
653                 for propname, allowed in crit.items():
654                     val = getattr(row, propname)
655                     if val not in allowed:
656                         return 0
657                 return 1
658             
659             iv = v.filter(ff)
660             v = v.remapwith(iv)
661         
662         #print "filter orcrit at %s" % time.time() 
663         if regexes:
664             def ff(row, r=regexes):
665                 for propname, regex in r.items():
666                     val = getattr(row, propname)
667                     if not regex.search(val):
668                         return 0
669                 return 1
670             
671             iv = v.filter(ff)
672             v = v.remapwith(iv)
673         #print "filter regexs at %s" % time.time() 
674         
675         if sort or group:
676             sortspec = []
677             rev = []
678             for propname in group + sort:
679                 isreversed = 0
680                 if propname[0] == '-':
681                     propname = propname[1:]
682                     isreversed = 1
683                 try:
684                     prop = getattr(v, propname)
685                 except AttributeError:
686                     # I can't sort on 'activity', cause it's psuedo!!
687                     continue
688                 if isreversed:
689                     rev.append(prop)
690                 sortspec.append(prop)
691             v = v.sortrev(sortspec, rev)[:] #XXX Aaaabh
692         #print "filter sort   at %s" % time.time() 
693             
694         rslt = []
695         for row in v:
696             id = str(row.id)
697             if search_matches is not None:
698                 if search_matches.has_key(id):
699                     rslt.append(id)
700             else:
701                 rslt.append(id)
702         return rslt
703     
704     def hasnode(self, nodeid):
705         return int(nodeid) < self.maxid
706     
707     def labelprop(self, default_to_id=0):
708         ''' Return the property name for a label for the given node.
710         This method attempts to generate a consistent label for the node.
711         It tries the following in order:
712             1. key property
713             2. "name" property
714             3. "title" property
715             4. first property from the sorted property name list
716         '''
717         k = self.getkey()
718         if  k:
719             return k
720         props = self.getprops()
721         if props.has_key('name'):
722             return 'name'
723         elif props.has_key('title'):
724             return 'title'
725         if default_to_id:
726             return 'id'
727         props = props.keys()
728         props.sort()
729         return props[0]
730     def stringFind(self, **requirements):
731         """Locate a particular node by matching a set of its String
732         properties in a caseless search.
734         If the property is not a String property, a TypeError is raised.
735         
736         The return is a list of the id of all nodes that match.
737         """
738         for propname in requirements.keys():
739             prop = self.properties[propname]
740             if isinstance(not prop, hyperdb.String):
741                 raise TypeError, "'%s' not a String property"%propname
742             requirements[propname] = requirements[propname].lower()
743         requirements['_isdel'] = 0
744         
745         l = []
746         for row in self.getview().select(requirements):
747             l.append(str(row.id))
748         return l
750     def addjournal(self, nodeid, action, params):
751         self.db.addjournal(self.classname, nodeid, action, params)
753     def index(self, nodeid):
754         ''' Add (or refresh) the node to search indexes '''
755         # find all the String properties that have indexme
756         for prop, propclass in self.getprops().items():
757             if isinstance(propclass, hyperdb.String) and propclass.indexme:
758                 # index them under (classname, nodeid, property)
759                 self.db.indexer.add_text((self.classname, nodeid, prop),
760                                 str(self.get(nodeid, prop)))
762     # --- used by Database
763     def _commit(self):
764         """ called post commit of the DB.
765             interested subclasses may override """
766         self.uncommitted = {}
767         self.rbactions = []
768         self.idcache = {}
769     def _rollback(self):  
770         """ called pre rollback of the DB.
771             interested subclasses may override """
772         for action in self.rbactions:
773             action()
774         self.rbactions = []
775         self.uncommitted = {}
776         self.idcache = {}
777     def _clear(self):
778         view = self.getview(1)
779         if len(view):
780             view[:] = []
781             self.db.dirty = 1
782         iv = self.getindexview(1)
783         if iv:
784             iv[:] = []
785     def rollbackaction(self, action):
786         """ call this to register a callback called on rollback
787             callback is removed on end of transaction """
788         self.rbactions.append(action)
789     # --- internal
790     def __getview(self):
791         db = self.db._db
792         view = db.view(self.classname)
793         if self.db.fastopen:
794             return view.ordered(1)
795         # is the definition the same?
796         mkprops = view.structure()
797         for nm, rutyp in self.ruprops.items():
798             for mkprop in mkprops:
799                 if mkprop.name == nm:
800                     break
801             else:
802                 mkprop = None
803             if mkprop is None:
804                 #print "%s missing prop %s (%s)" % (self.classname, nm, rutyp.__class__.__name__)
805                 break
806             if _typmap[rutyp.__class__] != mkprop.type:
807                 #print "%s - prop %s (%s) has wrong mktyp (%s)" % (self.classname, nm, rutyp.__class__.__name__, mkprop.type)
808                 break
809         else:
810             return view.ordered(1)
811         # need to create or restructure the mk view
812         # id comes first, so MK will order it for us
813         self.db.dirty = 1
814         s = ["%s[id:I" % self.classname]
815         for nm, rutyp in self.ruprops.items():
816             mktyp = _typmap[rutyp.__class__]
817             s.append('%s:%s' % (nm, mktyp))
818             if mktyp == 'V':
819                 s[-1] += ('[fid:I]')
820         s.append('_isdel:I,activity:I,creation:I,creator:I]')
821         v = db.getas(','.join(s))
822         return v.ordered(1)
823     def getview(self, RW=0):
824         if RW and self.db.isReadOnly():
825             self.db.getWriteAccess()
826         return self.db._db.view(self.classname).ordered(1)
827     def getindexview(self, RW=0):
828         if RW and self.db.isReadOnly():
829             self.db.getWriteAccess()
830         return self.db._db.view("_%s" % self.classname).ordered(1)
831     
832 def _fetchML(sv):
833     l = []
834     for row in sv:
835         if row.fid:
836             l.append(str(row.fid))
837     return l
839 def _fetchPW(s):
840     p = password.Password()
841     p.unpack(s)
842     return p
844 def _fetchLink(n):
845     return n and str(n) or None
847 def _fetchDate(n):
848     return date.Date(time.gmtime(n))
850 _converters = {
851     hyperdb.Date   : _fetchDate,
852     hyperdb.Link   : _fetchLink,
853     hyperdb.Multilink : _fetchML,
854     hyperdb.Interval  : date.Interval,
855     hyperdb.Password  : _fetchPW,
856 }                
858 class FileName(hyperdb.String):
859     isfilename = 1            
861 _typmap = {
862     FileName : 'S',
863     hyperdb.String : 'S',
864     hyperdb.Date   : 'I',
865     hyperdb.Link   : 'I',
866     hyperdb.Multilink : 'V',
867     hyperdb.Interval  : 'S',
868     hyperdb.Password  : 'S',
870 class FileClass(Class):
871     ' like Class but with a content property '
872     default_mime_type = 'text/plain'
873     def __init__(self, db, classname, **properties):
874         properties['content'] = FileName()
875         if not properties.has_key('type'):
876             properties['type'] = hyperdb.String()
877         Class.__init__(self, db, classname, **properties)
878     def get(self, nodeid, propname, default=_marker, cache=1):
879         x = Class.get(self, nodeid, propname, default, cache)
880         if propname == 'content':
881             if x.startswith('file:'):
882                 fnm = x[5:]
883                 try:
884                     x = open(fnm, 'rb').read()
885                 except Exception, e:
886                     x = repr(e)
887         return x
888     def create(self, **propvalues):
889         content = propvalues['content']
890         del propvalues['content']
891         newid = Class.create(self, **propvalues)
892         if not content:
893             return newid
894         if content.startswith('/tracker/download.php?'):
895             self.set(newid, content='http://sourceforge.net'+content)
896             return newid
897         nm = bnm = '%s%s' % (self.classname, newid)
898         sd = str(int(int(newid) / 1000))
899         d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
900         if not os.path.exists(d):
901             os.makedirs(d)
902         nm = os.path.join(d, nm)
903         open(nm, 'wb').write(content)
904         self.set(newid, content = 'file:'+nm)
905         mimetype = propvalues.get('type', self.default_mime_type)
906         self.db.indexer.add_text((self.classname, newid, 'content'), content, mimetype)
907         def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer):
908             remove(fnm)
909         self.rollbackaction(undo)
910         return newid
911     def index(self, nodeid):
912         Class.index(self, nodeid)
913         mimetype = self.get(nodeid, 'type')
914         if not mimetype:
915             mimetype = self.default_mime_type
916         self.db.indexer.add_text((self.classname, nodeid, 'content'),
917                     self.get(nodeid, 'content'), mimetype)
918  
919 # Yuck - c&p to avoid getting hyperdb.Class
920 class IssueClass(Class):
922     # Overridden methods:
924     def __init__(self, db, classname, **properties):
925         """The newly-created class automatically includes the "messages",
926         "files", "nosy", and "superseder" properties.  If the 'properties'
927         dictionary attempts to specify any of these properties or a
928         "creation" or "activity" property, a ValueError is raised."""
929         if not properties.has_key('title'):
930             properties['title'] = hyperdb.String(indexme='yes')
931         if not properties.has_key('messages'):
932             properties['messages'] = hyperdb.Multilink("msg")
933         if not properties.has_key('files'):
934             properties['files'] = hyperdb.Multilink("file")
935         if not properties.has_key('nosy'):
936             properties['nosy'] = hyperdb.Multilink("user")
937         if not properties.has_key('superseder'):
938             properties['superseder'] = hyperdb.Multilink(classname)
939         Class.__init__(self, db, classname, **properties)
941     # New methods:
943     def addmessage(self, nodeid, summary, text):
944         """Add a message to an issue's mail spool.
946         A new "msg" node is constructed using the current date, the user that
947         owns the database connection as the author, and the specified summary
948         text.
950         The "files" and "recipients" fields are left empty.
952         The given text is saved as the body of the message and the node is
953         appended to the "messages" field of the specified issue.
954         """
956     def nosymessage(self, nodeid, msgid, oldvalues):
957         """Send a message to the members of an issue's nosy list.
959         The message is sent only to users on the nosy list who are not
960         already on the "recipients" list for the message.
961         
962         These users are then added to the message's "recipients" list.
963         """
964         users = self.db.user
965         messages = self.db.msg
967         # figure the recipient ids
968         sendto = []
969         r = {}
970         recipients = messages.get(msgid, 'recipients')
971         for recipid in messages.get(msgid, 'recipients'):
972             r[recipid] = 1
974         # figure the author's id, and indicate they've received the message
975         authid = messages.get(msgid, 'author')
977         # possibly send the message to the author, as long as they aren't
978         # anonymous
979         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
980                 users.get(authid, 'username') != 'anonymous'):
981             sendto.append(authid)
982         r[authid] = 1
984         # now figure the nosy people who weren't recipients
985         nosy = self.get(nodeid, 'nosy')
986         for nosyid in nosy:
987             # Don't send nosy mail to the anonymous user (that user
988             # shouldn't appear in the nosy list, but just in case they
989             # do...)
990             if users.get(nosyid, 'username') == 'anonymous':
991                 continue
992             # make sure they haven't seen the message already
993             if not r.has_key(nosyid):
994                 # send it to them
995                 sendto.append(nosyid)
996                 recipients.append(nosyid)
998         # generate a change note
999         if oldvalues:
1000             note = self.generateChangeNote(nodeid, oldvalues)
1001         else:
1002             note = self.generateCreateNote(nodeid)
1004         # we have new recipients
1005         if sendto:
1006             # map userids to addresses
1007             sendto = [users.get(i, 'address') for i in sendto]
1009             # update the message's recipients list
1010             messages.set(msgid, recipients=recipients)
1012             # send the message
1013             self.send_message(nodeid, msgid, note, sendto)
1015     # XXX backwards compatibility - don't remove
1016     sendmessage = nosymessage
1018     def send_message(self, nodeid, msgid, note, sendto):
1019         '''Actually send the nominated message from this node to the sendto
1020            recipients, with the note appended.
1021         '''
1022         users = self.db.user
1023         messages = self.db.msg
1024         files = self.db.file
1026         # determine the messageid and inreplyto of the message
1027         inreplyto = messages.get(msgid, 'inreplyto')
1028         messageid = messages.get(msgid, 'messageid')
1030         # make up a messageid if there isn't one (web edit)
1031         if not messageid:
1032             # this is an old message that didn't get a messageid, so
1033             # create one
1034             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
1035                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
1036             messages.set(msgid, messageid=messageid)
1038         # send an email to the people who missed out
1039         cn = self.classname
1040         title = self.get(nodeid, 'title') or '%s message copy'%cn
1041         # figure author information
1042         authid = messages.get(msgid, 'author')
1043         authname = users.get(authid, 'realname')
1044         if not authname:
1045             authname = users.get(authid, 'username')
1046         authaddr = users.get(authid, 'address')
1047         if authaddr:
1048             authaddr = ' <%s>'%authaddr
1049         else:
1050             authaddr = ''
1052         # make the message body
1053         m = ['']
1055         # put in roundup's signature
1056         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
1057             m.append(self.email_signature(nodeid, msgid))
1059         # add author information
1060         if len(self.get(nodeid,'messages')) == 1:
1061             m.append("New submission from %s%s:"%(authname, authaddr))
1062         else:
1063             m.append("%s%s added the comment:"%(authname, authaddr))
1064         m.append('')
1066         # add the content
1067         m.append(messages.get(msgid, 'content'))
1069         # add the change note
1070         if note:
1071             m.append(note)
1073         # put in roundup's signature
1074         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
1075             m.append(self.email_signature(nodeid, msgid))
1077         # encode the content as quoted-printable
1078         content = cStringIO.StringIO('\n'.join(m))
1079         content_encoded = cStringIO.StringIO()
1080         quopri.encode(content, content_encoded, 0)
1081         content_encoded = content_encoded.getvalue()
1083         # get the files for this message
1084         message_files = messages.get(msgid, 'files')
1086         # make sure the To line is always the same (for testing mostly)
1087         sendto.sort()
1089         # create the message
1090         message = cStringIO.StringIO()
1091         writer = MimeWriter.MimeWriter(message)
1092         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
1093         writer.addheader('To', ', '.join(sendto))
1094         writer.addheader('From', '%s <%s>'%(authname,
1095             self.db.config.ISSUE_TRACKER_EMAIL))
1096         writer.addheader('Reply-To', '%s <%s>'%(self.db.config.INSTANCE_NAME,
1097             self.db.config.ISSUE_TRACKER_EMAIL))
1098         writer.addheader('MIME-Version', '1.0')
1099         if messageid:
1100             writer.addheader('Message-Id', messageid)
1101         if inreplyto:
1102             writer.addheader('In-Reply-To', inreplyto)
1104         # add a uniquely Roundup header to help filtering
1105         writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
1107         # attach files
1108         if message_files:
1109             part = writer.startmultipartbody('mixed')
1110             part = writer.nextpart()
1111             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
1112             body = part.startbody('text/plain')
1113             body.write(content_encoded)
1114             for fileid in message_files:
1115                 name = files.get(fileid, 'name')
1116                 mime_type = files.get(fileid, 'type')
1117                 content = files.get(fileid, 'content')
1118                 part = writer.nextpart()
1119                 if mime_type == 'text/plain':
1120                     part.addheader('Content-Disposition',
1121                         'attachment;\n filename="%s"'%name)
1122                     part.addheader('Content-Transfer-Encoding', '7bit')
1123                     body = part.startbody('text/plain')
1124                     body.write(content)
1125                 else:
1126                     # some other type, so encode it
1127                     if not mime_type:
1128                         # this should have been done when the file was saved
1129                         mime_type = mimetypes.guess_type(name)[0]
1130                     if mime_type is None:
1131                         mime_type = 'application/octet-stream'
1132                     part.addheader('Content-Disposition',
1133                         'attachment;\n filename="%s"'%name)
1134                     part.addheader('Content-Transfer-Encoding', 'base64')
1135                     body = part.startbody(mime_type)
1136                     body.write(base64.encodestring(content))
1137             writer.lastpart()
1138         else:
1139             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
1140             body = writer.startbody('text/plain')
1141             body.write(content_encoded)
1143         # now try to send the message
1144         if SENDMAILDEBUG:
1145             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
1146                 self.db.config.ADMIN_EMAIL,
1147                 ', '.join(sendto),message.getvalue()))
1148         else:
1149             try:
1150                 # send the message as admin so bounces are sent there
1151                 # instead of to roundup
1152                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
1153                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
1154                     message.getvalue())
1155             except socket.error, value:
1156                 raise MessageSendError, \
1157                     "Couldn't send confirmation email: mailhost %s"%value
1158             except smtplib.SMTPException, value:
1159                 raise MessageSendError, \
1160                     "Couldn't send confirmation email: %s"%value
1162     def email_signature(self, nodeid, msgid):
1163         ''' Add a signature to the e-mail with some useful information
1164         '''
1165         web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid
1166         email = '"%s" <%s>'%(self.db.config.INSTANCE_NAME,
1167             self.db.config.ISSUE_TRACKER_EMAIL)
1168         line = '_' * max(len(web), len(email))
1169         return '%s\n%s\n%s\n%s'%(line, email, web, line)
1171     def generateCreateNote(self, nodeid):
1172         """Generate a create note that lists initial property values
1173         """
1174         cn = self.classname
1175         cl = self.db.classes[cn]
1176         props = cl.getprops(protected=0)
1178         # list the values
1179         m = []
1180         l = props.items()
1181         l.sort()
1182         for propname, prop in l:
1183             value = cl.get(nodeid, propname, None)
1184             # skip boring entries
1185             if not value:
1186                 continue
1187             if isinstance(prop, hyperdb.Link):
1188                 link = self.db.classes[prop.classname]
1189                 if value:
1190                     key = link.labelprop(default_to_id=1)
1191                     if key:
1192                         value = link.get(value, key)
1193                 else:
1194                     value = ''
1195             elif isinstance(prop, hyperdb.Multilink):
1196                 if value is None: value = []
1197                 l = []
1198                 link = self.db.classes[prop.classname]
1199                 key = link.labelprop(default_to_id=1)
1200                 if key:
1201                     value = [link.get(entry, key) for entry in value]
1202                 value.sort()
1203                 value = ', '.join(value)
1204             m.append('%s: %s'%(propname, value))
1205         m.insert(0, '----------')
1206         m.insert(0, '')
1207         return '\n'.join(m)
1209     def generateChangeNote(self, nodeid, oldvalues):
1210         """Generate a change note that lists property changes
1211         """
1212         cn = self.classname
1213         cl = self.db.classes[cn]
1214         changed = {}
1215         props = cl.getprops(protected=0)
1217         # determine what changed
1218         for key in oldvalues.keys():
1219             if key in ['files','messages']: continue
1220             new_value = cl.get(nodeid, key)
1221             # the old value might be non existent
1222             try:
1223                 old_value = oldvalues[key]
1224                 if type(new_value) is type([]):
1225                     new_value.sort()
1226                     old_value.sort()
1227                 if new_value != old_value:
1228                     changed[key] = old_value
1229             except:
1230                 changed[key] = new_value
1232         # list the changes
1233         m = []
1234         l = changed.items()
1235         l.sort()
1236         for propname, oldvalue in l:
1237             prop = props[propname]
1238             value = cl.get(nodeid, propname, None)
1239             if isinstance(prop, hyperdb.Link):
1240                 link = self.db.classes[prop.classname]
1241                 key = link.labelprop(default_to_id=1)
1242                 if key:
1243                     if value:
1244                         value = link.get(value, key)
1245                     else:
1246                         value = ''
1247                     if oldvalue:
1248                         oldvalue = link.get(oldvalue, key)
1249                     else:
1250                         oldvalue = ''
1251                 change = '%s -> %s'%(oldvalue, value)
1252             elif isinstance(prop, hyperdb.Multilink):
1253                 change = ''
1254                 if value is None: value = []
1255                 if oldvalue is None: oldvalue = []
1256                 l = []
1257                 link = self.db.classes[prop.classname]
1258                 key = link.labelprop(default_to_id=1)
1259                 # check for additions
1260                 for entry in value:
1261                     if entry in oldvalue: continue
1262                     if key:
1263                         l.append(link.get(entry, key))
1264                     else:
1265                         l.append(entry)
1266                 if l:
1267                     change = '+%s'%(', '.join(l))
1268                     l = []
1269                 # check for removals
1270                 for entry in oldvalue:
1271                     if entry in value: continue
1272                     if key:
1273                         l.append(link.get(entry, key))
1274                     else:
1275                         l.append(entry)
1276                 if l:
1277                     change += ' -%s'%(', '.join(l))
1278             else:
1279                 change = '%s -> %s'%(oldvalue, value)
1280             m.append('%s: %s'%(propname, change))
1281         if m:
1282             m.insert(0, '----------')
1283             m.insert(0, '')
1284         return '\n'.join(m)