[MERGE] forward port of branch saas-3 up to f7a76cb
authorDenis Ledoux <dle@odoo.com>
Tue, 4 Nov 2014 17:05:58 +0000 (18:05 +0100)
committerDenis Ledoux <dle@odoo.com>
Tue, 4 Nov 2014 17:05:58 +0000 (18:05 +0100)
1  2 
addons/mail/mail_mail.py
addons/web/static/src/js/views.js
addons/web_kanban/static/src/js/kanban.js
openerp/addons/base/ir/ir_mail_server.py

diff --combined addons/mail/mail_mail.py
  
  import base64
  import logging
 -import re
  from email.utils import formataddr
 -from urllib import urlencode
  from urlparse import urljoin
  
 -from openerp import tools
 +from openerp import api, tools
  from openerp import SUPERUSER_ID
  from openerp.addons.base.ir.ir_mail_server import MailDeliveryException
  from openerp.osv import fields, osv
 +from openerp.tools.safe_eval import safe_eval as eval
  from openerp.tools.translate import _
  
  _logger = logging.getLogger(__name__)
@@@ -41,17 -42,16 +41,17 @@@ class mail_mail(osv.Model)
      _description = 'Outgoing Mails'
      _inherits = {'mail.message': 'mail_message_id'}
      _order = 'id desc'
 +    _rec_name = 'subject'
  
      _columns = {
 -        'mail_message_id': fields.many2one('mail.message', 'Message', required=True, ondelete='cascade'),
 +        'mail_message_id': fields.many2one('mail.message', 'Message', required=True, ondelete='cascade', auto_join=True),
          'state': fields.selection([
              ('outgoing', 'Outgoing'),
              ('sent', 'Sent'),
              ('received', 'Received'),
              ('exception', 'Delivery Failed'),
              ('cancel', 'Cancelled'),
 -        ], 'Status', readonly=True),
 +        ], 'Status', readonly=True, copy=False),
          'auto_delete': fields.boolean('Auto Delete',
              help="Permanently delete this email after sending it, to save space"),
          'references': fields.text('References', help='Message references, such as identifiers of previous messages', readonly=1),
@@@ -59,7 -59,6 +59,7 @@@
          'recipient_ids': fields.many2many('res.partner', string='To (Partners)'),
          'email_cc': fields.char('Cc', help='Carbon copy message recipients'),
          'body_html': fields.text('Rich-text Contents', help="Rich-text/HTML message"),
 +        'headers': fields.text('Headers', copy=False),
          # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification
          # and during unlink() we will not cascade delete the parent and its attachments
          'notification': fields.boolean('Is Notification',
@@@ -73,7 -72,7 +73,7 @@@
      def default_get(self, cr, uid, fields, context=None):
          # protection for `default_type` values leaking from menu action context (e.g. for invoices)
          # To remove when automatic context propagation is removed in web client
 -        if context and context.get('default_type') and context.get('default_type') not in self._all_columns['type'].column.selection:
 +        if context and context.get('default_type') and context.get('default_type') not in self._fields['type'].selection:
              context = dict(context, default_type=None)
          return super(mail_mail, self).default_get(cr, uid, fields, context=context)
  
@@@ -97,7 -96,6 +97,7 @@@
      def cancel(self, cr, uid, ids, context=None):
          return self.write(cr, uid, ids, {'state': 'cancel'}, context=context)
  
 +    @api.cr_uid
      def process_email_queue(self, cr, uid, ids=None, context=None):
          """Send immediately queued messages, committing after each
             message is sent - this is not transactional and should
              _logger.exception("Failed processing mail queue")
          return res
  
 -    def _postprocess_sent_message(self, cr, uid, mail, context=None):
 +    def _postprocess_sent_message(self, cr, uid, mail, context=None, mail_sent=True):
          """Perform any post-processing necessary after sending ``mail``
          successfully, including deleting it completely along with its
          attachment if the ``auto_delete`` flag of the mail was set.
          :param browse_record mail: the mail that was just sent
          :return: True
          """
 -        if mail.auto_delete:
 +        if mail_sent and mail.auto_delete:
              # done with SUPERUSER_ID to avoid giving large unlink access rights
              self.unlink(cr, SUPERUSER_ID, [mail.id], context=context)
          return True
      #------------------------------------------------------
  
      def _get_partner_access_link(self, cr, uid, mail, partner=None, context=None):
 -        """ Generate URLs for links in mails:
 -            - partner is an user and has read access to the document: direct link to document with model, res_id
 -        """
 +        """Generate URLs for links in mails: partner has access (is user):
 +        link to action_mail_redirect action that will redirect to doc or Inbox """
 +        if context is None:
 +            context = {}
          if partner and partner.user_ids:
              base_url = self.pool.get('ir.config_parameter').get_param(cr, uid, 'web.base.url')
 -            # the parameters to encode for the query and fragment part of url
 -            query = {'db': cr.dbname}
 -            fragment = {
 -                'login': partner.user_ids[0].login,
 -                'action': 'mail.action_mail_redirect',
 +            mail_model = mail.model or 'mail.thread'
 +            url = urljoin(base_url, self.pool[mail_model]._get_access_link(cr, uid, mail, partner, context=context))
 +            return "<span class='oe_mail_footer_access'><small>%(access_msg)s <a style='color:inherit' href='%(portal_link)s'>%(portal_msg)s</a></small></span>" % {
 +                'access_msg': _('about') if mail.record_name else _('access'),
 +                'portal_link': url,
 +                'portal_msg': '%s %s' % (context.get('model_name', ''), mail.record_name) if mail.record_name else _('your messages'),
              }
 -            if mail.notification:
 -                fragment['message_id'] = mail.mail_message_id.id
 -            elif mail.model and mail.res_id:
 -                fragment.update(model=mail.model, res_id=mail.res_id)
 -
 -            url = urljoin(base_url, "/web?%s#%s" % (urlencode(query), urlencode(fragment)))
 -            return _("""<span class='oe_mail_footer_access'><small>Access your messages and documents <a style='color:inherit' href="%s">in OpenERP</a></small></span>""") % url
          else:
              return None
  
      def send_get_mail_subject(self, cr, uid, mail, force=False, partner=None, context=None):
 -        """ If subject is void and record_name defined: '<Author> posted on <Resource>'
 +        """If subject is void, set the subject as 'Re: <Resource>' or
 +        'Re: <mail.parent_id.subject>'
  
              :param boolean force: force the subject replacement
 -            :param browse_record mail: mail.mail browse_record
 -            :param browse_record partner: specific recipient partner
          """
          if (force or not mail.subject) and mail.record_name:
              return 'Re: %s' % (mail.record_name)
          return mail.subject
  
      def send_get_mail_body(self, cr, uid, mail, partner=None, context=None):
 -        """ Return a specific ir_email body. The main purpose of this method
 -            is to be inherited to add custom content depending on some module.
 -
 -            :param browse_record mail: mail.mail browse_record
 -            :param browse_record partner: specific recipient partner
 -        """
 +        """Return a specific ir_email body. The main purpose of this method
 +        is to be inherited to add custom content depending on some module."""
          body = mail.body_html
  
 -        # generate footer
 -        link = self._get_partner_access_link(cr, uid, mail, partner, context=context)
 +        # generate access links for notifications or emails linked to a specific document with auto threading
 +        link = None
 +        if mail.notification or (mail.model and mail.res_id and not mail.no_auto_thread):
 +            link = self._get_partner_access_link(cr, uid, mail, partner, context=context)
          if link:
              body = tools.append_content_to_html(body, link, plaintext=False, container_tag='div')
          return body
  
 +    def send_get_mail_to(self, cr, uid, mail, partner=None, context=None):
 +        """Forge the email_to with the following heuristic:
 +          - if 'partner', recipient specific (Partner Name <email>)
 +          - else fallback on mail.email_to splitting """
 +        if partner:
 +            email_to = [formataddr((partner.name, partner.email))]
 +        else:
 +            email_to = tools.email_split(mail.email_to)
 +        return email_to
 +
      def send_get_email_dict(self, cr, uid, mail, partner=None, context=None):
 -        """ Return a dictionary for specific email values, depending on a
 -            partner, or generic to the whole recipients given by mail.email_to.
 +        """Return a dictionary for specific email values, depending on a
 +        partner, or generic to the whole recipients given by mail.email_to.
  
              :param browse_record mail: mail.mail browse_record
              :param browse_record partner: specific recipient partner
          """
          body = self.send_get_mail_body(cr, uid, mail, partner=partner, context=context)
 -        subject = self.send_get_mail_subject(cr, uid, mail, partner=partner, context=context)
          body_alternative = tools.html2plaintext(body)
 -
 -        # generate email_to, heuristic:
 -        # 1. if 'partner' is specified and there is a related document: Followers of 'Doc' <email>
 -        # 2. if 'partner' is specified, but no related document: Partner Name <email>
 -        # 3; fallback on mail.email_to that we split to have an email addresses list
 -        if partner and mail.record_name:
 -            email_to = [formataddr((_('Followers of %s') % mail.record_name, partner.email))]
 -        elif partner:
 -            email_to = [formataddr((partner.name, partner.email))]
 -        else:
 -            email_to = tools.email_split(mail.email_to)
 -
 -        return {
 +        res = {
              'body': body,
              'body_alternative': body_alternative,
 -            'subject': subject,
 -            'email_to': email_to,
 +            'subject': self.send_get_mail_subject(cr, uid, mail, partner=partner, context=context),
 +            'email_to': self.send_get_mail_to(cr, uid, mail, partner=partner, context=context),
          }
 +        return res
  
      def send(self, cr, uid, ids, auto_commit=False, raise_exception=False, context=None):
          """ Sends the selected emails immediately, ignoring their current
                  email sending process has failed
              :return: True
          """
 +        context = dict(context or {})
          ir_mail_server = self.pool.get('ir.mail_server')
          ir_attachment = self.pool['ir.attachment']
 -
          for mail in self.browse(cr, SUPERUSER_ID, ids, context=context):
              try:
 +                # TDE note: remove me when model_id field is present on mail.message - done here to avoid doing it multiple times in the sub method
 +                if mail.model:
 +                    model_id = self.pool['ir.model'].search(cr, SUPERUSER_ID, [('model', '=', mail.model)], context=context)[0]
 +                    model = self.pool['ir.model'].browse(cr, SUPERUSER_ID, model_id, context=context)
 +                else:
 +                    model = None
 +                if model:
 +                    context['model_name'] = model.name
 +
                  # load attachment binary data with a separate read(), as prefetching all
                  # `datas` (binary field) could bloat the browse cache, triggerring
                  # soft/hard mem limits with temporary data.
                  attachments = [(a['datas_fname'], base64.b64decode(a['datas']))
                                   for a in ir_attachment.read(cr, SUPERUSER_ID, attachment_ids,
                                                               ['datas_fname', 'datas'])]
 +
                  # specific behavior to customize the send email for notified partners
                  email_list = []
                  if mail.email_to:
                          headers['Return-Path'] = '%s-%d-%s-%d@%s' % (bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain)
                      else:
                          headers['Return-Path'] = '%s-%d@%s' % (bounce_alias, mail.id, catchall_domain)
 +                if mail.headers:
 +                    try:
 +                        headers.update(eval(mail.headers))
 +                    except Exception:
 +                        pass
 +
 +                # Writing on the mail object may fail (e.g. lock on user) which
 +                # would trigger a rollback *after* actually sending the email.
 +                # To avoid sending twice the same email, provoke the failure earlier
 +                mail.write({'state': 'exception'})
 +                mail_sent = False
  
                  # build an RFC2822 email.message.Message object and send it without queuing
                  res = None
                          subtype='html',
                          subtype_alternative='plain',
                          headers=headers)
-                     res = ir_mail_server.send_email(cr, uid, msg,
+                     try:
+                         res = ir_mail_server.send_email(cr, uid, msg,
                                                      mail_server_id=mail.mail_server_id.id,
                                                      context=context)
+                     except AssertionError as error:
+                         if error.message == ir_mail_server.NO_VALID_RECIPIENT:
+                             # No valid recipient found for this particular
+                             # mail item -> ignore error to avoid blocking
+                             # delivery to next recipients, if any. If this is
+                             # the only recipient, the mail will show as failed.
+                             _logger.warning("Ignoring invalid recipients for mail.mail %s: %s",
+                                             mail.message_id, email.get('email_to'))
+                         else:
+                             raise
                  if res:
                      mail.write({'state': 'sent', 'message_id': res})
                      mail_sent = True
 -                else:
 -                    mail.write({'state': 'exception'})
 -                    mail_sent = False
  
                  # /!\ can't use mail.state here, as mail.refresh() will cause an error
                  # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1
                  if mail_sent:
                      _logger.info('Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id)
 -                    self._postprocess_sent_message(cr, uid, mail, context=context)
 +                self._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=mail_sent)
              except MemoryError:
                  # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job
                  # instead of marking the mail as failed
              except Exception as e:
                  _logger.exception('failed sending mail.mail %s', mail.id)
                  mail.write({'state': 'exception'})
 +                self._postprocess_sent_message(cr, uid, mail, context=context, mail_sent=False)
                  if raise_exception:
                      if isinstance(e, AssertionError):
                          # get the args of the original error, wrap into a value and throw a MailDeliveryException
                          raise MailDeliveryException(_("Mail Delivery Failed"), value)
                      raise
  
 -            if auto_commit == True:
 +            if auto_commit is True:
                  cr.commit()
          return True
@@@ -271,7 -271,7 +271,7 @@@ instance.web.ActionManager = instance.w
                      }
                      action_loaded = this.do_action(state.action, { additional_context: add_context });
                      $.when(action_loaded || null).done(function() {
 -                        instance.webclient.menu.has_been_loaded.done(function() {
 +                        instance.webclient.menu.is_bound.done(function() {
                              if (self.inner_action && self.inner_action.id) {
                                  instance.webclient.menu.open_action(self.inner_action.id);
                              }
              return this.do_action(action_client, options);
          } else if (_.isNumber(action) || _.isString(action)) {
              var self = this;
 -            return self.rpc("/web/action/load", { action_id: action }).then(function(result) {
 +            var additional_context = {
 +                active_id : options.additional_context.active_id,
 +                active_ids : options.additional_context.active_ids,
 +                active_model : options.additional_context.active_model
 +            };
 +            return self.rpc("/web/action/load", { action_id: action, additional_context : additional_context }).then(function(result) {
                  return self.do_action(result, options);
              });
          }
          var type = action.type.replace(/\./g,'_');
          var popup = action.target === 'new';
          var inline = action.target === 'inline' || action.target === 'inlineview';
+         var form = _.str.startsWith(action.view_mode, 'form');
          action.flags = _.defaults(action.flags || {}, {
              views_switcher : !popup && !inline,
              search_view : !popup && !inline,
              action_buttons : !popup && !inline,
              sidebar : !popup && !inline,
-             pager : !popup && !inline,
+             pager : (!popup || !form) && !inline,
              display_title : !popup,
              search_disable_custom_filters: action.context && action.context.search_disable_custom_filters
          });
          }
          var widget = executor.widget();
          if (executor.action.target === 'new') {
 -            var pre_dialog = this.dialog;
 +            var pre_dialog = (this.dialog && !this.dialog.isDestroyed()) ? this.dialog : null;
              if (pre_dialog){
                  // prevent previous dialog to consider itself closed,
                  // right now, as we're opening a new one (prevents
              // it from reloading the original form view
              this.dialog_stop(executor.action);
              this.dialog = new instance.web.Dialog(this, {
 +                title: executor.action.name,
                  dialogClass: executor.klass,
              });
  
                  }
              };
              this.dialog.on("closing", null, this.dialog.on_close);
 -            this.dialog.dialog_title = executor.action.name;
              if (widget instanceof instance.web.ViewManager) {
                  _.extend(widget.flags, {
                      $buttons: this.dialog.$buttons,
      ir_actions_client: function (action, options) {
          var self = this;
          var ClientWidget = instance.web.client_actions.get_object(action.tag);
 +        if (!ClientWidget) {
 +            return self.do_warn("Action Error", "Could not find client action '" + action.tag + "'.");
 +        }
  
          if (!(ClientWidget.prototype instanceof instance.web.Widget)) {
              var next;
      ir_actions_report_xml: function(action, options) {
          var self = this;
          instance.web.blockUI();
 -        return instance.web.pyeval.eval_domains_and_contexts({
 -            contexts: [action.context],
 -            domains: []
 -        }).then(function(res) {
 -            action = _.clone(action);
 -            action.context = res.context;
 -
 -            // iOS devices doesn't allow iframe use the way we do it,
 -            // opening a new window seems the best way to workaround
 -            if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
 -                var params = {
 -                    action: JSON.stringify(action),
 -                    token: new Date().getTime()
 -                };
 -                var url = self.session.url('/web/report', params);
 -                instance.web.unblockUI();
 -                $('<a href="'+url+'" target="_blank"></a>')[0].click();
 -                return;
 -            }
 +        action = _.clone(action);
 +        var eval_contexts = ([instance.session.user_context] || []).concat([action.context]);
 +        action.context = instance.web.pyeval.eval('contexts',eval_contexts);
  
 -            var c = instance.webclient.crashmanager;
 -            return $.Deferred(function (d) {
 -                self.session.get_file({
 -                    url: '/web/report',
 -                    data: {action: JSON.stringify(action)},
 -                    complete: instance.web.unblockUI,
 -                    success: function(){
 -                        if (!self.dialog) {
 -                            options.on_close();
 -                        }
 -                        self.dialog_stop();
 -                        d.resolve();
 -                    },
 -                    error: function () {
 -                        c.rpc_error.apply(c, arguments);
 -                        d.reject();
 +        // iOS devices doesn't allow iframe use the way we do it,
 +        // opening a new window seems the best way to workaround
 +        if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
 +            var params = {
 +                action: JSON.stringify(action),
 +                token: new Date().getTime()
 +            };
 +            var url = self.session.url('/web/report', params);
 +            instance.web.unblockUI();
 +            $('<a href="'+url+'" target="_blank"></a>')[0].click();
 +            return;
 +        }
 +        var c = instance.webclient.crashmanager;
 +        return $.Deferred(function (d) {
 +            self.session.get_file({
 +                url: '/web/report',
 +                data: {action: JSON.stringify(action)},
 +                complete: instance.web.unblockUI,
 +                success: function(){
 +                    if (!self.dialog) {
 +                        options.on_close();
                      }
 -                });
 +                    self.dialog_stop();
 +                    d.resolve();
 +                },
 +                error: function () {
 +                    c.rpc_error.apply(c, arguments);
 +                    d.reject();
 +                }
              });
          });
      },
@@@ -613,7 -611,7 +614,7 @@@ instance.web.ViewManager =  instance.we
          var self = this;
          this.$el.find('.oe_view_manager_switch a').click(function() {
              self.switch_mode($(this).data('view-type'));
 -        }).tipsy();
 +        }).tooltip();
          var views_ids = {};
          _.each(this.views_src, function(view) {
              self.views[view.view_type] = $.extend({}, view, {
                      action_views_ids : views_ids
                  }, self.flags, self.flags[view.view_type] || {}, view.options || {})
              });
 -            
 +
              views_ids[view.view_type] = view.view_id;
          });
          if (this.flags.views_switcher === false) {
          }
          // If no default view defined, switch to the first one in sequence
          var default_view = this.flags.default_view || this.views_src[0].view_type;
 -  
  
          return this.switch_mode(default_view, null, this.flags[default_view] && this.flags[default_view].options);
 -      
 -        
 +
 +
      },
      switch_mode: function(view_type, no_store, view_options) {
          var self = this;
              _.each(_.keys(self.views), function(view_name) {
                  var controller = self.views[view_name].controller;
                  if (controller) {
 -                    var container = self.$el.find("> .oe_view_manager_body > .oe_view_manager_view_" + view_name);
 +                    var container = self.$el.find("> div > div > .oe_view_manager_body > .oe_view_manager_view_" + view_name);
                      if (view_name === view_type) {
                          container.show();
                          controller.do_show(view_options || {});
          }
          controller.on('switch_mode', self, this.switch_mode);
          controller.on('previous_view', self, this.prev_view);
 -        
 -        var container = this.$el.find("> .oe_view_manager_body > .oe_view_manager_view_" + view_type);
 +
 +        var container = this.$el.find("> div > div > .oe_view_manager_body > .oe_view_manager_view_" + view_type);
          var view_promise = controller.appendTo(container);
          this.views[view_type].controller = controller;
          return $.when(view_promise).done(function() {
              self.trigger("controller_inited",view_type,controller);
          });
      },
 +
      /**
       * @returns {Number|Boolean} the view id of the given type, false if not found
       */
          if (this.searchview) {
              this.searchview.destroy();
          }
 +
          var options = {
              hidden: this.flags.search_view === false,
              disable_custom_filters: this.flags.search_disable_custom_filters,
          this.searchview = new instance.web.SearchView(this, this.dataset, view_id, search_defaults, options);
  
          this.searchview.on('search_data', self, this.do_searchview_search);
 -        return this.searchview.appendTo(this.$el.find(".oe_view_manager_view_search"));
 +        return this.searchview.appendTo(this.$(".oe_view_manager_view_search"),
 +                                      this.$(".oe_searchview_drawer_container"));
      },
      do_searchview_search: function(domains, contexts, groupbys) {
          var self = this,
@@@ -915,20 -911,7 +916,20 @@@ instance.web.ViewManagerAction = instan
          this._super(parent, null, action.views, flags);
          this.session = parent.session;
          this.action = action;
 -        var dataset = new instance.web.DataSetSearch(this, action.res_model, action.context, action.domain);
 +        var context = action.context;
 +        if (action.target === 'current'){
 +            var active_context = {
 +                active_model: action.res_model,
 +            };
 +            context = new instance.web.CompoundContext(context, active_context).eval();
 +            delete context['active_id'];
 +            delete context['active_ids'];
 +            if (action.res_id){
 +                context['active_id'] = action.res_id;
 +                context['active_ids'] = [action.res_id];
 +            }
 +        }
 +        var dataset = new instance.web.DataSetSearch(this, action.res_model, context, action.domain);
          if (action.res_id) {
              dataset.ids.push(action.res_id);
              dataset.index = 0;
              current_view = this.views[this.active_view].controller;
          switch (val) {
              case 'fvg':
 -                var dialog = new instance.web.Dialog(this, { title: _t("Fields View Get"), width: '95%' }).open();
 +                var dialog = new instance.web.Dialog(this, { title: _t("Fields View Get") }).open();
                  $('<pre>').text(instance.web.json_node_to_xml(current_view.fields_view.arch, true)).appendTo(dialog.$el);
                  break;
              case 'tests':
                      url: '/web/tests?mod=*'
                  });
                  break;
 -            case 'perm_read':
 +            case 'get_metadata':
                  var ids = current_view.get_selected_ids();
                  if (ids.length === 1) {
 -                    this.dataset.call('perm_read', [ids]).done(function(result) {
 +                    this.dataset.call('get_metadata', [ids]).done(function(result) {
                          var dialog = new instance.web.Dialog(this, {
 -                            title: _.str.sprintf(_t("View Log (%s)"), self.dataset.model),
 -                            width: 400
 +                            title: _.str.sprintf(_t("Metadata (%s)"), self.dataset.model),
 +                            size: 'medium',
                          }, QWeb.render('ViewManagerDebugViewLog', {
                              perm : result[0],
                              format : instance.web.format_value
                      new instance.web.Dialog(self, {
                          title: _.str.sprintf(_t("Model %s fields"),
                                               self.dataset.model),
 -                        width: '95%'}, $root).open();
 +                        }, $root).open();
                  });
                  break;
              case 'edit_workflow':
                      return self.switch_mode(state.view_type, true);
                  })
              );
 -        } 
 +        }
  
          $.when(this.views[this.active_view] ? this.views[this.active_view].deferred : $.when(), defs).done(function() {
              self.views[self.active_view].controller.do_load_state(state, warm);
@@@ -1203,8 -1186,10 +1204,8 @@@ instance.web.Sidebar = instance.web.Wid
          this.$('.oe_form_dropdown_section').each(function() {
              $(this).toggle(!!$(this).find('li').length);
          });
 -
 -        self.$("[title]").tipsy({
 -            'html': true,
 -            'delayIn': 500,
 +        self.$("[title]").tooltip({
 +            delay: { show: 500, hide: 0}
          });
      },
      /**
                  domain = $.Deferred().resolve(undefined);
              }
              if (ids.length === 0) {
 -                instance.web.dialog($("<div />").text(_t("You must choose at least one record.")), { title: _t("Warning"), modal: true });
 +                new instance.web.Dialog(this, { title: _t("Warning"), size: 'medium',}, $("<div />").text(_t("You must choose at least one record."))).open();
                  return false;
              }
              var active_ids_context = {
@@@ -1471,12 -1456,12 +1472,12 @@@ instance.web.View = instance.web.Widget
          if (action_data.special === 'cancel') {
              return handler({"type":"ir.actions.act_window_close"});
          } else if (action_data.type=="object") {
 -            var args = [[record_id]], additional_args = [];
 +            var args = [[record_id]];
              if (action_data.args) {
                  try {
                      // Warning: quotes and double quotes problem due to json and xml clash
                      // Maybe we should force escaping in xml or do a better parse of the args array
 -                    additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
 +                    var additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
                      args = args.concat(additional_args);
                  } catch(e) {
                      console.error("Could not JSON.parse arguments", action_data.args);
          } else if (action_data.type=="action") {
              return this.rpc('/web/action/load', {
                  action_id: action_data.name,
 -                context: _.extend({'active_model': dataset.model, 'active_ids': dataset.ids, 'active_id': record_id}, instance.web.pyeval.eval('context', context)),
 +                context: _.extend(instance.web.pyeval.eval('context', context), {'active_model': dataset.model, 'active_ids': dataset.ids, 'active_id': record_id}),
                  do_not_eval: true
              }).then(handler);
          } else  {
      /**
       * Switches to a specific view type
       */
 -    do_switch_view: function() { 
 +    do_switch_view: function() {
          this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
      },
 -    /**
 -     * Cancels the switch to the current view, switches to the previous one
 -     *
 -     * @param {Object} [options]
 -     * @param {Boolean} [options.created=false] resource was created
 -     * @param {String} [options.default=null] view to switch to if no previous view
 -     */
 -
 -    do_search: function(view) {
 +    do_search: function(domain, context, group_by) {
      },
      on_sidebar_export: function() {
          new instance.web.DataExport(this, this.dataset).open();
@@@ -1609,12 -1602,7 +1610,12 @@@ instance.web.fields_view_get = function
      if (typeof model === 'string') {
          model = new instance.web.Model(args.model, args.context);
      }
 -    return args.model.call('fields_view_get', [args.view_id, args.view_type, args.context, args.toolbar]).then(function(fvg) {
 +    return args.model.call('fields_view_get', {
 +        view_id: args.view_id,
 +        view_type: args.view_type,
 +        context: args.context,
 +        toolbar: args.toolbar
 +    }).then(function(fvg) {
          return postprocess(fvg);
      });
  };
@@@ -43,6 -43,7 +43,6 @@@ instance.web_kanban.KanbanView = instan
          this.currently_dragging = {};
          this.limit = options.limit || 40;
          this.add_group_mutex = new $.Mutex();
 -        this.last_position = 'static';
      },
      view_loading: function(r) {
          return this.load_kanban(r);
      },
      load_kanban: function(data) {
          this.fields_view = data;
 +
 +        // use default order if defined in xml description
 +        var default_order = this.fields_view.arch.attrs.default_order,
 +            unsorted = !this.dataset._sort.length;
 +        if (unsorted && default_order) {
 +            this.dataset.set_sort(default_order.split(','));
 +        }
 +
          this.$el.addClass(this.fields_view.arch.attrs['class']);
          this.$buttons = $(QWeb.render("KanbanView.buttons", {'widget': this}));
          if (this.options.$buttons) {
                  return false;
              }
              self.nb_records = 0;
-             var remaining = groups.length - 1,
-                 groups_array = [];
+             var groups_array = [];
              return $.when.apply(null, _.map(groups, function (group, index) {
                  var def = $.when([]);
                  var dataset = new instance.web.DataSetSearch(self, self.dataset.model,
                          self.nb_records += records.length;
                          self.dataset.ids.push.apply(self.dataset.ids, dataset.ids);
                          groups_array[index] = new instance.web_kanban.KanbanGroup(self, records, group, dataset);
-                         if (self.dataset.index >= records.length){
-                             self.dataset.index = self.dataset.size() ? 0 : null;
-                         }
-                         if (!remaining--) {
-                             return self.do_add_groups(groups_array);
-                         }
                  });
              })).then(function () {
                  if(!self.nb_records) {
                      self.no_result();
                  }
+                 if (self.dataset.index >= self.nb_records){
+                     self.dataset.index = self.dataset.size() ? 0 : null;
+                 }
+                 return self.do_add_groups(groups_array);
              });
          });
      },
      },
      on_groups_started: function() {
          var self = this;
 -        if (this.group_by) {
 +        if (this.group_by || this.fields_keys.indexOf("sequence") !== -1) {
              // Kanban cards drag'n'drop
 -            var prev_widget, is_folded, record;
 -            var $columns = this.$el.find('.oe_kanban_column .oe_kanban_column_cards, .oe_kanban_column .oe_kanban_folded_column_cards');
 +            var prev_widget, is_folded, record, $columns;
 +            if (this.group_by) {
 +                $columns = this.$el.find('.oe_kanban_column .oe_kanban_column_cards, .oe_kanban_column .oe_kanban_folded_column_cards');
 +            } else {
 +                $columns = this.$el.find('.oe_kanban_column_cards');
 +            }
              $columns.sortable({
                  handle : '.oe_kanban_draghandle',
                  start: function(event, ui) {
      },
      on_record_moved : function(record, old_group, old_index, new_group, new_index) {
          var self = this;
 -        $.fn.tipsy.clear();
 +        record.$el.find('[title]').tooltip('destroy');
          $(old_group.$el).add(new_group.$el).find('.oe_kanban_aggregates, .oe_kanban_group_length').hide();
          if (old_group === new_group) {
              new_group.records.splice(old_index, 1);
              || (!this.options.action.help && !this.options.action.get_empty_list_help)) {
              return;
          }
 -        this.last_position = this.$el.find('table:first').css("position");
 -        this.$el.find('table:first').css("position", "absolute");
 -        $(QWeb.render('KanbanView.nocontent', { content : this.options.action.get_empty_list_help || this.options.action.help})).insertAfter(this.$('table:first'));
 +        this.$el.css("position", "relative");
 +        $(QWeb.render('KanbanView.nocontent', { content : this.options.action.get_empty_list_help || this.options.action.help})).insertBefore(this.$('table:first'));
          this.$el.find('.oe_view_nocontent').click(function() {
              self.$buttons.openerpBounce();
          });
      },
      remove_no_result: function() {
 -        this.$el.find('table:first').css("position", this.last_position);
 -        this.$el.find('.oe_view_nocontent').remove();        
 +        this.$el.css("position", "");
 +        this.$el.find('.oe_view_nocontent').remove();
      },
  
      /*
@@@ -667,7 -654,7 +664,7 @@@ instance.web_kanban.KanbanGroup = insta
          this.$records.data('widget', this);
          this.$has_been_started.resolve();
          var add_btn = this.$el.find('.oe_kanban_add');
 -        add_btn.tipsy({delayIn: 500, delayOut: 1000});
 +        add_btn.tooltip({delay: { show: 500, hide:1000 }});
          this.$records.find(".oe_kanban_column_cards").click(function (ev) {
              if (ev.target == ev.currentTarget) {
                  if (!self.state.folded) {
              return (new instance.web.Model(field.relation)).query([options.tooltip_on_group_by])
                      .filter([["id", "=", this.value]]).first().then(function(res) {
                  self.tooltip = res[options.tooltip_on_group_by];
 -                self.$(".oe_kanban_group_title_text").attr("title", self.tooltip || self.title || "").tipsy({html: true});
 +                self.$(".oe_kanban_group_title_text").attr("title", self.tooltip || self.title || "").tooltip();
              });
          }
      },
          });
          var am = instance.webclient.action_manager;
          var form = am.dialog_widget.views.form.controller;
 -        form.on("on_button_cancel", am.dialog, am.dialog.close);
 +        form.on("on_button_cancel", am.dialog, function() { return am.dialog.$dialog_box.modal('hide'); });
          form.on('record_saved', self, function() {
 -            am.dialog.close();
 +            am.dialog.$dialog_box.modal('hide');
              self.view.do_reload();
          });
      },
@@@ -944,17 -931,21 +941,17 @@@ instance.web_kanban.KanbanRecord = inst
      bind_events: function() {
          var self = this;
          this.setup_color_picker();
 -        this.$el.find('[tooltip]').tipsy({
 -            delayIn: 500,
 -            delayOut: 0,
 -            fade: true,
 -            title: function() {
 -                var template = $(this).attr('tooltip');
 -                if (!self.view.qweb.has_template(template)) {
 -                    return false;
 -                }
 -                return self.view.qweb.render(template, self.qweb_context);
 -            },
 -            gravity: 's',
 -            html: true,
 -            opacity: 0.8,
 -            trigger: 'hover'
 +        this.$el.find('[title]').each(function(){
 +            $(this).tooltip({
 +                delay: { show: 500, hide: 0},
 +                title: function() {
 +                    var template = $(this).attr('tooltip');
 +                    if (!self.view.qweb.has_template(template)) {
 +                        return false;
 +                    }
 +                    return self.view.qweb.render(template, self.qweb_context);
 +                },
 +            });
          });
  
          // If no draghandle is found, make the whole card as draghandle (provided one can edit)
@@@ -1273,95 -1264,7 +1270,95 @@@ instance.web_kanban.AbstractField = ins
      },
  });
  
 +instance.web_kanban.Priority = instance.web_kanban.AbstractField.extend({
 +    init: function(parent, field, $node) {
 +        this._super.apply(this, arguments);
 +        this.name = $node.attr('name')
 +        this.parent = parent;
 +    },
 +    prepare_priority: function() {
 +        var self = this;
 +        var selection = this.field.selection || [];
 +        var init_value = selection && selection[0][0] || 0;
 +        var data = _.map(selection.slice(1), function(element, index) {
 +            var value = {
 +                'value': element[0],
 +                'name': element[1],
 +                'click_value': element[0],
 +            }
 +            if (index == 0 && self.get('value') == element[0]) {
 +                value['click_value'] = init_value;
 +            }
 +            return value;
 +        });
 +        return data;
 +    },
 +    renderElement: function() {
 +        var self = this;
 +        this.record_id = self.parent.id;
 +        this.priorities = self.prepare_priority();
 +        this.$el = $(QWeb.render("Priority", {'widget': this}));
 +        this.$el.find('li').click(self.do_action.bind(self));
 +    },
 +    do_action: function(e) {
 +        var self = this;
 +        var li = $(e.target).closest( "li" );
 +        if (li.length) {
 +            var value = {};
 +            value[self.name] = String(li.data('value'));
 +            return self.parent.view.dataset._model.call('write', [[self.record_id], value, self.parent.view.dataset.get_context()]).done(self.reload_record.bind(self.parent));
 +        }
 +    },
 +    reload_record: function() {
 +        this.do_reload();
 +    },
 +});
 +
 +instance.web_kanban.KanbanSelection = instance.web_kanban.AbstractField.extend({
 +    init: function(parent, field, $node) {
 +        this._super.apply(this, arguments);
 +        this.name = $node.attr('name')
 +        this.parent = parent;
 +    },
 +    prepare_dropdown_selection: function() {
 +        var data = [];
 +        _.map(this.field.selection || [], function(res) {
 +            var value = {
 +                'name': res[0],
 +                'tooltip': res[1],
 +                'state_name': res[1],
 +            }
 +            if (res[0] == 'normal') { value['state_class'] = 'oe_kanban_status'; }
 +            else if (res[0] == 'done') { value['state_class'] = 'oe_kanban_status oe_kanban_status_green'; }
 +            else { value['state_class'] = 'oe_kanban_status oe_kanban_status_red'; }
 +            data.push(value);
 +        });
 +        return data;
 +    },
 +    renderElement: function() {
 +        var self = this;
 +        this.record_id = self.parent.id;
 +        this.states = self.prepare_dropdown_selection();;
 +        this.$el = $(QWeb.render("KanbanSelection", {'widget': self}));
 +        this.$el.find('li').click(self.do_action.bind(self));
 +    },
 +    do_action: function(e) {
 +        var self = this;
 +        var li = $(e.target).closest( "li" );
 +        if (li.length) {
 +            var value = {};
 +            value[self.name] = String(li.data('value'));
 +            return self.parent.view.dataset._model.call('write', [[self.record_id], value, self.parent.view.dataset.get_context()]).done(self.reload_record.bind(self.parent));
 +        }
 +    },
 +    reload_record: function() {
 +        this.do_reload();
 +    },
 +});
 +
  instance.web_kanban.fields_registry = new instance.web.Registry({});
 +instance.web_kanban.fields_registry.add('priority','instance.web_kanban.Priority');
 +instance.web_kanban.fields_registry.add('kanban_state_selection','instance.web_kanban.KanbanSelection');
  };
  
  // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax:
@@@ -2,7 -2,7 +2,7 @@@
  ##############################################################################
  #
  #    OpenERP, Open Source Management Solution
 -#    Copyright (C) 2011-2012 OpenERP S.A (<http://www.openerp.com>)
 +#    Copyright (C) 2011-2014 OpenERP S.A. (<http://www.openerp.com>)
  #
  #    This program is free software: you can redistribute it and/or modify
  #    it under the terms of the GNU Affero General Public License as
  #
  ##############################################################################
  
 -from email.MIMEText import MIMEText
 -from email.MIMEBase import MIMEBase
 -from email.MIMEMultipart import MIMEMultipart
 -from email.Charset import Charset
 -from email.Header import Header
 +from email.mime.text import MIMEText
 +from email.mime.base import MIMEBase
 +from email.mime.multipart import MIMEMultipart
 +from email.charset import Charset
 +from email.header import Header
  from email.utils import formatdate, make_msgid, COMMASPACE, getaddresses, formataddr
  from email import Encoders
  import logging
@@@ -149,13 -149,17 +149,17 @@@ def encode_rfc2822_address_header(heade
      addresses = getaddresses([tools.ustr(header_text).encode('utf-8')])
      return COMMASPACE.join(map(encode_addr, addresses))
  
  class ir_mail_server(osv.osv):
      """Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities."""
      _name = "ir.mail_server"
  
+     NO_VALID_RECIPIENT = ("At least one valid recipient address should be "
+                           "specified for outgoing emails (To/Cc/Bcc)")
      _columns = {
 -        'name': fields.char('Description', size=64, required=True, select=True),
 -        'smtp_host': fields.char('SMTP Server', size=128, required=True, help="Hostname or IP of SMTP server"),
 +        'name': fields.char('Description', required=True, select=True),
 +        'smtp_host': fields.char('SMTP Server', required=True, help="Hostname or IP of SMTP server"),
          'smtp_port': fields.integer('SMTP Port', size=5, required=True, help="SMTP Port. Usually 465 for SSL, and 25 or 587 for other cases."),
          'smtp_user': fields.char('Username', size=64, help="Optional username for SMTP authentication"),
          'smtp_pass': fields.char('Password', size=64, help="Optional password for SMTP authentication"),
          email_bcc = message['Bcc']
          
          smtp_to_list = filter(None, tools.flatten(map(extract_rfc2822_addresses,[email_to, email_cc, email_bcc])))
-         assert smtp_to_list, "At least one valid recipient address should be specified for outgoing emails (To/Cc/Bcc)"
+         assert smtp_to_list, self.NO_VALID_RECIPIENT
  
 +        x_forge_to = message['X-Forge-To']
 +        if x_forge_to:
 +            # `To:` header forged, e.g. for posting on mail.groups, to avoid confusion
 +            del message['X-Forge-To']
 +            del message['To'] # avoid multiple To: headers!
 +            message['To'] = x_forge_to
 +
          # Do not actually send emails in testing mode!
          if getattr(threading.currentThread(), 'testing', False):
              _test_logger.info("skip sending email in test mode")
                  mdir.add(message.as_string(True))
                  return message_id
  
 +            smtp = None
              try:
                  smtp = self.connect(smtp_server, smtp_port, smtp_user, smtp_password, smtp_encryption or False, smtp_debug)
                  smtp.sendmail(smtp_from, smtp_to_list, message.as_string())
              finally:
 -                try:
 -                    # Close Connection of SMTP Server
 +                if smtp is not None:
                      smtp.quit()
 -                except Exception:
 -                    # ignored, just a consequence of the previous exception
 -                    pass
          except Exception, e:
              msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % (tools.ustr(smtp_server),
                                                                               e.__class__.__name__,
                                                                               tools.ustr(e))
 -            _logger.exception(msg)
 +            _logger.error(msg)
              raise MailDeliveryException(_("Mail Delivery Failed"), msg)
          return message_id