Code

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