0bf115da95f8a627c9103b08a567c5777a164464
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.82 2003-02-24 05:16:57 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 (users.get(authid, 'username') != 'anonymous' and
134 not r.has_key(authid)):
135 if self.db.config.MESSAGES_TO_AUTHOR == 'yes':
136 # always CC the author of the message
137 sendto.append(authid)
138 recipients.append(authid)
139 elif self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues:
140 # only CC the author if the issue is new
141 sendto.append(authid)
142 recipients.append(authid)
143 r[authid] = 1
145 # now deal with cc people.
146 for cc_userid in cc :
147 if r.has_key(cc_userid):
148 continue
149 # send it to them
150 sendto.append(cc_userid)
151 recipients.append(cc_userid)
153 # now figure the nosy people who weren't recipients
154 nosy = self.get(nodeid, whichnosy)
155 for nosyid in nosy:
156 # Don't send nosy mail to the anonymous user (that user
157 # shouldn't appear in the nosy list, but just in case they
158 # do...)
159 if users.get(nosyid, 'username') == 'anonymous':
160 continue
161 # make sure they haven't seen the message already
162 if not r.has_key(nosyid):
163 # send it to them
164 sendto.append(nosyid)
165 recipients.append(nosyid)
167 # generate a change note
168 if oldvalues:
169 note = self.generateChangeNote(nodeid, oldvalues)
170 else:
171 note = self.generateCreateNote(nodeid)
173 # we have new recipients
174 if sendto:
175 # map userids to addresses
176 sendto = [users.get(i, 'address') for i in sendto]
178 # update the message's recipients list
179 messages.set(msgid, recipients=recipients)
181 # send the message
182 self.send_message(nodeid, msgid, note, sendto, from_address)
184 # backwards compatibility - don't remove
185 sendmessage = nosymessage
187 def send_message(self, nodeid, msgid, note, sendto, from_address=None):
188 '''Actually send the nominated message from this node to the sendto
189 recipients, with the note appended.
190 '''
191 users = self.db.user
192 messages = self.db.msg
193 files = self.db.file
195 # determine the messageid and inreplyto of the message
196 inreplyto = messages.get(msgid, 'inreplyto')
197 messageid = messages.get(msgid, 'messageid')
199 # make up a messageid if there isn't one (web edit)
200 if not messageid:
201 # this is an old message that didn't get a messageid, so
202 # create one
203 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
204 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
205 messages.set(msgid, messageid=messageid)
207 # send an email to the people who missed out
208 cn = self.classname
209 title = self.get(nodeid, 'title') or '%s message copy'%cn
210 # figure author information
211 authid = messages.get(msgid, 'author')
212 authname = users.get(authid, 'realname')
213 if not authname:
214 authname = users.get(authid, 'username')
215 authaddr = users.get(authid, 'address')
216 if authaddr:
217 authaddr = " <%s>" % straddr( ('',authaddr) )
218 else:
219 authaddr = ''
221 # make the message body
222 m = ['']
224 # put in roundup's signature
225 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
226 m.append(self.email_signature(nodeid, msgid))
228 # add author information
229 if len(self.get(nodeid,'messages')) == 1:
230 m.append("New submission from %s%s:"%(authname, authaddr))
231 else:
232 m.append("%s%s added the comment:"%(authname, authaddr))
233 m.append('')
235 # add the content
236 m.append(messages.get(msgid, 'content'))
238 # add the change note
239 if note:
240 m.append(note)
242 # put in roundup's signature
243 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
244 m.append(self.email_signature(nodeid, msgid))
246 # encode the content as quoted-printable
247 content = cStringIO.StringIO('\n'.join(m))
248 content_encoded = cStringIO.StringIO()
249 quopri.encode(content, content_encoded, 0)
250 content_encoded = content_encoded.getvalue()
252 # get the files for this message
253 message_files = messages.get(msgid, 'files')
255 # make sure the To line is always the same (for testing mostly)
256 sendto.sort()
258 # make sure we have a from address
259 if from_address is None:
260 from_address = self.db.config.TRACKER_EMAIL
262 # additional bit for after the From: "name"
263 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
264 if from_tag:
265 from_tag = ' ' + from_tag
267 # create the message
268 message = cStringIO.StringIO()
269 writer = MimeWriter.MimeWriter(message)
270 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid,
271 encode_header(title)))
272 writer.addheader('To', ', '.join(sendto))
273 writer.addheader('From', straddr((encode_header(authname) +
274 from_tag, from_address)))
275 tracker_name = encode_header(self.db.config.TRACKER_NAME)
276 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
277 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
278 time.gmtime()))
279 writer.addheader('MIME-Version', '1.0')
280 if messageid:
281 writer.addheader('Message-Id', messageid)
282 if inreplyto:
283 writer.addheader('In-Reply-To', inreplyto)
285 # add a uniquely Roundup header to help filtering
286 writer.addheader('X-Roundup-Name', tracker_name)
288 # avoid email loops
289 writer.addheader('X-Roundup-Loop', 'hello')
291 # attach files
292 if message_files:
293 part = writer.startmultipartbody('mixed')
294 part = writer.nextpart()
295 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
296 body = part.startbody('text/plain; charset=utf-8')
297 body.write(content_encoded)
298 for fileid in message_files:
299 name = files.get(fileid, 'name')
300 mime_type = files.get(fileid, 'type')
301 content = files.get(fileid, 'content')
302 part = writer.nextpart()
303 if mime_type == 'text/plain':
304 part.addheader('Content-Disposition',
305 'attachment;\n filename="%s"'%name)
306 part.addheader('Content-Transfer-Encoding', '7bit')
307 body = part.startbody('text/plain')
308 body.write(content)
309 else:
310 # some other type, so encode it
311 if not mime_type:
312 # this should have been done when the file was saved
313 mime_type = mimetypes.guess_type(name)[0]
314 if mime_type is None:
315 mime_type = 'application/octet-stream'
316 part.addheader('Content-Disposition',
317 'attachment;\n filename="%s"'%name)
318 part.addheader('Content-Transfer-Encoding', 'base64')
319 body = part.startbody(mime_type)
320 body.write(base64.encodestring(content))
321 writer.lastpart()
322 else:
323 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
324 body = writer.startbody('text/plain; charset=utf-8')
325 body.write(content_encoded)
327 # now try to send the message
328 if SENDMAILDEBUG:
329 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
330 self.db.config.ADMIN_EMAIL,
331 ', '.join(sendto),message.getvalue()))
332 else:
333 try:
334 # send the message as admin so bounces are sent there
335 # instead of to roundup
336 smtp = smtplib.SMTP(self.db.config.MAILHOST)
337 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
338 message.getvalue())
339 except socket.error, value:
340 raise MessageSendError, \
341 "Couldn't send confirmation email: mailhost %s"%value
342 except smtplib.SMTPException, value:
343 raise MessageSendError, \
344 "Couldn't send confirmation email: %s"%value
346 def email_signature(self, nodeid, msgid):
347 ''' Add a signature to the e-mail with some useful information
348 '''
349 # simplistic check to see if the url is valid,
350 # then append a trailing slash if it is missing
351 base = self.db.config.TRACKER_WEB
352 if (not isinstance(base , type('')) or
353 not (base.startswith('http://') or base.startswith('https://'))):
354 base = "Configuration Error: TRACKER_WEB isn't a " \
355 "fully-qualified URL"
356 elif base[-1] != '/' :
357 base += '/'
358 web = base + self.classname + nodeid
360 # ensure the email address is properly quoted
361 email = straddr((self.db.config.TRACKER_NAME,
362 self.db.config.TRACKER_EMAIL))
364 line = '_' * max(len(web), len(email))
365 return '%s\n%s\n%s\n%s'%(line, email, web, line)
368 def generateCreateNote(self, nodeid):
369 """Generate a create note that lists initial property values
370 """
371 cn = self.classname
372 cl = self.db.classes[cn]
373 props = cl.getprops(protected=0)
375 # list the values
376 m = []
377 l = props.items()
378 l.sort()
379 for propname, prop in l:
380 value = cl.get(nodeid, propname, None)
381 # skip boring entries
382 if not value:
383 continue
384 if isinstance(prop, hyperdb.Link):
385 link = self.db.classes[prop.classname]
386 if value:
387 key = link.labelprop(default_to_id=1)
388 if key:
389 value = link.get(value, key)
390 else:
391 value = ''
392 elif isinstance(prop, hyperdb.Multilink):
393 if value is None: value = []
394 l = []
395 link = self.db.classes[prop.classname]
396 key = link.labelprop(default_to_id=1)
397 if key:
398 value = [link.get(entry, key) for entry in value]
399 value.sort()
400 value = ', '.join(value)
401 m.append('%s: %s'%(propname, value))
402 m.insert(0, '----------')
403 m.insert(0, '')
404 return '\n'.join(m)
406 def generateChangeNote(self, nodeid, oldvalues):
407 """Generate a change note that lists property changes
408 """
409 if __debug__ :
410 if not isinstance(oldvalues, type({})) :
411 raise TypeError("'oldvalues' must be dict-like, not %s."%
412 type(oldvalues))
414 cn = self.classname
415 cl = self.db.classes[cn]
416 changed = {}
417 props = cl.getprops(protected=0)
419 # determine what changed
420 for key in oldvalues.keys():
421 if key in ['files','messages']:
422 continue
423 if key in ('activity', 'creator', 'creation'):
424 continue
425 new_value = cl.get(nodeid, key)
426 # the old value might be non existent
427 try:
428 old_value = oldvalues[key]
429 if type(new_value) is type([]):
430 new_value.sort()
431 old_value.sort()
432 if new_value != old_value:
433 changed[key] = old_value
434 except:
435 changed[key] = new_value
437 # list the changes
438 m = []
439 l = changed.items()
440 l.sort()
441 for propname, oldvalue in l:
442 prop = props[propname]
443 value = cl.get(nodeid, propname, None)
444 if isinstance(prop, hyperdb.Link):
445 link = self.db.classes[prop.classname]
446 key = link.labelprop(default_to_id=1)
447 if key:
448 if value:
449 value = link.get(value, key)
450 else:
451 value = ''
452 if oldvalue:
453 oldvalue = link.get(oldvalue, key)
454 else:
455 oldvalue = ''
456 change = '%s -> %s'%(oldvalue, value)
457 elif isinstance(prop, hyperdb.Multilink):
458 change = ''
459 if value is None: value = []
460 if oldvalue is None: oldvalue = []
461 l = []
462 link = self.db.classes[prop.classname]
463 key = link.labelprop(default_to_id=1)
464 # check for additions
465 for entry in value:
466 if entry in oldvalue: continue
467 if key:
468 l.append(link.get(entry, key))
469 else:
470 l.append(entry)
471 if l:
472 l.sort()
473 change = '+%s'%(', '.join(l))
474 l = []
475 # check for removals
476 for entry in oldvalue:
477 if entry in value: continue
478 if key:
479 l.append(link.get(entry, key))
480 else:
481 l.append(entry)
482 if l:
483 l.sort()
484 change += ' -%s'%(', '.join(l))
485 else:
486 change = '%s -> %s'%(oldvalue, value)
487 m.append('%s: %s'%(propname, change))
488 if m:
489 m.insert(0, '----------')
490 m.insert(0, '')
491 return '\n'.join(m)
493 # vim: set filetype=python ts=4 sw=4 et si