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