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