Code

7723174c01e5d3379ca9e54c0981bdb16a2c0921
[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.76 2003-01-12 00:41:26 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, whichnosy='nosy',
91             from_address=[], cc=[], bcc=[]):
92         """Send a message to the members of an issue's nosy list.
94         The message is sent only to users on the nosy list who are not
95         already on the "recipients" list for the message.
96         
97         These users are then added to the message's "recipients" list.
98         """
99         users = self.db.user
100         messages = self.db.msg
102         # figure the recipient ids
103         sendto = []
104         r = {}
105         recipients = messages.get(msgid, 'recipients')
106         for recipid in messages.get(msgid, 'recipients'):
107             r[recipid] = 1
109         # figure the author's id, and indicate they've received the message
110         authid = messages.get(msgid, 'author')
112         # possibly send the message to the author, as long as they aren't
113         # anonymous
114         if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
115                 users.get(authid, 'username') != 'anonymous'):
116             sendto.append(authid)
117         r[authid] = 1
119         # now deal with cc people.
120         for cc_userid in cc :
121             if r.has_key(cc_userid):
122                 continue
123             # send it to them
124             sendto.append(cc_userid)
125             recipients.append(cc_userid)
127         # now figure the nosy people who weren't recipients
128         nosy = self.get(nodeid, whichnosy)
129         for nosyid in nosy:
130             # Don't send nosy mail to the anonymous user (that user
131             # shouldn't appear in the nosy list, but just in case they
132             # do...)
133             if users.get(nosyid, 'username') == 'anonymous':
134                 continue
135             # make sure they haven't seen the message already
136             if not r.has_key(nosyid):
137                 # send it to them
138                 sendto.append(nosyid)
139                 recipients.append(nosyid)
141         # generate a change note
142         if oldvalues:
143             note = self.generateChangeNote(nodeid, oldvalues)
144         else:
145             note = self.generateCreateNote(nodeid)
147         # we have new recipients
148         if sendto:
149             # map userids to addresses
150             sendto = [users.get(i, 'address') for i in sendto]
152             # update the message's recipients list
153             messages.set(msgid, recipients=recipients)
155             # send the message
156             self.send_message(nodeid, msgid, note, sendto, from_address)
158     # backwards compatibility - don't remove
159     sendmessage = nosymessage
161     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
162         '''Actually send the nominated message from this node to the sendto
163            recipients, with the note appended.
164         '''
165         users = self.db.user
166         messages = self.db.msg
167         files = self.db.file
169         # determine the messageid and inreplyto of the message
170         inreplyto = messages.get(msgid, 'inreplyto')
171         messageid = messages.get(msgid, 'messageid')
173         # make up a messageid if there isn't one (web edit)
174         if not messageid:
175             # this is an old message that didn't get a messageid, so
176             # create one
177             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
178                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
179             messages.set(msgid, messageid=messageid)
181         # send an email to the people who missed out
182         cn = self.classname
183         title = self.get(nodeid, 'title') or '%s message copy'%cn
184         # figure author information
185         authid = messages.get(msgid, 'author')
186         authname = users.get(authid, 'realname')
187         if not authname:
188             authname = users.get(authid, 'username')
189         authaddr = users.get(authid, 'address')
190         if authaddr:
191             authaddr = " <%s>" % straddr( ('',authaddr) )
192         else:
193             authaddr = ''
195         # make the message body
196         m = ['']
198         # put in roundup's signature
199         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
200             m.append(self.email_signature(nodeid, msgid))
202         # add author information
203         if len(self.get(nodeid,'messages')) == 1:
204             m.append("New submission from %s%s:"%(authname, authaddr))
205         else:
206             m.append("%s%s added the comment:"%(authname, authaddr))
207         m.append('')
209         # add the content
210         m.append(messages.get(msgid, 'content'))
212         # add the change note
213         if note:
214             m.append(note)
216         # put in roundup's signature
217         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
218             m.append(self.email_signature(nodeid, msgid))
220         # encode the content as quoted-printable
221         content = cStringIO.StringIO('\n'.join(m))
222         content_encoded = cStringIO.StringIO()
223         quopri.encode(content, content_encoded, 0)
224         content_encoded = content_encoded.getvalue()
226         # get the files for this message
227         message_files = messages.get(msgid, 'files')
229         # make sure the To line is always the same (for testing mostly)
230         sendto.sort()
232         # make sure we have a from address
233         if from_address is None:
234             from_address = self.db.config.TRACKER_EMAIL
236         # additional bit for after the From: "name"
237         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
238         if from_tag:
239             from_tag = ' ' + from_tag
241         # create the message
242         message = cStringIO.StringIO()
243         writer = MimeWriter.MimeWriter(message)
244         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
245         writer.addheader('To', ', '.join(sendto))
246         writer.addheader('From', straddr((authname + from_tag, from_address)))
247         writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
248             from_address)))
249         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
250             time.gmtime()))
251         writer.addheader('MIME-Version', '1.0')
252         if messageid:
253             writer.addheader('Message-Id', messageid)
254         if inreplyto:
255             writer.addheader('In-Reply-To', inreplyto)
257         # add a uniquely Roundup header to help filtering
258         writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
260         # avoid email loops
261         writer.addheader('X-Roundup-Loop', 'hello')
263         # attach files
264         if message_files:
265             part = writer.startmultipartbody('mixed')
266             part = writer.nextpart()
267             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
268             body = part.startbody('text/plain')
269             body.write(content_encoded)
270             for fileid in message_files:
271                 name = files.get(fileid, 'name')
272                 mime_type = files.get(fileid, 'type')
273                 content = files.get(fileid, 'content')
274                 part = writer.nextpart()
275                 if mime_type == 'text/plain':
276                     part.addheader('Content-Disposition',
277                         'attachment;\n filename="%s"'%name)
278                     part.addheader('Content-Transfer-Encoding', '7bit')
279                     body = part.startbody('text/plain')
280                     body.write(content)
281                 else:
282                     # some other type, so encode it
283                     if not mime_type:
284                         # this should have been done when the file was saved
285                         mime_type = mimetypes.guess_type(name)[0]
286                     if mime_type is None:
287                         mime_type = 'application/octet-stream'
288                     part.addheader('Content-Disposition',
289                         'attachment;\n filename="%s"'%name)
290                     part.addheader('Content-Transfer-Encoding', 'base64')
291                     body = part.startbody(mime_type)
292                     body.write(base64.encodestring(content))
293             writer.lastpart()
294         else:
295             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
296             body = writer.startbody('text/plain')
297             body.write(content_encoded)
299         # now try to send the message
300         if SENDMAILDEBUG:
301             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
302                 self.db.config.ADMIN_EMAIL,
303                 ', '.join(sendto),message.getvalue()))
304         else:
305             try:
306                 # send the message as admin so bounces are sent there
307                 # instead of to roundup
308                 smtp = smtplib.SMTP(self.db.config.MAILHOST)
309                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
310                     message.getvalue())
311             except socket.error, value:
312                 raise MessageSendError, \
313                     "Couldn't send confirmation email: mailhost %s"%value
314             except smtplib.SMTPException, value:
315                 raise MessageSendError, \
316                     "Couldn't send confirmation email: %s"%value
318     def email_signature(self, nodeid, msgid):
319         ''' Add a signature to the e-mail with some useful information
320         '''
321         # simplistic check to see if the url is valid,
322         # then append a trailing slash if it is missing
323         base = self.db.config.TRACKER_WEB 
324         if (not isinstance(base , type('')) or
325             not (base.startswith('http://') or base.startswith('https://'))):
326             base = "Configuration Error: TRACKER_WEB isn't a " \
327                 "fully-qualified URL"
328         elif base[-1] != '/' :
329             base += '/'
330         web = base + self.classname + nodeid
332         # ensure the email address is properly quoted
333         email = straddr((self.db.config.TRACKER_NAME,
334             self.db.config.TRACKER_EMAIL))
336         line = '_' * max(len(web), len(email))
337         return '%s\n%s\n%s\n%s'%(line, email, web, line)
340     def generateCreateNote(self, nodeid):
341         """Generate a create note that lists initial property values
342         """
343         cn = self.classname
344         cl = self.db.classes[cn]
345         props = cl.getprops(protected=0)
347         # list the values
348         m = []
349         l = props.items()
350         l.sort()
351         for propname, prop in l:
352             value = cl.get(nodeid, propname, None)
353             # skip boring entries
354             if not value:
355                 continue
356             if isinstance(prop, hyperdb.Link):
357                 link = self.db.classes[prop.classname]
358                 if value:
359                     key = link.labelprop(default_to_id=1)
360                     if key:
361                         value = link.get(value, key)
362                 else:
363                     value = ''
364             elif isinstance(prop, hyperdb.Multilink):
365                 if value is None: value = []
366                 l = []
367                 link = self.db.classes[prop.classname]
368                 key = link.labelprop(default_to_id=1)
369                 if key:
370                     value = [link.get(entry, key) for entry in value]
371                 value.sort()
372                 value = ', '.join(value)
373             m.append('%s: %s'%(propname, value))
374         m.insert(0, '----------')
375         m.insert(0, '')
376         return '\n'.join(m)
378     def generateChangeNote(self, nodeid, oldvalues):
379         """Generate a change note that lists property changes
380         """
381         if __debug__ :
382             if not isinstance(oldvalues, type({})) :
383                 raise TypeError("'oldvalues' must be dict-like, not %s."%
384                     type(oldvalues))
386         cn = self.classname
387         cl = self.db.classes[cn]
388         changed = {}
389         props = cl.getprops(protected=0)
391         # determine what changed
392         for key in oldvalues.keys():
393             if key in ['files','messages']:
394                 continue
395             if key in ('activity', 'creator', 'creation'):
396                 continue
397             new_value = cl.get(nodeid, key)
398             # the old value might be non existent
399             try:
400                 old_value = oldvalues[key]
401                 if type(new_value) is type([]):
402                     new_value.sort()
403                     old_value.sort()
404                 if new_value != old_value:
405                     changed[key] = old_value
406             except:
407                 changed[key] = new_value
409         # list the changes
410         m = []
411         l = changed.items()
412         l.sort()
413         for propname, oldvalue in l:
414             prop = props[propname]
415             value = cl.get(nodeid, propname, None)
416             if isinstance(prop, hyperdb.Link):
417                 link = self.db.classes[prop.classname]
418                 key = link.labelprop(default_to_id=1)
419                 if key:
420                     if value:
421                         value = link.get(value, key)
422                     else:
423                         value = ''
424                     if oldvalue:
425                         oldvalue = link.get(oldvalue, key)
426                     else:
427                         oldvalue = ''
428                 change = '%s -> %s'%(oldvalue, value)
429             elif isinstance(prop, hyperdb.Multilink):
430                 change = ''
431                 if value is None: value = []
432                 if oldvalue is None: oldvalue = []
433                 l = []
434                 link = self.db.classes[prop.classname]
435                 key = link.labelprop(default_to_id=1)
436                 # check for additions
437                 for entry in value:
438                     if entry in oldvalue: continue
439                     if key:
440                         l.append(link.get(entry, key))
441                     else:
442                         l.append(entry)
443                 if l:
444                     l.sort()
445                     change = '+%s'%(', '.join(l))
446                     l = []
447                 # check for removals
448                 for entry in oldvalue:
449                     if entry in value: continue
450                     if key:
451                         l.append(link.get(entry, key))
452                     else:
453                         l.append(entry)
454                 if l:
455                     l.sort()
456                     change += ' -%s'%(', '.join(l))
457             else:
458                 change = '%s -> %s'%(oldvalue, value)
459             m.append('%s: %s'%(propname, change))
460         if m:
461             m.insert(0, '----------')
462             m.insert(0, '')
463         return '\n'.join(m)
465 # vim: set filetype=python ts=4 sw=4 et si