14e0622d491b9a80918d3fe9c069422220198615
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.88 2003-09-06 20:02:23 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 figure_curuserid(self):
76 """Figure out the 'curuserid'."""
77 if self.journaltag is None:
78 self.curuserid = None
79 elif self.journaltag == 'admin':
80 # admin user may not exist, but always has ID 1
81 self.curuserid = '1'
82 else:
83 self.curuserid = self.user.lookup(self.journaltag)
85 def confirm_registration(self, otk):
86 props = self.otks.getall(otk)
87 for propname, proptype in self.user.getprops().items():
88 value = props.get(propname, None)
89 if value is None:
90 pass
91 elif isinstance(proptype, hyperdb.Date):
92 props[propname] = date.Date(value)
93 elif isinstance(proptype, hyperdb.Interval):
94 props[propname] = date.Interval(value)
95 elif isinstance(proptype, hyperdb.Password):
96 props[propname] = password.Password()
97 props[propname].unpack(value)
99 # tag new user creation with 'admin'
100 self.journaltag = 'admin'
101 self.figure_curuserid()
103 # create the new user
104 cl = self.user
106 props['roles'] = self.config.NEW_WEB_USER_ROLES
107 del props['__time']
108 userid = cl.create(**props)
109 # clear the props from the otk database
110 self.otks.destroy(otk)
111 self.commit()
113 return userid
115 class MessageSendError(RuntimeError):
116 pass
118 class DetectorError(RuntimeError):
119 ''' Raised by detectors that want to indicate that something's amiss
120 '''
121 pass
123 # deviation from spec - was called IssueClass
124 class IssueClass:
125 """ This class is intended to be mixed-in with a hyperdb backend
126 implementation. The backend should provide a mechanism that
127 enforces the title, messages, files, nosy and superseder
128 properties:
129 properties['title'] = hyperdb.String(indexme='yes')
130 properties['messages'] = hyperdb.Multilink("msg")
131 properties['files'] = hyperdb.Multilink("file")
132 properties['nosy'] = hyperdb.Multilink("user")
133 properties['superseder'] = hyperdb.Multilink(classname)
134 """
136 # New methods:
137 def addmessage(self, nodeid, summary, text):
138 """Add a message to an issue's mail spool.
140 A new "msg" node is constructed using the current date, the user that
141 owns the database connection as the author, and the specified summary
142 text.
144 The "files" and "recipients" fields are left empty.
146 The given text is saved as the body of the message and the node is
147 appended to the "messages" field of the specified issue.
148 """
150 # XXX "bcc" is an optional extra here...
151 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
152 from_address=None, cc=[]): #, bcc=[]):
153 """Send a message to the members of an issue's nosy list.
155 The message is sent only to users on the nosy list who are not
156 already on the "recipients" list for the message.
158 These users are then added to the message's "recipients" list.
160 """
161 users = self.db.user
162 messages = self.db.msg
164 # figure the recipient ids
165 sendto = []
166 r = {}
167 recipients = messages.get(msgid, 'recipients')
168 for recipid in messages.get(msgid, 'recipients'):
169 r[recipid] = 1
171 # figure the author's id, and indicate they've received the message
172 authid = messages.get(msgid, 'author')
174 # possibly send the message to the author, as long as they aren't
175 # anonymous
176 if (users.get(authid, 'username') != 'anonymous' and
177 not r.has_key(authid)):
178 if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
179 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues)):
180 # make sure they have an address
181 add = users.get(authid, 'address')
182 if add:
183 # send it to them
184 sendto.append(add)
185 recipients.append(authid)
187 r[authid] = 1
189 # now deal with cc people.
190 for cc_userid in cc :
191 if r.has_key(cc_userid):
192 continue
193 # make sure they have an address
194 add = users.get(cc_userid, 'address')
195 if add:
196 # send it to them
197 sendto.append(add)
198 recipients.append(cc_userid)
200 # now figure the nosy people who weren't recipients
201 nosy = self.get(nodeid, whichnosy)
202 for nosyid in nosy:
203 # Don't send nosy mail to the anonymous user (that user
204 # shouldn't appear in the nosy list, but just in case they
205 # do...)
206 if users.get(nosyid, 'username') == 'anonymous':
207 continue
208 # make sure they haven't seen the message already
209 if not r.has_key(nosyid):
210 # make sure they have an address
211 add = users.get(nosyid, 'address')
212 if add:
213 # send it to them
214 sendto.append(add)
215 recipients.append(nosyid)
217 # generate a change note
218 if oldvalues:
219 note = self.generateChangeNote(nodeid, oldvalues)
220 else:
221 note = self.generateCreateNote(nodeid)
223 # we have new recipients
224 if sendto:
225 # update the message's recipients list
226 messages.set(msgid, recipients=recipients)
228 # send the message
229 self.send_message(nodeid, msgid, note, sendto, from_address)
231 # backwards compatibility - don't remove
232 sendmessage = nosymessage
234 def send_message(self, nodeid, msgid, note, sendto, from_address=None):
235 '''Actually send the nominated message from this node to the sendto
236 recipients, with the note appended.
237 '''
238 users = self.db.user
239 messages = self.db.msg
240 files = self.db.file
242 # determine the messageid and inreplyto of the message
243 inreplyto = messages.get(msgid, 'inreplyto')
244 messageid = messages.get(msgid, 'messageid')
246 # make up a messageid if there isn't one (web edit)
247 if not messageid:
248 # this is an old message that didn't get a messageid, so
249 # create one
250 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
251 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
252 messages.set(msgid, messageid=messageid)
254 # send an email to the people who missed out
255 cn = self.classname
256 title = self.get(nodeid, 'title') or '%s message copy'%cn
257 # figure author information
258 authid = messages.get(msgid, 'author')
259 authname = users.get(authid, 'realname')
260 if not authname:
261 authname = users.get(authid, 'username')
262 authaddr = users.get(authid, 'address')
263 if authaddr:
264 authaddr = " <%s>" % straddr( ('',authaddr) )
265 else:
266 authaddr = ''
268 # make the message body
269 m = ['']
271 # put in roundup's signature
272 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
273 m.append(self.email_signature(nodeid, msgid))
275 # add author information
276 if len(self.get(nodeid,'messages')) == 1:
277 m.append("New submission from %s%s:"%(authname, authaddr))
278 else:
279 m.append("%s%s added the comment:"%(authname, authaddr))
280 m.append('')
282 # add the content
283 m.append(messages.get(msgid, 'content'))
285 # add the change note
286 if note:
287 m.append(note)
289 # put in roundup's signature
290 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
291 m.append(self.email_signature(nodeid, msgid))
293 # encode the content as quoted-printable
294 content = cStringIO.StringIO('\n'.join(m))
295 content_encoded = cStringIO.StringIO()
296 quopri.encode(content, content_encoded, 0)
297 content_encoded = content_encoded.getvalue()
299 # get the files for this message
300 message_files = messages.get(msgid, 'files')
302 # make sure the To line is always the same (for testing mostly)
303 sendto.sort()
305 # make sure we have a from address
306 if from_address is None:
307 from_address = self.db.config.TRACKER_EMAIL
309 # additional bit for after the From: "name"
310 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
311 if from_tag:
312 from_tag = ' ' + from_tag
314 # create the message
315 message = cStringIO.StringIO()
316 writer = MimeWriter.MimeWriter(message)
317 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid,
318 encode_header(title)))
319 writer.addheader('To', ', '.join(sendto))
320 writer.addheader('From', straddr((encode_header(authname) +
321 from_tag, from_address)))
322 tracker_name = encode_header(self.db.config.TRACKER_NAME)
323 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
324 writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
325 time.gmtime()))
326 writer.addheader('MIME-Version', '1.0')
327 if messageid:
328 writer.addheader('Message-Id', messageid)
329 if inreplyto:
330 writer.addheader('In-Reply-To', inreplyto)
332 # add a uniquely Roundup header to help filtering
333 writer.addheader('X-Roundup-Name', tracker_name)
335 # avoid email loops
336 writer.addheader('X-Roundup-Loop', 'hello')
338 # attach files
339 if message_files:
340 part = writer.startmultipartbody('mixed')
341 part = writer.nextpart()
342 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
343 body = part.startbody('text/plain; charset=utf-8')
344 body.write(content_encoded)
345 for fileid in message_files:
346 name = files.get(fileid, 'name')
347 mime_type = files.get(fileid, 'type')
348 content = files.get(fileid, 'content')
349 part = writer.nextpart()
350 if mime_type == 'text/plain':
351 part.addheader('Content-Disposition',
352 'attachment;\n filename="%s"'%name)
353 part.addheader('Content-Transfer-Encoding', '7bit')
354 body = part.startbody('text/plain')
355 body.write(content)
356 else:
357 # some other type, so encode it
358 if not mime_type:
359 # this should have been done when the file was saved
360 mime_type = mimetypes.guess_type(name)[0]
361 if mime_type is None:
362 mime_type = 'application/octet-stream'
363 part.addheader('Content-Disposition',
364 'attachment;\n filename="%s"'%name)
365 part.addheader('Content-Transfer-Encoding', 'base64')
366 body = part.startbody(mime_type)
367 body.write(base64.encodestring(content))
368 writer.lastpart()
369 else:
370 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
371 body = writer.startbody('text/plain; charset=utf-8')
372 body.write(content_encoded)
374 # now try to send the message
375 if SENDMAILDEBUG:
376 open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
377 self.db.config.ADMIN_EMAIL,
378 ', '.join(sendto),message.getvalue()))
379 else:
380 try:
381 # send the message as admin so bounces are sent there
382 # instead of to roundup
383 smtp = openSMTPConnection(self.db.config)
384 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
385 message.getvalue())
386 except socket.error, value:
387 raise MessageSendError, \
388 "Couldn't send confirmation email: mailhost %s"%value
389 except smtplib.SMTPException, value:
390 raise MessageSendError, \
391 "Couldn't send confirmation email: %s"%value
393 def email_signature(self, nodeid, msgid):
394 ''' Add a signature to the e-mail with some useful information
395 '''
396 # simplistic check to see if the url is valid,
397 # then append a trailing slash if it is missing
398 base = self.db.config.TRACKER_WEB
399 if (not isinstance(base , type('')) or
400 not (base.startswith('http://') or base.startswith('https://'))):
401 base = "Configuration Error: TRACKER_WEB isn't a " \
402 "fully-qualified URL"
403 elif base[-1] != '/' :
404 base += '/'
405 web = base + self.classname + nodeid
407 # ensure the email address is properly quoted
408 email = straddr((self.db.config.TRACKER_NAME,
409 self.db.config.TRACKER_EMAIL))
411 line = '_' * max(len(web)+2, len(email))
412 return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
415 def generateCreateNote(self, nodeid):
416 """Generate a create note that lists initial property values
417 """
418 cn = self.classname
419 cl = self.db.classes[cn]
420 props = cl.getprops(protected=0)
422 # list the values
423 m = []
424 l = props.items()
425 l.sort()
426 for propname, prop in l:
427 value = cl.get(nodeid, propname, None)
428 # skip boring entries
429 if not value:
430 continue
431 if isinstance(prop, hyperdb.Link):
432 link = self.db.classes[prop.classname]
433 if value:
434 key = link.labelprop(default_to_id=1)
435 if key:
436 value = link.get(value, key)
437 else:
438 value = ''
439 elif isinstance(prop, hyperdb.Multilink):
440 if value is None: value = []
441 l = []
442 link = self.db.classes[prop.classname]
443 key = link.labelprop(default_to_id=1)
444 if key:
445 value = [link.get(entry, key) for entry in value]
446 value.sort()
447 value = ', '.join(value)
448 m.append('%s: %s'%(propname, value))
449 m.insert(0, '----------')
450 m.insert(0, '')
451 return '\n'.join(m)
453 def generateChangeNote(self, nodeid, oldvalues):
454 """Generate a change note that lists property changes
455 """
456 if __debug__ :
457 if not isinstance(oldvalues, type({})) :
458 raise TypeError("'oldvalues' must be dict-like, not %s."%
459 type(oldvalues))
461 cn = self.classname
462 cl = self.db.classes[cn]
463 changed = {}
464 props = cl.getprops(protected=0)
466 # determine what changed
467 for key in oldvalues.keys():
468 if key in ['files','messages']:
469 continue
470 if key in ('activity', 'creator', 'creation'):
471 continue
472 new_value = cl.get(nodeid, key)
473 # the old value might be non existent
474 try:
475 old_value = oldvalues[key]
476 if type(new_value) is type([]):
477 new_value.sort()
478 old_value.sort()
479 if new_value != old_value:
480 changed[key] = old_value
481 except:
482 changed[key] = new_value
484 # list the changes
485 m = []
486 l = changed.items()
487 l.sort()
488 for propname, oldvalue in l:
489 prop = props[propname]
490 value = cl.get(nodeid, propname, None)
491 if isinstance(prop, hyperdb.Link):
492 link = self.db.classes[prop.classname]
493 key = link.labelprop(default_to_id=1)
494 if key:
495 if value:
496 value = link.get(value, key)
497 else:
498 value = ''
499 if oldvalue:
500 oldvalue = link.get(oldvalue, key)
501 else:
502 oldvalue = ''
503 change = '%s -> %s'%(oldvalue, value)
504 elif isinstance(prop, hyperdb.Multilink):
505 change = ''
506 if value is None: value = []
507 if oldvalue is None: oldvalue = []
508 l = []
509 link = self.db.classes[prop.classname]
510 key = link.labelprop(default_to_id=1)
511 # check for additions
512 for entry in value:
513 if entry in oldvalue: continue
514 if key:
515 l.append(link.get(entry, key))
516 else:
517 l.append(entry)
518 if l:
519 l.sort()
520 change = '+%s'%(', '.join(l))
521 l = []
522 # check for removals
523 for entry in oldvalue:
524 if entry in value: continue
525 if key:
526 l.append(link.get(entry, key))
527 else:
528 l.append(entry)
529 if l:
530 l.sort()
531 change += ' -%s'%(', '.join(l))
532 else:
533 change = '%s -> %s'%(oldvalue, value)
534 m.append('%s: %s'%(propname, change))
535 if m:
536 m.insert(0, '----------')
537 m.insert(0, '')
538 return '\n'.join(m)
540 # vim: set filetype=python ts=4 sw=4 et si