Code

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