Code

python2.3 compatibility fixes
[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 from anypy.email_ import FeedParser
36 from roundup import password, date, hyperdb
37 from roundup.i18n import _
39 # MessageSendError is imported for backwards compatibility
40 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
41     nice_sender_header
43 class Database:
45     # remember the journal uid for the current journaltag so that:
46     # a. we don't have to look it up every time we need it, and
47     # b. if the journaltag disappears during a transaction, we don't barf
48     #    (eg. the current user edits their username)
49     journal_uid = None
50     def getuid(self):
51         """Return the id of the "user" node associated with the user
52         that owns this connection to the hyperdatabase."""
53         if self.journaltag is None:
54             return None
55         elif self.journaltag == 'admin':
56             # admin user may not exist, but always has ID 1
57             return '1'
58         else:
59             if (self.journal_uid is None or self.journal_uid[0] !=
60                     self.journaltag):
61                 uid = self.user.lookup(self.journaltag)
62                 self.journal_uid = (self.journaltag, uid)
63             return self.journal_uid[1]
65     def setCurrentUser(self, username):
66         """Set the user that is responsible for current database
67         activities.
68         """
69         self.journaltag = username
71     def isCurrentUser(self, username):
72         """Check if a given username equals the already active user.
73         """
74         return self.journaltag == username
76     def getUserTimezone(self):
77         """Return user timezone defined in 'timezone' property of user class.
78         If no such property exists return 0
79         """
80         userid = self.getuid()
81         timezone = None
82         try:
83             tz = self.user.get(userid, 'timezone')
84             date.get_timezone(tz)
85             timezone = tz
86         except KeyError:
87             pass
88         # If there is no class 'user' or current user doesn't have timezone
89         # property or that property is not set assume he/she lives in
90         # the timezone set in the tracker config.
91         if timezone is None:
92             timezone = self.config['TIMEZONE']
93         return timezone
95     def confirm_registration(self, otk):
96         props = self.getOTKManager().getall(otk)
97         for propname, proptype in self.user.getprops().items():
98             value = props.get(propname, None)
99             if value is None:
100                 pass
101             elif isinstance(proptype, hyperdb.Date):
102                 props[propname] = date.Date(value)
103             elif isinstance(proptype, hyperdb.Interval):
104                 props[propname] = date.Interval(value)
105             elif isinstance(proptype, hyperdb.Password):
106                 props[propname] = password.Password()
107                 props[propname].unpack(value)
109         # tag new user creation with 'admin'
110         self.journaltag = 'admin'
112         # create the new user
113         cl = self.user
115         props['roles'] = self.config.NEW_WEB_USER_ROLES
116         userid = cl.create(**props)
117         # clear the props from the otk database
118         self.getOTKManager().destroy(otk)
119         self.commit()
121         return userid
124     def log_debug(self, msg, *args, **kwargs):
125         """Log a message with level DEBUG."""
127         logger = self.get_logger()
128         logger.debug(msg, *args, **kwargs)
130     def log_info(self, msg, *args, **kwargs):
131         """Log a message with level INFO."""
133         logger = self.get_logger()
134         logger.info(msg, *args, **kwargs)
136     def get_logger(self):
137         """Return the logger for this database."""
139         # Because getting a logger requires acquiring a lock, we want
140         # to do it only once.
141         if not hasattr(self, '__logger'):
142             self.__logger = logging.getLogger('roundup.hyperdb')
144         return self.__logger
147 class DetectorError(RuntimeError):
148     """ Raised by detectors that want to indicate that something's amiss
149     """
150     pass
152 # deviation from spec - was called IssueClass
153 class IssueClass:
154     """This class is intended to be mixed-in with a hyperdb backend
155     implementation. The backend should provide a mechanism that
156     enforces the title, messages, files, nosy and superseder
157     properties:
159     - title = hyperdb.String(indexme='yes')
160     - messages = hyperdb.Multilink("msg")
161     - files = hyperdb.Multilink("file")
162     - nosy = hyperdb.Multilink("user")
163     - superseder = hyperdb.Multilink(classname)
164     """
166     # The tuple below does not affect the class definition.
167     # It just lists all names of all issue properties
168     # marked for message extraction tool.
169     #
170     # XXX is there better way to get property names into message catalog??
171     #
172     # Note that this list also includes properties
173     # defined in the classic template:
174     # assignedto, keyword, priority, status.
175     (
176         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
177         ''"assignedto", ''"keyword", ''"priority", ''"status",
178         # following properties are common for all hyperdb classes
179         # they are listed here to keep things in one place
180         ''"actor", ''"activity", ''"creator", ''"creation",
181     )
183     # New methods:
184     def addmessage(self, issueid, summary, text):
185         """Add a message to an issue's mail spool.
187         A new "msg" node is constructed using the current date, the user that
188         owns the database connection as the author, and the specified summary
189         text.
191         The "files" and "recipients" fields are left empty.
193         The given text is saved as the body of the message and the node is
194         appended to the "messages" field of the specified issue.
195         """
197     def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
198             from_address=None, cc=[], bcc=[]):
199         """Send a message to the members of an issue's nosy list.
201         The message is sent only to users on the nosy list who are not
202         already on the "recipients" list for the message.
204         These users are then added to the message's "recipients" list.
206         If 'msgid' is None, the message gets sent only to the nosy
207         list, and it's called a 'System Message'.
209         The "cc" argument indicates additional recipients to send the
210         message to that may not be specified in the message's recipients
211         list.
213         The "bcc" argument also indicates additional recipients to send the
214         message to that may not be specified in the message's recipients
215         list. These recipients will not be included in the To: or Cc:
216         address lists.
217         """
218         if msgid:
219             authid = self.db.msg.get(msgid, 'author')
220             recipients = self.db.msg.get(msgid, 'recipients', [])
221         else:
222             # "system message"
223             authid = None
224             recipients = []
226         sendto = []
227         bcc_sendto = []
228         seen_message = {}
229         for recipient in recipients:
230             seen_message[recipient] = 1
232         def add_recipient(userid, to):
233             """ make sure they have an address """
234             address = self.db.user.get(userid, 'address')
235             if address:
236                 to.append(address)
237                 recipients.append(userid)
239         def good_recipient(userid):
240             """ Make sure we don't send mail to either the anonymous
241                 user or a user who has already seen the message.
242                 Also check permissions on the message if not a system
243                 message: A user must have view permission on content and
244                 files to be on the receiver list. We do *not* check the 
245                 author etc. for now.
246             """
247             allowed = True
248             if msgid:
249                 for prop in 'content', 'files':
250                     if prop in self.db.msg.properties:
251                         allowed = allowed and self.db.security.hasPermission(
252                             'View', userid, 'msg', prop, msgid)
253             return (userid and
254                     (self.db.user.get(userid, 'username') != 'anonymous') and
255                     allowed and not seen_message.has_key(userid))
257         # possibly send the message to the author, as long as they aren't
258         # anonymous
259         if (good_recipient(authid) and
260             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
261              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
262             add_recipient(authid, sendto)
264         if authid:
265             seen_message[authid] = 1
267         # now deal with the nosy and cc people who weren't recipients.
268         for userid in cc + self.get(issueid, whichnosy):
269             if good_recipient(userid):
270                 add_recipient(userid, sendto)
272         # now deal with bcc people.
273         for userid in bcc:
274             if good_recipient(userid):
275                 add_recipient(userid, bcc_sendto)
277         if oldvalues:
278             note = self.generateChangeNote(issueid, oldvalues)
279         else:
280             note = self.generateCreateNote(issueid)
282         # If we have new recipients, update the message's recipients
283         # and send the mail.
284         if sendto or bcc_sendto:
285             if msgid is not None:
286                 self.db.msg.set(msgid, recipients=recipients)
287             self.send_message(issueid, msgid, note, sendto, from_address,
288                 bcc_sendto)
290     # backwards compatibility - don't remove
291     sendmessage = nosymessage
293     def send_message(self, issueid, msgid, note, sendto, from_address=None,
294             bcc_sendto=[]):
295         '''Actually send the nominated message from this issue to the sendto
296            recipients, with the note appended.
297         '''
298         users = self.db.user
299         messages = self.db.msg
300         files = self.db.file
302         if msgid is None:
303             inreplyto = None
304             messageid = None
305         else:
306             inreplyto = messages.get(msgid, 'inreplyto')
307             messageid = messages.get(msgid, 'messageid')
309         # make up a messageid if there isn't one (web edit)
310         if not messageid:
311             # this is an old message that didn't get a messageid, so
312             # create one
313             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
314                                            self.classname, issueid,
315                                            self.db.config.MAIL_DOMAIN)
316             if msgid is not None:
317                 messages.set(msgid, messageid=messageid)
319         # compose title
320         cn = self.classname
321         title = self.get(issueid, 'title') or '%s message copy'%cn
323         # figure author information
324         if msgid:
325             authid = messages.get(msgid, 'author')
326         else:
327             authid = self.db.getuid()
328         authname = users.get(authid, 'realname')
329         if not authname:
330             authname = users.get(authid, 'username', '')
331         authaddr = users.get(authid, 'address', '')
333         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
334             authaddr = " <%s>" % formataddr( ('',authaddr) )
335         elif authaddr:
336             authaddr = ""
338         # make the message body
339         m = ['']
341         # put in roundup's signature
342         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
343             m.append(self.email_signature(issueid, msgid))
345         # add author information
346         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
347             if msgid and len(self.get(issueid, 'messages')) == 1:
348                 m.append(_("New submission from %(authname)s%(authaddr)s:")
349                     % locals())
350             elif msgid:
351                 m.append(_("%(authname)s%(authaddr)s added the comment:")
352                     % locals())
353             else:
354                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
355             m.append('')
357         # add the content
358         if msgid is not None:
359             m.append(messages.get(msgid, 'content', ''))
361         # get the files for this message
362         message_files = []
363         if msgid :
364             for fileid in messages.get(msgid, 'files') :
365                 # check the attachment size
366                 filesize = self.db.filesize('file', fileid, None)
367                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
368                     message_files.append(fileid)
369                 else:
370                     base = self.db.config.TRACKER_WEB
371                     link = "".join((base, files.classname, fileid))
372                     filename = files.get(fileid, 'name')
373                     m.append(_("File '%(filename)s' not attached - "
374                         "you can download it from %(link)s.") % locals())
376         # add the change note
377         if note:
378             m.append(note)
380         # put in roundup's signature
381         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
382             m.append(self.email_signature(issueid, msgid))
384         # figure the encoding
385         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
387         # construct the content and convert to unicode object
388         body = unicode('\n'.join(m), 'utf-8').encode(charset)
390         # make sure the To line is always the same (for testing mostly)
391         sendto.sort()
393         # make sure we have a from address
394         if from_address is None:
395             from_address = self.db.config.TRACKER_EMAIL
397         # additional bit for after the From: "name"
398         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
399         if from_tag:
400             from_tag = ' ' + from_tag
402         subject = '[%s%s] %s'%(cn, issueid, title)
403         author = (authname + from_tag, from_address)
405         # send an individual message per recipient?
406         if self.db.config.NOSY_EMAIL_SENDING != 'single':
407             sendto = [[address] for address in sendto]
408         else:
409             sendto = [sendto]
411         # tracker sender info
412         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
413         tracker_name = nice_sender_header(tracker_name, from_address,
414             charset)
416         # now send one or more messages
417         # TODO: I believe we have to create a new message each time as we
418         # can't fiddle the recipients in the message ... worth testing
419         # and/or fixing some day
420         first = True
421         for sendto in sendto:
422             # create the message
423             mailer = Mailer(self.db.config)
425             message = mailer.get_standard_message(sendto, subject, author,
426                 multipart=message_files)
428             # set reply-to to the tracker
429             message['Reply-To'] = tracker_name
431             # message ids
432             if messageid:
433                 message['Message-Id'] = messageid
434             if inreplyto:
435                 message['In-Reply-To'] = inreplyto
437             # Generate a header for each link or multilink to
438             # a class that has a name attribute
439             for propname, prop in self.getprops().items():
440                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
441                     continue
442                 cl = self.db.getclass(prop.classname)
443                 if not 'name' in cl.getprops():
444                     continue
445                 if isinstance(prop, hyperdb.Link):
446                     value = self.get(issueid, propname)
447                     if value is None:
448                         continue
449                     values = [value]
450                 else:
451                     values = self.get(issueid, propname)
452                     if not values:
453                         continue
454                 values = [cl.get(v, 'name') for v in values]
455                 values = ', '.join(values)
456                 header = "X-Roundup-%s-%s"%(self.classname, propname)
457                 try:
458                     message[header] = values.encode('ascii')
459                 except UnicodeError:
460                     message[header] = Header(values, charset)
462             if not inreplyto:
463                 # Default the reply to the first message
464                 msgs = self.get(issueid, 'messages')
465                 # Assume messages are sorted by increasing message number here
466                 # If the issue is just being created, and the submitter didn't
467                 # provide a message, then msgs will be empty.
468                 if msgs and msgs[0] != msgid:
469                     inreplyto = messages.get(msgs[0], 'messageid')
470                     if inreplyto:
471                         message['In-Reply-To'] = inreplyto
473             # attach files
474             if message_files:
475                 # first up the text as a part
476                 part = MIMEText(body)
477                 part.set_charset(charset)
478                 encode_quopri(part)
479                 message.attach(part)
481                 for fileid in message_files:
482                     name = files.get(fileid, 'name')
483                     mime_type = files.get(fileid, 'type')
484                     content = files.get(fileid, 'content')
485                     if mime_type == 'text/plain':
486                         try:
487                             content.decode('ascii')
488                         except UnicodeError:
489                             # the content cannot be 7bit-encoded.
490                             # use quoted printable
491                             # XXX stuffed if we know the charset though :(
492                             part = MIMEText(content)
493                             encode_quopri(part)
494                         else:
495                             part = MIMEText(content)
496                             part['Content-Transfer-Encoding'] = '7bit'
497                     elif mime_type == 'message/rfc822':
498                         main, sub = mime_type.split('/')
499                         p = FeedParser()
500                         p.feed(content)
501                         part = MIMEBase(main, sub)
502                         part.set_payload([p.close()])
503                     else:
504                         # some other type, so encode it
505                         if not mime_type:
506                             # this should have been done when the file was saved
507                             mime_type = mimetypes.guess_type(name)[0]
508                         if mime_type is None:
509                             mime_type = 'application/octet-stream'
510                         main, sub = mime_type.split('/')
511                         part = MIMEBase(main, sub)
512                         part.set_payload(content)
513                         Encoders.encode_base64(part)
514                     cd = 'Content-Disposition'
515                     part[cd] = 'attachment;\n filename="%s"'%name
516                     message.attach(part)
518             else:
519                 message.set_payload(body)
520                 encode_quopri(message)
522             if first:
523                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
524             else:
525                 mailer.smtp_send(sendto, message.as_string())
526             first = False
528     def email_signature(self, issueid, msgid):
529         ''' Add a signature to the e-mail with some useful information
530         '''
531         # simplistic check to see if the url is valid,
532         # then append a trailing slash if it is missing
533         base = self.db.config.TRACKER_WEB
534         if (not isinstance(base , type('')) or
535             not (base.startswith('http://') or base.startswith('https://'))):
536             web = "Configuration Error: TRACKER_WEB isn't a " \
537                 "fully-qualified URL"
538         else:
539             if not base.endswith('/'):
540                 base = base + '/'
541             web = base + self.classname + issueid
543         # ensure the email address is properly quoted
544         email = formataddr((self.db.config.TRACKER_NAME,
545             self.db.config.TRACKER_EMAIL))
547         line = '_' * max(len(web)+2, len(email))
548         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
551     def generateCreateNote(self, issueid):
552         """Generate a create note that lists initial property values
553         """
554         cn = self.classname
555         cl = self.db.classes[cn]
556         props = cl.getprops(protected=0)
558         # list the values
559         m = []
560         prop_items = props.items()
561         prop_items.sort()
562         for propname, prop in prop_items:
563             value = cl.get(issueid, propname, None)
564             # skip boring entries
565             if not value:
566                 continue
567             if isinstance(prop, hyperdb.Link):
568                 link = self.db.classes[prop.classname]
569                 if value:
570                     key = link.labelprop(default_to_id=1)
571                     if key:
572                         value = link.get(value, key)
573                 else:
574                     value = ''
575             elif isinstance(prop, hyperdb.Multilink):
576                 if value is None: value = []
577                 l = []
578                 link = self.db.classes[prop.classname]
579                 key = link.labelprop(default_to_id=1)
580                 if key:
581                     value = [link.get(entry, key) for entry in value]
582                 value.sort()
583                 value = ', '.join(value)
584             else:
585                 value = str(value)
586                 if '\n' in value:
587                     value = '\n'+self.indentChangeNoteValue(value)
588             m.append('%s: %s'%(propname, value))
589         m.insert(0, '----------')
590         m.insert(0, '')
591         return '\n'.join(m)
593     def generateChangeNote(self, issueid, oldvalues):
594         """Generate a change note that lists property changes
595         """
596         if not isinstance(oldvalues, type({})):
597             raise TypeError("'oldvalues' must be dict-like, not %s."%
598                 type(oldvalues))
600         cn = self.classname
601         cl = self.db.classes[cn]
602         changed = {}
603         props = cl.getprops(protected=0)
605         # determine what changed
606         for key in oldvalues.keys():
607             if key in ['files','messages']:
608                 continue
609             if key in ('actor', 'activity', 'creator', 'creation'):
610                 continue
611             # not all keys from oldvalues might be available in database
612             # this happens when property was deleted
613             try:
614                 new_value = cl.get(issueid, key)
615             except KeyError:
616                 continue
617             # the old value might be non existent
618             # this happens when property was added
619             try:
620                 old_value = oldvalues[key]
621                 if type(new_value) is type([]):
622                     new_value.sort()
623                     old_value.sort()
624                 if new_value != old_value:
625                     changed[key] = old_value
626             except:
627                 changed[key] = new_value
629         # list the changes
630         m = []
631         changed_items = changed.items()
632         changed_items.sort()
633         for propname, oldvalue in changed_items:
634             prop = props[propname]
635             value = cl.get(issueid, propname, None)
636             if isinstance(prop, hyperdb.Link):
637                 link = self.db.classes[prop.classname]
638                 key = link.labelprop(default_to_id=1)
639                 if key:
640                     if value:
641                         value = link.get(value, key)
642                     else:
643                         value = ''
644                     if oldvalue:
645                         oldvalue = link.get(oldvalue, key)
646                     else:
647                         oldvalue = ''
648                 change = '%s -> %s'%(oldvalue, value)
649             elif isinstance(prop, hyperdb.Multilink):
650                 change = ''
651                 if value is None: value = []
652                 if oldvalue is None: oldvalue = []
653                 l = []
654                 link = self.db.classes[prop.classname]
655                 key = link.labelprop(default_to_id=1)
656                 # check for additions
657                 for entry in value:
658                     if entry in oldvalue: continue
659                     if key:
660                         l.append(link.get(entry, key))
661                     else:
662                         l.append(entry)
663                 if l:
664                     l.sort()
665                     change = '+%s'%(', '.join(l))
666                     l = []
667                 # check for removals
668                 for entry in oldvalue:
669                     if entry in value: continue
670                     if key:
671                         l.append(link.get(entry, key))
672                     else:
673                         l.append(entry)
674                 if l:
675                     l.sort()
676                     change += ' -%s'%(', '.join(l))
677             else:
678                 change = '%s -> %s'%(oldvalue, value)
679                 if '\n' in change:
680                     value = self.indentChangeNoteValue(str(value))
681                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
682                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
683                         "new": value, "old": oldvalue}
684             m.append('%s: %s'%(propname, change))
685         if m:
686             m.insert(0, '----------')
687             m.insert(0, '')
688         return '\n'.join(m)
690     def indentChangeNoteValue(self, text):
691         lines = text.rstrip('\n').split('\n')
692         lines = [ '  '+line for line in lines ]
693         return '\n'.join(lines)
695 # vim: set filetype=python sts=4 sw=4 et si :