Code

really fix bug 663235, and test it
[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.77 2003-01-14 22:19:27 richard Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, time, random
25 import MimeWriter, cStringIO
26 import base64, quopri, mimetypes
27 # if available, use the 'email' module, otherwise fallback to 'rfc822'
28 try :
29     from email.Utils import formataddr as straddr
30 except ImportError :
31     # code taken from the email package 2.4.3
32     def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
33             escapesre = re.compile(r'[][\()"]')):
34         name, address = pair
35         if name:
36             quotes = ''
37             if specialsre.search(name):
38                 quotes = '"'
39             name = escapesre.sub(r'\\\g<0>', name)
40             return '%s%s%s <%s>' % (quotes, name, quotes, address)
41         return address
43 import hyperdb
45 # set to indicate to roundup not to actually _send_ email
46 # this var must contain a file to write the mail to
47 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
49 class Database:
50     def getuid(self):
51         """Return the id of the "user" node associated with the user
52         that owns this connection to the hyperdatabase."""
53         return self.user.lookup(self.journaltag)
55 class MessageSendError(RuntimeError):
56     pass
58 class DetectorError(RuntimeError):
59     ''' Raised by detectors that want to indicate that something's amiss
60     '''
61     pass
63 # deviation from spec - was called IssueClass
64 class IssueClass:
65     """ This class is intended to be mixed-in with a hyperdb backend
66         implementation. The backend should provide a mechanism that
67         enforces the title, messages, files, nosy and superseder
68         properties:
69             properties['title'] = hyperdb.String(indexme='yes')
70             properties['messages'] = hyperdb.Multilink("msg")
71             properties['files'] = hyperdb.Multilink("file")
72             properties['nosy'] = hyperdb.Multilink("user")
73             properties['superseder'] = hyperdb.Multilink(classname)
74     """
76     # New methods:
77     def addmessage(self, nodeid, summary, text):
78         """Add a message to an issue's mail spool.
80         A new "msg" node is constructed using the current date, the user that
81         owns the database connection as the author, and the specified summary
82         text.
84         The "files" and "recipients" fields are left empty.
86         The given text is saved as the body of the message and the node is
87         appended to the "messages" field of the specified issue.
88         """
90     # XXX "bcc" is an optional extra here...
91     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
92             from_address=None, cc=[]): #, bcc=[]):
93         """Send a message to the members of an issue's nosy list.
95         The message is sent only to users on the nosy list who are not
96         already on the "recipients" list for the message.
97         
98         These users are then added to the message's "recipients" list.
100         """
101         users = self.db.user
102         messages = self.db.msg
104         # figure the recipient ids
105         sendto = []
106         r = {}
107         recipients = messages.get(msgid, 'recipients')
108         for recipid in messages.get(msgid, 'recipients'):
109             r[recipid] = 1
111         # figure the author's id, and indicate they've received the message
112         authid = messages.get(msgid, 'author')
114         # possibly send the message to the author, as long as they aren't
115         # anonymous
116         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
117                 users.get(authid, 'username') != 'anonymous'):
118             sendto.append(authid)
119         r[authid] = 1
121         # now deal with cc people.
122         for cc_userid in cc :
123             if r.has_key(cc_userid):
124                 continue
125             # send it to them
126             sendto.append(cc_userid)
127             recipients.append(cc_userid)
129         # now figure the nosy people who weren't recipients
130         nosy = self.get(nodeid, whichnosy)
131         for nosyid in nosy:
132             # Don't send nosy mail to the anonymous user (that user
133             # shouldn't appear in the nosy list, but just in case they
134             # do...)
135             if users.get(nosyid, 'username') == 'anonymous':
136                 continue
137             # make sure they haven't seen the message already
138             if not r.has_key(nosyid):
139                 # send it to them
140                 sendto.append(nosyid)
141                 recipients.append(nosyid)
143         # generate a change note
144         if oldvalues:
145             note = self.generateChangeNote(nodeid, oldvalues)
146         else:
147             note = self.generateCreateNote(nodeid)
149         # we have new recipients
150         if sendto:
151             # map userids to addresses
152             sendto = [users.get(i, 'address') for i in sendto]
154             # update the message's recipients list
155             messages.set(msgid, recipients=recipients)
157             # send the message
158             self.send_message(nodeid, msgid, note, sendto, from_address)
160     # backwards compatibility - don't remove
161     sendmessage = nosymessage
163     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
164         '''Actually send the nominated message from this node to the sendto
165            recipients, with the note appended.
166         '''
167         users = self.db.user
168         messages = self.db.msg
169         files = self.db.file
171         # determine the messageid and inreplyto of the message
172         inreplyto = messages.get(msgid, 'inreplyto')
173         messageid = messages.get(msgid, 'messageid')
175         # make up a messageid if there isn't one (web edit)
176         if not messageid:
177             # this is an old message that didn't get a messageid, so
178             # create one
179             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
180                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
181             messages.set(msgid, messageid=messageid)
183         # send an email to the people who missed out
184         cn = self.classname
185         title = self.get(nodeid, 'title') or '%s message copy'%cn
186         # figure author information
187         authid = messages.get(msgid, 'author')
188         authname = users.get(authid, 'realname')
189         if not authname:
190             authname = users.get(authid, 'username')
191         authaddr = users.get(authid, 'address')
192         if authaddr:
193             authaddr = " <%s>" % straddr( ('',authaddr) )
194         else:
195             authaddr = ''
197         # make the message body
198         m = ['']
200         # put in roundup's signature
201         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
202             m.append(self.email_signature(nodeid, msgid))
204         # add author information
205         if len(self.get(nodeid,'messages')) == 1:
206             m.append("New submission from %s%s:"%(authname, authaddr))
207         else:
208             m.append("%s%s added the comment:"%(authname, authaddr))
209         m.append('')
211         # add the content
212         m.append(messages.get(msgid, 'content'))
214         # add the change note
215         if note:
216             m.append(note)
218         # put in roundup's signature
219         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
220             m.append(self.email_signature(nodeid, msgid))
222         # encode the content as quoted-printable
223         content = cStringIO.StringIO('\n'.join(m))
224         content_encoded = cStringIO.StringIO()
225         quopri.encode(content, content_encoded, 0)
226         content_encoded = content_encoded.getvalue()
228         # get the files for this message
229         message_files = messages.get(msgid, 'files')
231         # make sure the To line is always the same (for testing mostly)
232         sendto.sort()
234         # make sure we have a from address
235         if from_address is None:
236             from_address = self.db.config.TRACKER_EMAIL
238         # additional bit for after the From: "name"
239         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
240         if from_tag:
241             from_tag = ' ' + from_tag
243         # create the message
244         message = cStringIO.StringIO()
245         writer = MimeWriter.MimeWriter(message)
246         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
247         writer.addheader('To', ', '.join(sendto))
248         writer.addheader('From', straddr((authname + from_tag, from_address)))
249         writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
250             from_address)))
251         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
252             time.gmtime()))
253         writer.addheader('MIME-Version', '1.0')
254         if messageid:
255             writer.addheader('Message-Id', messageid)
256         if inreplyto:
257             writer.addheader('In-Reply-To', inreplyto)
259         # add a uniquely Roundup header to help filtering
260         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
262         # avoid email loops
263         writer.addheader('X-Roundup-Loop', 'hello')
265         # attach files
266         if message_files:
267             part = writer.startmultipartbody('mixed')
268             part = writer.nextpart()
269             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
270             body = part.startbody('text/plain')
271             body.write(content_encoded)
272             for fileid in message_files:
273                 name = files.get(fileid, 'name')
274                 mime_type = files.get(fileid, 'type')
275                 content = files.get(fileid, 'content')
276                 part = writer.nextpart()
277                 if mime_type == 'text/plain':
278                     part.addheader('Content-Disposition',
279                         'attachment;\n filename="%s"'%name)
280                     part.addheader('Content-Transfer-Encoding', '7bit')
281                     body = part.startbody('text/plain')
282                     body.write(content)
283                 else:
284                     # some other type, so encode it
285                     if not mime_type:
286                         # this should have been done when the file was saved
287                         mime_type = mimetypes.guess_type(name)[0]
288                     if mime_type is None:
289                         mime_type = 'application/octet-stream'
290                     part.addheader('Content-Disposition',
291                         'attachment;\n filename="%s"'%name)
292                     part.addheader('Content-Transfer-Encoding', 'base64')
293                     body = part.startbody(mime_type)
294                     body.write(base64.encodestring(content))
295             writer.lastpart()
296         else:
297             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
298             body = writer.startbody('text/plain')
299             body.write(content_encoded)
301         # now try to send the message
302         if SENDMAILDEBUG:
303             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
304                 self.db.config.ADMIN_EMAIL,
305                 ', '.join(sendto),message.getvalue()))
306         else:
307             try:
308                 # send the message as admin so bounces are sent there
309                 # instead of to roundup
310                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
311                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
312                     message.getvalue())
313             except socket.error, value:
314                 raise MessageSendError, \
315                     "Couldn't send confirmation email: mailhost %s"%value
316             except smtplib.SMTPException, value:
317                 raise MessageSendError, \
318                     "Couldn't send confirmation email: %s"%value
320     def email_signature(self, nodeid, msgid):
321         ''' Add a signature to the e-mail with some useful information
322         '''
323         # simplistic check to see if the url is valid,
324         # then append a trailing slash if it is missing
325         base = self.db.config.TRACKER_WEB 
326         if (not isinstance(base , type('')) or
327             not (base.startswith('http://') or base.startswith('https://'))):
328             base = "Configuration Error: TRACKER_WEB isn't a " \
329                 "fully-qualified URL"
330         elif base[-1] != '/' :
331             base += '/'
332         web = base + self.classname + nodeid
334         # ensure the email address is properly quoted
335         email = straddr((self.db.config.TRACKER_NAME,
336             self.db.config.TRACKER_EMAIL))
338         line = '_' * max(len(web), len(email))
339         return '%s\n%s\n%s\n%s'%(line, email, web, line)
342     def generateCreateNote(self, nodeid):
343         """Generate a create note that lists initial property values
344         """
345         cn = self.classname
346         cl = self.db.classes[cn]
347         props = cl.getprops(protected=0)
349         # list the values
350         m = []
351         l = props.items()
352         l.sort()
353         for propname, prop in l:
354             value = cl.get(nodeid, propname, None)
355             # skip boring entries
356             if not value:
357                 continue
358             if isinstance(prop, hyperdb.Link):
359                 link = self.db.classes[prop.classname]
360                 if value:
361                     key = link.labelprop(default_to_id=1)
362                     if key:
363                         value = link.get(value, key)
364                 else:
365                     value = ''
366             elif isinstance(prop, hyperdb.Multilink):
367                 if value is None: value = []
368                 l = []
369                 link = self.db.classes[prop.classname]
370                 key = link.labelprop(default_to_id=1)
371                 if key:
372                     value = [link.get(entry, key) for entry in value]
373                 value.sort()
374                 value = ', '.join(value)
375             m.append('%s: %s'%(propname, value))
376         m.insert(0, '----------')
377         m.insert(0, '')
378         return '\n'.join(m)
380     def generateChangeNote(self, nodeid, oldvalues):
381         """Generate a change note that lists property changes
382         """
383         if __debug__ :
384             if not isinstance(oldvalues, type({})) :
385                 raise TypeError("'oldvalues' must be dict-like, not %s."%
386                     type(oldvalues))
388         cn = self.classname
389         cl = self.db.classes[cn]
390         changed = {}
391         props = cl.getprops(protected=0)
393         # determine what changed
394         for key in oldvalues.keys():
395             if key in ['files','messages']:
396                 continue
397             if key in ('activity', 'creator', 'creation'):
398                 continue
399             new_value = cl.get(nodeid, key)
400             # the old value might be non existent
401             try:
402                 old_value = oldvalues[key]
403                 if type(new_value) is type([]):
404                     new_value.sort()
405                     old_value.sort()
406                 if new_value != old_value:
407                     changed[key] = old_value
408             except:
409                 changed[key] = new_value
411         # list the changes
412         m = []
413         l = changed.items()
414         l.sort()
415         for propname, oldvalue in l:
416             prop = props[propname]
417             value = cl.get(nodeid, propname, None)
418             if isinstance(prop, hyperdb.Link):
419                 link = self.db.classes[prop.classname]
420                 key = link.labelprop(default_to_id=1)
421                 if key:
422                     if value:
423                         value = link.get(value, key)
424                     else:
425                         value = ''
426                     if oldvalue:
427                         oldvalue = link.get(oldvalue, key)
428                     else:
429                         oldvalue = ''
430                 change = '%s -> %s'%(oldvalue, value)
431             elif isinstance(prop, hyperdb.Multilink):
432                 change = ''
433                 if value is None: value = []
434                 if oldvalue is None: oldvalue = []
435                 l = []
436                 link = self.db.classes[prop.classname]
437                 key = link.labelprop(default_to_id=1)
438                 # check for additions
439                 for entry in value:
440                     if entry in oldvalue: continue
441                     if key:
442                         l.append(link.get(entry, key))
443                     else:
444                         l.append(entry)
445                 if l:
446                     l.sort()
447                     change = '+%s'%(', '.join(l))
448                     l = []
449                 # check for removals
450                 for entry in oldvalue:
451                     if entry in value: continue
452                     if key:
453                         l.append(link.get(entry, key))
454                     else:
455                         l.append(entry)
456                 if l:
457                     l.sort()
458                     change += ' -%s'%(', '.join(l))
459             else:
460                 change = '%s -> %s'%(oldvalue, value)
461             m.append('%s: %s'%(propname, change))
462         if m:
463             m.insert(0, '----------')
464             m.insert(0, '')
465         return '\n'.join(m)
467 # vim: set filetype=python ts=4 sw=4 et si