Code

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