Code

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