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