Code

14e0622d491b9a80918d3fe9c069422220198615
[roundup.git] / roundup / roundupdb.py
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.88 2003-09-06 20:02:23 jlgijsbers Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, time, random
25 import MimeWriter, cStringIO
26 import base64, quopri, mimetypes
28 from rfc2822 import encode_header
30 from roundup import password, date
32 # if available, use the 'email' module, otherwise fallback to 'rfc822'
33 try :
34     from email.Utils import formataddr as straddr
35 except ImportError :
36     # code taken from the email package 2.4.3
37     def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
38             escapesre = re.compile(r'[][\()"]')):
39         name, address = pair
40         if name:
41             quotes = ''
42             if specialsre.search(name):
43                 quotes = '"'
44             name = escapesre.sub(r'\\\g<0>', name)
45             return '%s%s%s <%s>' % (quotes, name, quotes, address)
46         return address
48 from roundup import hyperdb
49 from roundup.mailgw import openSMTPConnection
51 # set to indicate to roundup not to actually _send_ email
52 # this var must contain a file to write the mail to
53 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
55 class Database:
56     def getuid(self):
57         """Return the id of the "user" node associated with the user
58         that owns this connection to the hyperdatabase."""
59         return self.user.lookup(self.journaltag)
61     def getUserTimezone(self):
62         """Return user timezone defined in 'timezone' property of user class.
63         If no such property exists return 0
64         """
65         userid = self.getuid()
66         try:
67             timezone = int(self.user.get(userid, 'timezone'))
68         except (KeyError, ValueError, TypeError):
69             # If there is no class 'user' or current user doesn't have timezone 
70             # property or that property is not numeric assume he/she lives in 
71             # Greenwich :)
72             timezone = 0
73         return timezone
75     def figure_curuserid(self):
76         """Figure out the 'curuserid'."""
77         if self.journaltag is None:
78             self.curuserid = None
79         elif self.journaltag == 'admin':
80             # admin user may not exist, but always has ID 1
81             self.curuserid = '1'
82         else:
83             self.curuserid = self.user.lookup(self.journaltag)
85     def confirm_registration(self, otk):
86         props = self.otks.getall(otk)
87         for propname, proptype in self.user.getprops().items():
88             value = props.get(propname, None)
89             if value is None:
90                 pass
91             elif isinstance(proptype, hyperdb.Date):
92                 props[propname] = date.Date(value)
93             elif isinstance(proptype, hyperdb.Interval):
94                 props[propname] = date.Interval(value)
95             elif isinstance(proptype, hyperdb.Password):
96                 props[propname] = password.Password()
97                 props[propname].unpack(value)
99         # tag new user creation with 'admin'
100         self.journaltag = 'admin'
101         self.figure_curuserid()
103         # create the new user
104         cl = self.user
105       
106         props['roles'] = self.config.NEW_WEB_USER_ROLES
107         del props['__time']
108         userid = cl.create(**props)
109         # clear the props from the otk database
110         self.otks.destroy(otk)
111         self.commit()
112         
113         return userid
115 class MessageSendError(RuntimeError):
116     pass
118 class DetectorError(RuntimeError):
119     ''' Raised by detectors that want to indicate that something's amiss
120     '''
121     pass
123 # deviation from spec - was called IssueClass
124 class IssueClass:
125     """ This class is intended to be mixed-in with a hyperdb backend
126         implementation. The backend should provide a mechanism that
127         enforces the title, messages, files, nosy and superseder
128         properties:
129             properties['title'] = hyperdb.String(indexme='yes')
130             properties['messages'] = hyperdb.Multilink("msg")
131             properties['files'] = hyperdb.Multilink("file")
132             properties['nosy'] = hyperdb.Multilink("user")
133             properties['superseder'] = hyperdb.Multilink(classname)
134     """
136     # New methods:
137     def addmessage(self, nodeid, summary, text):
138         """Add a message to an issue's mail spool.
140         A new "msg" node is constructed using the current date, the user that
141         owns the database connection as the author, and the specified summary
142         text.
144         The "files" and "recipients" fields are left empty.
146         The given text is saved as the body of the message and the node is
147         appended to the "messages" field of the specified issue.
148         """
150     # XXX "bcc" is an optional extra here...
151     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
152             from_address=None, cc=[]): #, bcc=[]):
153         """Send a message to the members of an issue's nosy list.
155         The message is sent only to users on the nosy list who are not
156         already on the "recipients" list for the message.
157         
158         These users are then added to the message's "recipients" list.
160         """
161         users = self.db.user
162         messages = self.db.msg
164         # figure the recipient ids
165         sendto = []
166         r = {}
167         recipients = messages.get(msgid, 'recipients')
168         for recipid in messages.get(msgid, 'recipients'):
169             r[recipid] = 1
171         # figure the author's id, and indicate they've received the message
172         authid = messages.get(msgid, 'author')
174         # possibly send the message to the author, as long as they aren't
175         # anonymous
176         if (users.get(authid, 'username') != 'anonymous' and
177                 not r.has_key(authid)):
178             if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
179                 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues)):
180                 # make sure they have an address
181                 add = users.get(authid, 'address')
182                 if add:
183                     # send it to them
184                     sendto.append(add)
185                     recipients.append(authid)
187         r[authid] = 1
189         # now deal with cc people.
190         for cc_userid in cc :
191             if r.has_key(cc_userid):
192                 continue
193             # make sure they have an address
194             add = users.get(cc_userid, 'address')
195             if add:
196                 # send it to them
197                 sendto.append(add)
198                 recipients.append(cc_userid)
200         # now figure the nosy people who weren't recipients
201         nosy = self.get(nodeid, whichnosy)
202         for nosyid in nosy:
203             # Don't send nosy mail to the anonymous user (that user
204             # shouldn't appear in the nosy list, but just in case they
205             # do...)
206             if users.get(nosyid, 'username') == 'anonymous':
207                 continue
208             # make sure they haven't seen the message already
209             if not r.has_key(nosyid):
210                 # make sure they have an address
211                 add = users.get(nosyid, 'address')
212                 if add:
213                     # send it to them
214                     sendto.append(add)
215                     recipients.append(nosyid)
217         # generate a change note
218         if oldvalues:
219             note = self.generateChangeNote(nodeid, oldvalues)
220         else:
221             note = self.generateCreateNote(nodeid)
223         # we have new recipients
224         if sendto:
225             # update the message's recipients list
226             messages.set(msgid, recipients=recipients)
228             # send the message
229             self.send_message(nodeid, msgid, note, sendto, from_address)
231     # backwards compatibility - don't remove
232     sendmessage = nosymessage
234     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
235         '''Actually send the nominated message from this node to the sendto
236            recipients, with the note appended.
237         '''
238         users = self.db.user
239         messages = self.db.msg
240         files = self.db.file
242         # determine the messageid and inreplyto of the message
243         inreplyto = messages.get(msgid, 'inreplyto')
244         messageid = messages.get(msgid, 'messageid')
246         # make up a messageid if there isn't one (web edit)
247         if not messageid:
248             # this is an old message that didn't get a messageid, so
249             # create one
250             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
251                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
252             messages.set(msgid, messageid=messageid)
254         # send an email to the people who missed out
255         cn = self.classname
256         title = self.get(nodeid, 'title') or '%s message copy'%cn
257         # figure author information
258         authid = messages.get(msgid, 'author')
259         authname = users.get(authid, 'realname')
260         if not authname:
261             authname = users.get(authid, 'username')
262         authaddr = users.get(authid, 'address')
263         if authaddr:
264             authaddr = " <%s>" % straddr( ('',authaddr) )
265         else:
266             authaddr = ''
268         # make the message body
269         m = ['']
271         # put in roundup's signature
272         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
273             m.append(self.email_signature(nodeid, msgid))
275         # add author information
276         if len(self.get(nodeid,'messages')) == 1:
277             m.append("New submission from %s%s:"%(authname, authaddr))
278         else:
279             m.append("%s%s added the comment:"%(authname, authaddr))
280         m.append('')
282         # add the content
283         m.append(messages.get(msgid, 'content'))
285         # add the change note
286         if note:
287             m.append(note)
289         # put in roundup's signature
290         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
291             m.append(self.email_signature(nodeid, msgid))
293         # encode the content as quoted-printable
294         content = cStringIO.StringIO('\n'.join(m))
295         content_encoded = cStringIO.StringIO()
296         quopri.encode(content, content_encoded, 0)
297         content_encoded = content_encoded.getvalue()
299         # get the files for this message
300         message_files = messages.get(msgid, 'files')
302         # make sure the To line is always the same (for testing mostly)
303         sendto.sort()
305         # make sure we have a from address
306         if from_address is None:
307             from_address = self.db.config.TRACKER_EMAIL
309         # additional bit for after the From: "name"
310         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
311         if from_tag:
312             from_tag = ' ' + from_tag
314         # create the message
315         message = cStringIO.StringIO()
316         writer = MimeWriter.MimeWriter(message)
317         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid,
318             encode_header(title)))
319         writer.addheader('To', ', '.join(sendto))
320         writer.addheader('From', straddr((encode_header(authname) + 
321             from_tag, from_address)))
322         tracker_name = encode_header(self.db.config.TRACKER_NAME)
323         writer.addheader('Reply-To', straddr((tracker_name, from_address)))
324         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
325             time.gmtime()))
326         writer.addheader('MIME-Version', '1.0')
327         if messageid:
328             writer.addheader('Message-Id', messageid)
329         if inreplyto:
330             writer.addheader('In-Reply-To', inreplyto)
332         # add a uniquely Roundup header to help filtering
333         writer.addheader('X-Roundup-Name', tracker_name)
335         # avoid email loops
336         writer.addheader('X-Roundup-Loop', 'hello')
338         # attach files
339         if message_files:
340             part = writer.startmultipartbody('mixed')
341             part = writer.nextpart()
342             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
343             body = part.startbody('text/plain; charset=utf-8')
344             body.write(content_encoded)
345             for fileid in message_files:
346                 name = files.get(fileid, 'name')
347                 mime_type = files.get(fileid, 'type')
348                 content = files.get(fileid, 'content')
349                 part = writer.nextpart()
350                 if mime_type == 'text/plain':
351                     part.addheader('Content-Disposition',
352                         'attachment;\n filename="%s"'%name)
353                     part.addheader('Content-Transfer-Encoding', '7bit')
354                     body = part.startbody('text/plain')
355                     body.write(content)
356                 else:
357                     # some other type, so encode it
358                     if not mime_type:
359                         # this should have been done when the file was saved
360                         mime_type = mimetypes.guess_type(name)[0]
361                     if mime_type is None:
362                         mime_type = 'application/octet-stream'
363                     part.addheader('Content-Disposition',
364                         'attachment;\n filename="%s"'%name)
365                     part.addheader('Content-Transfer-Encoding', 'base64')
366                     body = part.startbody(mime_type)
367                     body.write(base64.encodestring(content))
368             writer.lastpart()
369         else:
370             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
371             body = writer.startbody('text/plain; charset=utf-8')
372             body.write(content_encoded)
374         # now try to send the message
375         if SENDMAILDEBUG:
376             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
377                 self.db.config.ADMIN_EMAIL,
378                 ', '.join(sendto),message.getvalue()))
379         else:
380             try:
381                 # send the message as admin so bounces are sent there
382                 # instead of to roundup
383                 smtp = openSMTPConnection(self.db.config)
384                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
385                     message.getvalue())
386             except socket.error, value:
387                 raise MessageSendError, \
388                     "Couldn't send confirmation email: mailhost %s"%value
389             except smtplib.SMTPException, value:
390                 raise MessageSendError, \
391                     "Couldn't send confirmation email: %s"%value
393     def email_signature(self, nodeid, msgid):
394         ''' Add a signature to the e-mail with some useful information
395         '''
396         # simplistic check to see if the url is valid,
397         # then append a trailing slash if it is missing
398         base = self.db.config.TRACKER_WEB 
399         if (not isinstance(base , type('')) or
400             not (base.startswith('http://') or base.startswith('https://'))):
401             base = "Configuration Error: TRACKER_WEB isn't a " \
402                 "fully-qualified URL"
403         elif base[-1] != '/' :
404             base += '/'
405         web = base + self.classname + nodeid
407         # ensure the email address is properly quoted
408         email = straddr((self.db.config.TRACKER_NAME,
409             self.db.config.TRACKER_EMAIL))
411         line = '_' * max(len(web)+2, len(email))
412         return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
415     def generateCreateNote(self, nodeid):
416         """Generate a create note that lists initial property values
417         """
418         cn = self.classname
419         cl = self.db.classes[cn]
420         props = cl.getprops(protected=0)
422         # list the values
423         m = []
424         l = props.items()
425         l.sort()
426         for propname, prop in l:
427             value = cl.get(nodeid, propname, None)
428             # skip boring entries
429             if not value:
430                 continue
431             if isinstance(prop, hyperdb.Link):
432                 link = self.db.classes[prop.classname]
433                 if value:
434                     key = link.labelprop(default_to_id=1)
435                     if key:
436                         value = link.get(value, key)
437                 else:
438                     value = ''
439             elif isinstance(prop, hyperdb.Multilink):
440                 if value is None: value = []
441                 l = []
442                 link = self.db.classes[prop.classname]
443                 key = link.labelprop(default_to_id=1)
444                 if key:
445                     value = [link.get(entry, key) for entry in value]
446                 value.sort()
447                 value = ', '.join(value)
448             m.append('%s: %s'%(propname, value))
449         m.insert(0, '----------')
450         m.insert(0, '')
451         return '\n'.join(m)
453     def generateChangeNote(self, nodeid, oldvalues):
454         """Generate a change note that lists property changes
455         """
456         if __debug__ :
457             if not isinstance(oldvalues, type({})) :
458                 raise TypeError("'oldvalues' must be dict-like, not %s."%
459                     type(oldvalues))
461         cn = self.classname
462         cl = self.db.classes[cn]
463         changed = {}
464         props = cl.getprops(protected=0)
466         # determine what changed
467         for key in oldvalues.keys():
468             if key in ['files','messages']:
469                 continue
470             if key in ('activity', 'creator', 'creation'):
471                 continue
472             new_value = cl.get(nodeid, key)
473             # the old value might be non existent
474             try:
475                 old_value = oldvalues[key]
476                 if type(new_value) is type([]):
477                     new_value.sort()
478                     old_value.sort()
479                 if new_value != old_value:
480                     changed[key] = old_value
481             except:
482                 changed[key] = new_value
484         # list the changes
485         m = []
486         l = changed.items()
487         l.sort()
488         for propname, oldvalue in l:
489             prop = props[propname]
490             value = cl.get(nodeid, propname, None)
491             if isinstance(prop, hyperdb.Link):
492                 link = self.db.classes[prop.classname]
493                 key = link.labelprop(default_to_id=1)
494                 if key:
495                     if value:
496                         value = link.get(value, key)
497                     else:
498                         value = ''
499                     if oldvalue:
500                         oldvalue = link.get(oldvalue, key)
501                     else:
502                         oldvalue = ''
503                 change = '%s -> %s'%(oldvalue, value)
504             elif isinstance(prop, hyperdb.Multilink):
505                 change = ''
506                 if value is None: value = []
507                 if oldvalue is None: oldvalue = []
508                 l = []
509                 link = self.db.classes[prop.classname]
510                 key = link.labelprop(default_to_id=1)
511                 # check for additions
512                 for entry in value:
513                     if entry in oldvalue: continue
514                     if key:
515                         l.append(link.get(entry, key))
516                     else:
517                         l.append(entry)
518                 if l:
519                     l.sort()
520                     change = '+%s'%(', '.join(l))
521                     l = []
522                 # check for removals
523                 for entry in oldvalue:
524                     if entry in value: continue
525                     if key:
526                         l.append(link.get(entry, key))
527                     else:
528                         l.append(entry)
529                 if l:
530                     l.sort()
531                     change += ' -%s'%(', '.join(l))
532             else:
533                 change = '%s -> %s'%(oldvalue, value)
534             m.append('%s: %s'%(propname, change))
535         if m:
536             m.insert(0, '----------')
537             m.insert(0, '')
538         return '\n'.join(m)
540 # vim: set filetype=python ts=4 sw=4 et si