Code

00b6092b602dee2ab5045a75c3e6c1bc04b268bc
[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.87 2003-09-06 07:27:30 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 confirm_registration(self, otk):
76         props = self.otks.getall(otk)
77         for propname, proptype in self.user.getprops().items():
78             value = props.get(propname, None)
79             if value is None:
80                 pass
81             elif isinstance(proptype, hyperdb.Date):
82                 props[propname] = date.Date(value)
83             elif isinstance(proptype, hyperdb.Interval):
84                 props[propname] = date.Interval(value)
85             elif isinstance(proptype, hyperdb.Password):
86                 props[propname] = password.Password()
87                 props[propname].unpack(value)
89         # tag new user creation with 'admin'
90         self.journaltag = 'admin'
91         self.figure_curuserid()
93         # create the new user
94         cl = self.user
95       
96         props['roles'] = self.config.NEW_WEB_USER_ROLES
97         del props['__time']
98         userid = cl.create(**props)
99         # clear the props from the otk database
100         self.otks.destroy(otk)
101         self.commit()
102         
103         return userid
105 class MessageSendError(RuntimeError):
106     pass
108 class DetectorError(RuntimeError):
109     ''' Raised by detectors that want to indicate that something's amiss
110     '''
111     pass
113 # deviation from spec - was called IssueClass
114 class IssueClass:
115     """ This class is intended to be mixed-in with a hyperdb backend
116         implementation. The backend should provide a mechanism that
117         enforces the title, messages, files, nosy and superseder
118         properties:
119             properties['title'] = hyperdb.String(indexme='yes')
120             properties['messages'] = hyperdb.Multilink("msg")
121             properties['files'] = hyperdb.Multilink("file")
122             properties['nosy'] = hyperdb.Multilink("user")
123             properties['superseder'] = hyperdb.Multilink(classname)
124     """
126     # New methods:
127     def addmessage(self, nodeid, summary, text):
128         """Add a message to an issue's mail spool.
130         A new "msg" node is constructed using the current date, the user that
131         owns the database connection as the author, and the specified summary
132         text.
134         The "files" and "recipients" fields are left empty.
136         The given text is saved as the body of the message and the node is
137         appended to the "messages" field of the specified issue.
138         """
140     # XXX "bcc" is an optional extra here...
141     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
142             from_address=None, cc=[]): #, bcc=[]):
143         """Send a message to the members of an issue's nosy list.
145         The message is sent only to users on the nosy list who are not
146         already on the "recipients" list for the message.
147         
148         These users are then added to the message's "recipients" list.
150         """
151         users = self.db.user
152         messages = self.db.msg
154         # figure the recipient ids
155         sendto = []
156         r = {}
157         recipients = messages.get(msgid, 'recipients')
158         for recipid in messages.get(msgid, 'recipients'):
159             r[recipid] = 1
161         # figure the author's id, and indicate they've received the message
162         authid = messages.get(msgid, 'author')
164         # possibly send the message to the author, as long as they aren't
165         # anonymous
166         if (users.get(authid, 'username') != 'anonymous' and
167                 not r.has_key(authid)):
168             if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
169                 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues)):
170                 # make sure they have an address
171                 add = users.get(authid, 'address')
172                 if add:
173                     # send it to them
174                     sendto.append(add)
175                     recipients.append(authid)
177         r[authid] = 1
179         # now deal with cc people.
180         for cc_userid in cc :
181             if r.has_key(cc_userid):
182                 continue
183             # make sure they have an address
184             add = users.get(cc_userid, 'address')
185             if add:
186                 # send it to them
187                 sendto.append(add)
188                 recipients.append(cc_userid)
190         # now figure the nosy people who weren't recipients
191         nosy = self.get(nodeid, whichnosy)
192         for nosyid in nosy:
193             # Don't send nosy mail to the anonymous user (that user
194             # shouldn't appear in the nosy list, but just in case they
195             # do...)
196             if users.get(nosyid, 'username') == 'anonymous':
197                 continue
198             # make sure they haven't seen the message already
199             if not r.has_key(nosyid):
200                 # make sure they have an address
201                 add = users.get(nosyid, 'address')
202                 if add:
203                     # send it to them
204                     sendto.append(add)
205                     recipients.append(nosyid)
207         # generate a change note
208         if oldvalues:
209             note = self.generateChangeNote(nodeid, oldvalues)
210         else:
211             note = self.generateCreateNote(nodeid)
213         # we have new recipients
214         if sendto:
215             # update the message's recipients list
216             messages.set(msgid, recipients=recipients)
218             # send the message
219             self.send_message(nodeid, msgid, note, sendto, from_address)
221     # backwards compatibility - don't remove
222     sendmessage = nosymessage
224     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
225         '''Actually send the nominated message from this node to the sendto
226            recipients, with the note appended.
227         '''
228         users = self.db.user
229         messages = self.db.msg
230         files = self.db.file
232         # determine the messageid and inreplyto of the message
233         inreplyto = messages.get(msgid, 'inreplyto')
234         messageid = messages.get(msgid, 'messageid')
236         # make up a messageid if there isn't one (web edit)
237         if not messageid:
238             # this is an old message that didn't get a messageid, so
239             # create one
240             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
241                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
242             messages.set(msgid, messageid=messageid)
244         # send an email to the people who missed out
245         cn = self.classname
246         title = self.get(nodeid, 'title') or '%s message copy'%cn
247         # figure author information
248         authid = messages.get(msgid, 'author')
249         authname = users.get(authid, 'realname')
250         if not authname:
251             authname = users.get(authid, 'username')
252         authaddr = users.get(authid, 'address')
253         if authaddr:
254             authaddr = " <%s>" % straddr( ('',authaddr) )
255         else:
256             authaddr = ''
258         # make the message body
259         m = ['']
261         # put in roundup's signature
262         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
263             m.append(self.email_signature(nodeid, msgid))
265         # add author information
266         if len(self.get(nodeid,'messages')) == 1:
267             m.append("New submission from %s%s:"%(authname, authaddr))
268         else:
269             m.append("%s%s added the comment:"%(authname, authaddr))
270         m.append('')
272         # add the content
273         m.append(messages.get(msgid, 'content'))
275         # add the change note
276         if note:
277             m.append(note)
279         # put in roundup's signature
280         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
281             m.append(self.email_signature(nodeid, msgid))
283         # encode the content as quoted-printable
284         content = cStringIO.StringIO('\n'.join(m))
285         content_encoded = cStringIO.StringIO()
286         quopri.encode(content, content_encoded, 0)
287         content_encoded = content_encoded.getvalue()
289         # get the files for this message
290         message_files = messages.get(msgid, 'files')
292         # make sure the To line is always the same (for testing mostly)
293         sendto.sort()
295         # make sure we have a from address
296         if from_address is None:
297             from_address = self.db.config.TRACKER_EMAIL
299         # additional bit for after the From: "name"
300         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
301         if from_tag:
302             from_tag = ' ' + from_tag
304         # create the message
305         message = cStringIO.StringIO()
306         writer = MimeWriter.MimeWriter(message)
307         writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid,
308             encode_header(title)))
309         writer.addheader('To', ', '.join(sendto))
310         writer.addheader('From', straddr((encode_header(authname) + 
311             from_tag, from_address)))
312         tracker_name = encode_header(self.db.config.TRACKER_NAME)
313         writer.addheader('Reply-To', straddr((tracker_name, from_address)))
314         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
315             time.gmtime()))
316         writer.addheader('MIME-Version', '1.0')
317         if messageid:
318             writer.addheader('Message-Id', messageid)
319         if inreplyto:
320             writer.addheader('In-Reply-To', inreplyto)
322         # add a uniquely Roundup header to help filtering
323         writer.addheader('X-Roundup-Name', tracker_name)
325         # avoid email loops
326         writer.addheader('X-Roundup-Loop', 'hello')
328         # attach files
329         if message_files:
330             part = writer.startmultipartbody('mixed')
331             part = writer.nextpart()
332             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
333             body = part.startbody('text/plain; charset=utf-8')
334             body.write(content_encoded)
335             for fileid in message_files:
336                 name = files.get(fileid, 'name')
337                 mime_type = files.get(fileid, 'type')
338                 content = files.get(fileid, 'content')
339                 part = writer.nextpart()
340                 if mime_type == 'text/plain':
341                     part.addheader('Content-Disposition',
342                         'attachment;\n filename="%s"'%name)
343                     part.addheader('Content-Transfer-Encoding', '7bit')
344                     body = part.startbody('text/plain')
345                     body.write(content)
346                 else:
347                     # some other type, so encode it
348                     if not mime_type:
349                         # this should have been done when the file was saved
350                         mime_type = mimetypes.guess_type(name)[0]
351                     if mime_type is None:
352                         mime_type = 'application/octet-stream'
353                     part.addheader('Content-Disposition',
354                         'attachment;\n filename="%s"'%name)
355                     part.addheader('Content-Transfer-Encoding', 'base64')
356                     body = part.startbody(mime_type)
357                     body.write(base64.encodestring(content))
358             writer.lastpart()
359         else:
360             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
361             body = writer.startbody('text/plain; charset=utf-8')
362             body.write(content_encoded)
364         # now try to send the message
365         if SENDMAILDEBUG:
366             open(SENDMAILDEBUG, 'a').write('FROM: %s\nTO: %s\n%s\n'%(
367                 self.db.config.ADMIN_EMAIL,
368                 ', '.join(sendto),message.getvalue()))
369         else:
370             try:
371                 # send the message as admin so bounces are sent there
372                 # instead of to roundup
373                 smtp = openSMTPConnection(self.db.config)
374                 smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto,
375                     message.getvalue())
376             except socket.error, value:
377                 raise MessageSendError, \
378                     "Couldn't send confirmation email: mailhost %s"%value
379             except smtplib.SMTPException, value:
380                 raise MessageSendError, \
381                     "Couldn't send confirmation email: %s"%value
383     def email_signature(self, nodeid, msgid):
384         ''' Add a signature to the e-mail with some useful information
385         '''
386         # simplistic check to see if the url is valid,
387         # then append a trailing slash if it is missing
388         base = self.db.config.TRACKER_WEB 
389         if (not isinstance(base , type('')) or
390             not (base.startswith('http://') or base.startswith('https://'))):
391             base = "Configuration Error: TRACKER_WEB isn't a " \
392                 "fully-qualified URL"
393         elif base[-1] != '/' :
394             base += '/'
395         web = base + self.classname + nodeid
397         # ensure the email address is properly quoted
398         email = straddr((self.db.config.TRACKER_NAME,
399             self.db.config.TRACKER_EMAIL))
401         line = '_' * max(len(web)+2, len(email))
402         return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
405     def generateCreateNote(self, nodeid):
406         """Generate a create note that lists initial property values
407         """
408         cn = self.classname
409         cl = self.db.classes[cn]
410         props = cl.getprops(protected=0)
412         # list the values
413         m = []
414         l = props.items()
415         l.sort()
416         for propname, prop in l:
417             value = cl.get(nodeid, propname, None)
418             # skip boring entries
419             if not value:
420                 continue
421             if isinstance(prop, hyperdb.Link):
422                 link = self.db.classes[prop.classname]
423                 if value:
424                     key = link.labelprop(default_to_id=1)
425                     if key:
426                         value = link.get(value, key)
427                 else:
428                     value = ''
429             elif isinstance(prop, hyperdb.Multilink):
430                 if value is None: value = []
431                 l = []
432                 link = self.db.classes[prop.classname]
433                 key = link.labelprop(default_to_id=1)
434                 if key:
435                     value = [link.get(entry, key) for entry in value]
436                 value.sort()
437                 value = ', '.join(value)
438             m.append('%s: %s'%(propname, value))
439         m.insert(0, '----------')
440         m.insert(0, '')
441         return '\n'.join(m)
443     def generateChangeNote(self, nodeid, oldvalues):
444         """Generate a change note that lists property changes
445         """
446         if __debug__ :
447             if not isinstance(oldvalues, type({})) :
448                 raise TypeError("'oldvalues' must be dict-like, not %s."%
449                     type(oldvalues))
451         cn = self.classname
452         cl = self.db.classes[cn]
453         changed = {}
454         props = cl.getprops(protected=0)
456         # determine what changed
457         for key in oldvalues.keys():
458             if key in ['files','messages']:
459                 continue
460             if key in ('activity', 'creator', 'creation'):
461                 continue
462             new_value = cl.get(nodeid, key)
463             # the old value might be non existent
464             try:
465                 old_value = oldvalues[key]
466                 if type(new_value) is type([]):
467                     new_value.sort()
468                     old_value.sort()
469                 if new_value != old_value:
470                     changed[key] = old_value
471             except:
472                 changed[key] = new_value
474         # list the changes
475         m = []
476         l = changed.items()
477         l.sort()
478         for propname, oldvalue in l:
479             prop = props[propname]
480             value = cl.get(nodeid, propname, None)
481             if isinstance(prop, hyperdb.Link):
482                 link = self.db.classes[prop.classname]
483                 key = link.labelprop(default_to_id=1)
484                 if key:
485                     if value:
486                         value = link.get(value, key)
487                     else:
488                         value = ''
489                     if oldvalue:
490                         oldvalue = link.get(oldvalue, key)
491                     else:
492                         oldvalue = ''
493                 change = '%s -> %s'%(oldvalue, value)
494             elif isinstance(prop, hyperdb.Multilink):
495                 change = ''
496                 if value is None: value = []
497                 if oldvalue is None: oldvalue = []
498                 l = []
499                 link = self.db.classes[prop.classname]
500                 key = link.labelprop(default_to_id=1)
501                 # check for additions
502                 for entry in value:
503                     if entry in oldvalue: continue
504                     if key:
505                         l.append(link.get(entry, key))
506                     else:
507                         l.append(entry)
508                 if l:
509                     l.sort()
510                     change = '+%s'%(', '.join(l))
511                     l = []
512                 # check for removals
513                 for entry in oldvalue:
514                     if entry in value: continue
515                     if key:
516                         l.append(link.get(entry, key))
517                     else:
518                         l.append(entry)
519                 if l:
520                     l.sort()
521                     change += ' -%s'%(', '.join(l))
522             else:
523                 change = '%s -> %s'%(oldvalue, value)
524             m.append('%s: %s'%(propname, change))
525         if m:
526             m.insert(0, '----------')
527             m.insert(0, '')
528         return '\n'.join(m)
530 # vim: set filetype=python ts=4 sw=4 et si