Code

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