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