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.89 2003-09-08 09:28:28 jlgijsbers Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, time, random
25 import cStringIO, base64, quopri, mimetypes
27 from rfc2822 import encode_header
29 from roundup import password, date, hyperdb
31 # MessageSendError is imported for backwards compatibility
32 from roundup.mailer import Mailer, straddr, MessageSendError
34 class Database:
35 def getuid(self):
36 """Return the id of the "user" node associated with the user
37 that owns this connection to the hyperdatabase."""
38 return self.user.lookup(self.journaltag)
40 def getUserTimezone(self):
41 """Return user timezone defined in 'timezone' property of user class.
42 If no such property exists return 0
43 """
44 userid = self.getuid()
45 try:
46 timezone = int(self.user.get(userid, 'timezone'))
47 except (KeyError, ValueError, TypeError):
48 # If there is no class 'user' or current user doesn't have timezone
49 # property or that property is not numeric assume he/she lives in
50 # Greenwich :)
51 timezone = 0
52 return timezone
54 def figure_curuserid(self):
55 """Figure out the 'curuserid'."""
56 if self.journaltag is None:
57 self.curuserid = None
58 elif self.journaltag == 'admin':
59 # admin user may not exist, but always has ID 1
60 self.curuserid = '1'
61 else:
62 self.curuserid = self.user.lookup(self.journaltag)
64 def confirm_registration(self, otk):
65 props = self.otks.getall(otk)
66 for propname, proptype in self.user.getprops().items():
67 value = props.get(propname, None)
68 if value is None:
69 pass
70 elif isinstance(proptype, hyperdb.Date):
71 props[propname] = date.Date(value)
72 elif isinstance(proptype, hyperdb.Interval):
73 props[propname] = date.Interval(value)
74 elif isinstance(proptype, hyperdb.Password):
75 props[propname] = password.Password()
76 props[propname].unpack(value)
78 # tag new user creation with 'admin'
79 self.journaltag = 'admin'
80 self.figure_curuserid()
82 # create the new user
83 cl = self.user
85 props['roles'] = self.config.NEW_WEB_USER_ROLES
86 del props['__time']
87 userid = cl.create(**props)
88 # clear the props from the otk database
89 self.otks.destroy(otk)
90 self.commit()
92 return userid
95 class DetectorError(RuntimeError):
96 """ Raised by detectors that want to indicate that something's amiss
97 """
98 pass
100 # deviation from spec - was called IssueClass
101 class IssueClass:
102 """ This class is intended to be mixed-in with a hyperdb backend
103 implementation. The backend should provide a mechanism that
104 enforces the title, messages, files, nosy and superseder
105 properties:
106 properties['title'] = hyperdb.String(indexme='yes')
107 properties['messages'] = hyperdb.Multilink("msg")
108 properties['files'] = hyperdb.Multilink("file")
109 properties['nosy'] = hyperdb.Multilink("user")
110 properties['superseder'] = hyperdb.Multilink(classname)
111 """
113 # New methods:
114 def addmessage(self, nodeid, summary, text):
115 """Add a message to an issue's mail spool.
117 A new "msg" node is constructed using the current date, the user that
118 owns the database connection as the author, and the specified summary
119 text.
121 The "files" and "recipients" fields are left empty.
123 The given text is saved as the body of the message and the node is
124 appended to the "messages" field of the specified issue.
125 """
127 # XXX "bcc" is an optional extra here...
128 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
129 from_address=None, cc=[]): #, bcc=[]):
130 """Send a message to the members of an issue's nosy list.
132 The message is sent only to users on the nosy list who are not
133 already on the "recipients" list for the message.
135 These users are then added to the message's "recipients" list.
137 """
138 users = self.db.user
139 messages = self.db.msg
141 # figure the recipient ids
142 sendto = []
143 r = {}
144 recipients = messages.get(msgid, 'recipients')
145 for recipid in messages.get(msgid, 'recipients'):
146 r[recipid] = 1
148 # figure the author's id, and indicate they've received the message
149 authid = messages.get(msgid, 'author')
151 # possibly send the message to the author, as long as they aren't
152 # anonymous
153 if (users.get(authid, 'username') != 'anonymous' and
154 not r.has_key(authid)):
155 if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
156 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues)):
157 # make sure they have an address
158 add = users.get(authid, 'address')
159 if add:
160 # send it to them
161 sendto.append(add)
162 recipients.append(authid)
164 r[authid] = 1
166 # now deal with cc people.
167 for cc_userid in cc :
168 if r.has_key(cc_userid):
169 continue
170 # make sure they have an address
171 add = users.get(cc_userid, 'address')
172 if add:
173 # send it to them
174 sendto.append(add)
175 recipients.append(cc_userid)
177 # now figure the nosy people who weren't recipients
178 nosy = self.get(nodeid, whichnosy)
179 for nosyid in nosy:
180 # Don't send nosy mail to the anonymous user (that user
181 # shouldn't appear in the nosy list, but just in case they
182 # do...)
183 if users.get(nosyid, 'username') == 'anonymous':
184 continue
185 # make sure they haven't seen the message already
186 if not r.has_key(nosyid):
187 # make sure they have an address
188 add = users.get(nosyid, 'address')
189 if add:
190 # send it to them
191 sendto.append(add)
192 recipients.append(nosyid)
194 # generate a change note
195 if oldvalues:
196 note = self.generateChangeNote(nodeid, oldvalues)
197 else:
198 note = self.generateCreateNote(nodeid)
200 # we have new recipients
201 if sendto:
202 # update the message's recipients list
203 messages.set(msgid, recipients=recipients)
205 # send the message
206 self.send_message(nodeid, msgid, note, sendto, from_address)
208 # backwards compatibility - don't remove
209 sendmessage = nosymessage
211 def send_message(self, nodeid, msgid, note, sendto, from_address=None):
212 '''Actually send the nominated message from this node to the sendto
213 recipients, with the note appended.
214 '''
215 users = self.db.user
216 messages = self.db.msg
217 files = self.db.file
219 # determine the messageid and inreplyto of the message
220 inreplyto = messages.get(msgid, 'inreplyto')
221 messageid = messages.get(msgid, 'messageid')
223 # make up a messageid if there isn't one (web edit)
224 if not messageid:
225 # this is an old message that didn't get a messageid, so
226 # create one
227 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
228 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
229 messages.set(msgid, messageid=messageid)
231 # send an email to the people who missed out
232 cn = self.classname
233 title = self.get(nodeid, 'title') or '%s message copy'%cn
234 # figure author information
235 authid = messages.get(msgid, 'author')
236 authname = users.get(authid, 'realname')
237 if not authname:
238 authname = users.get(authid, 'username')
239 authaddr = users.get(authid, 'address')
240 if authaddr:
241 authaddr = " <%s>" % straddr( ('',authaddr) )
242 else:
243 authaddr = ''
245 # make the message body
246 m = ['']
248 # put in roundup's signature
249 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
250 m.append(self.email_signature(nodeid, msgid))
252 # add author information
253 if len(self.get(nodeid,'messages')) == 1:
254 m.append("New submission from %s%s:"%(authname, authaddr))
255 else:
256 m.append("%s%s added the comment:"%(authname, authaddr))
257 m.append('')
259 # add the content
260 m.append(messages.get(msgid, 'content'))
262 # add the change note
263 if note:
264 m.append(note)
266 # put in roundup's signature
267 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
268 m.append(self.email_signature(nodeid, msgid))
270 # encode the content as quoted-printable
271 content = cStringIO.StringIO('\n'.join(m))
272 content_encoded = cStringIO.StringIO()
273 quopri.encode(content, content_encoded, 0)
274 content_encoded = content_encoded.getvalue()
276 # get the files for this message
277 message_files = messages.get(msgid, 'files')
279 # make sure the To line is always the same (for testing mostly)
280 sendto.sort()
282 # make sure we have a from address
283 if from_address is None:
284 from_address = self.db.config.TRACKER_EMAIL
286 # additional bit for after the From: "name"
287 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
288 if from_tag:
289 from_tag = ' ' + from_tag
291 subject = '[%s%s] %s' % (cn, nodeid, encode_header(title))
292 author = straddr((encode_header(authname) + from_tag, from_address))
294 # create the message
295 mailer = Mailer(self.db.config)
296 message, writer = mailer.get_standard_message(', '.join(sendto),
297 subject, author)
299 tracker_name = encode_header(self.db.config.TRACKER_NAME)
300 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
301 if messageid:
302 writer.addheader('Message-Id', messageid)
303 if inreplyto:
304 writer.addheader('In-Reply-To', inreplyto)
306 # attach files
307 if message_files:
308 part = writer.startmultipartbody('mixed')
309 part = writer.nextpart()
310 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
311 body = part.startbody('text/plain; charset=utf-8')
312 body.write(content_encoded)
313 for fileid in message_files:
314 name = files.get(fileid, 'name')
315 mime_type = files.get(fileid, 'type')
316 content = files.get(fileid, 'content')
317 part = writer.nextpart()
318 if mime_type == 'text/plain':
319 part.addheader('Content-Disposition',
320 'attachment;\n filename="%s"'%name)
321 part.addheader('Content-Transfer-Encoding', '7bit')
322 body = part.startbody('text/plain')
323 body.write(content)
324 else:
325 # some other type, so encode it
326 if not mime_type:
327 # this should have been done when the file was saved
328 mime_type = mimetypes.guess_type(name)[0]
329 if mime_type is None:
330 mime_type = 'application/octet-stream'
331 part.addheader('Content-Disposition',
332 'attachment;\n filename="%s"'%name)
333 part.addheader('Content-Transfer-Encoding', 'base64')
334 body = part.startbody(mime_type)
335 body.write(base64.encodestring(content))
336 writer.lastpart()
337 else:
338 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
339 body = writer.startbody('text/plain; charset=utf-8')
340 body.write(content_encoded)
342 mailer.smtp_send(sendto, message)
344 def email_signature(self, nodeid, msgid):
345 ''' Add a signature to the e-mail with some useful information
346 '''
347 # simplistic check to see if the url is valid,
348 # then append a trailing slash if it is missing
349 base = self.db.config.TRACKER_WEB
350 if (not isinstance(base , type('')) or
351 not (base.startswith('http://') or base.startswith('https://'))):
352 base = "Configuration Error: TRACKER_WEB isn't a " \
353 "fully-qualified URL"
354 elif base[-1] != '/' :
355 base += '/'
356 web = base + self.classname + nodeid
358 # ensure the email address is properly quoted
359 email = straddr((self.db.config.TRACKER_NAME,
360 self.db.config.TRACKER_EMAIL))
362 line = '_' * max(len(web)+2, len(email))
363 return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
366 def generateCreateNote(self, nodeid):
367 """Generate a create note that lists initial property values
368 """
369 cn = self.classname
370 cl = self.db.classes[cn]
371 props = cl.getprops(protected=0)
373 # list the values
374 m = []
375 l = props.items()
376 l.sort()
377 for propname, prop in l:
378 value = cl.get(nodeid, propname, None)
379 # skip boring entries
380 if not value:
381 continue
382 if isinstance(prop, hyperdb.Link):
383 link = self.db.classes[prop.classname]
384 if value:
385 key = link.labelprop(default_to_id=1)
386 if key:
387 value = link.get(value, key)
388 else:
389 value = ''
390 elif isinstance(prop, hyperdb.Multilink):
391 if value is None: value = []
392 l = []
393 link = self.db.classes[prop.classname]
394 key = link.labelprop(default_to_id=1)
395 if key:
396 value = [link.get(entry, key) for entry in value]
397 value.sort()
398 value = ', '.join(value)
399 m.append('%s: %s'%(propname, value))
400 m.insert(0, '----------')
401 m.insert(0, '')
402 return '\n'.join(m)
404 def generateChangeNote(self, nodeid, oldvalues):
405 """Generate a change note that lists property changes
406 """
407 if __debug__ :
408 if not isinstance(oldvalues, type({})) :
409 raise TypeError("'oldvalues' must be dict-like, not %s."%
410 type(oldvalues))
412 cn = self.classname
413 cl = self.db.classes[cn]
414 changed = {}
415 props = cl.getprops(protected=0)
417 # determine what changed
418 for key in oldvalues.keys():
419 if key in ['files','messages']:
420 continue
421 if key in ('activity', 'creator', 'creation'):
422 continue
423 new_value = cl.get(nodeid, key)
424 # the old value might be non existent
425 try:
426 old_value = oldvalues[key]
427 if type(new_value) is type([]):
428 new_value.sort()
429 old_value.sort()
430 if new_value != old_value:
431 changed[key] = old_value
432 except:
433 changed[key] = new_value
435 # list the changes
436 m = []
437 l = changed.items()
438 l.sort()
439 for propname, oldvalue in l:
440 prop = props[propname]
441 value = cl.get(nodeid, propname, None)
442 if isinstance(prop, hyperdb.Link):
443 link = self.db.classes[prop.classname]
444 key = link.labelprop(default_to_id=1)
445 if key:
446 if value:
447 value = link.get(value, key)
448 else:
449 value = ''
450 if oldvalue:
451 oldvalue = link.get(oldvalue, key)
452 else:
453 oldvalue = ''
454 change = '%s -> %s'%(oldvalue, value)
455 elif isinstance(prop, hyperdb.Multilink):
456 change = ''
457 if value is None: value = []
458 if oldvalue is None: oldvalue = []
459 l = []
460 link = self.db.classes[prop.classname]
461 key = link.labelprop(default_to_id=1)
462 # check for additions
463 for entry in value:
464 if entry in oldvalue: continue
465 if key:
466 l.append(link.get(entry, key))
467 else:
468 l.append(entry)
469 if l:
470 l.sort()
471 change = '+%s'%(', '.join(l))
472 l = []
473 # check for removals
474 for entry in oldvalue:
475 if entry in value: continue
476 if key:
477 l.append(link.get(entry, key))
478 else:
479 l.append(entry)
480 if l:
481 l.sort()
482 change += ' -%s'%(', '.join(l))
483 else:
484 change = '%s -> %s'%(oldvalue, value)
485 m.append('%s: %s'%(propname, change))
486 if m:
487 m.insert(0, '----------')
488 m.insert(0, '')
489 return '\n'.join(m)
491 # vim: set filetype=python ts=4 sw=4 et si