diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py
index bd1933eefc5a52282fa78c658de1dcd595eb46a4..3f5620b513513807154e04a650e428bc1fac6c84 100644 (file)
--- a/roundup/roundupdb.py
+++ b/roundup/roundupdb.py
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
#
-# $Id: roundupdb.py,v 1.36 2002-01-02 02:31:38 richard Exp $
+# $Id: roundupdb.py,v 1.59 2002-06-18 03:55:25 dman13 Exp $
__doc__ = """
Extending hyperdb with types specific to issue-tracking.
"""
import re, os, smtplib, socket, copy, time, random
-import mimetools, MimeWriter, cStringIO
-import base64, mimetypes
+import MimeWriter, cStringIO
+import base64, quopri, mimetypes
+# if available, use the 'email' module, otherwise fallback to 'rfc822'
+try :
+ from email.Utils import dump_address_pair as straddr
+except ImportError :
+ from rfc822 import dump_address_pair as straddr
import hyperdb, date
return m.group(1), m.group(2)
+def extractUserFromList(userClass, users):
+ '''Given a list of users, try to extract the first non-anonymous user
+ and return that user, otherwise return None
+ '''
+ if len(users) > 1:
+ # make sure we don't match the anonymous or admin user
+ for user in users:
+ if user == '1': continue
+ if userClass.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]
+ return None
+
class Database:
def getuid(self):
"""Return the id of the "user" node associated with the user
user is created if they don't exist in the db already
'''
(realname, address) = address
- users = self.user.stringFind(address=address)
- 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)
+
+ # try a straight match of the address
+ user = extractUserFromList(self.user,
+ self.user.stringFind(address=address))
+ if user is not None: return user
+
+ # try the user alternate addresses if possible
+ props = self.user.getprops()
+ if props.has_key('alternate_addresses'):
+ users = self.user.filter(None, {'alternate_addresses': address},
+ [], [])
+ user = extractUserFromList(self.user, users)
+ if user is not None: return user
+
+ # try to match the username to the address (for local
+ # submissions where the address is empty)
+ user = extractUserFromList(self.user,
+ self.user.stringFind(username=address))
# couldn't match address or username, so create a new user
if create:
- print 'CREATING USER', address
return self.user.create(username=address, address=address,
realname=realname)
else:
"""
if propvalues.has_key('creation') or propvalues.has_key('activity'):
raise KeyError, '"creation" and "activity" are reserved'
- for audit in self.auditors['create']:
- audit(self.db, self, None, propvalues)
+ self.fireAuditors('create', None, propvalues)
nodeid = hyperdb.Class.create(self, **propvalues)
- for react in self.reactors['create']:
- react(self.db, self, nodeid, None)
+ self.fireReactors('create', nodeid, None)
return nodeid
def set(self, nodeid, **propvalues):
"""
if propvalues.has_key('creation') or propvalues.has_key('activity'):
raise KeyError, '"creation" and "activity" are reserved'
- for audit in self.auditors['set']:
- audit(self.db, self, nodeid, propvalues)
+ self.fireAuditors('set', nodeid, propvalues)
# Take a copy of the node dict so that the subsequent set
# operation doesn't modify the oldvalues structure.
try:
# with no intervening commit()
oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
hyperdb.Class.set(self, nodeid, **propvalues)
- for react in self.reactors['set']:
- react(self.db, self, nodeid, oldvalues)
+ self.fireReactors('set', nodeid, oldvalues)
def retire(self, nodeid):
"""These operations trigger detectors and can be vetoed. Attempts
to modify the "creation" or "activity" properties cause a KeyError.
"""
- for audit in self.auditors['retire']:
- audit(self.db, self, nodeid, None)
+ self.fireAuditors('retire', nodeid, None)
hyperdb.Class.retire(self, nodeid)
- for react in self.reactors['retire']:
- react(self.db, self, nodeid, None)
+ self.fireReactors('retire', nodeid, None)
def get(self, nodeid, propname, default=_marker, cache=1):
"""Attempts to get the "creation" or "activity" properties should
if detector not in l:
self.auditors[event].append(detector)
+ def fireAuditors(self, action, nodeid, newvalues):
+ """Fire all registered auditors.
+ """
+ for audit in self.auditors[action]:
+ audit(self.db, self, nodeid, newvalues)
+
def react(self, event, detector):
"""Register a detector
"""
if detector not in l:
self.reactors[event].append(detector)
+ def fireReactors(self, action, nodeid, oldvalues):
+ """Fire all registered reactors.
+ """
+ for react in self.reactors[action]:
+ react(self.db, self, nodeid, oldvalues)
class FileClass(Class):
def create(self, **propvalues):
def get(self, nodeid, propname, default=_marker, cache=1):
''' trap the content propname and get it from the file
'''
+
+ poss_msg = 'Possibly a access right configuration problem.'
if propname == 'content':
- return self.db.getfile(self.classname, nodeid, None)
+ try:
+ return self.db.getfile(self.classname, nodeid, None)
+ except IOError, (strerror):
+ # BUG: by catching this we donot see an error in the log.
+ return 'ERROR reading file: %s%s\n%s\n%s'%(
+ self.classname, nodeid, poss_msg, strerror)
if default is not _marker:
return Class.get(self, nodeid, propname, default, cache=cache)
else:
# 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:
appended to the "messages" field of the specified issue.
"""
- def sendmessage(self, nodeid, msgid, change_note):
+ def nosymessage(self, nodeid, msgid, oldvalues):
"""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
"""
users = self.db.user
messages = self.db.msg
- files = self.db.file
# figure the recipient ids
sendto = []
# figure the author's id, and indicate they've received the message
authid = messages.get(msgid, 'author')
- # get the current nosy list, we'll need it
- nosy = self.get(nodeid, 'nosy')
-
# possibly send the message to the author, as long as they aren't
# anonymous
- if (self.MESSAGES_TO_AUTHOR == 'yes' and
+ if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
users.get(authid, 'username') != 'anonymous'):
sendto.append(authid)
r[authid] = 1
# now figure the nosy people who weren't recipients
+ 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
sendto.append(nosyid)
recipients.append(nosyid)
- # no new recipients
- if not sendto:
- return
+ # generate a change note
+ if oldvalues:
+ note = self.generateChangeNote(nodeid, oldvalues)
+ else:
+ note = self.generateCreateNote(nodeid)
+
+ # we have new recipients
+ if sendto:
+ # map userids to addresses
+ sendto = [users.get(i, 'address') for i in sendto]
+
+ # update the message's recipients list
+ messages.set(msgid, recipients=recipients)
+
+ # send the message
+ self.send_message(nodeid, msgid, note, sendto)
+
+ # XXX backwards compatibility - don't remove
+ sendmessage = nosymessage
+
+ def send_message(self, nodeid, msgid, note, sendto):
+ '''Actually send the nominated message from this node to the sendto
+ recipients, with the note appended.
+ '''
+ users = self.db.user
+ messages = self.db.msg
+ files = self.db.file
# determine the messageid and inreplyto of the message
inreplyto = messages.get(msgid, 'inreplyto')
messageid = messages.get(msgid, 'messageid')
+
+ # make up a messageid if there isn't one (web edit)
if not messageid:
# this is an old message that didn't get a messageid, so
# create one
- messageid = "%s.%s.%s%s-%s"%(time.time(), random.random(),
- self.classname, nodeid, self.MAIL_DOMAIN)
+ messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
+ self.classname, nodeid, self.db.config.MAIL_DOMAIN)
messages.set(msgid, messageid=messageid)
- # update the message's recipients list
- messages.set(msgid, recipients=recipients)
-
# send an email to the people who missed out
- sendto = [users.get(i, 'address') for i in sendto]
cn = self.classname
title = self.get(nodeid, 'title') or '%s message copy'%cn
# figure author information
+ authid = messages.get(msgid, 'author')
authname = users.get(authid, 'realname')
if not authname:
authname = users.get(authid, 'username')
authaddr = users.get(authid, 'address')
if authaddr:
- authaddr = ' <%s>'%authaddr
+ authaddr = " <%s>" % straddr( ('',authaddr) )
else:
authaddr = ''
m = ['']
# put in roundup's signature
- if self.EMAIL_SIGNATURE_POSITION == 'top':
+ if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
m.append(self.email_signature(nodeid, msgid))
# add author information
m.append(messages.get(msgid, 'content'))
# add the change note
- if change_note:
- m.append(change_note)
+ if note:
+ m.append(note)
# put in roundup's signature
- if self.EMAIL_SIGNATURE_POSITION == 'bottom':
+ if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
m.append(self.email_signature(nodeid, msgid))
+ # encode the content as quoted-printable
+ content = cStringIO.StringIO('\n'.join(m))
+ content_encoded = cStringIO.StringIO()
+ quopri.encode(content, content_encoded, 0)
+ content_encoded = content_encoded.getvalue()
+
# get the files for this message
- files = messages.get(msgid, 'files')
+ message_files = messages.get(msgid, 'files')
+
+ # make sure the To line is always the same (for testing mostly)
+ sendto.sort()
# 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('From', straddr(
+ (authname, self.db.config.ISSUE_TRACKER_EMAIL) ) )
+ writer.addheader('Reply-To', straddr(
+ (self.db.config.INSTANCE_NAME,
+ self.db.config.ISSUE_TRACKER_EMAIL) ) )
writer.addheader('MIME-Version', '1.0')
if messageid:
writer.addheader('Message-Id', messageid)
if inreplyto:
writer.addheader('In-Reply-To', inreplyto)
+ # add a uniquely Roundup header to help filtering
+ writer.addheader('X-Roundup-Name', self.db.config.INSTANCE_NAME)
+
# attach files
- if files:
+ if message_files:
part = writer.startmultipartbody('mixed')
part = writer.nextpart()
+ part.addheader('Content-Transfer-Encoding', 'quoted-printable')
body = part.startbody('text/plain')
- body.write('\n'.join(m))
- for fileid in files:
+ body.write(content_encoded)
+ for fileid in message_files:
name = files.get(fileid, 'name')
mime_type = files.get(fileid, 'type')
content = files.get(fileid, 'content')
body.write(base64.encodestring(content))
writer.lastpart()
else:
+ writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
body = writer.startbody('text/plain')
- body.write('\n'.join(m))
+ body.write(content_encoded)
# now try to send the message
if SENDMAILDEBUG:
open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
- self.ADMIN_EMAIL, ', '.join(sendto), message.getvalue()))
+ self.db.config.ADMIN_EMAIL,
+ ', '.join(sendto),message.getvalue()))
else:
try:
# send the message as admin so bounces are sent there
# instead of to roundup
- smtp = smtplib.SMTP(self.MAILHOST)
- smtp.sendmail(self.ADMIN_EMAIL, sendto, message.getvalue())
+ smtp = smtplib.SMTP(self.db.config.MAILHOST)
+ smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
+ message.getvalue())
except socket.error, value:
raise MessageSendError, \
"Couldn't send confirmation email: mailhost %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)
+
+ # simplistic check to see if the url is valid,
+ # then append a trailing slash if it is missing
+ base = self.db.config.ISSUE_TRACKER_WEB
+ if not isinstance( base , type('') ) or not base.startswith( "http://" ) :
+ base = "Configuration Error: ISSUE_TRACKER_WEB isn't a fully-qualified URL"
+ elif base[-1] != '/' :
+ base += '/'
+ web = base + 'issue'+ nodeid
+
+ # ensure the email address is properly quoted
+ email = straddr( (self.db.config.INSTANCE_NAME ,
+ self.db.config.ISSUE_TRACKER_EMAIL) )
+
line = '_' * max(len(web), len(email))
return '%s\n%s\n%s\n%s'%(line, email, web, line)
+
+ def generateCreateNote(self, nodeid):
+ """Generate a create note that lists initial property values
+ """
+ cn = self.classname
+ cl = self.db.classes[cn]
+ props = cl.getprops(protected=0)
+
+ # list the values
+ m = []
+ l = props.items()
+ l.sort()
+ for propname, prop in l:
+ value = cl.get(nodeid, propname, None)
+ # skip boring entries
+ if not value:
+ continue
+ if isinstance(prop, hyperdb.Link):
+ link = self.db.classes[prop.classname]
+ if value:
+ key = link.labelprop(default_to_id=1)
+ if key:
+ value = link.get(value, key)
+ else:
+ value = ''
+ elif isinstance(prop, hyperdb.Multilink):
+ if value is None: value = []
+ l = []
+ link = self.db.classes[prop.classname]
+ key = link.labelprop(default_to_id=1)
+ if key:
+ value = [link.get(entry, key) for entry in value]
+ value.sort()
+ value = ', '.join(value)
+ m.append('%s: %s'%(propname, value))
+ m.insert(0, '----------')
+ m.insert(0, '')
+ return '\n'.join(m)
+
def generateChangeNote(self, nodeid, oldvalues):
"""Generate a change note that lists property changes
"""
+
+ if __debug__ :
+ if not isinstance( oldvalues , type({}) ) :
+ raise TypeError(
+ "'oldvalues' must be dict-like, not %s."
+ % str(type(oldvalues)) )
+
cn = self.classname
cl = self.db.classes[cn]
changed = {}
# list the changes
m = []
- for propname, oldvalue in changed.items():
- prop = cl.properties[propname]
+ l = changed.items()
+ l.sort()
+ for propname, oldvalue in l:
+ prop = props[propname]
value = cl.get(nodeid, propname, None)
if isinstance(prop, hyperdb.Link):
link = self.db.classes[prop.classname]
#
# $Log: not supported by cvs2svn $
+# Revision 1.58 2002/06/16 01:05:15 dman13
+# Removed temporary workaround -- it seems it was a bug in the
+# nosyreaction detector in the 0.4.1 extended template and has already
+# been fixed in CVS. We'll see.
+#
+# Revision 1.57 2002/06/15 15:49:29 dman13
+# Use 'email' instead of 'rfc822', if available.
+# Don't use isinstance() on a string (not allowed in python 2.1).
+# Return an error message instead of crashing if 'oldvalues' isn't a
+# dict (in generateChangeNote).
+#
+# Revision 1.56 2002/06/14 03:54:21 dman13
+# #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it
+#
+# use the rfc822 module to ensure that every (oddball) email address and
+# real-name is properly quoted
+#
+# Revision 1.55 2002/06/11 04:58:07 richard
+# detabbing
+#
+# Revision 1.54 2002/05/29 01:16:17 richard
+# Sorry about this huge checkin! It's fixing a lot of related stuff in one go
+# though.
+#
+# . #541941 ] changing multilink properties by mail
+# . #526730 ] search for messages capability
+# . #505180 ] split MailGW.handle_Message
+# - also changed cgi client since it was duplicating the functionality
+# . build htmlbase if tests are run using CVS checkout (removed note from
+# installation.txt)
+# . don't create an empty message on email issue creation if the email is empty
+#
+# Revision 1.53 2002/05/25 07:16:24 rochecompaan
+# Merged search_indexing-branch with HEAD
+#
+# Revision 1.52 2002/05/15 03:27:16 richard
+# . fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
+# (thanks dman)
+# . fixed some sorting issues that were breaking some unit tests under py2.2
+# . mailgw test output dir was confusing the init test (but only on 2.2 *shrug*)
+#
+# fixed bug in the init unit test that meant only the bsddb test ran if it
+# could (it clobbered the anydbm test)
+#
+# Revision 1.51 2002/04/08 03:46:42 richard
+# make it work
+#
+# Revision 1.50 2002/04/08 03:40:31 richard
+# . added a "detectors" directory for people to put their useful auditors and
+# reactors in. Note - the roundupdb.IssueClass.sendmessage method has been
+# split and renamed "nosymessage" specifically for things like the nosy
+# reactor, and "send_message" which just sends the message.
+#
+# The initial detector is one that we'll be using here at ekit - it bounces new
+# issue messages to a team address.
+#
+# Revision 1.49.2.1 2002/04/19 19:54:42 rochecompaan
+# cgi_client.py
+# removed search link for the time being
+# moved rendering of matches to htmltemplate
+# hyperdb.py
+# filtering of nodes on full text search incorporated in filter method
+# roundupdb.py
+# added paramater to call of filter method
+# roundup_indexer.py
+# added search method to RoundupIndexer class
+#
+# Revision 1.49 2002/03/19 06:41:49 richard
+# Faster, easier, less mess ;)
+#
+# Revision 1.48 2002/03/18 18:32:00 rochecompaan
+# All messages sent to the nosy list are now encoded as quoted-printable.
+#
+# Revision 1.47 2002/02/27 03:16:02 richard
+# Fixed a couple of dodgy bits found by pychekcer.
+#
+# Revision 1.46 2002/02/25 14:22:59 grubert
+# . roundup db: catch only IOError in getfile.
+#
+# Revision 1.44 2002/02/15 07:08:44 richard
+# . Alternate email addresses are now available for users. See the MIGRATION
+# file for info on how to activate the feature.
+#
+# Revision 1.43 2002/02/14 22:33:15 richard
+# . Added a uniquely Roundup header to email, "X-Roundup-Name"
+#
+# Revision 1.42 2002/01/21 09:55:14 rochecompaan
+# Properties in change note are now sorted
+#
+# Revision 1.41 2002/01/15 00:12:40 richard
+# #503340 ] creating issue with [asignedto=p.ohly]
+#
+# Revision 1.40 2002/01/14 22:21:38 richard
+# #503353 ] setting properties in initial email
+#
+# Revision 1.39 2002/01/14 02:20:15 richard
+# . changed all config accesses so they access either the instance or the
+# config attriubute on the db. This means that all config is obtained from
+# instance_config instead of the mish-mash of classes. This will make
+# switching to a ConfigParser setup easier too, I hope.
+#
+# At a minimum, this makes migration a _little_ easier (a lot easier in the
+# 0.5.0 switch, I hope!)
+#
+# Revision 1.38 2002/01/10 05:57:45 richard
+# namespace clobberation
+#
+# Revision 1.37 2002/01/08 04:12:05 richard
+# Changed message-id format to "<%s.%s.%s%s@%s>" so it complies with RFC822
+#
+# Revision 1.36 2002/01/02 02:31:38 richard
+# Sorry for the huge checkin message - I was only intending to implement #496356
+# but I found a number of places where things had been broken by transactions:
+# . modified ROUNDUPDBSENDMAILDEBUG to be SENDMAILDEBUG and hold a filename
+# for _all_ roundup-generated smtp messages to be sent to.
+# . the transaction cache had broken the roundupdb.Class set() reactors
+# . newly-created author users in the mailgw weren't being committed to the db
+#
+# Stuff that made it into CHANGES.txt (ie. the stuff I was actually working
+# on when I found that stuff :):
+# . #496356 ] Use threading in messages
+# . detectors were being registered multiple times
+# . added tests for mailgw
+# . much better attaching of erroneous messages in the mail gateway
+#
# Revision 1.35 2001/12/20 15:43:01 rochecompaan
# Features added:
# . Multilink properties are now displayed as comma separated values in