Code

Add 'safeget' method to hyperdb, including tests, and use it to simplify code
[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.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
80       
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()
87         
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.
130         
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', [])
138         
139         sendto = []
140         seen_message = dict([(recipient, 1) for recipient in recipients])
141                
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)
148         
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))
155         
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)
162         
163         if authid:
164             seen_message[authid] = 1
165         
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
193        
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