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.96 2003-12-05 04:43:30 richard 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 = {}
141 for recipient in recipients:
142 seen_message[recipient] = 1
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 = messages.safeget(msgid, 'inreplyto')
197 messageid = messages.safeget(msgid, 'messageid')
199 # make up a messageid if there isn't one (web edit)
200 if not messageid:
201 # this is an old message that didn't get a messageid, so
202 # create one
203 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
204 self.classname, nodeid,
205 self.db.config.MAIL_DOMAIN)
206 messages.set(msgid, messageid=messageid)
208 # send an email to the people who missed out
209 cn = self.classname
210 title = self.get(nodeid, 'title') or '%s message copy'%cn
212 authid = messages.safeget(msgid, 'author')
213 authname = users.safeget(authid, 'realname')
214 if not authname:
215 authname = users.safeget(authid, 'username', '')
216 authaddr = users.safeget(authid, 'address', '')
217 if authaddr:
218 authaddr = " <%s>" % straddr( ('',authaddr) )
220 # make the message body
221 m = ['']
223 # put in roundup's signature
224 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
225 m.append(self.email_signature(nodeid, msgid))
227 # add author information
228 if authid:
229 if len(self.get(nodeid,'messages')) == 1:
230 m.append("New submission from %s%s:"%(authname, authaddr))
231 else:
232 m.append("%s%s added the comment:"%(authname, authaddr))
233 else:
234 m.append("System message:")
235 m.append('')
237 # add the content
238 m.append(messages.safeget(msgid, 'content', ''))
240 # add the change note
241 if note:
242 m.append(note)
244 # put in roundup's signature
245 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
246 m.append(self.email_signature(nodeid, msgid))
248 # encode the content as quoted-printable
249 content = cStringIO.StringIO('\n'.join(m))
250 content_encoded = cStringIO.StringIO()
251 quopri.encode(content, content_encoded, 0)
252 content_encoded = content_encoded.getvalue()
254 # get the files for this message
255 message_files = msgid and messages.get(msgid, 'files') or None
257 # make sure the To line is always the same (for testing mostly)
258 sendto.sort()
260 # make sure we have a from address
261 if from_address is None:
262 from_address = self.db.config.TRACKER_EMAIL
264 # additional bit for after the From: "name"
265 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
266 if from_tag:
267 from_tag = ' ' + from_tag
269 subject = '[%s%s] %s' % (cn, nodeid, encode_header(title))
270 author = straddr((encode_header(authname) + from_tag, from_address))
272 # create the message
273 mailer = Mailer(self.db.config)
274 message, writer = mailer.get_standard_message(sendto, subject, author)
276 tracker_name = encode_header(self.db.config.TRACKER_NAME)
277 writer.addheader('Reply-To', straddr((tracker_name, from_address)))
278 if messageid:
279 writer.addheader('Message-Id', messageid)
280 if inreplyto:
281 writer.addheader('In-Reply-To', inreplyto)
283 # attach files
284 if message_files:
285 part = writer.startmultipartbody('mixed')
286 part = writer.nextpart()
287 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
288 body = part.startbody('text/plain; charset=utf-8')
289 body.write(content_encoded)
290 for fileid in message_files:
291 name = files.get(fileid, 'name')
292 mime_type = files.get(fileid, 'type')
293 content = files.get(fileid, 'content')
294 part = writer.nextpart()
295 if mime_type == 'text/plain':
296 part.addheader('Content-Disposition',
297 'attachment;\n filename="%s"'%name)
298 part.addheader('Content-Transfer-Encoding', '7bit')
299 body = part.startbody('text/plain')
300 body.write(content)
301 else:
302 # some other type, so encode it
303 if not mime_type:
304 # this should have been done when the file was saved
305 mime_type = mimetypes.guess_type(name)[0]
306 if mime_type is None:
307 mime_type = 'application/octet-stream'
308 part.addheader('Content-Disposition',
309 'attachment;\n filename="%s"'%name)
310 part.addheader('Content-Transfer-Encoding', 'base64')
311 body = part.startbody(mime_type)
312 body.write(base64.encodestring(content))
313 writer.lastpart()
314 else:
315 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
316 body = writer.startbody('text/plain; charset=utf-8')
317 body.write(content_encoded)
319 mailer.smtp_send(sendto, message)
321 def email_signature(self, nodeid, msgid):
322 ''' Add a signature to the e-mail with some useful information
323 '''
324 # simplistic check to see if the url is valid,
325 # then append a trailing slash if it is missing
326 base = self.db.config.TRACKER_WEB
327 if (not isinstance(base , type('')) or
328 not (base.startswith('http://') or base.startswith('https://'))):
329 base = "Configuration Error: TRACKER_WEB isn't a " \
330 "fully-qualified URL"
331 elif base[-1] != '/' :
332 base += '/'
333 web = base + self.classname + nodeid
335 # ensure the email address is properly quoted
336 email = straddr((self.db.config.TRACKER_NAME,
337 self.db.config.TRACKER_EMAIL))
339 line = '_' * max(len(web)+2, len(email))
340 return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
343 def generateCreateNote(self, nodeid):
344 """Generate a create note that lists initial property values
345 """
346 cn = self.classname
347 cl = self.db.classes[cn]
348 props = cl.getprops(protected=0)
350 # list the values
351 m = []
352 l = props.items()
353 l.sort()
354 for propname, prop in l:
355 value = cl.get(nodeid, propname, None)
356 # skip boring entries
357 if not value:
358 continue
359 if isinstance(prop, hyperdb.Link):
360 link = self.db.classes[prop.classname]
361 if value:
362 key = link.labelprop(default_to_id=1)
363 if key:
364 value = link.get(value, key)
365 else:
366 value = ''
367 elif isinstance(prop, hyperdb.Multilink):
368 if value is None: value = []
369 l = []
370 link = self.db.classes[prop.classname]
371 key = link.labelprop(default_to_id=1)
372 if key:
373 value = [link.get(entry, key) for entry in value]
374 value.sort()
375 value = ', '.join(value)
376 m.append('%s: %s'%(propname, value))
377 m.insert(0, '----------')
378 m.insert(0, '')
379 return '\n'.join(m)
381 def generateChangeNote(self, nodeid, oldvalues):
382 """Generate a change note that lists property changes
383 """
384 if __debug__ :
385 if not isinstance(oldvalues, type({})) :
386 raise TypeError("'oldvalues' must be dict-like, not %s."%
387 type(oldvalues))
389 cn = self.classname
390 cl = self.db.classes[cn]
391 changed = {}
392 props = cl.getprops(protected=0)
394 # determine what changed
395 for key in oldvalues.keys():
396 if key in ['files','messages']:
397 continue
398 if key in ('activity', 'creator', 'creation'):
399 continue
400 # not all keys from oldvalues might be available in database
401 # this happens when property was deleted
402 try:
403 new_value = cl.get(nodeid, key)
404 except KeyError:
405 continue
406 # the old value might be non existent
407 # this happens when property was added
408 try:
409 old_value = oldvalues[key]
410 if type(new_value) is type([]):
411 new_value.sort()
412 old_value.sort()
413 if new_value != old_value:
414 changed[key] = old_value
415 except:
416 changed[key] = new_value
418 # list the changes
419 m = []
420 l = changed.items()
421 l.sort()
422 for propname, oldvalue in l:
423 prop = props[propname]
424 value = cl.get(nodeid, propname, None)
425 if isinstance(prop, hyperdb.Link):
426 link = self.db.classes[prop.classname]
427 key = link.labelprop(default_to_id=1)
428 if key:
429 if value:
430 value = link.get(value, key)
431 else:
432 value = ''
433 if oldvalue:
434 oldvalue = link.get(oldvalue, key)
435 else:
436 oldvalue = ''
437 change = '%s -> %s'%(oldvalue, value)
438 elif isinstance(prop, hyperdb.Multilink):
439 change = ''
440 if value is None: value = []
441 if oldvalue is None: oldvalue = []
442 l = []
443 link = self.db.classes[prop.classname]
444 key = link.labelprop(default_to_id=1)
445 # check for additions
446 for entry in value:
447 if entry in oldvalue: continue
448 if key:
449 l.append(link.get(entry, key))
450 else:
451 l.append(entry)
452 if l:
453 l.sort()
454 change = '+%s'%(', '.join(l))
455 l = []
456 # check for removals
457 for entry in oldvalue:
458 if entry in value: continue
459 if key:
460 l.append(link.get(entry, key))
461 else:
462 l.append(entry)
463 if l:
464 l.sort()
465 change += ' -%s'%(', '.join(l))
466 else:
467 change = '%s -> %s'%(oldvalue, value)
468 m.append('%s: %s'%(propname, change))
469 if m:
470 m.insert(0, '----------')
471 m.insert(0, '')
472 return '\n'.join(m)
474 # vim: set filetype=python ts=4 sw=4 et si