Code

fix py2.4 compatibility
[roundup.git] / roundup / roundupdb.py
1 from __future__ import nested_scopes
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
20 """Extending hyperdb with types specific to issue-tracking.
21 """
22 __docformat__ = 'restructuredtext'
24 import re, os, smtplib, socket, time, random
25 import cStringIO, base64, mimetypes
26 import os.path
27 import logging
28 from email import Encoders
29 from email.Utils import formataddr
30 from email.Header import Header
31 from email.MIMEText import MIMEText
32 from email.MIMEBase import MIMEBase
34 try:
35     # Python 2.5+
36     from email.parser import FeedParser
37 except ImportError:
38     # Python 2.4
39     from email.Parser import FeedParser
41 from roundup import password, date, hyperdb
42 from roundup.i18n import _
44 # MessageSendError is imported for backwards compatibility
45 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
46     nice_sender_header
48 class Database:
50     # remember the journal uid for the current journaltag so that:
51     # a. we don't have to look it up every time we need it, and
52     # b. if the journaltag disappears during a transaction, we don't barf
53     #    (eg. the current user edits their username)
54     journal_uid = None
55     def getuid(self):
56         """Return the id of the "user" node associated with the user
57         that owns this connection to the hyperdatabase."""
58         if self.journaltag is None:
59             return None
60         elif self.journaltag == 'admin':
61             # admin user may not exist, but always has ID 1
62             return '1'
63         else:
64             if (self.journal_uid is None or self.journal_uid[0] !=
65                     self.journaltag):
66                 uid = self.user.lookup(self.journaltag)
67                 self.journal_uid = (self.journaltag, uid)
68             return self.journal_uid[1]
70     def setCurrentUser(self, username):
71         """Set the user that is responsible for current database
72         activities.
73         """
74         self.journaltag = username
76     def isCurrentUser(self, username):
77         """Check if a given username equals the already active user.
78         """
79         return self.journaltag == username
81     def getUserTimezone(self):
82         """Return user timezone defined in 'timezone' property of user class.
83         If no such property exists return 0
84         """
85         userid = self.getuid()
86         timezone = None
87         try:
88             tz = self.user.get(userid, 'timezone')
89             date.get_timezone(tz)
90             timezone = tz
91         except KeyError:
92             pass
93         # If there is no class 'user' or current user doesn't have timezone
94         # property or that property is not set assume he/she lives in
95         # the timezone set in the tracker config.
96         if timezone is None:
97             timezone = self.config['TIMEZONE']
98         return timezone
100     def confirm_registration(self, otk):
101         props = self.getOTKManager().getall(otk)
102         for propname, proptype in self.user.getprops().items():
103             value = props.get(propname, None)
104             if value is None:
105                 pass
106             elif isinstance(proptype, hyperdb.Date):
107                 props[propname] = date.Date(value)
108             elif isinstance(proptype, hyperdb.Interval):
109                 props[propname] = date.Interval(value)
110             elif isinstance(proptype, hyperdb.Password):
111                 props[propname] = password.Password()
112                 props[propname].unpack(value)
114         # tag new user creation with 'admin'
115         self.journaltag = 'admin'
117         # create the new user
118         cl = self.user
120         props['roles'] = self.config.NEW_WEB_USER_ROLES
121         userid = cl.create(**props)
122         # clear the props from the otk database
123         self.getOTKManager().destroy(otk)
124         self.commit()
126         return userid
129     def log_debug(self, msg, *args, **kwargs):
130         """Log a message with level DEBUG."""
132         logger = self.get_logger()
133         logger.debug(msg, *args, **kwargs)
135     def log_info(self, msg, *args, **kwargs):
136         """Log a message with level INFO."""
138         logger = self.get_logger()
139         logger.info(msg, *args, **kwargs)
141     def get_logger(self):
142         """Return the logger for this database."""
144         # Because getting a logger requires acquiring a lock, we want
145         # to do it only once.
146         if not hasattr(self, '__logger'):
147             self.__logger = logging.getLogger('roundup.hyperdb')
149         return self.__logger
152 class DetectorError(RuntimeError):
153     """ Raised by detectors that want to indicate that something's amiss
154     """
155     pass
157 # deviation from spec - was called IssueClass
158 class IssueClass:
159     """This class is intended to be mixed-in with a hyperdb backend
160     implementation. The backend should provide a mechanism that
161     enforces the title, messages, files, nosy and superseder
162     properties:
164     - title = hyperdb.String(indexme='yes')
165     - messages = hyperdb.Multilink("msg")
166     - files = hyperdb.Multilink("file")
167     - nosy = hyperdb.Multilink("user")
168     - superseder = hyperdb.Multilink(classname)
169     """
171     # The tuple below does not affect the class definition.
172     # It just lists all names of all issue properties
173     # marked for message extraction tool.
174     #
175     # XXX is there better way to get property names into message catalog??
176     #
177     # Note that this list also includes properties
178     # defined in the classic template:
179     # assignedto, keyword, priority, status.
180     (
181         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
182         ''"assignedto", ''"keyword", ''"priority", ''"status",
183         # following properties are common for all hyperdb classes
184         # they are listed here to keep things in one place
185         ''"actor", ''"activity", ''"creator", ''"creation",
186     )
188     # New methods:
189     def addmessage(self, issueid, summary, text):
190         """Add a message to an issue's mail spool.
192         A new "msg" node is constructed using the current date, the user that
193         owns the database connection as the author, and the specified summary
194         text.
196         The "files" and "recipients" fields are left empty.
198         The given text is saved as the body of the message and the node is
199         appended to the "messages" field of the specified issue.
200         """
202     def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
203             from_address=None, cc=[], bcc=[]):
204         """Send a message to the members of an issue's nosy list.
206         The message is sent only to users on the nosy list who are not
207         already on the "recipients" list for the message.
209         These users are then added to the message's "recipients" list.
211         If 'msgid' is None, the message gets sent only to the nosy
212         list, and it's called a 'System Message'.
214         The "cc" argument indicates additional recipients to send the
215         message to that may not be specified in the message's recipients
216         list.
218         The "bcc" argument also indicates additional recipients to send the
219         message to that may not be specified in the message's recipients
220         list. These recipients will not be included in the To: or Cc:
221         address lists.
222         """
223         if msgid:
224             authid = self.db.msg.get(msgid, 'author')
225             recipients = self.db.msg.get(msgid, 'recipients', [])
226         else:
227             # "system message"
228             authid = None
229             recipients = []
231         sendto = []
232         bcc_sendto = []
233         seen_message = {}
234         for recipient in recipients:
235             seen_message[recipient] = 1
237         def add_recipient(userid, to):
238             """ make sure they have an address """
239             address = self.db.user.get(userid, 'address')
240             if address:
241                 to.append(address)
242                 recipients.append(userid)
244         def good_recipient(userid):
245             """ Make sure we don't send mail to either the anonymous
246                 user or a user who has already seen the message.
247                 Also check permissions on the message if not a system
248                 message: A user must have view permission on content and
249                 files to be on the receiver list. We do *not* check the 
250                 author etc. for now.
251             """
252             allowed = True
253             if msgid:
254                 for prop in 'content', 'files':
255                     if prop in self.db.msg.properties:
256                         allowed = allowed and self.db.security.hasPermission(
257                             'View', userid, 'msg', prop, msgid)
258             return (userid and
259                     (self.db.user.get(userid, 'username') != 'anonymous') and
260                     allowed and not seen_message.has_key(userid))
262         # possibly send the message to the author, as long as they aren't
263         # anonymous
264         if (good_recipient(authid) and
265             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
266              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
267             add_recipient(authid, sendto)
269         if authid:
270             seen_message[authid] = 1
272         # now deal with the nosy and cc people who weren't recipients.
273         for userid in cc + self.get(issueid, whichnosy):
274             if good_recipient(userid):
275                 add_recipient(userid, sendto)
277         # now deal with bcc people.
278         for userid in bcc:
279             if good_recipient(userid):
280                 add_recipient(userid, bcc_sendto)
282         if oldvalues:
283             note = self.generateChangeNote(issueid, oldvalues)
284         else:
285             note = self.generateCreateNote(issueid)
287         # If we have new recipients, update the message's recipients
288         # and send the mail.
289         if sendto or bcc_sendto:
290             if msgid is not None:
291                 self.db.msg.set(msgid, recipients=recipients)
292             self.send_message(issueid, msgid, note, sendto, from_address,
293                 bcc_sendto)
295     # backwards compatibility - don't remove
296     sendmessage = nosymessage
298     def send_message(self, issueid, msgid, note, sendto, from_address=None,
299             bcc_sendto=[]):
300         '''Actually send the nominated message from this issue to the sendto
301            recipients, with the note appended.
302         '''
303         users = self.db.user
304         messages = self.db.msg
305         files = self.db.file
307         if msgid is None:
308             inreplyto = None
309             messageid = None
310         else:
311             inreplyto = messages.get(msgid, 'inreplyto')
312             messageid = messages.get(msgid, 'messageid')
314         # make up a messageid if there isn't one (web edit)
315         if not messageid:
316             # this is an old message that didn't get a messageid, so
317             # create one
318             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
319                                            self.classname, issueid,
320                                            self.db.config.MAIL_DOMAIN)
321             if msgid is not None:
322                 messages.set(msgid, messageid=messageid)
324         # compose title
325         cn = self.classname
326         title = self.get(issueid, 'title') or '%s message copy'%cn
328         # figure author information
329         if msgid:
330             authid = messages.get(msgid, 'author')
331         else:
332             authid = self.db.getuid()
333         authname = users.get(authid, 'realname')
334         if not authname:
335             authname = users.get(authid, 'username', '')
336         authaddr = users.get(authid, 'address', '')
338         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
339             authaddr = " <%s>" % formataddr( ('',authaddr) )
340         elif authaddr:
341             authaddr = ""
343         # make the message body
344         m = ['']
346         # put in roundup's signature
347         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
348             m.append(self.email_signature(issueid, msgid))
350         # add author information
351         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
352             if msgid and len(self.get(issueid, 'messages')) == 1:
353                 m.append(_("New submission from %(authname)s%(authaddr)s:")
354                     % locals())
355             elif msgid:
356                 m.append(_("%(authname)s%(authaddr)s added the comment:")
357                     % locals())
358             else:
359                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
360             m.append('')
362         # add the content
363         if msgid is not None:
364             m.append(messages.get(msgid, 'content', ''))
366         # get the files for this message
367         message_files = []
368         if msgid :
369             for fileid in messages.get(msgid, 'files') :
370                 # check the attachment size
371                 filesize = self.db.filesize('file', fileid, None)
372                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
373                     message_files.append(fileid)
374                 else:
375                     base = self.db.config.TRACKER_WEB
376                     link = "".join((base, files.classname, fileid))
377                     filename = files.get(fileid, 'name')
378                     m.append(_("File '%(filename)s' not attached - "
379                         "you can download it from %(link)s.") % locals())
381         # add the change note
382         if note:
383             m.append(note)
385         # put in roundup's signature
386         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
387             m.append(self.email_signature(issueid, msgid))
389         # figure the encoding
390         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
392         # construct the content and convert to unicode object
393         body = unicode('\n'.join(m), 'utf-8').encode(charset)
395         # make sure the To line is always the same (for testing mostly)
396         sendto.sort()
398         # make sure we have a from address
399         if from_address is None:
400             from_address = self.db.config.TRACKER_EMAIL
402         # additional bit for after the From: "name"
403         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
404         if from_tag:
405             from_tag = ' ' + from_tag
407         subject = '[%s%s] %s'%(cn, issueid, title)
408         author = (authname + from_tag, from_address)
410         # send an individual message per recipient?
411         if self.db.config.NOSY_EMAIL_SENDING != 'single':
412             sendto = [[address] for address in sendto]
413         else:
414             sendto = [sendto]
416         # tracker sender info
417         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
418         tracker_name = nice_sender_header(tracker_name, from_address,
419             charset)
421         # now send one or more messages
422         # TODO: I believe we have to create a new message each time as we
423         # can't fiddle the recipients in the message ... worth testing
424         # and/or fixing some day
425         first = True
426         for sendto in sendto:
427             # create the message
428             mailer = Mailer(self.db.config)
430             message = mailer.get_standard_message(sendto, subject, author,
431                 multipart=message_files)
433             # set reply-to to the tracker
434             message['Reply-To'] = tracker_name
436             # message ids
437             if messageid:
438                 message['Message-Id'] = messageid
439             if inreplyto:
440                 message['In-Reply-To'] = inreplyto
442             # Generate a header for each link or multilink to
443             # a class that has a name attribute
444             for propname, prop in self.getprops().items():
445                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
446                     continue
447                 cl = self.db.getclass(prop.classname)
448                 if not 'name' in cl.getprops():
449                     continue
450                 if isinstance(prop, hyperdb.Link):
451                     value = self.get(issueid, propname)
452                     if value is None:
453                         continue
454                     values = [value]
455                 else:
456                     values = self.get(issueid, propname)
457                     if not values:
458                         continue
459                 values = [cl.get(v, 'name') for v in values]
460                 values = ', '.join(values)
461                 header = "X-Roundup-%s-%s"%(self.classname, propname)
462                 try:
463                     message[header] = values.encode('ascii')
464                 except UnicodeError:
465                     message[header] = Header(values, charset)
467             if not inreplyto:
468                 # Default the reply to the first message
469                 msgs = self.get(issueid, 'messages')
470                 # Assume messages are sorted by increasing message number here
471                 # If the issue is just being created, and the submitter didn't
472                 # provide a message, then msgs will be empty.
473                 if msgs and msgs[0] != msgid:
474                     inreplyto = messages.get(msgs[0], 'messageid')
475                     if inreplyto:
476                         message['In-Reply-To'] = inreplyto
478             # attach files
479             if message_files:
480                 # first up the text as a part
481                 part = MIMEText(body)
482                 part.set_charset(charset)
483                 encode_quopri(part)
484                 message.attach(part)
486                 for fileid in message_files:
487                     name = files.get(fileid, 'name')
488                     mime_type = files.get(fileid, 'type')
489                     content = files.get(fileid, 'content')
490                     if mime_type == 'text/plain':
491                         try:
492                             content.decode('ascii')
493                         except UnicodeError:
494                             # the content cannot be 7bit-encoded.
495                             # use quoted printable
496                             # XXX stuffed if we know the charset though :(
497                             part = MIMEText(content)
498                             encode_quopri(part)
499                         else:
500                             part = MIMEText(content)
501                             part['Content-Transfer-Encoding'] = '7bit'
502                     elif mime_type == 'message/rfc822':
503                         main, sub = mime_type.split('/')
504                         p = FeedParser()
505                         p.feed(content)
506                         part = MIMEBase(main, sub)
507                         part.set_payload([p.close()])
508                     else:
509                         # some other type, so encode it
510                         if not mime_type:
511                             # this should have been done when the file was saved
512                             mime_type = mimetypes.guess_type(name)[0]
513                         if mime_type is None:
514                             mime_type = 'application/octet-stream'
515                         main, sub = mime_type.split('/')
516                         part = MIMEBase(main, sub)
517                         part.set_payload(content)
518                         Encoders.encode_base64(part)
519                     cd = 'Content-Disposition'
520                     part[cd] = 'attachment;\n filename="%s"'%name
521                     message.attach(part)
523             else:
524                 message.set_payload(body)
525                 encode_quopri(message)
527             if first:
528                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
529             else:
530                 mailer.smtp_send(sendto, message.as_string())
531             first = False
533     def email_signature(self, issueid, msgid):
534         ''' Add a signature to the e-mail with some useful information
535         '''
536         # simplistic check to see if the url is valid,
537         # then append a trailing slash if it is missing
538         base = self.db.config.TRACKER_WEB
539         if (not isinstance(base , type('')) or
540             not (base.startswith('http://') or base.startswith('https://'))):
541             web = "Configuration Error: TRACKER_WEB isn't a " \
542                 "fully-qualified URL"
543         else:
544             if not base.endswith('/'):
545                 base = base + '/'
546             web = base + self.classname + issueid
548         # ensure the email address is properly quoted
549         email = formataddr((self.db.config.TRACKER_NAME,
550             self.db.config.TRACKER_EMAIL))
552         line = '_' * max(len(web)+2, len(email))
553         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
556     def generateCreateNote(self, issueid):
557         """Generate a create note that lists initial property values
558         """
559         cn = self.classname
560         cl = self.db.classes[cn]
561         props = cl.getprops(protected=0)
563         # list the values
564         m = []
565         prop_items = props.items()
566         prop_items.sort()
567         for propname, prop in prop_items:
568             value = cl.get(issueid, propname, None)
569             # skip boring entries
570             if not value:
571                 continue
572             if isinstance(prop, hyperdb.Link):
573                 link = self.db.classes[prop.classname]
574                 if value:
575                     key = link.labelprop(default_to_id=1)
576                     if key:
577                         value = link.get(value, key)
578                 else:
579                     value = ''
580             elif isinstance(prop, hyperdb.Multilink):
581                 if value is None: value = []
582                 l = []
583                 link = self.db.classes[prop.classname]
584                 key = link.labelprop(default_to_id=1)
585                 if key:
586                     value = [link.get(entry, key) for entry in value]
587                 value.sort()
588                 value = ', '.join(value)
589             else:
590                 value = str(value)
591                 if '\n' in value:
592                     value = '\n'+self.indentChangeNoteValue(value)
593             m.append('%s: %s'%(propname, value))
594         m.insert(0, '----------')
595         m.insert(0, '')
596         return '\n'.join(m)
598     def generateChangeNote(self, issueid, oldvalues):
599         """Generate a change note that lists property changes
600         """
601         if not isinstance(oldvalues, type({})):
602             raise TypeError("'oldvalues' must be dict-like, not %s."%
603                 type(oldvalues))
605         cn = self.classname
606         cl = self.db.classes[cn]
607         changed = {}
608         props = cl.getprops(protected=0)
610         # determine what changed
611         for key in oldvalues.keys():
612             if key in ['files','messages']:
613                 continue
614             if key in ('actor', 'activity', 'creator', 'creation'):
615                 continue
616             # not all keys from oldvalues might be available in database
617             # this happens when property was deleted
618             try:
619                 new_value = cl.get(issueid, key)
620             except KeyError:
621                 continue
622             # the old value might be non existent
623             # this happens when property was added
624             try:
625                 old_value = oldvalues[key]
626                 if type(new_value) is type([]):
627                     new_value.sort()
628                     old_value.sort()
629                 if new_value != old_value:
630                     changed[key] = old_value
631             except:
632                 changed[key] = new_value
634         # list the changes
635         m = []
636         changed_items = changed.items()
637         changed_items.sort()
638         for propname, oldvalue in changed_items:
639             prop = props[propname]
640             value = cl.get(issueid, propname, None)
641             if isinstance(prop, hyperdb.Link):
642                 link = self.db.classes[prop.classname]
643                 key = link.labelprop(default_to_id=1)
644                 if key:
645                     if value:
646                         value = link.get(value, key)
647                     else:
648                         value = ''
649                     if oldvalue:
650                         oldvalue = link.get(oldvalue, key)
651                     else:
652                         oldvalue = ''
653                 change = '%s -> %s'%(oldvalue, value)
654             elif isinstance(prop, hyperdb.Multilink):
655                 change = ''
656                 if value is None: value = []
657                 if oldvalue is None: oldvalue = []
658                 l = []
659                 link = self.db.classes[prop.classname]
660                 key = link.labelprop(default_to_id=1)
661                 # check for additions
662                 for entry in value:
663                     if entry in oldvalue: continue
664                     if key:
665                         l.append(link.get(entry, key))
666                     else:
667                         l.append(entry)
668                 if l:
669                     l.sort()
670                     change = '+%s'%(', '.join(l))
671                     l = []
672                 # check for removals
673                 for entry in oldvalue:
674                     if entry in value: continue
675                     if key:
676                         l.append(link.get(entry, key))
677                     else:
678                         l.append(entry)
679                 if l:
680                     l.sort()
681                     change += ' -%s'%(', '.join(l))
682             else:
683                 change = '%s -> %s'%(oldvalue, value)
684                 if '\n' in change:
685                     value = self.indentChangeNoteValue(str(value))
686                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
687                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
688                         "new": value, "old": oldvalue}
689             m.append('%s: %s'%(propname, change))
690         if m:
691             m.insert(0, '----------')
692             m.insert(0, '')
693         return '\n'.join(m)
695     def indentChangeNoteValue(self, text):
696         lines = text.rstrip('\n').split('\n')
697         lines = [ '  '+line for line in lines ]
698         return '\n'.join(lines)
700 # vim: set filetype=python sts=4 sw=4 et si :