Code

af624eba974697dfd9fd599eee7b98a0cc0ffb8e
[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.74 2002-12-10 00:23:36 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     def nosymessage(self, nodeid, msgid, oldvalues):
91         """Send a message to the members of an issue's nosy list.
93         The message is sent only to users on the nosy list who are not
94         already on the "recipients" list for the message.
95         
96         These users are then added to the message's "recipients" list.
97         """
98         users = self.db.user
99         messages = self.db.msg
101         # figure the recipient ids
102         sendto = []
103         r = {}
104         recipients = messages.get(msgid, 'recipients')
105         for recipid in messages.get(msgid, 'recipients'):
106             r[recipid] = 1
108         # figure the author's id, and indicate they've received the message
109         authid = messages.get(msgid, 'author')
111         # possibly send the message to the author, as long as they aren't
112         # anonymous
113         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
114                 users.get(authid, 'username') != 'anonymous'):
115             sendto.append(authid)
116         r[authid] = 1
118         # now figure the nosy people who weren't recipients
119         nosy = self.get(nodeid, 'nosy')
120         for nosyid in nosy:
121             # Don't send nosy mail to the anonymous user (that user
122             # shouldn't appear in the nosy list, but just in case they
123             # do...)
124             if users.get(nosyid, 'username') == 'anonymous':
125                 continue
126             # make sure they haven't seen the message already
127             if not r.has_key(nosyid):
128                 # send it to them
129                 sendto.append(nosyid)
130                 recipients.append(nosyid)
132         # generate a change note
133         if oldvalues:
134             note = self.generateChangeNote(nodeid, oldvalues)
135         else:
136             note = self.generateCreateNote(nodeid)
138         # we have new recipients
139         if sendto:
140             # map userids to addresses
141             sendto = [users.get(i, 'address') for i in sendto]
143             # update the message's recipients list
144             messages.set(msgid, recipients=recipients)
146             # send the message
147             self.send_message(nodeid, msgid, note, sendto)
149     # backwards compatibility - don't remove
150     sendmessage = nosymessage
152     def send_message(self, nodeid, msgid, note, sendto):
153         '''Actually send the nominated message from this node to the sendto
154            recipients, with the note appended.
155         '''
156         users = self.db.user
157         messages = self.db.msg
158         files = self.db.file
160         # determine the messageid and inreplyto of the message
161         inreplyto = messages.get(msgid, 'inreplyto')
162         messageid = messages.get(msgid, 'messageid')
164         # make up a messageid if there isn't one (web edit)
165         if not messageid:
166             # this is an old message that didn't get a messageid, so
167             # create one
168             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
169                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
170             messages.set(msgid, messageid=messageid)
172         # send an email to the people who missed out
173         cn = self.classname
174         title = self.get(nodeid, 'title') or '%s message copy'%cn
175         # figure author information
176         authid = messages.get(msgid, 'author')
177         authname = users.get(authid, 'realname')
178         if not authname:
179             authname = users.get(authid, 'username')
180         authaddr = users.get(authid, 'address')
181         if authaddr:
182             authaddr = " <%s>" % straddr( ('',authaddr) )
183         else:
184             authaddr = ''
186         # make the message body
187         m = ['']
189         # put in roundup's signature
190         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
191             m.append(self.email_signature(nodeid, msgid))
193         # add author information
194         if len(self.get(nodeid,'messages')) == 1:
195             m.append("New submission from %s%s:"%(authname, authaddr))
196         else:
197             m.append("%s%s added the comment:"%(authname, authaddr))
198         m.append('')
200         # add the content
201         m.append(messages.get(msgid, 'content'))
203         # add the change note
204         if note:
205             m.append(note)
207         # put in roundup's signature
208         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
209             m.append(self.email_signature(nodeid, msgid))
211         # encode the content as quoted-printable
212         content = cStringIO.StringIO('\n'.join(m))
213         content_encoded = cStringIO.StringIO()
214         quopri.encode(content, content_encoded, 0)
215         content_encoded = content_encoded.getvalue()
217         # get the files for this message
218         message_files = messages.get(msgid, 'files')
220         # make sure the To line is always the same (for testing mostly)
221         sendto.sort()
223         # create the message
224         message = cStringIO.StringIO()
225         writer = MimeWriter.MimeWriter(message)
226         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
227         writer.addheader('To', ', '.join(sendto))
228         writer.addheader('From', straddr(
229                               (authname, self.db.config.TRACKER_EMAIL) ) )
230         writer.addheader('Reply-To', straddr( 
231                                         (self.db.config.TRACKER_NAME,
232                                          self.db.config.TRACKER_EMAIL) ) )
233         writer.addheader('MIME-Version', '1.0')
234         if messageid:
235             writer.addheader('Message-Id', messageid)
236         if inreplyto:
237             writer.addheader('In-Reply-To', inreplyto)
239         # add a uniquely Roundup header to help filtering
240         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
242         # avoid email loops
243         writer.addheader('X-Roundup-Loop', 'hello')
245         # attach files
246         if message_files:
247             part = writer.startmultipartbody('mixed')
248             part = writer.nextpart()
249             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
250             body = part.startbody('text/plain')
251             body.write(content_encoded)
252             for fileid in message_files:
253                 name = files.get(fileid, 'name')
254                 mime_type = files.get(fileid, 'type')
255                 content = files.get(fileid, 'content')
256                 part = writer.nextpart()
257                 if mime_type == 'text/plain':
258                     part.addheader('Content-Disposition',
259                         'attachment;\n filename="%s"'%name)
260                     part.addheader('Content-Transfer-Encoding', '7bit')
261                     body = part.startbody('text/plain')
262                     body.write(content)
263                 else:
264                     # some other type, so encode it
265                     if not mime_type:
266                         # this should have been done when the file was saved
267                         mime_type = mimetypes.guess_type(name)[0]
268                     if mime_type is None:
269                         mime_type = 'application/octet-stream'
270                     part.addheader('Content-Disposition',
271                         'attachment;\n filename="%s"'%name)
272                     part.addheader('Content-Transfer-Encoding', 'base64')
273                     body = part.startbody(mime_type)
274                     body.write(base64.encodestring(content))
275             writer.lastpart()
276         else:
277             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
278             body = writer.startbody('text/plain')
279             body.write(content_encoded)
281         # now try to send the message
282         if SENDMAILDEBUG:
283             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
284                 self.db.config.ADMIN_EMAIL,
285                 ', '.join(sendto),message.getvalue()))
286         else:
287             try:
288                 # send the message as admin so bounces are sent there
289                 # instead of to roundup
290                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
291                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
292                     message.getvalue())
293             except socket.error, value:
294                 raise MessageSendError, \
295                     "Couldn't send confirmation email: mailhost %s"%value
296             except smtplib.SMTPException, value:
297                 raise MessageSendError, \
298                     "Couldn't send confirmation email: %s"%value
300     def email_signature(self, nodeid, msgid):
301         ''' Add a signature to the e-mail with some useful information
302         '''
303         # simplistic check to see if the url is valid,
304         # then append a trailing slash if it is missing
305         base = self.db.config.TRACKER_WEB 
306         if (not isinstance(base , type('')) or
307             not (base.startswith('http://') or base.startswith('https://'))):
308             base = "Configuration Error: TRACKER_WEB isn't a " \
309                 "fully-qualified URL"
310         elif base[-1] != '/' :
311             base += '/'
312         web = base + self.classname + nodeid
314         # ensure the email address is properly quoted
315         email = straddr((self.db.config.TRACKER_NAME,
316             self.db.config.TRACKER_EMAIL))
318         line = '_' * max(len(web), len(email))
319         return '%s\n%s\n%s\n%s'%(line, email, web, line)
322     def generateCreateNote(self, nodeid):
323         """Generate a create note that lists initial property values
324         """
325         cn = self.classname
326         cl = self.db.classes[cn]
327         props = cl.getprops(protected=0)
329         # list the values
330         m = []
331         l = props.items()
332         l.sort()
333         for propname, prop in l:
334             value = cl.get(nodeid, propname, None)
335             # skip boring entries
336             if not value:
337                 continue
338             if isinstance(prop, hyperdb.Link):
339                 link = self.db.classes[prop.classname]
340                 if value:
341                     key = link.labelprop(default_to_id=1)
342                     if key:
343                         value = link.get(value, key)
344                 else:
345                     value = ''
346             elif isinstance(prop, hyperdb.Multilink):
347                 if value is None: value = []
348                 l = []
349                 link = self.db.classes[prop.classname]
350                 key = link.labelprop(default_to_id=1)
351                 if key:
352                     value = [link.get(entry, key) for entry in value]
353                 value.sort()
354                 value = ', '.join(value)
355             m.append('%s: %s'%(propname, value))
356         m.insert(0, '----------')
357         m.insert(0, '')
358         return '\n'.join(m)
360     def generateChangeNote(self, nodeid, oldvalues):
361         """Generate a change note that lists property changes
362         """
363         if __debug__ :
364             if not isinstance(oldvalues, type({})) :
365                 raise TypeError("'oldvalues' must be dict-like, not %s."%
366                     type(oldvalues))
368         cn = self.classname
369         cl = self.db.classes[cn]
370         changed = {}
371         props = cl.getprops(protected=0)
373         # determine what changed
374         for key in oldvalues.keys():
375             if key in ['files','messages']:
376                 continue
377             if key in ('activity', 'creator', 'creation'):
378                 continue
379             new_value = cl.get(nodeid, key)
380             # the old value might be non existent
381             try:
382                 old_value = oldvalues[key]
383                 if type(new_value) is type([]):
384                     new_value.sort()
385                     old_value.sort()
386                 if new_value != old_value:
387                     changed[key] = old_value
388             except:
389                 changed[key] = new_value
391         # list the changes
392         m = []
393         l = changed.items()
394         l.sort()
395         for propname, oldvalue in l:
396             prop = props[propname]
397             value = cl.get(nodeid, propname, None)
398             if isinstance(prop, hyperdb.Link):
399                 link = self.db.classes[prop.classname]
400                 key = link.labelprop(default_to_id=1)
401                 if key:
402                     if value:
403                         value = link.get(value, key)
404                     else:
405                         value = ''
406                     if oldvalue:
407                         oldvalue = link.get(oldvalue, key)
408                     else:
409                         oldvalue = ''
410                 change = '%s -> %s'%(oldvalue, value)
411             elif isinstance(prop, hyperdb.Multilink):
412                 change = ''
413                 if value is None: value = []
414                 if oldvalue is None: oldvalue = []
415                 l = []
416                 link = self.db.classes[prop.classname]
417                 key = link.labelprop(default_to_id=1)
418                 # check for additions
419                 for entry in value:
420                     if entry in oldvalue: continue
421                     if key:
422                         l.append(link.get(entry, key))
423                     else:
424                         l.append(entry)
425                 if l:
426                     l.sort()
427                     change = '+%s'%(', '.join(l))
428                     l = []
429                 # check for removals
430                 for entry in oldvalue:
431                     if entry in value: continue
432                     if key:
433                         l.append(link.get(entry, key))
434                     else:
435                         l.append(entry)
436                 if l:
437                     l.sort()
438                     change += ' -%s'%(', '.join(l))
439             else:
440                 change = '%s -> %s'%(oldvalue, value)
441             m.append('%s: %s'%(propname, change))
442         if m:
443             m.insert(0, '----------')
444             m.insert(0, '')
445         return '\n'.join(m)
447 # vim: set filetype=python ts=4 sw=4 et si