Code

65739b0bd9a38135c47c44b1a77e1a53dc9ef0fc
[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.71 2002-10-08 04:11:13 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 dump_address_pair as straddr
30 except ImportError :
31     from rfc822 import dump_address_pair as straddr
33 import hyperdb
35 # set to indicate to roundup not to actually _send_ email
36 # this var must contain a file to write the mail to
37 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
39 class Database:
40     def getuid(self):
41         """Return the id of the "user" node associated with the user
42         that owns this connection to the hyperdatabase."""
43         return self.user.lookup(self.journaltag)
45 class MessageSendError(RuntimeError):
46     pass
48 class DetectorError(RuntimeError):
49     ''' Raised by detectors that want to indicate that something's amiss
50     '''
51     pass
53 # deviation from spec - was called IssueClass
54 class IssueClass:
55     """ This class is intended to be mixed-in with a hyperdb backend
56         implementation. The backend should provide a mechanism that
57         enforces the title, messages, files, nosy and superseder
58         properties:
59             properties['title'] = hyperdb.String(indexme='yes')
60             properties['messages'] = hyperdb.Multilink("msg")
61             properties['files'] = hyperdb.Multilink("file")
62             properties['nosy'] = hyperdb.Multilink("user")
63             properties['superseder'] = hyperdb.Multilink(classname)
64     """
66     # New methods:
67     def addmessage(self, nodeid, summary, text):
68         """Add a message to an issue's mail spool.
70         A new "msg" node is constructed using the current date, the user that
71         owns the database connection as the author, and the specified summary
72         text.
74         The "files" and "recipients" fields are left empty.
76         The given text is saved as the body of the message and the node is
77         appended to the "messages" field of the specified issue.
78         """
80     def nosymessage(self, nodeid, msgid, oldvalues):
81         """Send a message to the members of an issue's nosy list.
83         The message is sent only to users on the nosy list who are not
84         already on the "recipients" list for the message.
85         
86         These users are then added to the message's "recipients" list.
87         """
88         users = self.db.user
89         messages = self.db.msg
91         # figure the recipient ids
92         sendto = []
93         r = {}
94         recipients = messages.get(msgid, 'recipients')
95         for recipid in messages.get(msgid, 'recipients'):
96             r[recipid] = 1
98         # figure the author's id, and indicate they've received the message
99         authid = messages.get(msgid, 'author')
101         # possibly send the message to the author, as long as they aren't
102         # anonymous
103         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
104                 users.get(authid, 'username') != 'anonymous'):
105             sendto.append(authid)
106         r[authid] = 1
108         # now figure the nosy people who weren't recipients
109         nosy = self.get(nodeid, 'nosy')
110         for nosyid in nosy:
111             # Don't send nosy mail to the anonymous user (that user
112             # shouldn't appear in the nosy list, but just in case they
113             # do...)
114             if users.get(nosyid, 'username') == 'anonymous':
115                 continue
116             # make sure they haven't seen the message already
117             if not r.has_key(nosyid):
118                 # send it to them
119                 sendto.append(nosyid)
120                 recipients.append(nosyid)
122         # generate a change note
123         if oldvalues:
124             note = self.generateChangeNote(nodeid, oldvalues)
125         else:
126             note = self.generateCreateNote(nodeid)
128         # we have new recipients
129         if sendto:
130             # map userids to addresses
131             sendto = [users.get(i, 'address') for i in sendto]
133             # update the message's recipients list
134             messages.set(msgid, recipients=recipients)
136             # send the message
137             self.send_message(nodeid, msgid, note, sendto)
139     # backwards compatibility - don't remove
140     sendmessage = nosymessage
142     def send_message(self, nodeid, msgid, note, sendto):
143         '''Actually send the nominated message from this node to the sendto
144            recipients, with the note appended.
145         '''
146         users = self.db.user
147         messages = self.db.msg
148         files = self.db.file
150         # determine the messageid and inreplyto of the message
151         inreplyto = messages.get(msgid, 'inreplyto')
152         messageid = messages.get(msgid, 'messageid')
154         # make up a messageid if there isn't one (web edit)
155         if not messageid:
156             # this is an old message that didn't get a messageid, so
157             # create one
158             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
159                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
160             messages.set(msgid, messageid=messageid)
162         # send an email to the people who missed out
163         cn = self.classname
164         title = self.get(nodeid, 'title') or '%s message copy'%cn
165         # figure author information
166         authid = messages.get(msgid, 'author')
167         authname = users.get(authid, 'realname')
168         if not authname:
169             authname = users.get(authid, 'username')
170         authaddr = users.get(authid, 'address')
171         if authaddr:
172             authaddr = " <%s>" % straddr( ('',authaddr) )
173         else:
174             authaddr = ''
176         # make the message body
177         m = ['']
179         # put in roundup's signature
180         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
181             m.append(self.email_signature(nodeid, msgid))
183         # add author information
184         if len(self.get(nodeid,'messages')) == 1:
185             m.append("New submission from %s%s:"%(authname, authaddr))
186         else:
187             m.append("%s%s added the comment:"%(authname, authaddr))
188         m.append('')
190         # add the content
191         m.append(messages.get(msgid, 'content'))
193         # add the change note
194         if note:
195             m.append(note)
197         # put in roundup's signature
198         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
199             m.append(self.email_signature(nodeid, msgid))
201         # encode the content as quoted-printable
202         content = cStringIO.StringIO('\n'.join(m))
203         content_encoded = cStringIO.StringIO()
204         quopri.encode(content, content_encoded, 0)
205         content_encoded = content_encoded.getvalue()
207         # get the files for this message
208         message_files = messages.get(msgid, 'files')
210         # make sure the To line is always the same (for testing mostly)
211         sendto.sort()
213         # create the message
214         message = cStringIO.StringIO()
215         writer = MimeWriter.MimeWriter(message)
216         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
217         writer.addheader('To', ', '.join(sendto))
218         writer.addheader('From', straddr(
219                               (authname, self.db.config.TRACKER_EMAIL) ) )
220         writer.addheader('Reply-To', straddr( 
221                                         (self.db.config.TRACKER_NAME,
222                                          self.db.config.TRACKER_EMAIL) ) )
223         writer.addheader('MIME-Version', '1.0')
224         if messageid:
225             writer.addheader('Message-Id', messageid)
226         if inreplyto:
227             writer.addheader('In-Reply-To', inreplyto)
229         # add a uniquely Roundup header to help filtering
230         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
232         # attach files
233         if message_files:
234             part = writer.startmultipartbody('mixed')
235             part = writer.nextpart()
236             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
237             body = part.startbody('text/plain')
238             body.write(content_encoded)
239             for fileid in message_files:
240                 name = files.get(fileid, 'name')
241                 mime_type = files.get(fileid, 'type')
242                 content = files.get(fileid, 'content')
243                 part = writer.nextpart()
244                 if mime_type == 'text/plain':
245                     part.addheader('Content-Disposition',
246                         'attachment;\n filename="%s"'%name)
247                     part.addheader('Content-Transfer-Encoding', '7bit')
248                     body = part.startbody('text/plain')
249                     body.write(content)
250                 else:
251                     # some other type, so encode it
252                     if not mime_type:
253                         # this should have been done when the file was saved
254                         mime_type = mimetypes.guess_type(name)[0]
255                     if mime_type is None:
256                         mime_type = 'application/octet-stream'
257                     part.addheader('Content-Disposition',
258                         'attachment;\n filename="%s"'%name)
259                     part.addheader('Content-Transfer-Encoding', 'base64')
260                     body = part.startbody(mime_type)
261                     body.write(base64.encodestring(content))
262             writer.lastpart()
263         else:
264             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
265             body = writer.startbody('text/plain')
266             body.write(content_encoded)
268         # now try to send the message
269         if SENDMAILDEBUG:
270             open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
271                 self.db.config.ADMIN_EMAIL,
272                 ', '.join(sendto),message.getvalue()))
273         else:
274             try:
275                 # send the message as admin so bounces are sent there
276                 # instead of to roundup
277                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
278                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
279                     message.getvalue())
280             except socket.error, value:
281                 raise MessageSendError, \
282                     "Couldn't send confirmation email: mailhost %s"%value
283             except smtplib.SMTPException, value:
284                 raise MessageSendError, \
285                     "Couldn't send confirmation email: %s"%value
287     def email_signature(self, nodeid, msgid):
288         ''' Add a signature to the e-mail with some useful information
289         '''
290         # simplistic check to see if the url is valid,
291         # then append a trailing slash if it is missing
292         base = self.db.config.TRACKER_WEB 
293         if (not isinstance(base , type('')) or
294             not base.startswith('http://') or
295             not base.startswith('https://')):
296             base = "Configuration Error: TRACKER_WEB isn't a " \
297                 "fully-qualified URL"
298         elif base[-1] != '/' :
299             base += '/'
300         web = base + self.classname + nodeid
302         # ensure the email address is properly quoted
303         email = straddr((self.db.config.TRACKER_NAME,
304             self.db.config.TRACKER_EMAIL))
306         line = '_' * max(len(web), len(email))
307         return '%s\n%s\n%s\n%s'%(line, email, web, line)
310     def generateCreateNote(self, nodeid):
311         """Generate a create note that lists initial property values
312         """
313         cn = self.classname
314         cl = self.db.classes[cn]
315         props = cl.getprops(protected=0)
317         # list the values
318         m = []
319         l = props.items()
320         l.sort()
321         for propname, prop in l:
322             value = cl.get(nodeid, propname, None)
323             # skip boring entries
324             if not value:
325                 continue
326             if isinstance(prop, hyperdb.Link):
327                 link = self.db.classes[prop.classname]
328                 if value:
329                     key = link.labelprop(default_to_id=1)
330                     if key:
331                         value = link.get(value, key)
332                 else:
333                     value = ''
334             elif isinstance(prop, hyperdb.Multilink):
335                 if value is None: value = []
336                 l = []
337                 link = self.db.classes[prop.classname]
338                 key = link.labelprop(default_to_id=1)
339                 if key:
340                     value = [link.get(entry, key) for entry in value]
341                 value.sort()
342                 value = ', '.join(value)
343             m.append('%s: %s'%(propname, value))
344         m.insert(0, '----------')
345         m.insert(0, '')
346         return '\n'.join(m)
348     def generateChangeNote(self, nodeid, oldvalues):
349         """Generate a change note that lists property changes
350         """
351         if __debug__ :
352             if not isinstance(oldvalues, type({})) :
353                 raise TypeError("'oldvalues' must be dict-like, not %s."%
354                     type(oldvalues))
356         cn = self.classname
357         cl = self.db.classes[cn]
358         changed = {}
359         props = cl.getprops(protected=0)
361         # determine what changed
362         for key in oldvalues.keys():
363             if key in ['files','messages']:
364                 continue
365             if key in ('activity', 'creator', 'creation'):
366                 continue
367             new_value = cl.get(nodeid, key)
368             # the old value might be non existent
369             try:
370                 old_value = oldvalues[key]
371                 if type(new_value) is type([]):
372                     new_value.sort()
373                     old_value.sort()
374                 if new_value != old_value:
375                     changed[key] = old_value
376             except:
377                 changed[key] = new_value
379         # list the changes
380         m = []
381         l = changed.items()
382         l.sort()
383         for propname, oldvalue in l:
384             prop = props[propname]
385             value = cl.get(nodeid, propname, None)
386             if isinstance(prop, hyperdb.Link):
387                 link = self.db.classes[prop.classname]
388                 key = link.labelprop(default_to_id=1)
389                 if key:
390                     if value:
391                         value = link.get(value, key)
392                     else:
393                         value = ''
394                     if oldvalue:
395                         oldvalue = link.get(oldvalue, key)
396                     else:
397                         oldvalue = ''
398                 change = '%s -> %s'%(oldvalue, value)
399             elif isinstance(prop, hyperdb.Multilink):
400                 change = ''
401                 if value is None: value = []
402                 if oldvalue is None: oldvalue = []
403                 l = []
404                 link = self.db.classes[prop.classname]
405                 key = link.labelprop(default_to_id=1)
406                 # check for additions
407                 for entry in value:
408                     if entry in oldvalue: continue
409                     if key:
410                         l.append(link.get(entry, key))
411                     else:
412                         l.append(entry)
413                 if l:
414                     l.sort()
415                     change = '+%s'%(', '.join(l))
416                     l = []
417                 # check for removals
418                 for entry in oldvalue:
419                     if entry in value: continue
420                     if key:
421                         l.append(link.get(entry, key))
422                     else:
423                         l.append(entry)
424                 if l:
425                     l.sort()
426                     change += ' -%s'%(', '.join(l))
427             else:
428                 change = '%s -> %s'%(oldvalue, value)
429             m.append('%s: %s'%(propname, change))
430         if m:
431             m.insert(0, '----------')
432             m.insert(0, '')
433         return '\n'.join(m)
435 # vim: set filetype=python ts=4 sw=4 et si