Code

remove debug printing
[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.81 2003-02-17 06:45:38 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 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,
264             encode_header(title)))
265         writer.addheader('To', ', '.join(sendto))
266         writer.addheader('From', straddr((encode_header(authname) + 
267             from_tag, from_address)))
268         tracker_name = encode_header(self.db.config.TRACKER_NAME)
269         writer.addheader('Reply-To', straddr((tracker_name, from_address)))
270         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
271             time.gmtime()))
272         writer.addheader('MIME-Version', '1.0')
273         if messageid:
274             writer.addheader('Message-Id', messageid)
275         if inreplyto:
276             writer.addheader('In-Reply-To', inreplyto)
278         # add a uniquely Roundup header to help filtering
279         writer.addheader('X-Roundup-Name', tracker_name)
281         # avoid email loops
282         writer.addheader('X-Roundup-Loop', 'hello')
284         # attach files
285         if message_files:
286             part = writer.startmultipartbody('mixed')
287             part = writer.nextpart()
288             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
289             body = part.startbody('text/plain; charset=utf-8')
290             body.write(content_encoded)
291             for fileid in message_files:
292                 name = files.get(fileid, 'name')
293                 mime_type = files.get(fileid, 'type')
294                 content = files.get(fileid, 'content')
295                 part = writer.nextpart()
296                 if mime_type == 'text/plain':
297                     part.addheader('Content-Disposition',
298                         'attachment;\n filename="%s"'%name)
299                     part.addheader('Content-Transfer-Encoding', '7bit')
300                     body = part.startbody('text/plain')
301                     body.write(content)
302                 else:
303                     # some other type, so encode it
304                     if not mime_type:
305                         # this should have been done when the file was saved
306                         mime_type = mimetypes.guess_type(name)[0]
307                     if mime_type is None:
308                         mime_type = 'application/octet-stream'
309                     part.addheader('Content-Disposition',
310                         'attachment;\n filename="%s"'%name)
311                     part.addheader('Content-Transfer-Encoding', 'base64')
312                     body = part.startbody(mime_type)
313                     body.write(base64.encodestring(content))
314             writer.lastpart()
315         else:
316             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
317             body = writer.startbody('text/plain; charset=utf-8')
318             body.write(content_encoded)
320         # now try to send the message
321         if SENDMAILDEBUG:
322             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
323                 self.db.config.ADMIN_EMAIL,
324                 ', '.join(sendto),message.getvalue()))
325         else:
326             try:
327                 # send the message as admin so bounces are sent there
328                 # instead of to roundup
329                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
330                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
331                     message.getvalue())
332             except socket.error, value:
333                 raise MessageSendError, \
334                     "Couldn't send confirmation email: mailhost %s"%value
335             except smtplib.SMTPException, value:
336                 raise MessageSendError, \
337                     "Couldn't send confirmation email: %s"%value
339     def email_signature(self, nodeid, msgid):
340         ''' Add a signature to the e-mail with some useful information
341         '''
342         # simplistic check to see if the url is valid,
343         # then append a trailing slash if it is missing
344         base = self.db.config.TRACKER_WEB 
345         if (not isinstance(base , type('')) or
346             not (base.startswith('http://') or base.startswith('https://'))):
347             base = "Configuration Error: TRACKER_WEB isn't a " \
348                 "fully-qualified URL"
349         elif base[-1] != '/' :
350             base += '/'
351         web = base + self.classname + nodeid
353         # ensure the email address is properly quoted
354         email = straddr((self.db.config.TRACKER_NAME,
355             self.db.config.TRACKER_EMAIL))
357         line = '_' * max(len(web), len(email))
358         return '%s\n%s\n%s\n%s'%(line, email, web, line)
361     def generateCreateNote(self, nodeid):
362         """Generate a create note that lists initial property values
363         """
364         cn = self.classname
365         cl = self.db.classes[cn]
366         props = cl.getprops(protected=0)
368         # list the values
369         m = []
370         l = props.items()
371         l.sort()
372         for propname, prop in l:
373             value = cl.get(nodeid, propname, None)
374             # skip boring entries
375             if not value:
376                 continue
377             if isinstance(prop, hyperdb.Link):
378                 link = self.db.classes[prop.classname]
379                 if value:
380                     key = link.labelprop(default_to_id=1)
381                     if key:
382                         value = link.get(value, key)
383                 else:
384                     value = ''
385             elif isinstance(prop, hyperdb.Multilink):
386                 if value is None: value = []
387                 l = []
388                 link = self.db.classes[prop.classname]
389                 key = link.labelprop(default_to_id=1)
390                 if key:
391                     value = [link.get(entry, key) for entry in value]
392                 value.sort()
393                 value = ', '.join(value)
394             m.append('%s: %s'%(propname, value))
395         m.insert(0, '----------')
396         m.insert(0, '')
397         return '\n'.join(m)
399     def generateChangeNote(self, nodeid, oldvalues):
400         """Generate a change note that lists property changes
401         """
402         if __debug__ :
403             if not isinstance(oldvalues, type({})) :
404                 raise TypeError("'oldvalues' must be dict-like, not %s."%
405                     type(oldvalues))
407         cn = self.classname
408         cl = self.db.classes[cn]
409         changed = {}
410         props = cl.getprops(protected=0)
412         # determine what changed
413         for key in oldvalues.keys():
414             if key in ['files','messages']:
415                 continue
416             if key in ('activity', 'creator', 'creation'):
417                 continue
418             new_value = cl.get(nodeid, key)
419             # the old value might be non existent
420             try:
421                 old_value = oldvalues[key]
422                 if type(new_value) is type([]):
423                     new_value.sort()
424                     old_value.sort()
425                 if new_value != old_value:
426                     changed[key] = old_value
427             except:
428                 changed[key] = new_value
430         # list the changes
431         m = []
432         l = changed.items()
433         l.sort()
434         for propname, oldvalue in l:
435             prop = props[propname]
436             value = cl.get(nodeid, propname, None)
437             if isinstance(prop, hyperdb.Link):
438                 link = self.db.classes[prop.classname]
439                 key = link.labelprop(default_to_id=1)
440                 if key:
441                     if value:
442                         value = link.get(value, key)
443                     else:
444                         value = ''
445                     if oldvalue:
446                         oldvalue = link.get(oldvalue, key)
447                     else:
448                         oldvalue = ''
449                 change = '%s -> %s'%(oldvalue, value)
450             elif isinstance(prop, hyperdb.Multilink):
451                 change = ''
452                 if value is None: value = []
453                 if oldvalue is None: oldvalue = []
454                 l = []
455                 link = self.db.classes[prop.classname]
456                 key = link.labelprop(default_to_id=1)
457                 # check for additions
458                 for entry in value:
459                     if entry in oldvalue: continue
460                     if key:
461                         l.append(link.get(entry, key))
462                     else:
463                         l.append(entry)
464                 if l:
465                     l.sort()
466                     change = '+%s'%(', '.join(l))
467                     l = []
468                 # check for removals
469                 for entry in oldvalue:
470                     if entry in value: continue
471                     if key:
472                         l.append(link.get(entry, key))
473                     else:
474                         l.append(entry)
475                 if l:
476                     l.sort()
477                     change += ' -%s'%(', '.join(l))
478             else:
479                 change = '%s -> %s'%(oldvalue, value)
480             m.append('%s: %s'%(propname, change))
481         if m:
482             m.insert(0, '----------')
483             m.insert(0, '')
484         return '\n'.join(m)
486 # vim: set filetype=python ts=4 sw=4 et si