X-Git-Url: https://git.tokkee.org/?a=blobdiff_plain;f=roundup%2Froundupdb.py;h=c2aeafef6fec1129d52186001cbc43c88f815b3a;hb=d5a0ddfc00544e210af7276f25a904e23f2556a3;hp=c0b6201294c8e625cdf43793efbb3de71da3488d;hpb=0fb7b386ffbd9e79cef2917739fedd30202ef0cd;p=roundup.git diff --git a/roundup/roundupdb.py b/roundup/roundupdb.py index c0b6201..c2aeafe 100644 --- a/roundup/roundupdb.py +++ b/roundup/roundupdb.py @@ -15,18 +15,22 @@ # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. # -# $Id: roundupdb.py,v 1.25 2001-11-30 20:28:10 rochecompaan Exp $ +# $Id: roundupdb.py,v 1.41 2002-01-15 00:12:40 richard Exp $ __doc__ = """ Extending hyperdb with types specific to issue-tracking. """ -import re, os, smtplib, socket +import re, os, smtplib, socket, copy, time, random import mimetools, MimeWriter, cStringIO import base64, mimetypes import hyperdb, date +# set to indicate to roundup not to actually _send_ email +# this var must contain a file to write the mail to +SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') + class DesignatorError(ValueError): pass def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')): @@ -68,8 +72,12 @@ class Database: 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) + if create: + print 'CREATING USER', address + return self.user.create(username=address, address=address, + realname=realname) + else: + return 0 _marker = [] # XXX: added the 'creator' faked attribute @@ -104,7 +112,16 @@ class Class(hyperdb.Class): raise KeyError, '"creation" and "activity" are reserved' for audit in self.auditors['set']: audit(self.db, self, nodeid, propvalues) - oldvalues = self.db.getnode(self.classname, nodeid) + # Take a copy of the node dict so that the subsequent set + # operation doesn't modify the oldvalues structure. + try: + # try not using the cache initially + oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid, + cache=0)) + except IndexError: + # this will be needed if somone does a create() and set() + # 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) @@ -119,7 +136,7 @@ class Class(hyperdb.Class): for react in self.reactors['retire']: react(self.db, self, nodeid, None) - def get(self, nodeid, propname, default=_marker): + def get(self, nodeid, propname, default=_marker, cache=1): """Attempts to get the "creation" or "activity" properties should do the right thing. """ @@ -145,9 +162,10 @@ class Class(hyperdb.Class): return None return self.db.user.lookup(name) if default is not _marker: - return hyperdb.Class.get(self, nodeid, propname, default) + return hyperdb.Class.get(self, nodeid, propname, default, + cache=cache) else: - return hyperdb.Class.get(self, nodeid, propname) + return hyperdb.Class.get(self, nodeid, propname, cache=cache) def getprops(self, protected=1): """In addition to the actual properties on the node, these @@ -168,12 +186,16 @@ class Class(hyperdb.Class): def audit(self, event, detector): """Register a detector """ - self.auditors[event].append(detector) + l = self.auditors[event] + if detector not in l: + self.auditors[event].append(detector) def react(self, event, detector): """Register a detector """ - self.reactors[event].append(detector) + l = self.reactors[event] + if detector not in l: + self.reactors[event].append(detector) class FileClass(Class): @@ -183,32 +205,18 @@ class FileClass(Class): content = propvalues['content'] del propvalues['content'] newid = Class.create(self, **propvalues) - self.setcontent(self.classname, newid, content) + self.db.storefile(self.classname, newid, None, content) return newid - def filename(self, classname, nodeid): - # TODO: split into multiple files directories - return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid)) - - def setcontent(self, classname, nodeid, content): - ''' set the content file for this file - ''' - open(self.filename(classname, nodeid), 'wb').write(content) - - def getcontent(self, classname, nodeid): - ''' get the content file for this file - ''' - return open(self.filename(classname, nodeid), 'rb').read() - - def get(self, nodeid, propname, default=_marker): + def get(self, nodeid, propname, default=_marker, cache=1): ''' trap the content propname and get it from the file ''' if propname == 'content': - return self.getcontent(self.classname, nodeid) + return self.db.getfile(self.classname, nodeid, None) if default is not _marker: - return Class.get(self, nodeid, propname, default) + return Class.get(self, nodeid, propname, default, cache=cache) else: - return Class.get(self, nodeid, propname) + return Class.get(self, nodeid, propname, cache=cache) def getprops(self, protected=1): ''' In addition to the actual properties on the node, these methods @@ -229,10 +237,6 @@ class DetectorError(RuntimeError): # 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: @@ -268,7 +272,7 @@ class IssueClass(Class): appended to the "messages" field of the specified issue. """ - def sendmessage(self, nodeid, msgid, oldvalues): + 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 @@ -276,25 +280,28 @@ class IssueClass(Class): These users are then added to the message's "recipients" list. """ + users = self.db.user + messages = self.db.msg + files = self.db.file + # figure the recipient ids - recipients = self.db.msg.get(msgid, 'recipients') + sendto = [] r = {} - for recipid in recipients: + recipients = messages.get(msgid, 'recipients') + for recipid in messages.get(msgid, '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') + authid = messages.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) + # possibly send the message to the author, as long as they aren't + # anonymous + 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 @@ -302,37 +309,40 @@ class IssueClass(Class): # 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 users.get(nosyid, 'username') == 'anonymous': + continue + # make sure they haven't seen the message already if not r.has_key(nosyid): + # send it to them + sendto.append(nosyid) recipients.append(nosyid) # no new recipients - if rlen == len(recipients): + if not sendto: return - # get the change note - if oldvalues: - change_note = self.generateChangeNote(nodeid, oldvalues) - else: - change_note = '' - - # add the change note to the message content - content = self.db.msg.get(msgid, 'content') - content += change_note + # determine the messageid and inreplyto of the message + inreplyto = messages.get(msgid, 'inreplyto') + messageid = messages.get(msgid, 'messageid') + 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.db.config.MAIL_DOMAIN) + messages.set(msgid, messageid=messageid) # update the message's recipients list - self.db.msg.set(msgid, recipients=recipients) - self.db.msg.setcontent('msg', msgid, content) + messages.set(msgid, recipients=recipients) # send an email to the people who missed out - sendto = [self.db.user.get(i, 'address') for i in recipients] + 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 - authname = self.db.user.get(authid, 'realname') + authname = users.get(authid, 'realname') if not authname: - authname = self.db.user.get(authid, 'username') - authaddr = self.db.user.get(authid, 'address') + authname = users.get(authid, 'username') + authaddr = users.get(authid, 'address') if authaddr: authaddr = ' <%s>'%authaddr else: @@ -342,47 +352,55 @@ class IssueClass(Class): 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 - if oldvalues: - m.append("%s%s added the comment:"%(authname, authaddr)) - else: + 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(content) + m.append(messages.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': + if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom': m.append(self.email_signature(nodeid, msgid)) # get the files for this message - files = self.db.msg.get(msgid, 'files') + message_files = messages.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>'%(self.INSTANCE_NAME, - self.ISSUE_TRACKER_EMAIL)) - writer.addheader('Reply-To', '%s <%s>'%(self.INSTANCE_NAME, - self.ISSUE_TRACKER_EMAIL)) + writer.addheader('From', '%s <%s>'%(authname, + self.db.config.ISSUE_TRACKER_EMAIL)) + writer.addheader('Reply-To', '%s <%s>'%(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) # attach files - if files: + if message_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') + for fileid in message_files: + name = files.get(fileid, 'name') + mime_type = files.get(fileid, 'type') + content = files.get(fileid, 'content') part = writer.nextpart() if mime_type == 'text/plain': part.addheader('Content-Disposition', @@ -408,24 +426,69 @@ class IssueClass(Class): body.write('\n'.join(m)) # now try to send the message - try: - smtp = smtplib.SMTP(self.MAILHOST) - smtp.sendmail(self.ISSUE_TRACKER_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 + if SENDMAILDEBUG: + open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%( + 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.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 + 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) + web = self.db.config.ISSUE_TRACKER_WEB + 'issue'+ nodeid + email = '"%s" <%s>'%(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 = ', '.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 """ @@ -435,27 +498,25 @@ class IssueClass(Class): props = cl.getprops(protected=0) # determine what changed - for key in props.keys(): + 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: old_value = oldvalues[key] - if type(old_value) is type([]): - old_value.sort() + if type(new_value) is type([]): new_value.sort() - if old_value != new_value: + old_value.sort() + if new_value != old_value: changed[key] = old_value except: - old_value = None - changed[key] = old_value + changed[key] = new_value # list the changes - m = ['','----------'] + m = [] for propname, oldvalue in changed.items(): prop = cl.properties[propname] value = cl.get(nodeid, propname, None) - change = '%s -> %s'%(oldvalue, value) if isinstance(prop, hyperdb.Link): link = self.db.classes[prop.classname] key = link.labelprop(default_to_id=1) @@ -472,10 +533,10 @@ class IssueClass(Class): 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) - if oldvalue is None: oldvalue = [] # check for additions for entry in value: if entry in oldvalue: continue @@ -495,11 +556,106 @@ class IssueClass(Class): 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.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 +# a textbox +# . The add user link is now only visible to the admin user +# . Modified the mail gateway to reject submissions from unknown +# addresses if ANONYMOUS_ACCESS is denied +# +# Revision 1.34 2001/12/17 03:52:48 richard +# Implemented file store rollback. As a bonus, the hyperdb is now capable of +# storing more than one file per node - if a property name is supplied, +# the file is called designator.property. +# I decided not to migrate the existing files stored over to the new naming +# scheme - the FileClass just doesn't specify the property name. +# +# Revision 1.33 2001/12/16 10:53:37 richard +# take a copy of the node dict so that the subsequent set +# operation doesn't modify the oldvalues structure +# +# Revision 1.32 2001/12/15 23:48:35 richard +# Added ROUNDUPDBSENDMAILDEBUG so one can test the sendmail method without +# actually sending mail :) +# +# Revision 1.31 2001/12/15 19:24:39 rochecompaan +# . Modified cgi interface to change properties only once all changes are +# collected, files created and messages generated. +# . Moved generation of change note to nosyreactors. +# . We now check for changes to "assignedto" to ensure it's added to the +# nosy list. +# +# 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.29 2001/12/11 04:50:49 richard +# fixed the order of the blank line and '-------' line +# +# 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. +# +# Revision 1.27 2001/12/10 21:02:53 richard +# only insert the -------- change note marker if there is a change note +# +# 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 #