4ad3eb952cad36a928fdbeafe826141ce7ae8a7e
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.68 2002-09-11 02:20:35 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 not base.startswith('http://'):
294 base = "Configuration Error: TRACKER_WEB isn't a " \
295 "fully-qualified URL"
296 elif base[-1] != '/' :
297 base += '/'
298 web = base + self.classname + nodeid
300 # ensure the email address is properly quoted
301 email = straddr((self.db.config.TRACKER_NAME,
302 self.db.config.TRACKER_EMAIL))
304 line = '_' * max(len(web), len(email))
305 return '%s\n%s\n%s\n%s'%(line, email, web, line)
308 def generateCreateNote(self, nodeid):
309 """Generate a create note that lists initial property values
310 """
311 cn = self.classname
312 cl = self.db.classes[cn]
313 props = cl.getprops(protected=0)
315 # list the values
316 m = []
317 l = props.items()
318 l.sort()
319 for propname, prop in l:
320 value = cl.get(nodeid, propname, None)
321 # skip boring entries
322 if not value:
323 continue
324 if isinstance(prop, hyperdb.Link):
325 link = self.db.classes[prop.classname]
326 if value:
327 key = link.labelprop(default_to_id=1)
328 if key:
329 value = link.get(value, key)
330 else:
331 value = ''
332 elif isinstance(prop, hyperdb.Multilink):
333 if value is None: value = []
334 l = []
335 link = self.db.classes[prop.classname]
336 key = link.labelprop(default_to_id=1)
337 if key:
338 value = [link.get(entry, key) for entry in value]
339 value.sort()
340 value = ', '.join(value)
341 m.append('%s: %s'%(propname, value))
342 m.insert(0, '----------')
343 m.insert(0, '')
344 return '\n'.join(m)
346 def generateChangeNote(self, nodeid, oldvalues):
347 """Generate a change note that lists property changes
348 """
349 if __debug__ :
350 if not isinstance(oldvalues, type({})) :
351 raise TypeError("'oldvalues' must be dict-like, not %s."%
352 type(oldvalues))
354 cn = self.classname
355 cl = self.db.classes[cn]
356 changed = {}
357 props = cl.getprops(protected=0)
359 # determine what changed
360 for key in oldvalues.keys():
361 if key in ['files','messages']: continue
362 new_value = cl.get(nodeid, key)
363 # the old value might be non existent
364 try:
365 old_value = oldvalues[key]
366 if type(new_value) is type([]):
367 new_value.sort()
368 old_value.sort()
369 if new_value != old_value:
370 changed[key] = old_value
371 except:
372 changed[key] = new_value
374 # list the changes
375 m = []
376 l = changed.items()
377 l.sort()
378 for propname, oldvalue in l:
379 prop = props[propname]
380 value = cl.get(nodeid, propname, None)
381 if isinstance(prop, hyperdb.Link):
382 link = self.db.classes[prop.classname]
383 key = link.labelprop(default_to_id=1)
384 if key:
385 if value:
386 value = link.get(value, key)
387 else:
388 value = ''
389 if oldvalue:
390 oldvalue = link.get(oldvalue, key)
391 else:
392 oldvalue = ''
393 change = '%s -> %s'%(oldvalue, value)
394 elif isinstance(prop, hyperdb.Multilink):
395 change = ''
396 if value is None: value = []
397 if oldvalue is None: oldvalue = []
398 l = []
399 link = self.db.classes[prop.classname]
400 key = link.labelprop(default_to_id=1)
401 # check for additions
402 for entry in value:
403 if entry in oldvalue: continue
404 if key:
405 l.append(link.get(entry, key))
406 else:
407 l.append(entry)
408 if l:
409 l.sort()
410 change = '+%s'%(', '.join(l))
411 l = []
412 # check for removals
413 for entry in oldvalue:
414 if entry in value: continue
415 if key:
416 l.append(link.get(entry, key))
417 else:
418 l.append(entry)
419 if l:
420 l.sort()
421 change += ' -%s'%(', '.join(l))
422 else:
423 change = '%s -> %s'%(oldvalue, value)
424 m.append('%s: %s'%(propname, change))
425 if m:
426 m.insert(0, '----------')
427 m.insert(0, '')
428 return '\n'.join(m)
430 # vim: set filetype=python ts=4 sw=4 et si