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