Code

handle :add: better in cgi form parsing (sf bug 663235)
[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.75 2002-12-11 01:52:20 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('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
234             time.gmtime()))
235         writer.addheader('MIME-Version', '1.0')
236         if messageid:
237             writer.addheader('Message-Id', messageid)
238         if inreplyto:
239             writer.addheader('In-Reply-To', inreplyto)
241         # add a uniquely Roundup header to help filtering
242         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
244         # avoid email loops
245         writer.addheader('X-Roundup-Loop', 'hello')
247         # attach files
248         if message_files:
249             part = writer.startmultipartbody('mixed')
250             part = writer.nextpart()
251             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
252             body = part.startbody('text/plain')
253             body.write(content_encoded)
254             for fileid in message_files:
255                 name = files.get(fileid, 'name')
256                 mime_type = files.get(fileid, 'type')
257                 content = files.get(fileid, 'content')
258                 part = writer.nextpart()
259                 if mime_type == 'text/plain':
260                     part.addheader('Content-Disposition',
261                         'attachment;\n filename="%s"'%name)
262                     part.addheader('Content-Transfer-Encoding', '7bit')
263                     body = part.startbody('text/plain')
264                     body.write(content)
265                 else:
266                     # some other type, so encode it
267                     if not mime_type:
268                         # this should have been done when the file was saved
269                         mime_type = mimetypes.guess_type(name)[0]
270                     if mime_type is None:
271                         mime_type = 'application/octet-stream'
272                     part.addheader('Content-Disposition',
273                         'attachment;\n filename="%s"'%name)
274                     part.addheader('Content-Transfer-Encoding', 'base64')
275                     body = part.startbody(mime_type)
276                     body.write(base64.encodestring(content))
277             writer.lastpart()
278         else:
279             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
280             body = writer.startbody('text/plain')
281             body.write(content_encoded)
283         # now try to send the message
284         if SENDMAILDEBUG:
285             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
286                 self.db.config.ADMIN_EMAIL,
287                 ', '.join(sendto),message.getvalue()))
288         else:
289             try:
290                 # send the message as admin so bounces are sent there
291                 # instead of to roundup
292                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
293                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
294                     message.getvalue())
295             except socket.error, value:
296                 raise MessageSendError, \
297                     "Couldn't send confirmation email: mailhost %s"%value
298             except smtplib.SMTPException, value:
299                 raise MessageSendError, \
300                     "Couldn't send confirmation email: %s"%value
302     def email_signature(self, nodeid, msgid):
303         ''' Add a signature to the e-mail with some useful information
304         '''
305         # simplistic check to see if the url is valid,
306         # then append a trailing slash if it is missing
307         base = self.db.config.TRACKER_WEB 
308         if (not isinstance(base , type('')) or
309             not (base.startswith('http://') or base.startswith('https://'))):
310             base = "Configuration Error: TRACKER_WEB isn't a " \
311                 "fully-qualified URL"
312         elif base[-1] != '/' :
313             base += '/'
314         web = base + self.classname + nodeid
316         # ensure the email address is properly quoted
317         email = straddr((self.db.config.TRACKER_NAME,
318             self.db.config.TRACKER_EMAIL))
320         line = '_' * max(len(web), len(email))
321         return '%s\n%s\n%s\n%s'%(line, email, web, line)
324     def generateCreateNote(self, nodeid):
325         """Generate a create note that lists initial property values
326         """
327         cn = self.classname
328         cl = self.db.classes[cn]
329         props = cl.getprops(protected=0)
331         # list the values
332         m = []
333         l = props.items()
334         l.sort()
335         for propname, prop in l:
336             value = cl.get(nodeid, propname, None)
337             # skip boring entries
338             if not value:
339                 continue
340             if isinstance(prop, hyperdb.Link):
341                 link = self.db.classes[prop.classname]
342                 if value:
343                     key = link.labelprop(default_to_id=1)
344                     if key:
345                         value = link.get(value, key)
346                 else:
347                     value = ''
348             elif isinstance(prop, hyperdb.Multilink):
349                 if value is None: value = []
350                 l = []
351                 link = self.db.classes[prop.classname]
352                 key = link.labelprop(default_to_id=1)
353                 if key:
354                     value = [link.get(entry, key) for entry in value]
355                 value.sort()
356                 value = ', '.join(value)
357             m.append('%s: %s'%(propname, value))
358         m.insert(0, '----------')
359         m.insert(0, '')
360         return '\n'.join(m)
362     def generateChangeNote(self, nodeid, oldvalues):
363         """Generate a change note that lists property changes
364         """
365         if __debug__ :
366             if not isinstance(oldvalues, type({})) :
367                 raise TypeError("'oldvalues' must be dict-like, not %s."%
368                     type(oldvalues))
370         cn = self.classname
371         cl = self.db.classes[cn]
372         changed = {}
373         props = cl.getprops(protected=0)
375         # determine what changed
376         for key in oldvalues.keys():
377             if key in ['files','messages']:
378                 continue
379             if key in ('activity', 'creator', 'creation'):
380                 continue
381             new_value = cl.get(nodeid, key)
382             # the old value might be non existent
383             try:
384                 old_value = oldvalues[key]
385                 if type(new_value) is type([]):
386                     new_value.sort()
387                     old_value.sort()
388                 if new_value != old_value:
389                     changed[key] = old_value
390             except:
391                 changed[key] = new_value
393         # list the changes
394         m = []
395         l = changed.items()
396         l.sort()
397         for propname, oldvalue in l:
398             prop = props[propname]
399             value = cl.get(nodeid, propname, None)
400             if isinstance(prop, hyperdb.Link):
401                 link = self.db.classes[prop.classname]
402                 key = link.labelprop(default_to_id=1)
403                 if key:
404                     if value:
405                         value = link.get(value, key)
406                     else:
407                         value = ''
408                     if oldvalue:
409                         oldvalue = link.get(oldvalue, key)
410                     else:
411                         oldvalue = ''
412                 change = '%s -> %s'%(oldvalue, value)
413             elif isinstance(prop, hyperdb.Multilink):
414                 change = ''
415                 if value is None: value = []
416                 if oldvalue is None: oldvalue = []
417                 l = []
418                 link = self.db.classes[prop.classname]
419                 key = link.labelprop(default_to_id=1)
420                 # check for additions
421                 for entry in value:
422                     if entry in oldvalue: continue
423                     if key:
424                         l.append(link.get(entry, key))
425                     else:
426                         l.append(entry)
427                 if l:
428                     l.sort()
429                     change = '+%s'%(', '.join(l))
430                     l = []
431                 # check for removals
432                 for entry in oldvalue:
433                     if entry in value: continue
434                     if key:
435                         l.append(link.get(entry, key))
436                     else:
437                         l.append(entry)
438                 if l:
439                     l.sort()
440                     change += ' -%s'%(', '.join(l))
441             else:
442                 change = '%s -> %s'%(oldvalue, value)
443             m.append('%s: %s'%(propname, change))
444         if m:
445             m.insert(0, '----------')
446             m.insert(0, '')
447         return '\n'.join(m)
449 # vim: set filetype=python ts=4 sw=4 et si