diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py
index b27493a34a8026347c5d6f423fb49182ef4e6727..6cb939a2b988665f5234c97c2eb1b29e47e0d70e 100644 (file)
--- a/roundup/roundupdb.py
+++ b/roundup/roundupdb.py
-# $Id: roundupdb.py,v 1.1 2001-07-22 11:58:35 richard Exp $
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: roundupdb.py,v 1.31 2001-12-15 19:24:39 rochecompaan Exp $
+
+__doc__ = """
+Extending hyperdb with types specific to issue-tracking.
+"""
import re, os, smtplib, socket
+import mimetools, MimeWriter, cStringIO
+import base64, mimetypes
import hyperdb, date
+class DesignatorError(ValueError):
+ pass
def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
''' Take a foo123 and return ('foo', 123)
'''
m = dre.match(designator)
+ if m is None:
+ raise DesignatorError, '"%s" not a node designator'%designator
return m.group(1), m.group(2)
+
class Database:
def getuid(self):
"""Return the id of the "user" node associated with the user
that owns this connection to the hyperdatabase."""
return self.user.lookup(self.journaltag)
- def uidFromAddress(self, address):
+ def uidFromAddress(self, address, create=1):
''' address is from the rfc822 module, and therefore is (name, addr)
user is created if they don't exist in the db already
'''
(realname, address) = address
users = self.user.stringFind(address=address)
- if users: return users[0]
+ for dummy in range(2):
+ if len(users) > 1:
+ # make sure we don't match the anonymous or admin user
+ for user in users:
+ if user == '1': continue
+ if self.user.get(user, 'username') == 'anonymous': continue
+ # first valid match will do
+ return user
+ # well, I guess we have no choice
+ return user[0]
+ elif users:
+ return users[0]
+ # try to match the username to the address (for local
+ # submissions where the address is empty)
+ users = self.user.stringFind(username=address)
+
+ # couldn't match address or username, so create a new user
return self.user.create(username=address, address=address,
realname=realname)
+_marker = []
+# XXX: added the 'creator' faked attribute
class Class(hyperdb.Class):
# Overridden methods:
def __init__(self, db, classname, **properties):
+ if (properties.has_key('creation') or properties.has_key('activity')
+ or properties.has_key('creator')):
+ raise ValueError, '"creation", "activity" and "creator" are reserved'
hyperdb.Class.__init__(self, db, classname, **properties)
self.auditors = {'create': [], 'set': [], 'retire': []}
self.reactors = {'create': [], 'set': [], 'retire': []}
for react in self.reactors['retire']:
react(self.db, self, nodeid, None)
- # New methods:
+ def get(self, nodeid, propname, default=_marker):
+ """Attempts to get the "creation" or "activity" properties should
+ do the right thing.
+ """
+ if propname == 'creation':
+ journal = self.db.getjournal(self.classname, nodeid)
+ if journal:
+ return self.db.getjournal(self.classname, nodeid)[0][1]
+ else:
+ # on the strange chance that there's no journal
+ return date.Date()
+ if propname == 'activity':
+ journal = self.db.getjournal(self.classname, nodeid)
+ if journal:
+ return self.db.getjournal(self.classname, nodeid)[-1][1]
+ else:
+ # on the strange chance that there's no journal
+ return date.Date()
+ if propname == 'creator':
+ journal = self.db.getjournal(self.classname, nodeid)
+ if journal:
+ name = self.db.getjournal(self.classname, nodeid)[0][2]
+ else:
+ return None
+ return self.db.user.lookup(name)
+ if default is not _marker:
+ return hyperdb.Class.get(self, nodeid, propname, default)
+ else:
+ return hyperdb.Class.get(self, nodeid, propname)
+
+ def getprops(self, protected=1):
+ """In addition to the actual properties on the node, these
+ methods provide the "creation" and "activity" properties. If the
+ "protected" flag is true, we include protected properties - those
+ which may not be modified.
+ """
+ d = hyperdb.Class.getprops(self, protected=protected).copy()
+ if protected:
+ d['creation'] = hyperdb.Date()
+ d['activity'] = hyperdb.Date()
+ d['creator'] = hyperdb.Link("user")
+ return d
+ #
+ # Detector interface
+ #
def audit(self, event, detector):
"""Register a detector
"""
"""
self.reactors[event].append(detector)
+
class FileClass(Class):
def create(self, **propvalues):
''' snaffle the file propvalue and store in a file
'''
return open(self.filename(classname, nodeid), 'rb').read()
- def get(self, nodeid, propname):
+ def get(self, nodeid, propname, default=_marker):
''' trap the content propname and get it from the file
'''
if propname == 'content':
return self.getcontent(self.classname, nodeid)
- return Class.get(self, nodeid, propname)
+ if default is not _marker:
+ return Class.get(self, nodeid, propname, default)
+ else:
+ return Class.get(self, nodeid, propname)
- def getprops(self):
+ def getprops(self, protected=1):
''' In addition to the actual properties on the node, these methods
- provide the "content" property.
+ provide the "content" property. If the "protected" flag is true,
+ we include protected properties - those which may not be
+ modified.
'''
- d = Class.getprops(self).copy()
- d['content'] = hyperdb.String()
+ d = Class.getprops(self, protected=protected).copy()
+ if protected:
+ d['content'] = hyperdb.String()
return d
+class MessageSendError(RuntimeError):
+ pass
+
+class DetectorError(RuntimeError):
+ pass
+
# XXX deviation from spec - was called ItemClass
class IssueClass(Class):
+ # configuration
+ MESSAGES_TO_AUTHOR = 'no'
+ INSTANCE_NAME = 'Roundup issue tracker'
+ EMAIL_SIGNATURE_POSITION = 'bottom'
+
# Overridden methods:
def __init__(self, db, classname, **properties):
if not properties.has_key('nosy'):
properties['nosy'] = hyperdb.Multilink("user")
if not properties.has_key('superseder'):
- properties['superseder'] = hyperdb.Multilink("issue")
- if (properties.has_key('creation') or properties.has_key('activity')
- or properties.has_key('creator')):
- raise ValueError, '"creation", "activity" and "creator" are reserved'
+ properties['superseder'] = hyperdb.Multilink(classname)
Class.__init__(self, db, classname, **properties)
- def get(self, nodeid, propname):
- if propname == 'creation':
- return self.db.getjournal(self.classname, nodeid)[0][1]
- if propname == 'activity':
- return self.db.getjournal(self.classname, nodeid)[-1][1]
- if propname == 'creator':
- name = self.db.getjournal(self.classname, nodeid)[0][2]
- return self.db.user.lookup(name)
- return Class.get(self, nodeid, propname)
-
- def getprops(self):
- """In addition to the actual properties on the node, these
- methods provide the "creation" and "activity" properties."""
- d = Class.getprops(self).copy()
- d['creation'] = hyperdb.Date()
- d['activity'] = hyperdb.Date()
- d['creator'] = hyperdb.Link("user")
- return d
-
# New methods:
def addmessage(self, nodeid, summary, text):
appended to the "messages" field of the specified issue.
"""
- def sendmessage(self, nodeid, msgid):
+ def sendmessage(self, nodeid, msgid, change_note):
"""Send a message to the members of an issue's nosy list.
The message is sent only to users on the nosy list who are not
r = {}
for recipid in recipients:
r[recipid] = 1
+ rlen = len(recipients)
+
+ # figure the author's id, and indicate they've received the message
authid = self.db.msg.get(msgid, 'author')
+
+ # get the current nosy list, we'll need it
+ nosy = self.get(nodeid, 'nosy')
+
+ # ... but duplicate the message to the author as long as it's not
+ # the anonymous user
+ if (self.MESSAGES_TO_AUTHOR == 'yes' and
+ self.db.user.get(authid, 'username') != 'anonymous'):
+ if not r.has_key(authid):
+ recipients.append(authid)
r[authid] = 1
# now figure the nosy people who weren't recipients
- sendto = []
- nosy = self.get(nodeid, 'nosy')
for nosyid in nosy:
+ # Don't send nosy mail to the anonymous user (that user
+ # shouldn't appear in the nosy list, but just in case they
+ # do...)
+ if self.db.user.get(nosyid, 'username') == 'anonymous': continue
if not r.has_key(nosyid):
- sendto.append(nosyid)
recipients.append(nosyid)
- if sendto:
- # update the message's recipients list
- self.db.msg.set(msgid, recipients=recipients)
-
- # send an email to the people who missed out
- sendto = [self.db.user.get(i, 'address') for i in recipients]
- cn = self.classname
- title = self.get(nodeid, 'title') or '%s message copy'%cn
- m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
- m.append('To: %s'%', '.join(sendto))
- m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
- m.append('')
- m.append(self.db.msg.get(msgid, 'content'))
- # TODO attachments
+ # no new recipients
+ if rlen == len(recipients):
+ return
+
+ # update the message's recipients list
+ self.db.msg.set(msgid, recipients=recipients)
+
+ # send an email to the people who missed out
+ sendto = [self.db.user.get(i, 'address') for i in recipients]
+ cn = self.classname
+ title = self.get(nodeid, 'title') or '%s message copy'%cn
+ # figure author information
+ authname = self.db.user.get(authid, 'realname')
+ if not authname:
+ authname = self.db.user.get(authid, 'username')
+ authaddr = self.db.user.get(authid, 'address')
+ if authaddr:
+ authaddr = ' <%s>'%authaddr
+ else:
+ authaddr = ''
+
+ # make the message body
+ m = ['']
+
+ # put in roundup's signature
+ if self.EMAIL_SIGNATURE_POSITION == 'top':
+ m.append(self.email_signature(nodeid, msgid))
+
+ # add author information
+ if len(self.get(nodeid,'messages')) == 1:
+ m.append("New submission from %s%s:"%(authname, authaddr))
+ else:
+ m.append("%s%s added the comment:"%(authname, authaddr))
+ m.append('')
+
+ # add the content
+ m.append(self.db.msg.get(msgid, 'content'))
+
+ # add the change note
+ if change_note:
+ m.append(change_note)
+
+ # put in roundup's signature
+ if self.EMAIL_SIGNATURE_POSITION == 'bottom':
+ m.append(self.email_signature(nodeid, msgid))
+
+ # get the files for this message
+ files = self.db.msg.get(msgid, 'files')
+
+ # create the message
+ message = cStringIO.StringIO()
+ writer = MimeWriter.MimeWriter(message)
+ writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
+ writer.addheader('To', ', '.join(sendto))
+ writer.addheader('From', '%s <%s>'%(authname, self.ISSUE_TRACKER_EMAIL))
+ writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME,
+ self.ISSUE_TRACKER_EMAIL))
+ writer.addheader('MIME-Version', '1.0')
+
+ # attach files
+ if files:
+ part = writer.startmultipartbody('mixed')
+ part = writer.nextpart()
+ body = part.startbody('text/plain')
+ body.write('\n'.join(m))
+ for fileid in files:
+ name = self.db.file.get(fileid, 'name')
+ mime_type = self.db.file.get(fileid, 'type')
+ content = self.db.file.get(fileid, 'content')
+ part = writer.nextpart()
+ if mime_type == 'text/plain':
+ part.addheader('Content-Disposition',
+ 'attachment;\n filename="%s"'%name)
+ part.addheader('Content-Transfer-Encoding', '7bit')
+ body = part.startbody('text/plain')
+ body.write(content)
+ else:
+ # some other type, so encode it
+ if not mime_type:
+ # this should have been done when the file was saved
+ mime_type = mimetypes.guess_type(name)[0]
+ if mime_type is None:
+ mime_type = 'application/octet-stream'
+ part.addheader('Content-Disposition',
+ 'attachment;\n filename="%s"'%name)
+ part.addheader('Content-Transfer-Encoding', 'base64')
+ body = part.startbody(mime_type)
+ body.write(base64.encodestring(content))
+ writer.lastpart()
+ else:
+ body = writer.startbody('text/plain')
+ body.write('\n'.join(m))
+
+ # now try to send the message
+ try:
+ smtp = smtplib.SMTP(self.MAILHOST)
+ # send the message as admin so bounces are sent there instead
+ # of to roundup
+ smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
+ except socket.error, value:
+ raise MessageSendError, \
+ "Couldn't send confirmation email: mailhost %s"%value
+ except smtplib.SMTPException, value:
+ raise MessageSendError, \
+ "Couldn't send confirmation email: %s"%value
+
+ def email_signature(self, nodeid, msgid):
+ ''' Add a signature to the e-mail with some useful information
+ '''
+ web = self.ISSUE_TRACKER_WEB + 'issue'+ nodeid
+ email = '"%s" <%s>'%(self.INSTANCE_NAME, self.ISSUE_TRACKER_EMAIL)
+ line = '_' * max(len(web), len(email))
+ return '%s\n%s\n%s\n%s'%(line, email, web, line)
+
+ def generateChangeNote(self, nodeid, oldvalues):
+ """Generate a change note that lists property changes
+ """
+ cn = self.classname
+ cl = self.db.classes[cn]
+ changed = {}
+ props = cl.getprops(protected=0)
+
+ # determine what changed
+ for key in oldvalues.keys():
+ if key in ['files','messages']: continue
+ new_value = cl.get(nodeid, key)
+ # the old value might be non existent
try:
- smtp = smtplib.SMTP(self.MAILHOST)
- smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
- except socket.error, value:
- return "Couldn't send confirmation email: mailhost %s"%value
- except smtplib.SMTPException, value:
- return "Couldn't send confirmation email: %s"%value
+ old_value = oldvalues[key]
+ if type(new_value) is type([]):
+ new_value.sort()
+ old_value.sort()
+ if new_value != old_value:
+ changed[key] = old_value
+ except:
+ changed[key] = new_value
+
+ # list the changes
+ m = []
+ for propname, oldvalue in changed.items():
+ prop = cl.properties[propname]
+ value = cl.get(nodeid, propname, None)
+ if isinstance(prop, hyperdb.Link):
+ link = self.db.classes[prop.classname]
+ key = link.labelprop(default_to_id=1)
+ if key:
+ if value:
+ value = link.get(value, key)
+ else:
+ value = ''
+ if oldvalue:
+ oldvalue = link.get(oldvalue, key)
+ else:
+ oldvalue = ''
+ change = '%s -> %s'%(oldvalue, value)
+ elif isinstance(prop, hyperdb.Multilink):
+ change = ''
+ if value is None: value = []
+ if oldvalue is None: oldvalue = []
+ l = []
+ link = self.db.classes[prop.classname]
+ key = link.labelprop(default_to_id=1)
+ # check for additions
+ for entry in value:
+ if entry in oldvalue: continue
+ if key:
+ l.append(link.get(entry, key))
+ else:
+ l.append(entry)
+ if l:
+ change = '+%s'%(', '.join(l))
+ l = []
+ # check for removals
+ for entry in oldvalue:
+ if entry in value: continue
+ if key:
+ l.append(link.get(entry, key))
+ else:
+ l.append(entry)
+ if l:
+ change += ' -%s'%(', '.join(l))
+ else:
+ change = '%s -> %s'%(oldvalue, value)
+ m.append('%s: %s'%(propname, change))
+ if m:
+ m.insert(0, '----------')
+ m.insert(0, '')
+ return '\n'.join(m)
#
# $Log: not supported by cvs2svn $
-# Revision 1.6 2001/07/20 07:35:55 richard
-# largish changes as a start of splitting off bits and pieces to allow more
-# flexible installation / database back-ends
+# Revision 1.30 2001/12/12 21:47:45 richard
+# . Message author's name appears in From: instead of roundup instance name
+# (which still appears in the Reply-To:)
+# . envelope-from is now set to the roundup-admin and not roundup itself so
+# delivery reports aren't sent to roundup (thanks Patrick Ohly)
#
-# Revision 1.5 2001/07/20 00:22:50 richard
-# Priority list changes - removed the redundant TODO and added support. See
-# roundup-devel for details.
+# Revision 1.29 2001/12/11 04:50:49 richard
+# fixed the order of the blank line and '-------' line
#
-# Revision 1.4 2001/07/19 06:27:07 anthonybaxter
-# fixing (manually) the (dollarsign)Log(dollarsign) entries caused by
-# my using the magic (dollarsign)Id(dollarsign) and (dollarsign)Log(dollarsign)
-# strings in a commit message. I'm a twonk.
+# Revision 1.28 2001/12/10 22:20:01 richard
+# Enabled transaction support in the bsddb backend. It uses the anydbm code
+# where possible, only replacing methods where the db is opened (it uses the
+# btree opener specifically.)
+# Also cleaned up some change note generation.
+# Made the backends package work with pydoc too.
#
-# Also broke the help string in two.
+# Revision 1.27 2001/12/10 21:02:53 richard
+# only insert the -------- change note marker if there is a change note
#
-# Revision 1.3 2001/07/19 05:52:22 anthonybaxter
-# Added CVS keywords Id and Log to all python files.
+# Revision 1.26 2001/12/05 14:26:44 rochecompaan
+# Removed generation of change note from "sendmessage" in roundupdb.py.
+# The change note is now generated when the message is created.
#
+# Revision 1.25 2001/11/30 20:28:10 rochecompaan
+# Property changes are now completely traceable, whether changes are
+# made through the web or by email
#
-
+# Revision 1.24 2001/11/30 11:29:04 rochecompaan
+# Property changes are now listed in emails generated by Roundup
+#
+# Revision 1.23 2001/11/27 03:17:13 richard
+# oops
+#
+# Revision 1.22 2001/11/27 03:00:50 richard
+# couple of bugfixes from latest patch integration
+#
+# Revision 1.21 2001/11/26 22:55:56 richard
+# Feature:
+# . Added INSTANCE_NAME to configuration - used in web and email to identify
+# the instance.
+# . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
+# signature info in e-mails.
+# . Some more flexibility in the mail gateway and more error handling.
+# . Login now takes you to the page you back to the were denied access to.
+#
+# Fixed:
+# . Lots of bugs, thanks Roché and others on the devel mailing list!
+#
+# Revision 1.20 2001/11/25 10:11:14 jhermann
+# Typo fix
+#
+# Revision 1.19 2001/11/22 15:46:42 jhermann
+# Added module docstrings to all modules.
+#
+# Revision 1.18 2001/11/15 10:36:17 richard
+# . incorporated patch from Roch'e Compaan implementing attachments in nosy
+# e-mail
+#
+# Revision 1.17 2001/11/12 22:01:06 richard
+# Fixed issues with nosy reaction and author copies.
+#
+# Revision 1.16 2001/10/30 00:54:45 richard
+# Features:
+# . #467129 ] Lossage when username=e-mail-address
+# . #473123 ] Change message generation for author
+# . MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
+#
+# Revision 1.15 2001/10/23 01:00:18 richard
+# Re-enabled login and registration access after lopping them off via
+# disabling access for anonymous users.
+# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
+# a couple of bugs while I was there. Probably introduced a couple, but
+# things seem to work OK at the moment.
+#
+# Revision 1.14 2001/10/21 07:26:35 richard
+# feature #473127: Filenames. I modified the file.index and htmltemplate
+# source so that the filename is used in the link and the creation
+# information is displayed.
+#
+# Revision 1.13 2001/10/21 00:45:15 richard
+# Added author identification to e-mail messages from roundup.
+#
+# Revision 1.12 2001/10/04 02:16:15 richard
+# Forgot to pass the protected flag down *sigh*.
+#
+# Revision 1.11 2001/10/04 02:12:42 richard
+# Added nicer command-line item adding: passing no arguments will enter an
+# interactive more which asks for each property in turn. While I was at it, I
+# fixed an implementation problem WRT the spec - I wasn't raising a
+# ValueError if the key property was missing from a create(). Also added a
+# protected=boolean argument to getprops() so we can list only the mutable
+# properties (defaults to yes, which lists the immutables).
+#
+# Revision 1.10 2001/08/07 00:24:42 richard
+# stupid typo
+#
+# Revision 1.9 2001/08/07 00:15:51 richard
+# Added the copyright/license notice to (nearly) all files at request of
+# Bizar Software.
+#
+# Revision 1.8 2001/08/02 06:38:17 richard
+# Roundupdb now appends "mailing list" information to its messages which
+# include the e-mail address and web interface address. Templates may
+# override this in their db classes to include specific information (support
+# instructions, etc).
+#
+# Revision 1.7 2001/07/30 02:38:31 richard
+# get() now has a default arg - for migration only.
+#
+# Revision 1.6 2001/07/30 00:05:54 richard
+# Fixed IssueClass so that superseders links to its classname rather than
+# hard-coded to "issue".
+#
+# Revision 1.5 2001/07/29 07:01:39 richard
+# Added vim command to all source so that we don't get no steenkin' tabs :)
+#
+# Revision 1.4 2001/07/29 04:05:37 richard
+# Added the fabricated property "id".
+#
+# Revision 1.3 2001/07/23 07:14:41 richard
+# Moved the database backends off into backends.
+#
+# Revision 1.2 2001/07/22 12:09:32 richard
+# Final commit of Grande Splite
+#
+# Revision 1.1 2001/07/22 11:58:35 richard
+# More Grande Splite
+#
+#
+# vim: set filetype=python ts=4 sw=4 et si