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