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