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.72 2002-10-08 07:28:34 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
27 # if available, use the 'email' module, otherwise fallback to 'rfc822'
28 try :
29 from email.Utils import dump_address_pair as straddr
30 except ImportError :
31 from rfc822 import dump_address_pair as straddr
33 import hyperdb
35 # set to indicate to roundup not to actually _send_ email
36 # this var must contain a file to write the mail to
37 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
39 class Database:
40 def getuid(self):
41 """Return the id of the "user" node associated with the user
42 that owns this connection to the hyperdatabase."""
43 return self.user.lookup(self.journaltag)
45 class MessageSendError(RuntimeError):
46 pass
48 class DetectorError(RuntimeError):
49 ''' Raised by detectors that want to indicate that something's amiss
50 '''
51 pass
53 # deviation from spec - was called IssueClass
54 class IssueClass:
55 """ This class is intended to be mixed-in with a hyperdb backend
56 implementation. The backend should provide a mechanism that
57 enforces the title, messages, files, nosy and superseder
58 properties:
59 properties['title'] = hyperdb.String(indexme='yes')
60 properties['messages'] = hyperdb.Multilink("msg")
61 properties['files'] = hyperdb.Multilink("file")
62 properties['nosy'] = hyperdb.Multilink("user")
63 properties['superseder'] = hyperdb.Multilink(classname)
64 """
66 # New methods:
67 def addmessage(self, nodeid, summary, text):
68 """Add a message to an issue's mail spool.
70 A new "msg" node is constructed using the current date, the user that
71 owns the database connection as the author, and the specified summary
72 text.
74 The "files" and "recipients" fields are left empty.
76 The given text is saved as the body of the message and the node is
77 appended to the "messages" field of the specified issue.
78 """
80 def nosymessage(self, nodeid, msgid, oldvalues):
81 """Send a message to the members of an issue's nosy list.
83 The message is sent only to users on the nosy list who are not
84 already on the "recipients" list for the message.
86 These users are then added to the message's "recipients" list.
87 """
88 users = self.db.user
89 messages = self.db.msg
91 # figure the recipient ids
92 sendto = []
93 r = {}
94 recipients = messages.get(msgid, 'recipients')
95 for recipid in messages.get(msgid, 'recipients'):
96 r[recipid] = 1
98 # figure the author's id, and indicate they've received the message
99 authid = messages.get(msgid, 'author')
101 # possibly send the message to the author, as long as they aren't
102 # anonymous
103 if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and
104 users.get(authid, 'username') != 'anonymous'):
105 sendto.append(authid)
106 r[authid] = 1
108 # now figure the nosy people who weren't recipients
109 nosy = self.get(nodeid, 'nosy')
110 for nosyid in nosy:
111 # Don't send nosy mail to the anonymous user (that user
112 # shouldn't appear in the nosy list, but just in case they
113 # do...)
114 if users.get(nosyid, 'username') == 'anonymous':
115 continue
116 # make sure they haven't seen the message already
117 if not r.has_key(nosyid):
118 # send it to them
119 sendto.append(nosyid)
120 recipients.append(nosyid)
122 # generate a change note
123 if oldvalues:
124 note = self.generateChangeNote(nodeid, oldvalues)
125 else:
126 note = self.generateCreateNote(nodeid)
128 # we have new recipients
129 if sendto:
130 # map userids to addresses
131 sendto = [users.get(i, 'address') for i in sendto]
133 # update the message's recipients list
134 messages.set(msgid, recipients=recipients)
136 # send the message
137 self.send_message(nodeid, msgid, note, sendto)
139 # backwards compatibility - don't remove
140 sendmessage = nosymessage
142 def send_message(self, nodeid, msgid, note, sendto):
143 '''Actually send the nominated message from this node to the sendto
144 recipients, with the note appended.
145 '''
146 users = self.db.user
147 messages = self.db.msg
148 files = self.db.file
150 # determine the messageid and inreplyto of the message
151 inreplyto = messages.get(msgid, 'inreplyto')
152 messageid = messages.get(msgid, 'messageid')
154 # make up a messageid if there isn't one (web edit)
155 if not messageid:
156 # this is an old message that didn't get a messageid, so
157 # create one
158 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
159 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
160 messages.set(msgid, messageid=messageid)
162 # send an email to the people who missed out
163 cn = self.classname
164 title = self.get(nodeid, 'title') or '%s message copy'%cn
165 # figure author information
166 authid = messages.get(msgid, 'author')
167 authname = users.get(authid, 'realname')
168 if not authname:
169 authname = users.get(authid, 'username')
170 authaddr = users.get(authid, 'address')
171 if authaddr:
172 authaddr = " <%s>" % straddr( ('',authaddr) )
173 else:
174 authaddr = ''
176 # make the message body
177 m = ['']
179 # put in roundup's signature
180 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
181 m.append(self.email_signature(nodeid, msgid))
183 # add author information
184 if len(self.get(nodeid,'messages')) == 1:
185 m.append("New submission from %s%s:"%(authname, authaddr))
186 else:
187 m.append("%s%s added the comment:"%(authname, authaddr))
188 m.append('')
190 # add the content
191 m.append(messages.get(msgid, 'content'))
193 # add the change note
194 if note:
195 m.append(note)
197 # put in roundup's signature
198 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
199 m.append(self.email_signature(nodeid, msgid))
201 # encode the content as quoted-printable
202 content = cStringIO.StringIO('\n'.join(m))
203 content_encoded = cStringIO.StringIO()
204 quopri.encode(content, content_encoded, 0)
205 content_encoded = content_encoded.getvalue()
207 # get the files for this message
208 message_files = messages.get(msgid, 'files')
210 # make sure the To line is always the same (for testing mostly)
211 sendto.sort()
213 # create the message
214 message = cStringIO.StringIO()
215 writer = MimeWriter.MimeWriter(message)
216 writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
217 writer.addheader('To', ', '.join(sendto))
218 writer.addheader('From', straddr(
219 (authname, self.db.config.TRACKER_EMAIL) ) )
220 writer.addheader('Reply-To', straddr(
221 (self.db.config.TRACKER_NAME,
222 self.db.config.TRACKER_EMAIL) ) )
223 writer.addheader('MIME-Version', '1.0')
224 if messageid:
225 writer.addheader('Message-Id', messageid)
226 if inreplyto:
227 writer.addheader('In-Reply-To', inreplyto)
229 # add a uniquely Roundup header to help filtering
230 writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME)
232 # attach files
233 if message_files:
234 part = writer.startmultipartbody('mixed')
235 part = writer.nextpart()
236 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
237 body = part.startbody('text/plain')
238 body.write(content_encoded)
239 for fileid in message_files:
240 name = files.get(fileid, 'name')
241 mime_type = files.get(fileid, 'type')
242 content = files.get(fileid, 'content')
243 part = writer.nextpart()
244 if mime_type == 'text/plain':
245 part.addheader('Content-Disposition',
246 'attachment;\n filename="%s"'%name)
247 part.addheader('Content-Transfer-Encoding', '7bit')
248 body = part.startbody('text/plain')
249 body.write(content)
250 else:
251 # some other type, so encode it
252 if not mime_type:
253 # this should have been done when the file was saved
254 mime_type = mimetypes.guess_type(name)[0]
255 if mime_type is None:
256 mime_type = 'application/octet-stream'
257 part.addheader('Content-Disposition',
258 'attachment;\n filename="%s"'%name)
259 part.addheader('Content-Transfer-Encoding', 'base64')
260 body = part.startbody(mime_type)
261 body.write(base64.encodestring(content))
262 writer.lastpart()
263 else:
264 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
265 body = writer.startbody('text/plain')
266 body.write(content_encoded)
268 # now try to send the message
269 if SENDMAILDEBUG:
270 open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%(
271 self.db.config.ADMIN_EMAIL,
272 ', '.join(sendto),message.getvalue()))
273 else:
274 try:
275 # send the message as admin so bounces are sent there
276 # instead of to roundup
277 smtp = smtplib.SMTP(self.db.config.MAILHOST)
278 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
279 message.getvalue())
280 except socket.error, value:
281 raise MessageSendError, \
282 "Couldn't send confirmation email: mailhost %s"%value
283 except smtplib.SMTPException, value:
284 raise MessageSendError, \
285 "Couldn't send confirmation email: %s"%value
287 def email_signature(self, nodeid, msgid):
288 ''' Add a signature to the e-mail with some useful information
289 '''
290 # simplistic check to see if the url is valid,
291 # then append a trailing slash if it is missing
292 base = self.db.config.TRACKER_WEB
293 if (not isinstance(base , type('')) or
294 not (base.startswith('http://') or base.startswith('https://'))):
295 base = "Configuration Error: TRACKER_WEB isn't a " \
296 "fully-qualified URL"
297 elif base[-1] != '/' :
298 base += '/'
299 web = base + self.classname + nodeid
301 # ensure the email address is properly quoted
302 email = straddr((self.db.config.TRACKER_NAME,
303 self.db.config.TRACKER_EMAIL))
305 line = '_' * max(len(web), len(email))
306 return '%s\n%s\n%s\n%s'%(line, email, web, line)
309 def generateCreateNote(self, nodeid):
310 """Generate a create note that lists initial property values
311 """
312 cn = self.classname
313 cl = self.db.classes[cn]
314 props = cl.getprops(protected=0)
316 # list the values
317 m = []
318 l = props.items()
319 l.sort()
320 for propname, prop in l:
321 value = cl.get(nodeid, propname, None)
322 # skip boring entries
323 if not value:
324 continue
325 if isinstance(prop, hyperdb.Link):
326 link = self.db.classes[prop.classname]
327 if value:
328 key = link.labelprop(default_to_id=1)
329 if key:
330 value = link.get(value, key)
331 else:
332 value = ''
333 elif isinstance(prop, hyperdb.Multilink):
334 if value is None: value = []
335 l = []
336 link = self.db.classes[prop.classname]
337 key = link.labelprop(default_to_id=1)
338 if key:
339 value = [link.get(entry, key) for entry in value]
340 value.sort()
341 value = ', '.join(value)
342 m.append('%s: %s'%(propname, value))
343 m.insert(0, '----------')
344 m.insert(0, '')
345 return '\n'.join(m)
347 def generateChangeNote(self, nodeid, oldvalues):
348 """Generate a change note that lists property changes
349 """
350 if __debug__ :
351 if not isinstance(oldvalues, type({})) :
352 raise TypeError("'oldvalues' must be dict-like, not %s."%
353 type(oldvalues))
355 cn = self.classname
356 cl = self.db.classes[cn]
357 changed = {}
358 props = cl.getprops(protected=0)
360 # determine what changed
361 for key in oldvalues.keys():
362 if key in ['files','messages']:
363 continue
364 if key in ('activity', 'creator', 'creation'):
365 continue
366 new_value = cl.get(nodeid, key)
367 # the old value might be non existent
368 try:
369 old_value = oldvalues[key]
370 if type(new_value) is type([]):
371 new_value.sort()
372 old_value.sort()
373 if new_value != old_value:
374 changed[key] = old_value
375 except:
376 changed[key] = new_value
378 # list the changes
379 m = []
380 l = changed.items()
381 l.sort()
382 for propname, oldvalue in l:
383 prop = props[propname]
384 value = cl.get(nodeid, propname, None)
385 if isinstance(prop, hyperdb.Link):
386 link = self.db.classes[prop.classname]
387 key = link.labelprop(default_to_id=1)
388 if key:
389 if value:
390 value = link.get(value, key)
391 else:
392 value = ''
393 if oldvalue:
394 oldvalue = link.get(oldvalue, key)
395 else:
396 oldvalue = ''
397 change = '%s -> %s'%(oldvalue, value)
398 elif isinstance(prop, hyperdb.Multilink):
399 change = ''
400 if value is None: value = []
401 if oldvalue is None: oldvalue = []
402 l = []
403 link = self.db.classes[prop.classname]
404 key = link.labelprop(default_to_id=1)
405 # check for additions
406 for entry in value:
407 if entry in oldvalue: continue
408 if key:
409 l.append(link.get(entry, key))
410 else:
411 l.append(entry)
412 if l:
413 l.sort()
414 change = '+%s'%(', '.join(l))
415 l = []
416 # check for removals
417 for entry in oldvalue:
418 if entry in value: continue
419 if key:
420 l.append(link.get(entry, key))
421 else:
422 l.append(entry)
423 if l:
424 l.sort()
425 change += ' -%s'%(', '.join(l))
426 else:
427 change = '%s -> %s'%(oldvalue, value)
428 m.append('%s: %s'%(propname, change))
429 if m:
430 m.insert(0, '----------')
431 m.insert(0, '')
432 return '\n'.join(m)
434 # vim: set filetype=python ts=4 sw=4 et si