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