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.94 2003-11-16 19:59:09 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 = self.db.msg.safeget(msgid, 'author')
137 recipients = self.db.msg.safeget(msgid, 'recipients', [])
139 sendto = []
140 seen_message = dict([(recipient, 1) for recipient in recipients])
142 def add_recipient(userid):
143 # make sure they have an address
144 address = self.db.user.get(userid, 'address')
145 if address:
146 sendto.append(address)
147 recipients.append(userid)
149 def good_recipient(userid):
150 # Make sure we don't send mail to either the anonymous
151 # user or a user who has already seen the message.
152 return (userid and
153 self.db.user.get(userid, 'username') != 'anonymous' and
154 not seen_message.has_key(userid))
156 # possibly send the message to the author, as long as they aren't
157 # anonymous
158 if (good_recipient(authid) and
159 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
160 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
161 add_recipient(authid)
163 if authid:
164 seen_message[authid] = 1
166 # now deal with the nosy and cc people who weren't recipients.
167 for userid in cc + self.get(nodeid, whichnosy):
168 if good_recipient(userid):
169 add_recipient(userid)
171 if oldvalues:
172 note = self.generateChangeNote(nodeid, oldvalues)
173 else:
174 note = self.generateCreateNote(nodeid)
176 # If we have new recipients, update the message's recipients
177 # and send the mail.
178 if sendto:
179 if msgid:
180 self.db.msg.set(msgid, recipients=recipients)
181 self.send_message(nodeid, msgid, note, sendto, from_address)
183 # backwards compatibility - don't remove
184 sendmessage = nosymessage
186 def send_message(self, nodeid, msgid, note, sendto, from_address=None):
187 '''Actually send the nominated message from this node to the sendto
188 recipients, with the note appended.
189 '''
190 users = self.db.user
191 messages = self.db.msg
192 files = self.db.file
194 inreplyto = messages.safeget(msgid, 'inreplyto')
195 messageid = messages.safeget(msgid, 'messageid')
197 # make up a messageid if there isn't one (web edit)
198 if not messageid:
199 # this is an old message that didn't get a messageid, so
200 # create one
201 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
202 self.classname, nodeid,
203 self.db.config.MAIL_DOMAIN)
204 messages.set(msgid, messageid=messageid)
206 # send an email to the people who missed out
207 cn = self.classname
208 title = self.get(nodeid, 'title') or '%s message copy'%cn
210 authid = messages.safeget(msgid, 'author')
211 authname = users.safeget(authid, 'realname')
212 if not authname:
213 authname = users.safeget(authid, 'username', '')
214 authaddr = users.safeget(authid, 'address', '')
215 if authaddr:
216 authaddr = " <%s>" % straddr( ('',authaddr) )
218 # make the message body
219 m = ['']
221 # put in roundup's signature
222 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
223 m.append(self.email_signature(nodeid, msgid))
225 # add author information
226 if authid:
227 if len(self.get(nodeid,'messages')) == 1:
228 m.append("New submission from %s%s:"%(authname, authaddr))
229 else:
230 m.append("%s%s added the comment:"%(authname, authaddr))
231 else:
232 m.append("System message:")
233 m.append('')
235 # add the content
236 m.append(messages.safeget(msgid, 'content', ''))
238 # add the change note
239 if note:
240 m.append(note)
242 # put in roundup's signature
243 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
244 m.append(self.email_signature(nodeid, msgid))
246 # encode the content as quoted-printable
247 content = cStringIO.StringIO('\n'.join(m))
248 content_encoded = cStringIO.StringIO()
249 quopri.encode(content, content_encoded, 0)
250 content_encoded = content_encoded.getvalue()
252 # get the files for this message
253 message_files = msgid and messages.get(msgid, 'files') or None
255 # make sure the To line is always the same (for testing mostly)
256 sendto.sort()
258 # make sure we have a from address
259 if from_address is None:
260 from_address = self.db.config.TRACKER_EMAIL
262 # additional bit for after the From: "name"
263 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
264 if from_tag:
265 from_tag = ' ' + from_tag
267 subject = '[%s%s] %s' % (cn, nodeid, encode_header(title))
268 author = straddr((encode_header(authname) + from_tag, from_address))
270 # create the message
271 mailer = Mailer(self.db.config)
272 message, writer = mailer.get_standard_message(sendto, subject, author)
274 tracker_name = encode_header(self.db.config.TRACKER_NAME)
275 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
276 if messageid:
277 writer.addheader('Message-Id', messageid)
278 if inreplyto:
279 writer.addheader('In-Reply-To', inreplyto)
281 # attach files
282 if message_files:
283 part = writer.startmultipartbody('mixed')
284 part = writer.nextpart()
285 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
286 body = part.startbody('text/plain; charset=utf-8')
287 body.write(content_encoded)
288 for fileid in message_files:
289 name = files.get(fileid, 'name')
290 mime_type = files.get(fileid, 'type')
291 content = files.get(fileid, 'content')
292 part = writer.nextpart()
293 if mime_type == 'text/plain':
294 part.addheader('Content-Disposition',
295 'attachment;\n filename="%s"'%name)
296 part.addheader('Content-Transfer-Encoding', '7bit')
297 body = part.startbody('text/plain')
298 body.write(content)
299 else:
300 # some other type, so encode it
301 if not mime_type:
302 # this should have been done when the file was saved
303 mime_type = mimetypes.guess_type(name)[0]
304 if mime_type is None:
305 mime_type = 'application/octet-stream'
306 part.addheader('Content-Disposition',
307 'attachment;\n filename="%s"'%name)
308 part.addheader('Content-Transfer-Encoding', 'base64')
309 body = part.startbody(mime_type)
310 body.write(base64.encodestring(content))
311 writer.lastpart()
312 else:
313 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
314 body = writer.startbody('text/plain; charset=utf-8')
315 body.write(content_encoded)
317 mailer.smtp_send(sendto, message)
319 def email_signature(self, nodeid, msgid):
320 ''' Add a signature to the e-mail with some useful information
321 '''
322 # simplistic check to see if the url is valid,
323 # then append a trailing slash if it is missing
324 base = self.db.config.TRACKER_WEB
325 if (not isinstance(base , type('')) or
326 not (base.startswith('http://') or base.startswith('https://'))):
327 base = "Configuration Error: TRACKER_WEB isn't a " \
328 "fully-qualified URL"
329 elif base[-1] != '/' :
330 base += '/'
331 web = base + self.classname + nodeid
333 # ensure the email address is properly quoted
334 email = straddr((self.db.config.TRACKER_NAME,
335 self.db.config.TRACKER_EMAIL))
337 line = '_' * max(len(web)+2, len(email))
338 return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
341 def generateCreateNote(self, nodeid):
342 """Generate a create note that lists initial property values
343 """
344 cn = self.classname
345 cl = self.db.classes[cn]
346 props = cl.getprops(protected=0)
348 # list the values
349 m = []
350 l = props.items()
351 l.sort()
352 for propname, prop in l:
353 value = cl.get(nodeid, propname, None)
354 # skip boring entries
355 if not value:
356 continue
357 if isinstance(prop, hyperdb.Link):
358 link = self.db.classes[prop.classname]
359 if value:
360 key = link.labelprop(default_to_id=1)
361 if key:
362 value = link.get(value, key)
363 else:
364 value = ''
365 elif isinstance(prop, hyperdb.Multilink):
366 if value is None: value = []
367 l = []
368 link = self.db.classes[prop.classname]
369 key = link.labelprop(default_to_id=1)
370 if key:
371 value = [link.get(entry, key) for entry in value]
372 value.sort()
373 value = ', '.join(value)
374 m.append('%s: %s'%(propname, value))
375 m.insert(0, '----------')
376 m.insert(0, '')
377 return '\n'.join(m)
379 def generateChangeNote(self, nodeid, oldvalues):
380 """Generate a change note that lists property changes
381 """
382 if __debug__ :
383 if not isinstance(oldvalues, type({})) :
384 raise TypeError("'oldvalues' must be dict-like, not %s."%
385 type(oldvalues))
387 cn = self.classname
388 cl = self.db.classes[cn]
389 changed = {}
390 props = cl.getprops(protected=0)
392 # determine what changed
393 for key in oldvalues.keys():
394 if key in ['files','messages']:
395 continue
396 if key in ('activity', 'creator', 'creation'):
397 continue
398 # not all keys from oldvalues might be available in database
399 # this happens when property was deleted
400 try:
401 new_value = cl.get(nodeid, key)
402 except KeyError:
403 continue
404 # the old value might be non existent
405 # this happens when property was added
406 try:
407 old_value = oldvalues[key]
408 if type(new_value) is type([]):
409 new_value.sort()
410 old_value.sort()
411 if new_value != old_value:
412 changed[key] = old_value
413 except:
414 changed[key] = new_value
416 # list the changes
417 m = []
418 l = changed.items()
419 l.sort()
420 for propname, oldvalue in l:
421 prop = props[propname]
422 value = cl.get(nodeid, propname, None)
423 if isinstance(prop, hyperdb.Link):
424 link = self.db.classes[prop.classname]
425 key = link.labelprop(default_to_id=1)
426 if key:
427 if value:
428 value = link.get(value, key)
429 else:
430 value = ''
431 if oldvalue:
432 oldvalue = link.get(oldvalue, key)
433 else:
434 oldvalue = ''
435 change = '%s -> %s'%(oldvalue, value)
436 elif isinstance(prop, hyperdb.Multilink):
437 change = ''
438 if value is None: value = []
439 if oldvalue is None: oldvalue = []
440 l = []
441 link = self.db.classes[prop.classname]
442 key = link.labelprop(default_to_id=1)
443 # check for additions
444 for entry in value:
445 if entry in oldvalue: continue
446 if key:
447 l.append(link.get(entry, key))
448 else:
449 l.append(entry)
450 if l:
451 l.sort()
452 change = '+%s'%(', '.join(l))
453 l = []
454 # check for removals
455 for entry in oldvalue:
456 if entry in value: continue
457 if key:
458 l.append(link.get(entry, key))
459 else:
460 l.append(entry)
461 if l:
462 l.sort()
463 change += ' -%s'%(', '.join(l))
464 else:
465 change = '%s -> %s'%(oldvalue, value)
466 m.append('%s: %s'%(propname, change))
467 if m:
468 m.insert(0, '----------')
469 m.insert(0, '')
470 return '\n'.join(m)
472 # vim: set filetype=python ts=4 sw=4 et si