b75b6115f357ad3546ed90bef40266511368386a
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.80 2003-01-27 17:02:46 kedder 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, encode_header(title)))
264 writer.addheader('To', ', '.join(sendto))
265 writer.addheader('From', straddr((encode_header(authname) +
266 from_tag, from_address)))
267 writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
268 from_address)))
269 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
270 time.gmtime()))
271 writer.addheader('MIME-Version', '1.0')
272 if messageid:
273 writer.addheader('Message-Id', messageid)
274 if inreplyto:
275 writer.addheader('In-Reply-To', inreplyto)
277 # add a uniquely Roundup header to help filtering
278 writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
280 # avoid email loops
281 writer.addheader('X-Roundup-Loop', 'hello')
283 # attach files
284 if message_files:
285 part = writer.startmultipartbody('mixed')
286 part = writer.nextpart()
287 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
288 body = part.startbody('text/plain; charset=utf-8')
289 body.write(content_encoded)
290 for fileid in message_files:
291 name = files.get(fileid, 'name')
292 mime_type = files.get(fileid, 'type')
293 content = files.get(fileid, 'content')
294 part = writer.nextpart()
295 if mime_type == 'text/plain':
296 part.addheader('Content-Disposition',
297 'attachment;\n filename="%s"'%name)
298 part.addheader('Content-Transfer-Encoding', '7bit')
299 body = part.startbody('text/plain')
300 body.write(content)
301 else:
302 # some other type, so encode it
303 if not mime_type:
304 # this should have been done when the file was saved
305 mime_type = mimetypes.guess_type(name)[0]
306 if mime_type is None:
307 mime_type = 'application/octet-stream'
308 part.addheader('Content-Disposition',
309 'attachment;\n filename="%s"'%name)
310 part.addheader('Content-Transfer-Encoding', 'base64')
311 body = part.startbody(mime_type)
312 body.write(base64.encodestring(content))
313 writer.lastpart()
314 else:
315 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
316 body = writer.startbody('text/plain; charset=utf-8')
317 body.write(content_encoded)
319 # now try to send the message
320 if SENDMAILDEBUG:
321 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
322 self.db.config.ADMIN_EMAIL,
323 ', '.join(sendto),message.getvalue()))
324 else:
325 try:
326 # send the message as admin so bounces are sent there
327 # instead of to roundup
328 smtp = smtplib.SMTP(self.db.config.MAILHOST)
329 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
330 message.getvalue())
331 except socket.error, value:
332 raise MessageSendError, \
333 "Couldn't send confirmation email: mailhost %s"%value
334 except smtplib.SMTPException, value:
335 raise MessageSendError, \
336 "Couldn't send confirmation email: %s"%value
338 def email_signature(self, nodeid, msgid):
339 ''' Add a signature to the e-mail with some useful information
340 '''
341 # simplistic check to see if the url is valid,
342 # then append a trailing slash if it is missing
343 base = self.db.config.TRACKER_WEB
344 if (not isinstance(base , type('')) or
345 not (base.startswith('http://') or base.startswith('https://'))):
346 base = "Configuration Error: TRACKER_WEB isn't a " \
347 "fully-qualified URL"
348 elif base[-1] != '/' :
349 base += '/'
350 web = base + self.classname + nodeid
352 # ensure the email address is properly quoted
353 email = straddr((self.db.config.TRACKER_NAME,
354 self.db.config.TRACKER_EMAIL))
356 line = '_' * max(len(web), len(email))
357 return '%s\n%s\n%s\n%s'%(line, email, web, line)
360 def generateCreateNote(self, nodeid):
361 """Generate a create note that lists initial property values
362 """
363 cn = self.classname
364 cl = self.db.classes[cn]
365 props = cl.getprops(protected=0)
367 # list the values
368 m = []
369 l = props.items()
370 l.sort()
371 for propname, prop in l:
372 value = cl.get(nodeid, propname, None)
373 # skip boring entries
374 if not value:
375 continue
376 if isinstance(prop, hyperdb.Link):
377 link = self.db.classes[prop.classname]
378 if value:
379 key = link.labelprop(default_to_id=1)
380 if key:
381 value = link.get(value, key)
382 else:
383 value = ''
384 elif isinstance(prop, hyperdb.Multilink):
385 if value is None: value = []
386 l = []
387 link = self.db.classes[prop.classname]
388 key = link.labelprop(default_to_id=1)
389 if key:
390 value = [link.get(entry, key) for entry in value]
391 value.sort()
392 value = ', '.join(value)
393 m.append('%s: %s'%(propname, value))
394 m.insert(0, '----------')
395 m.insert(0, '')
396 return '\n'.join(m)
398 def generateChangeNote(self, nodeid, oldvalues):
399 """Generate a change note that lists property changes
400 """
401 if __debug__ :
402 if not isinstance(oldvalues, type({})) :
403 raise TypeError("'oldvalues' must be dict-like, not %s."%
404 type(oldvalues))
406 cn = self.classname
407 cl = self.db.classes[cn]
408 changed = {}
409 props = cl.getprops(protected=0)
411 # determine what changed
412 for key in oldvalues.keys():
413 if key in ['files','messages']:
414 continue
415 if key in ('activity', 'creator', 'creation'):
416 continue
417 new_value = cl.get(nodeid, key)
418 # the old value might be non existent
419 try:
420 old_value = oldvalues[key]
421 if type(new_value) is type([]):
422 new_value.sort()
423 old_value.sort()
424 if new_value != old_value:
425 changed[key] = old_value
426 except:
427 changed[key] = new_value
429 # list the changes
430 m = []
431 l = changed.items()
432 l.sort()
433 for propname, oldvalue in l:
434 prop = props[propname]
435 value = cl.get(nodeid, propname, None)
436 if isinstance(prop, hyperdb.Link):
437 link = self.db.classes[prop.classname]
438 key = link.labelprop(default_to_id=1)
439 if key:
440 if value:
441 value = link.get(value, key)
442 else:
443 value = ''
444 if oldvalue:
445 oldvalue = link.get(oldvalue, key)
446 else:
447 oldvalue = ''
448 change = '%s -> %s'%(oldvalue, value)
449 elif isinstance(prop, hyperdb.Multilink):
450 change = ''
451 if value is None: value = []
452 if oldvalue is None: oldvalue = []
453 l = []
454 link = self.db.classes[prop.classname]
455 key = link.labelprop(default_to_id=1)
456 # check for additions
457 for entry in value:
458 if entry in oldvalue: continue
459 if key:
460 l.append(link.get(entry, key))
461 else:
462 l.append(entry)
463 if l:
464 l.sort()
465 change = '+%s'%(', '.join(l))
466 l = []
467 # check for removals
468 for entry in oldvalue:
469 if entry in value: continue
470 if key:
471 l.append(link.get(entry, key))
472 else:
473 l.append(entry)
474 if l:
475 l.sort()
476 change += ' -%s'%(', '.join(l))
477 else:
478 change = '%s -> %s'%(oldvalue, value)
479 m.append('%s: %s'%(propname, change))
480 if m:
481 m.insert(0, '----------')
482 m.insert(0, '')
483 return '\n'.join(m)
485 # vim: set filetype=python ts=4 sw=4 et si