Code

Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without
[roundup.git] / roundup / roundupdb.py
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.
279         
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)
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.
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)
520 # Revision 1.29  2001/12/11 04:50:49  richard
521 # fixed the order of the blank line and '-------' line
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.
530 # Revision 1.27  2001/12/10 21:02:53  richard
531 # only insert the -------- change note marker if there is a change note
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.
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
541 # Revision 1.24  2001/11/30 11:29:04  rochecompaan
542 # Property changes are now listed in emails generated by Roundup
544 # Revision 1.23  2001/11/27 03:17:13  richard
545 # oops
547 # Revision 1.22  2001/11/27 03:00:50  richard
548 # couple of bugfixes from latest patch integration
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.
559 # Fixed:
560 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
562 # Revision 1.20  2001/11/25 10:11:14  jhermann
563 # Typo fix
565 # Revision 1.19  2001/11/22 15:46:42  jhermann
566 # Added module docstrings to all modules.
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
572 # Revision 1.17  2001/11/12 22:01:06  richard
573 # Fixed issues with nosy reaction and author copies.
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.
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.
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.
593 # Revision 1.13  2001/10/21 00:45:15  richard
594 # Added author identification to e-mail messages from roundup.
596 # Revision 1.12  2001/10/04 02:16:15  richard
597 # Forgot to pass the protected flag down *sigh*.
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).
607 # Revision 1.10  2001/08/07 00:24:42  richard
608 # stupid typo
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.
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).
620 # Revision 1.7  2001/07/30 02:38:31  richard
621 # get() now has a default arg - for migration only.
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".
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 :)
630 # Revision 1.4  2001/07/29 04:05:37  richard
631 # Added the fabricated property "id".
633 # Revision 1.3  2001/07/23 07:14:41  richard
634 # Moved the database backends off into backends.
636 # Revision 1.2  2001/07/22 12:09:32  richard
637 # Final commit of Grande Splite
639 # Revision 1.1  2001/07/22 11:58:35  richard
640 # More Grande Splite
643 # vim: set filetype=python ts=4 sw=4 et si