1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17 #
18 # $Id: roundupdb.py,v 1.31 2001-12-15 19:24:39 rochecompaan Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket
25 import mimetools, MimeWriter, cStringIO
26 import base64, mimetypes
28 import hyperdb, date
30 class DesignatorError(ValueError):
31 pass
32 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
33 ''' Take a foo123 and return ('foo', 123)
34 '''
35 m = dre.match(designator)
36 if m is None:
37 raise DesignatorError, '"%s" not a node designator'%designator
38 return m.group(1), m.group(2)
41 class Database:
42 def getuid(self):
43 """Return the id of the "user" node associated with the user
44 that owns this connection to the hyperdatabase."""
45 return self.user.lookup(self.journaltag)
47 def uidFromAddress(self, address, create=1):
48 ''' address is from the rfc822 module, and therefore is (name, addr)
50 user is created if they don't exist in the db already
51 '''
52 (realname, address) = address
53 users = self.user.stringFind(address=address)
54 for dummy in range(2):
55 if len(users) > 1:
56 # make sure we don't match the anonymous or admin user
57 for user in users:
58 if user == '1': continue
59 if self.user.get(user, 'username') == 'anonymous': continue
60 # first valid match will do
61 return user
62 # well, I guess we have no choice
63 return user[0]
64 elif users:
65 return users[0]
66 # try to match the username to the address (for local
67 # submissions where the address is empty)
68 users = self.user.stringFind(username=address)
70 # couldn't match address or username, so create a new user
71 return self.user.create(username=address, address=address,
72 realname=realname)
74 _marker = []
75 # XXX: added the 'creator' faked attribute
76 class Class(hyperdb.Class):
77 # Overridden methods:
78 def __init__(self, db, classname, **properties):
79 if (properties.has_key('creation') or properties.has_key('activity')
80 or properties.has_key('creator')):
81 raise ValueError, '"creation", "activity" and "creator" are reserved'
82 hyperdb.Class.__init__(self, db, classname, **properties)
83 self.auditors = {'create': [], 'set': [], 'retire': []}
84 self.reactors = {'create': [], 'set': [], 'retire': []}
86 def create(self, **propvalues):
87 """These operations trigger detectors and can be vetoed. Attempts
88 to modify the "creation" or "activity" properties cause a KeyError.
89 """
90 if propvalues.has_key('creation') or propvalues.has_key('activity'):
91 raise KeyError, '"creation" and "activity" are reserved'
92 for audit in self.auditors['create']:
93 audit(self.db, self, None, propvalues)
94 nodeid = hyperdb.Class.create(self, **propvalues)
95 for react in self.reactors['create']:
96 react(self.db, self, nodeid, None)
97 return nodeid
99 def set(self, nodeid, **propvalues):
100 """These operations trigger detectors and can be vetoed. Attempts
101 to modify the "creation" or "activity" properties cause a KeyError.
102 """
103 if propvalues.has_key('creation') or propvalues.has_key('activity'):
104 raise KeyError, '"creation" and "activity" are reserved'
105 for audit in self.auditors['set']:
106 audit(self.db, self, nodeid, propvalues)
107 oldvalues = self.db.getnode(self.classname, nodeid)
108 hyperdb.Class.set(self, nodeid, **propvalues)
109 for react in self.reactors['set']:
110 react(self.db, self, nodeid, oldvalues)
112 def retire(self, nodeid):
113 """These operations trigger detectors and can be vetoed. Attempts
114 to modify the "creation" or "activity" properties cause a KeyError.
115 """
116 for audit in self.auditors['retire']:
117 audit(self.db, self, nodeid, None)
118 hyperdb.Class.retire(self, nodeid)
119 for react in self.reactors['retire']:
120 react(self.db, self, nodeid, None)
122 def get(self, nodeid, propname, default=_marker):
123 """Attempts to get the "creation" or "activity" properties should
124 do the right thing.
125 """
126 if propname == 'creation':
127 journal = self.db.getjournal(self.classname, nodeid)
128 if journal:
129 return self.db.getjournal(self.classname, nodeid)[0][1]
130 else:
131 # on the strange chance that there's no journal
132 return date.Date()
133 if propname == 'activity':
134 journal = self.db.getjournal(self.classname, nodeid)
135 if journal:
136 return self.db.getjournal(self.classname, nodeid)[-1][1]
137 else:
138 # on the strange chance that there's no journal
139 return date.Date()
140 if propname == 'creator':
141 journal = self.db.getjournal(self.classname, nodeid)
142 if journal:
143 name = self.db.getjournal(self.classname, nodeid)[0][2]
144 else:
145 return None
146 return self.db.user.lookup(name)
147 if default is not _marker:
148 return hyperdb.Class.get(self, nodeid, propname, default)
149 else:
150 return hyperdb.Class.get(self, nodeid, propname)
152 def getprops(self, protected=1):
153 """In addition to the actual properties on the node, these
154 methods provide the "creation" and "activity" properties. If the
155 "protected" flag is true, we include protected properties - those
156 which may not be modified.
157 """
158 d = hyperdb.Class.getprops(self, protected=protected).copy()
159 if protected:
160 d['creation'] = hyperdb.Date()
161 d['activity'] = hyperdb.Date()
162 d['creator'] = hyperdb.Link("user")
163 return d
165 #
166 # Detector interface
167 #
168 def audit(self, event, detector):
169 """Register a detector
170 """
171 self.auditors[event].append(detector)
173 def react(self, event, detector):
174 """Register a detector
175 """
176 self.reactors[event].append(detector)
179 class FileClass(Class):
180 def create(self, **propvalues):
181 ''' snaffle the file propvalue and store in a file
182 '''
183 content = propvalues['content']
184 del propvalues['content']
185 newid = Class.create(self, **propvalues)
186 self.setcontent(self.classname, newid, content)
187 return newid
189 def filename(self, classname, nodeid):
190 # TODO: split into multiple files directories
191 return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
193 def setcontent(self, classname, nodeid, content):
194 ''' set the content file for this file
195 '''
196 open(self.filename(classname, nodeid), 'wb').write(content)
198 def getcontent(self, classname, nodeid):
199 ''' get the content file for this file
200 '''
201 return open(self.filename(classname, nodeid), 'rb').read()
203 def get(self, nodeid, propname, default=_marker):
204 ''' trap the content propname and get it from the file
205 '''
206 if propname == 'content':
207 return self.getcontent(self.classname, nodeid)
208 if default is not _marker:
209 return Class.get(self, nodeid, propname, default)
210 else:
211 return Class.get(self, nodeid, propname)
213 def getprops(self, protected=1):
214 ''' In addition to the actual properties on the node, these methods
215 provide the "content" property. If the "protected" flag is true,
216 we include protected properties - those which may not be
217 modified.
218 '''
219 d = Class.getprops(self, protected=protected).copy()
220 if protected:
221 d['content'] = hyperdb.String()
222 return d
224 class MessageSendError(RuntimeError):
225 pass
227 class DetectorError(RuntimeError):
228 pass
230 # XXX deviation from spec - was called ItemClass
231 class IssueClass(Class):
232 # configuration
233 MESSAGES_TO_AUTHOR = 'no'
234 INSTANCE_NAME = 'Roundup issue tracker'
235 EMAIL_SIGNATURE_POSITION = 'bottom'
237 # Overridden methods:
239 def __init__(self, db, classname, **properties):
240 """The newly-created class automatically includes the "messages",
241 "files", "nosy", and "superseder" properties. If the 'properties'
242 dictionary attempts to specify any of these properties or a
243 "creation" or "activity" property, a ValueError is raised."""
244 if not properties.has_key('title'):
245 properties['title'] = hyperdb.String()
246 if not properties.has_key('messages'):
247 properties['messages'] = hyperdb.Multilink("msg")
248 if not properties.has_key('files'):
249 properties['files'] = hyperdb.Multilink("file")
250 if not properties.has_key('nosy'):
251 properties['nosy'] = hyperdb.Multilink("user")
252 if not properties.has_key('superseder'):
253 properties['superseder'] = hyperdb.Multilink(classname)
254 Class.__init__(self, db, classname, **properties)
256 # New methods:
258 def addmessage(self, nodeid, summary, text):
259 """Add a message to an issue's mail spool.
261 A new "msg" node is constructed using the current date, the user that
262 owns the database connection as the author, and the specified summary
263 text.
265 The "files" and "recipients" fields are left empty.
267 The given text is saved as the body of the message and the node is
268 appended to the "messages" field of the specified issue.
269 """
271 def sendmessage(self, nodeid, msgid, change_note):
272 """Send a message to the members of an issue's nosy list.
274 The message is sent only to users on the nosy list who are not
275 already on the "recipients" list for the message.
277 These users are then added to the message's "recipients" list.
278 """
279 # figure the recipient ids
280 recipients = self.db.msg.get(msgid, 'recipients')
281 r = {}
282 for recipid in recipients:
283 r[recipid] = 1
284 rlen = len(recipients)
286 # figure the author's id, and indicate they've received the message
287 authid = self.db.msg.get(msgid, 'author')
289 # get the current nosy list, we'll need it
290 nosy = self.get(nodeid, 'nosy')
292 # ... but duplicate the message to the author as long as it's not
293 # the anonymous user
294 if (self.MESSAGES_TO_AUTHOR == 'yes' and
295 self.db.user.get(authid, 'username') != 'anonymous'):
296 if not r.has_key(authid):
297 recipients.append(authid)
298 r[authid] = 1
300 # now figure the nosy people who weren't recipients
301 for nosyid in nosy:
302 # Don't send nosy mail to the anonymous user (that user
303 # shouldn't appear in the nosy list, but just in case they
304 # do...)
305 if self.db.user.get(nosyid, 'username') == 'anonymous': continue
306 if not r.has_key(nosyid):
307 recipients.append(nosyid)
309 # no new recipients
310 if rlen == len(recipients):
311 return
313 # update the message's recipients list
314 self.db.msg.set(msgid, recipients=recipients)
316 # send an email to the people who missed out
317 sendto = [self.db.user.get(i, 'address') for i in recipients]
318 cn = self.classname
319 title = self.get(nodeid, 'title') or '%s message copy'%cn
320 # figure author information
321 authname = self.db.user.get(authid, 'realname')
322 if not authname:
323 authname = self.db.user.get(authid, 'username')
324 authaddr = self.db.user.get(authid, 'address')
325 if authaddr:
326 authaddr = ' <%s>'%authaddr
327 else:
328 authaddr = ''
330 # make the message body
331 m = ['']
333 # put in roundup's signature
334 if self.EMAIL_SIGNATURE_POSITION == 'top':
335 m.append(self.email_signature(nodeid, msgid))
337 # add author information
338 if len(self.get(nodeid,'messages')) == 1:
339 m.append("New submission from %s%s:"%(authname, authaddr))
340 else:
341 m.append("%s%s added the comment:"%(authname, authaddr))
342 m.append('')
344 # add the content
345 m.append(self.db.msg.get(msgid, 'content'))
347 # add the change note
348 if change_note:
349 m.append(change_note)
351 # put in roundup's signature
352 if self.EMAIL_SIGNATURE_POSITION == 'bottom':
353 m.append(self.email_signature(nodeid, msgid))
355 # get the files for this message
356 files = self.db.msg.get(msgid, 'files')
358 # create the message
359 message = cStringIO.StringIO()
360 writer = MimeWriter.MimeWriter(message)
361 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
362 writer.addheader('To', ', '.join(sendto))
363 writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
364 writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
365 self.ISSUE_TRACKER_EMAIL))
366 writer.addheader('MIME-Version', '1.0')
368 # attach files
369 if files:
370 part = writer.startmultipartbody('mixed')
371 part = writer.nextpart()
372 body = part.startbody('text/plain')
373 body.write('\n'.join(m))
374 for fileid in files:
375 name = self.db.file.get(fileid, 'name')
376 mime_type = self.db.file.get(fileid, 'type')
377 content = self.db.file.get(fileid, 'content')
378 part = writer.nextpart()
379 if mime_type == 'text/plain':
380 part.addheader('Content-Disposition',
381 'attachment;\n filename="%s"'%name)
382 part.addheader('Content-Transfer-Encoding', '7bit')
383 body = part.startbody('text/plain')
384 body.write(content)
385 else:
386 # some other type, so encode it
387 if not mime_type:
388 # this should have been done when the file was saved
389 mime_type = mimetypes.guess_type(name)[0]
390 if mime_type is None:
391 mime_type = 'application/octet-stream'
392 part.addheader('Content-Disposition',
393 'attachment;\n filename="%s"'%name)
394 part.addheader('Content-Transfer-Encoding', 'base64')
395 body = part.startbody(mime_type)
396 body.write(base64.encodestring(content))
397 writer.lastpart()
398 else:
399 body = writer.startbody('text/plain')
400 body.write('\n'.join(m))
402 # now try to send the message
403 try:
404 smtp = smtplib.SMTP(self.MAILHOST)
405 # send the message as admin so bounces are sent there instead
406 # of to roundup
407 smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
408 except socket.error, value:
409 raise MessageSendError, \
410 "Couldn't send confirmation email: mailhost %s"%value
411 except smtplib.SMTPException, value:
412 raise MessageSendError, \
413 "Couldn't send confirmation email: %s"%value
415 def email_signature(self, nodeid, msgid):
416 ''' Add a signature to the e-mail with some useful information
417 '''
418 web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
419 email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
420 line = '_' * max(len(web), len(email))
421 return '%s\n%s\n%s\n%s'%(line, email, web, line)
423 def generateChangeNote(self, nodeid, oldvalues):
424 """Generate a change note that lists property changes
425 """
426 cn = self.classname
427 cl = self.db.classes[cn]
428 changed = {}
429 props = cl.getprops(protected=0)
431 # determine what changed
432 for key in oldvalues.keys():
433 if key in ['files','messages']: continue
434 new_value = cl.get(nodeid, key)
435 # the old value might be non existent
436 try:
437 old_value = oldvalues[key]
438 if type(new_value) is type([]):
439 new_value.sort()
440 old_value.sort()
441 if new_value != old_value:
442 changed[key] = old_value
443 except:
444 changed[key] = new_value
446 # list the changes
447 m = []
448 for propname, oldvalue in changed.items():
449 prop = cl.properties[propname]
450 value = cl.get(nodeid, propname, None)
451 if isinstance(prop, hyperdb.Link):
452 link = self.db.classes[prop.classname]
453 key = link.labelprop(default_to_id=1)
454 if key:
455 if value:
456 value = link.get(value, key)
457 else:
458 value = ''
459 if oldvalue:
460 oldvalue = link.get(oldvalue, key)
461 else:
462 oldvalue = ''
463 change = '%s -> %s'%(oldvalue, value)
464 elif isinstance(prop, hyperdb.Multilink):
465 change = ''
466 if value is None: value = []
467 if oldvalue is None: oldvalue = []
468 l = []
469 link = self.db.classes[prop.classname]
470 key = link.labelprop(default_to_id=1)
471 # check for additions
472 for entry in value:
473 if entry in oldvalue: continue
474 if key:
475 l.append(link.get(entry, key))
476 else:
477 l.append(entry)
478 if l:
479 change = '+%s'%(', '.join(l))
480 l = []
481 # check for removals
482 for entry in oldvalue:
483 if entry in value: continue
484 if key:
485 l.append(link.get(entry, key))
486 else:
487 l.append(entry)
488 if l:
489 change += ' -%s'%(', '.join(l))
490 else:
491 change = '%s -> %s'%(oldvalue, value)
492 m.append('%s: %s'%(propname, change))
493 if m:
494 m.insert(0, '----------')
495 m.insert(0, '')
496 return '\n'.join(m)
498 #
499 # $Log: not supported by cvs2svn $
500 # Revision 1.30 2001/12/12 21:47:45 richard
501 # . Message author's name appears in From: instead of roundup instance name
502 # (which still appears in the Reply-To:)
503 # . envelope-from is now set to the roundup-admin and not roundup itself so
504 # delivery reports aren't sent to roundup (thanks Patrick Ohly)
505 #
506 # Revision 1.29 2001/12/11 04:50:49 richard
507 # fixed the order of the blank line and '-------' line
508 #
509 # Revision 1.28 2001/12/10 22:20:01 richard
510 # Enabled transaction support in the bsddb backend. It uses the anydbm code
511 # where possible, only replacing methods where the db is opened (it uses the
512 # btree opener specifically.)
513 # Also cleaned up some change note generation.
514 # Made the backends package work with pydoc too.
515 #
516 # Revision 1.27 2001/12/10 21:02:53 richard
517 # only insert the -------- change note marker if there is a change note
518 #
519 # Revision 1.26 2001/12/05 14:26:44 rochecompaan
520 # Removed generation of change note from "sendmessage" in roundupdb.py.
521 # The change note is now generated when the message is created.
522 #
523 # Revision 1.25 2001/11/30 20:28:10 rochecompaan
524 # Property changes are now completely traceable, whether changes are
525 # made through the web or by email
526 #
527 # Revision 1.24 2001/11/30 11:29:04 rochecompaan
528 # Property changes are now listed in emails generated by Roundup
529 #
530 # Revision 1.23 2001/11/27 03:17:13 richard
531 # oops
532 #
533 # Revision 1.22 2001/11/27 03:00:50 richard
534 # couple of bugfixes from latest patch integration
535 #
536 # Revision 1.21 2001/11/26 22:55:56 richard
537 # Feature:
538 # . Added INSTANCE_NAME to configuration - used in web and email to identify
539 # the instance.
540 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
541 # signature info in e-mails.
542 # . Some more flexibility in the mail gateway and more error handling.
543 # . Login now takes you to the page you back to the were denied access to.
544 #
545 # Fixed:
546 # . Lots of bugs, thanks Roché and others on the devel mailing list!
547 #
548 # Revision 1.20 2001/11/25 10:11:14 jhermann
549 # Typo fix
550 #
551 # Revision 1.19 2001/11/22 15:46:42 jhermann
552 # Added module docstrings to all modules.
553 #
554 # Revision 1.18 2001/11/15 10:36:17 richard
555 # . incorporated patch from Roch'e Compaan implementing attachments in nosy
556 # e-mail
557 #
558 # Revision 1.17 2001/11/12 22:01:06 richard
559 # Fixed issues with nosy reaction and author copies.
560 #
561 # Revision 1.16 2001/10/30 00:54:45 richard
562 # Features:
563 # . #467129 ] Lossage when username=e-mail-address
564 # . #473123 ] Change message generation for author
565 # . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
566 #
567 # Revision 1.15 2001/10/23 01:00:18 richard
568 # Re-enabled login and registration access after lopping them off via
569 # disabling access for anonymous users.
570 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
571 # a couple of bugs while I was there. Probably introduced a couple, but
572 # things seem to work OK at the moment.
573 #
574 # Revision 1.14 2001/10/21 07:26:35 richard
575 # feature #473127: Filenames. I modified the file.index and htmltemplate
576 # source so that the filename is used in the link and the creation
577 # information is displayed.
578 #
579 # Revision 1.13 2001/10/21 00:45:15 richard
580 # Added author identification to e-mail messages from roundup.
581 #
582 # Revision 1.12 2001/10/04 02:16:15 richard
583 # Forgot to pass the protected flag down *sigh*.
584 #
585 # Revision 1.11 2001/10/04 02:12:42 richard
586 # Added nicer command-line item adding: passing no arguments will enter an
587 # interactive more which asks for each property in turn. While I was at it, I
588 # fixed an implementation problem WRT the spec - I wasn't raising a
589 # ValueError if the key property was missing from a create(). Also added a
590 # protected=boolean argument to getprops() so we can list only the mutable
591 # properties (defaults to yes, which lists the immutables).
592 #
593 # Revision 1.10 2001/08/07 00:24:42 richard
594 # stupid typo
595 #
596 # Revision 1.9 2001/08/07 00:15:51 richard
597 # Added the copyright/license notice to (nearly) all files at request of
598 # Bizar Software.
599 #
600 # Revision 1.8 2001/08/02 06:38:17 richard
601 # Roundupdb now appends "mailing list" information to its messages which
602 # include the e-mail address and web interface address. Templates may
603 # override this in their db classes to include specific information (support
604 # instructions, etc).
605 #
606 # Revision 1.7 2001/07/30 02:38:31 richard
607 # get() now has a default arg - for migration only.
608 #
609 # Revision 1.6 2001/07/30 00:05:54 richard
610 # Fixed IssueClass so that superseders links to its classname rather than
611 # hard-coded to "issue".
612 #
613 # Revision 1.5 2001/07/29 07:01:39 richard
614 # Added vim command to all source so that we don't get no steenkin' tabs :)
615 #
616 # Revision 1.4 2001/07/29 04:05:37 richard
617 # Added the fabricated property "id".
618 #
619 # Revision 1.3 2001/07/23 07:14:41 richard
620 # Moved the database backends off into backends.
621 #
622 # Revision 1.2 2001/07/22 12:09:32 richard
623 # Final commit of Grande Splite
624 #
625 # Revision 1.1 2001/07/22 11:58:35 richard
626 # More Grande Splite
627 #
628 #
629 # vim: set filetype=python ts=4 sw=4 et si