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