Code

must be backward-compatible to py2.1
[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.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
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 = {}
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)
150         
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))
157         
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)
164         
165         if authid:
166             seen_message[authid] = 1
167         
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
195        
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