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