56176a908fdd335ba183cd4f8550a10ace25f64e
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.85 2003-04-24 07:19:58 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
28 from rfc2822 import encode_header
30 # if available, use the 'email' module, otherwise fallback to 'rfc822'
31 try :
32 from email.Utils import formataddr as straddr
33 except ImportError :
34 # code taken from the email package 2.4.3
35 def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
36 escapesre = re.compile(r'[][\()"]')):
37 name, address = pair
38 if name:
39 quotes = ''
40 if specialsre.search(name):
41 quotes = '"'
42 name = escapesre.sub(r'\\\g<0>', name)
43 return '%s%s%s <%s>' % (quotes, name, quotes, address)
44 return address
46 from roundup import hyperdb
47 from roundup.mailgw import openSMTPConnection
49 # set to indicate to roundup not to actually _send_ email
50 # this var must contain a file to write the mail to
51 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
53 class Database:
54 def getuid(self):
55 """Return the id of the "user" node associated with the user
56 that owns this connection to the hyperdatabase."""
57 return self.user.lookup(self.journaltag)
59 def getUserTimezone(self):
60 """Return user timezone defined in 'timezone' property of user class.
61 If no such property exists return 0
62 """
63 userid = self.getuid()
64 try:
65 timezone = int(self.user.get(userid, 'timezone'))
66 except (KeyError, ValueError, TypeError):
67 # If there is no class 'user' or current user doesn't have timezone
68 # property or that property is not numeric assume he/she lives in
69 # Greenwich :)
70 timezone = 0
71 return timezone
73 class MessageSendError(RuntimeError):
74 pass
76 class DetectorError(RuntimeError):
77 ''' Raised by detectors that want to indicate that something's amiss
78 '''
79 pass
81 # deviation from spec - was called IssueClass
82 class IssueClass:
83 """ This class is intended to be mixed-in with a hyperdb backend
84 implementation. The backend should provide a mechanism that
85 enforces the title, messages, files, nosy and superseder
86 properties:
87 properties['title'] = hyperdb.String(indexme='yes')
88 properties['messages'] = hyperdb.Multilink("msg")
89 properties['files'] = hyperdb.Multilink("file")
90 properties['nosy'] = hyperdb.Multilink("user")
91 properties['superseder'] = hyperdb.Multilink(classname)
92 """
94 # New methods:
95 def addmessage(self, nodeid, summary, text):
96 """Add a message to an issue's mail spool.
98 A new "msg" node is constructed using the current date, the user that
99 owns the database connection as the author, and the specified summary
100 text.
102 The "files" and "recipients" fields are left empty.
104 The given text is saved as the body of the message and the node is
105 appended to the "messages" field of the specified issue.
106 """
108 # XXX "bcc" is an optional extra here...
109 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
110 from_address=None, cc=[]): #, bcc=[]):
111 """Send a message to the members of an issue's nosy list.
113 The message is sent only to users on the nosy list who are not
114 already on the "recipients" list for the message.
116 These users are then added to the message's "recipients" list.
118 """
119 users = self.db.user
120 messages = self.db.msg
122 # figure the recipient ids
123 sendto = []
124 r = {}
125 recipients = messages.get(msgid, 'recipients')
126 for recipid in messages.get(msgid, 'recipients'):
127 r[recipid] = 1
129 # figure the author's id, and indicate they've received the message
130 authid = messages.get(msgid, 'author')
132 # possibly send the message to the author, as long as they aren't
133 # anonymous
134 if (users.get(authid, 'username') != 'anonymous' and
135 not r.has_key(authid)):
136 if self.db.config.MESSAGES_TO_AUTHOR == 'yes':
137 # always CC the author of the message
138 sendto.append(authid)
139 recipients.append(authid)
140 elif self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues:
141 # only CC the author if the issue is new
142 sendto.append(authid)
143 recipients.append(authid)
144 r[authid] = 1
146 # now deal with cc people.
147 for cc_userid in cc :
148 if r.has_key(cc_userid):
149 continue
150 # send it to them
151 sendto.append(cc_userid)
152 recipients.append(cc_userid)
154 # now figure the nosy people who weren't recipients
155 nosy = self.get(nodeid, whichnosy)
156 for nosyid in nosy:
157 # Don't send nosy mail to the anonymous user (that user
158 # shouldn't appear in the nosy list, but just in case they
159 # do...)
160 if users.get(nosyid, 'username') == 'anonymous':
161 continue
162 # make sure they haven't seen the message already
163 if not r.has_key(nosyid):
164 # send it to them
165 sendto.append(nosyid)
166 recipients.append(nosyid)
168 # generate a change note
169 if oldvalues:
170 note = self.generateChangeNote(nodeid, oldvalues)
171 else:
172 note = self.generateCreateNote(nodeid)
174 # we have new recipients
175 if sendto:
176 # map userids to addresses
177 sendto = [users.get(i, 'address') for i in sendto]
179 # update the message's recipients list
180 messages.set(msgid, recipients=recipients)
182 # send the message
183 self.send_message(nodeid, msgid, note, sendto, from_address)
185 # backwards compatibility - don't remove
186 sendmessage = nosymessage
188 def send_message(self, nodeid, msgid, note, sendto, from_address=None):
189 '''Actually send the nominated message from this node to the sendto
190 recipients, with the note appended.
191 '''
192 users = self.db.user
193 messages = self.db.msg
194 files = self.db.file
196 # determine the messageid and inreplyto of the message
197 inreplyto = messages.get(msgid, 'inreplyto')
198 messageid = messages.get(msgid, 'messageid')
200 # make up a messageid if there isn't one (web edit)
201 if not messageid:
202 # this is an old message that didn't get a messageid, so
203 # create one
204 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
205 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
206 messages.set(msgid, messageid=messageid)
208 # send an email to the people who missed out
209 cn = self.classname
210 title = self.get(nodeid, 'title') or '%s message copy'%cn
211 # figure author information
212 authid = messages.get(msgid, 'author')
213 authname = users.get(authid, 'realname')
214 if not authname:
215 authname = users.get(authid, 'username')
216 authaddr = users.get(authid, 'address')
217 if authaddr:
218 authaddr = " <%s>" % straddr( ('',authaddr) )
219 else:
220 authaddr = ''
222 # make the message body
223 m = ['']
225 # put in roundup's signature
226 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
227 m.append(self.email_signature(nodeid, msgid))
229 # add author information
230 if len(self.get(nodeid,'messages')) == 1:
231 m.append("New submission from %s%s:"%(authname, authaddr))
232 else:
233 m.append("%s%s added the comment:"%(authname, authaddr))
234 m.append('')
236 # add the content
237 m.append(messages.get(msgid, 'content'))
239 # add the change note
240 if note:
241 m.append(note)
243 # put in roundup's signature
244 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
245 m.append(self.email_signature(nodeid, msgid))
247 # encode the content as quoted-printable
248 content = cStringIO.StringIO('\n'.join(m))
249 content_encoded = cStringIO.StringIO()
250 quopri.encode(content, content_encoded, 0)
251 content_encoded = content_encoded.getvalue()
253 # get the files for this message
254 message_files = messages.get(msgid, 'files')
256 # make sure the To line is always the same (for testing mostly)
257 sendto.sort()
259 # make sure we have a from address
260 if from_address is None:
261 from_address = self.db.config.TRACKER_EMAIL
263 # additional bit for after the From: "name"
264 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
265 if from_tag:
266 from_tag = ' ' + from_tag
268 # create the message
269 message = cStringIO.StringIO()
270 writer = MimeWriter.MimeWriter(message)
271 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid,
272 encode_header(title)))
273 writer.addheader('To', ', '.join(sendto))
274 writer.addheader('From', straddr((encode_header(authname) +
275 from_tag, from_address)))
276 tracker_name = encode_header(self.db.config.TRACKER_NAME)
277 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
278 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
279 time.gmtime()))
280 writer.addheader('MIME-Version', '1.0')
281 if messageid:
282 writer.addheader('Message-Id', messageid)
283 if inreplyto:
284 writer.addheader('In-Reply-To', inreplyto)
286 # add a uniquely Roundup header to help filtering
287 writer.addheader('X-Roundup-Name', tracker_name)
289 # avoid email loops
290 writer.addheader('X-Roundup-Loop', 'hello')
292 # attach files
293 if message_files:
294 part = writer.startmultipartbody('mixed')
295 part = writer.nextpart()
296 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
297 body = part.startbody('text/plain; charset=utf-8')
298 body.write(content_encoded)
299 for fileid in message_files:
300 name = files.get(fileid, 'name')
301 mime_type = files.get(fileid, 'type')
302 content = files.get(fileid, 'content')
303 part = writer.nextpart()
304 if mime_type == 'text/plain':
305 part.addheader('Content-Disposition',
306 'attachment;\n filename="%s"'%name)
307 part.addheader('Content-Transfer-Encoding', '7bit')
308 body = part.startbody('text/plain')
309 body.write(content)
310 else:
311 # some other type, so encode it
312 if not mime_type:
313 # this should have been done when the file was saved
314 mime_type = mimetypes.guess_type(name)[0]
315 if mime_type is None:
316 mime_type = 'application/octet-stream'
317 part.addheader('Content-Disposition',
318 'attachment;\n filename="%s"'%name)
319 part.addheader('Content-Transfer-Encoding', 'base64')
320 body = part.startbody(mime_type)
321 body.write(base64.encodestring(content))
322 writer.lastpart()
323 else:
324 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
325 body = writer.startbody('text/plain; charset=utf-8')
326 body.write(content_encoded)
328 # now try to send the message
329 if SENDMAILDEBUG:
330 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
331 self.db.config.ADMIN_EMAIL,
332 ', '.join(sendto),message.getvalue()))
333 else:
334 try:
335 # send the message as admin so bounces are sent there
336 # instead of to roundup
337 smtp = openSMTPConnection(self.db.config)
338 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
339 message.getvalue())
340 except socket.error, value:
341 raise MessageSendError, \
342 "Couldn't send confirmation email: mailhost %s"%value
343 except smtplib.SMTPException, value:
344 raise MessageSendError, \
345 "Couldn't send confirmation email: %s"%value
347 def email_signature(self, nodeid, msgid):
348 ''' Add a signature to the e-mail with some useful information
349 '''
350 # simplistic check to see if the url is valid,
351 # then append a trailing slash if it is missing
352 base = self.db.config.TRACKER_WEB
353 if (not isinstance(base , type('')) or
354 not (base.startswith('http://') or base.startswith('https://'))):
355 base = "Configuration Error: TRACKER_WEB isn't a " \
356 "fully-qualified URL"
357 elif base[-1] != '/' :
358 base += '/'
359 web = base + self.classname + nodeid
361 # ensure the email address is properly quoted
362 email = straddr((self.db.config.TRACKER_NAME,
363 self.db.config.TRACKER_EMAIL))
365 line = '_' * max(len(web)+2, len(email))
366 return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
369 def generateCreateNote(self, nodeid):
370 """Generate a create note that lists initial property values
371 """
372 cn = self.classname
373 cl = self.db.classes[cn]
374 props = cl.getprops(protected=0)
376 # list the values
377 m = []
378 l = props.items()
379 l.sort()
380 for propname, prop in l:
381 value = cl.get(nodeid, propname, None)
382 # skip boring entries
383 if not value:
384 continue
385 if isinstance(prop, hyperdb.Link):
386 link = self.db.classes[prop.classname]
387 if value:
388 key = link.labelprop(default_to_id=1)
389 if key:
390 value = link.get(value, key)
391 else:
392 value = ''
393 elif isinstance(prop, hyperdb.Multilink):
394 if value is None: value = []
395 l = []
396 link = self.db.classes[prop.classname]
397 key = link.labelprop(default_to_id=1)
398 if key:
399 value = [link.get(entry, key) for entry in value]
400 value.sort()
401 value = ', '.join(value)
402 m.append('%s: %s'%(propname, value))
403 m.insert(0, '----------')
404 m.insert(0, '')
405 return '\n'.join(m)
407 def generateChangeNote(self, nodeid, oldvalues):
408 """Generate a change note that lists property changes
409 """
410 if __debug__ :
411 if not isinstance(oldvalues, type({})) :
412 raise TypeError("'oldvalues' must be dict-like, not %s."%
413 type(oldvalues))
415 cn = self.classname
416 cl = self.db.classes[cn]
417 changed = {}
418 props = cl.getprops(protected=0)
420 # determine what changed
421 for key in oldvalues.keys():
422 if key in ['files','messages']:
423 continue
424 if key in ('activity', 'creator', 'creation'):
425 continue
426 new_value = cl.get(nodeid, key)
427 # the old value might be non existent
428 try:
429 old_value = oldvalues[key]
430 if type(new_value) is type([]):
431 new_value.sort()
432 old_value.sort()
433 if new_value != old_value:
434 changed[key] = old_value
435 except:
436 changed[key] = new_value
438 # list the changes
439 m = []
440 l = changed.items()
441 l.sort()
442 for propname, oldvalue in l:
443 prop = props[propname]
444 value = cl.get(nodeid, propname, None)
445 if isinstance(prop, hyperdb.Link):
446 link = self.db.classes[prop.classname]
447 key = link.labelprop(default_to_id=1)
448 if key:
449 if value:
450 value = link.get(value, key)
451 else:
452 value = ''
453 if oldvalue:
454 oldvalue = link.get(oldvalue, key)
455 else:
456 oldvalue = ''
457 change = '%s -> %s'%(oldvalue, value)
458 elif isinstance(prop, hyperdb.Multilink):
459 change = ''
460 if value is None: value = []
461 if oldvalue is None: oldvalue = []
462 l = []
463 link = self.db.classes[prop.classname]
464 key = link.labelprop(default_to_id=1)
465 # check for additions
466 for entry in value:
467 if entry in oldvalue: continue
468 if key:
469 l.append(link.get(entry, key))
470 else:
471 l.append(entry)
472 if l:
473 l.sort()
474 change = '+%s'%(', '.join(l))
475 l = []
476 # check for removals
477 for entry in oldvalue:
478 if entry in value: continue
479 if key:
480 l.append(link.get(entry, key))
481 else:
482 l.append(entry)
483 if l:
484 l.sort()
485 change += ' -%s'%(', '.join(l))
486 else:
487 change = '%s -> %s'%(oldvalue, value)
488 m.append('%s: %s'%(propname, change))
489 if m:
490 m.insert(0, '----------')
491 m.insert(0, '')
492 return '\n'.join(m)
494 # vim: set filetype=python ts=4 sw=4 et si