Code

b75b6115f357ad3546ed90bef40266511368386a
[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.80 2003-01-27 17:02:46 kedder 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 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
134                 users.get(authid, 'username') != 'anonymous'):
135             sendto.append(authid)
136         r[authid] = 1
138         # now deal with cc people.
139         for cc_userid in cc :
140             if r.has_key(cc_userid):
141                 continue
142             # send it to them
143             sendto.append(cc_userid)
144             recipients.append(cc_userid)
146         # now figure the nosy people who weren't recipients
147         nosy = self.get(nodeid, whichnosy)
148         for nosyid in nosy:
149             # Don't send nosy mail to the anonymous user (that user
150             # shouldn't appear in the nosy list, but just in case they
151             # do...)
152             if users.get(nosyid, 'username') == 'anonymous':
153                 continue
154             # make sure they haven't seen the message already
155             if not r.has_key(nosyid):
156                 # send it to them
157                 sendto.append(nosyid)
158                 recipients.append(nosyid)
160         # generate a change note
161         if oldvalues:
162             note = self.generateChangeNote(nodeid, oldvalues)
163         else:
164             note = self.generateCreateNote(nodeid)
166         # we have new recipients
167         if sendto:
168             # map userids to addresses
169             sendto = [users.get(i, 'address') for i in sendto]
171             # update the message's recipients list
172             messages.set(msgid, recipients=recipients)
174             # send the message
175             self.send_message(nodeid, msgid, note, sendto, from_address)
177     # backwards compatibility - don't remove
178     sendmessage = nosymessage
180     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
181         '''Actually send the nominated message from this node to the sendto
182            recipients, with the note appended.
183         '''
184         users = self.db.user
185         messages = self.db.msg
186         files = self.db.file
188         # determine the messageid and inreplyto of the message
189         inreplyto = messages.get(msgid, 'inreplyto')
190         messageid = messages.get(msgid, 'messageid')
192         # make up a messageid if there isn't one (web edit)
193         if not messageid:
194             # this is an old message that didn't get a messageid, so
195             # create one
196             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
197                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
198             messages.set(msgid, messageid=messageid)
200         # send an email to the people who missed out
201         cn = self.classname
202         title = self.get(nodeid, 'title') or '%s message copy'%cn
203         # figure author information
204         authid = messages.get(msgid, 'author')
205         authname = users.get(authid, 'realname')
206         if not authname:
207             authname = users.get(authid, 'username')
208         authaddr = users.get(authid, 'address')
209         if authaddr:
210             authaddr = " <%s>" % straddr( ('',authaddr) )
211         else:
212             authaddr = ''
214         # make the message body
215         m = ['']
217         # put in roundup's signature
218         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
219             m.append(self.email_signature(nodeid, msgid))
221         # add author information
222         if len(self.get(nodeid,'messages')) == 1:
223             m.append("New submission from %s%s:"%(authname, authaddr))
224         else:
225             m.append("%s%s added the comment:"%(authname, authaddr))
226         m.append('')
228         # add the content
229         m.append(messages.get(msgid, 'content'))
231         # add the change note
232         if note:
233             m.append(note)
235         # put in roundup's signature
236         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
237             m.append(self.email_signature(nodeid, msgid))
239         # encode the content as quoted-printable
240         content = cStringIO.StringIO('\n'.join(m))
241         content_encoded = cStringIO.StringIO()
242         quopri.encode(content, content_encoded, 0)
243         content_encoded = content_encoded.getvalue()
245         # get the files for this message
246         message_files = messages.get(msgid, 'files')
248         # make sure the To line is always the same (for testing mostly)
249         sendto.sort()
251         # make sure we have a from address
252         if from_address is None:
253             from_address = self.db.config.TRACKER_EMAIL
255         # additional bit for after the From: "name"
256         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
257         if from_tag:
258             from_tag = ' ' + from_tag
260         # create the message
261         message = cStringIO.StringIO()
262         writer = MimeWriter.MimeWriter(message)
263         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, encode_header(title)))
264         writer.addheader('To', ', '.join(sendto))
265         writer.addheader('From', straddr((encode_header(authname) + 
266             from_tag, from_address)))
267         writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
268             from_address)))
269         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
270             time.gmtime()))
271         writer.addheader('MIME-Version', '1.0')
272         if messageid:
273             writer.addheader('Message-Id', messageid)
274         if inreplyto:
275             writer.addheader('In-Reply-To', inreplyto)
277         # add a uniquely Roundup header to help filtering
278         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
280         # avoid email loops
281         writer.addheader('X-Roundup-Loop', 'hello')
283         # attach files
284         if message_files:
285             part = writer.startmultipartbody('mixed')
286             part = writer.nextpart()
287             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
288             body = part.startbody('text/plain; charset=utf-8')
289             body.write(content_encoded)
290             for fileid in message_files:
291                 name = files.get(fileid, 'name')
292                 mime_type = files.get(fileid, 'type')
293                 content = files.get(fileid, 'content')
294                 part = writer.nextpart()
295                 if mime_type == 'text/plain':
296                     part.addheader('Content-Disposition',
297                         'attachment;\n filename="%s"'%name)
298                     part.addheader('Content-Transfer-Encoding', '7bit')
299                     body = part.startbody('text/plain')
300                     body.write(content)
301                 else:
302                     # some other type, so encode it
303                     if not mime_type:
304                         # this should have been done when the file was saved
305                         mime_type = mimetypes.guess_type(name)[0]
306                     if mime_type is None:
307                         mime_type = 'application/octet-stream'
308                     part.addheader('Content-Disposition',
309                         'attachment;\n filename="%s"'%name)
310                     part.addheader('Content-Transfer-Encoding', 'base64')
311                     body = part.startbody(mime_type)
312                     body.write(base64.encodestring(content))
313             writer.lastpart()
314         else:
315             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
316             body = writer.startbody('text/plain; charset=utf-8')
317             body.write(content_encoded)
319         # now try to send the message
320         if SENDMAILDEBUG:
321             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
322                 self.db.config.ADMIN_EMAIL,
323                 ', '.join(sendto),message.getvalue()))
324         else:
325             try:
326                 # send the message as admin so bounces are sent there
327                 # instead of to roundup
328                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
329                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
330                     message.getvalue())
331             except socket.error, value:
332                 raise MessageSendError, \
333                     "Couldn't send confirmation email: mailhost %s"%value
334             except smtplib.SMTPException, value:
335                 raise MessageSendError, \
336                     "Couldn't send confirmation email: %s"%value
338     def email_signature(self, nodeid, msgid):
339         ''' Add a signature to the e-mail with some useful information
340         '''
341         # simplistic check to see if the url is valid,
342         # then append a trailing slash if it is missing
343         base = self.db.config.TRACKER_WEB 
344         if (not isinstance(base , type('')) or
345             not (base.startswith('http://') or base.startswith('https://'))):
346             base = "Configuration Error: TRACKER_WEB isn't a " \
347                 "fully-qualified URL"
348         elif base[-1] != '/' :
349             base += '/'
350         web = base + self.classname + nodeid
352         # ensure the email address is properly quoted
353         email = straddr((self.db.config.TRACKER_NAME,
354             self.db.config.TRACKER_EMAIL))
356         line = '_' * max(len(web), len(email))
357         return '%s\n%s\n%s\n%s'%(line, email, web, line)
360     def generateCreateNote(self, nodeid):
361         """Generate a create note that lists initial property values
362         """
363         cn = self.classname
364         cl = self.db.classes[cn]
365         props = cl.getprops(protected=0)
367         # list the values
368         m = []
369         l = props.items()
370         l.sort()
371         for propname, prop in l:
372             value = cl.get(nodeid, propname, None)
373             # skip boring entries
374             if not value:
375                 continue
376             if isinstance(prop, hyperdb.Link):
377                 link = self.db.classes[prop.classname]
378                 if value:
379                     key = link.labelprop(default_to_id=1)
380                     if key:
381                         value = link.get(value, key)
382                 else:
383                     value = ''
384             elif isinstance(prop, hyperdb.Multilink):
385                 if value is None: value = []
386                 l = []
387                 link = self.db.classes[prop.classname]
388                 key = link.labelprop(default_to_id=1)
389                 if key:
390                     value = [link.get(entry, key) for entry in value]
391                 value.sort()
392                 value = ', '.join(value)
393             m.append('%s: %s'%(propname, value))
394         m.insert(0, '----------')
395         m.insert(0, '')
396         return '\n'.join(m)
398     def generateChangeNote(self, nodeid, oldvalues):
399         """Generate a change note that lists property changes
400         """
401         if __debug__ :
402             if not isinstance(oldvalues, type({})) :
403                 raise TypeError("'oldvalues' must be dict-like, not %s."%
404                     type(oldvalues))
406         cn = self.classname
407         cl = self.db.classes[cn]
408         changed = {}
409         props = cl.getprops(protected=0)
411         # determine what changed
412         for key in oldvalues.keys():
413             if key in ['files','messages']:
414                 continue
415             if key in ('activity', 'creator', 'creation'):
416                 continue
417             new_value = cl.get(nodeid, key)
418             # the old value might be non existent
419             try:
420                 old_value = oldvalues[key]
421                 if type(new_value) is type([]):
422                     new_value.sort()
423                     old_value.sort()
424                 if new_value != old_value:
425                     changed[key] = old_value
426             except:
427                 changed[key] = new_value
429         # list the changes
430         m = []
431         l = changed.items()
432         l.sort()
433         for propname, oldvalue in l:
434             prop = props[propname]
435             value = cl.get(nodeid, propname, None)
436             if isinstance(prop, hyperdb.Link):
437                 link = self.db.classes[prop.classname]
438                 key = link.labelprop(default_to_id=1)
439                 if key:
440                     if value:
441                         value = link.get(value, key)
442                     else:
443                         value = ''
444                     if oldvalue:
445                         oldvalue = link.get(oldvalue, key)
446                     else:
447                         oldvalue = ''
448                 change = '%s -> %s'%(oldvalue, value)
449             elif isinstance(prop, hyperdb.Multilink):
450                 change = ''
451                 if value is None: value = []
452                 if oldvalue is None: oldvalue = []
453                 l = []
454                 link = self.db.classes[prop.classname]
455                 key = link.labelprop(default_to_id=1)
456                 # check for additions
457                 for entry in value:
458                     if entry in oldvalue: continue
459                     if key:
460                         l.append(link.get(entry, key))
461                     else:
462                         l.append(entry)
463                 if l:
464                     l.sort()
465                     change = '+%s'%(', '.join(l))
466                     l = []
467                 # check for removals
468                 for entry in oldvalue:
469                     if entry in value: continue
470                     if key:
471                         l.append(link.get(entry, key))
472                     else:
473                         l.append(entry)
474                 if l:
475                     l.sort()
476                     change += ' -%s'%(', '.join(l))
477             else:
478                 change = '%s -> %s'%(oldvalue, value)
479             m.append('%s: %s'%(propname, change))
480         if m:
481             m.insert(0, '----------')
482             m.insert(0, '')
483         return '\n'.join(m)
485 # vim: set filetype=python ts=4 sw=4 et si