Code

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