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.
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