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()
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
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
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)
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',
188 }
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 = {}
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)
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
272 def set(self, nodeid, **propvalues):
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 = {}
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
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
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))
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
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
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
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
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()
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()
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
659 iv = v.filter(ff)
660 v = v.remapwith(iv)
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
671 iv = v.filter(ff)
672 v = v.remapwith(iv)
673 #print "filter regexs at %s" % time.time()
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()
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
704 def hasnode(self, nodeid):
705 return int(nodeid) < self.maxid
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.
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
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)
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',
869 }
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)
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.
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)