[merge] backport stable
authorAurelien Campeas <aurelien.campeas@logilab.fr>
Wed, 09 Jan 2013 16:06:20 +0100
changeset 8645 310040c668c0
parent 8644 97202ea671e4 (current diff)
parent 8641 459d0c48dfaf (diff)
child 8646 82c7c2e0f69f
[merge] backport stable
__pkginfo__.py
devtools/testlib.py
misc/scripts/ldapuser2ldapfeed.py
server/migractions.py
sobjects/ldapparser.py
web/request.py
web/test/unittest_viewselector.py
--- a/.hgtags	Wed Jan 09 15:46:05 2013 +0100
+++ b/.hgtags	Wed Jan 09 16:06:20 2013 +0100
@@ -270,3 +270,5 @@
 19e115ae5442c427c0adbda8b9d8ceccf2931b5c cubicweb-debian-version-3.15.5-1
 0163bd9f4880d5531e433c1500f9298a0adef6b7 cubicweb-version-3.15.6
 b05e156b8fe720494293b08e7060ba43ad57a5c8 cubicweb-debian-version-3.15.6-1
+d8916cee7b705fec66fa2797ab89ba3e3b617ced cubicweb-version-3.15.7
+c5400558f37079a8bf6f2cd27a1ffd49321f3d8b cubicweb-debian-version-3.15.7-1
--- a/__pkginfo__.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/__pkginfo__.py	Wed Jan 09 16:06:20 2013 +0100
@@ -22,7 +22,7 @@
 
 modname = distname = "cubicweb"
 
-numversion = (3, 15, 6)
+numversion = (3, 15, 8)
 version = '.'.join(str(num) for num in numversion)
 
 description = "a repository of entities / relations for knowledge management"
--- a/debian/changelog	Wed Jan 09 15:46:05 2013 +0100
+++ b/debian/changelog	Wed Jan 09 16:06:20 2013 +0100
@@ -1,3 +1,15 @@
+cubicweb (3.15.8-1) squeeze; urgency=low
+
+  * New upstream release
+
+ -- Aurélien Campéas <aurelien.campeas@logilab.fr>  Wed, 09 Jan 2013 15:40:00 +0100
+
+cubicweb (3.15.7-1) squeeze; urgency=low
+
+  * New upstream release
+
+ -- David Douard <david.douard@logilab.fr>  Wed, 12 Dec 2012 22:10:45 +0100
+
 cubicweb (3.15.6-1) squeeze; urgency=low
 
   * New upstream release
--- a/devtools/testlib.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/devtools/testlib.py	Wed Jan 09 16:06:20 2013 +0100
@@ -815,7 +815,11 @@
         :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo`
                   encapsulation the generated HTML
         """
-        req = req or rset and rset.req or self.request()
+        if req is None:
+            if rset is None:
+                req = self.request()
+            else:
+                req = rset.req
         req.form['vid'] = vid
         viewsreg = self.vreg['views']
         view = viewsreg.select(vid, req, rset=rset, **kwargs)
--- a/doc/book/en/admin/ldap.rst	Wed Jan 09 15:46:05 2013 +0100
+++ b/doc/book/en/admin/ldap.rst	Wed Jan 09 16:06:20 2013 +0100
@@ -81,13 +81,20 @@
 Other notes
 -----------
 
-* Yes, cubicweb is able to start if ldap cannot be reached, even on c-c start,
-  though that will slow down the instance, since it will indefinitly attempt
-  to connect to the ldap on each query on users.
+* Cubicweb is able to start if ldap cannot be reached, even on
+  cubicweb-ctl start ... If some source ldap server cannot be used
+  while an instance is running, the corresponding users won't be
+  authenticated but their status will not change (e.g. they will not
+  be deactivated)
 
 * Changing the name of the ldap server in your script is fine, changing the base
   DN isn't since it's used to identify already known users from others
 
+* When a user is removed from an LDAP source, it is deactivated in the
+  CubicWeb instance; when a deactivated user comes back in the LDAP
+  source, it (automatically) is activated again
+
+
 * You can use the :class:`CWSourceHostConfig` to have variants for a source
   configuration according to the host the instance is running on. To do so go on
   the source's view from the sources management view.
--- a/doc/book/en/annexes/rql/language.rst	Wed Jan 09 15:46:05 2013 +0100
+++ b/doc/book/en/annexes/rql/language.rst	Wed Jan 09 16:06:20 2013 +0100
@@ -640,7 +640,7 @@
 | :func:`FSPATH(X)`     | expect X to be an attribute whose value is stored in a             |
 |                       | :class:`BFSStorage` and return its path on the file system         |
 +-----------------------+--------------------------------------------------------------------+
-| :func:`FTKIRANK(X)`   | expect X to be an entity used in a has_text relation, and return a |
+| :func:`FTIRANK(X)`    | expect X to be an entity used in a has_text relation, and return a |
 |                       | number corresponding to the rank order of each resulting entity    |
 +-----------------------+--------------------------------------------------------------------+
 | :func:`CAST(Type, X)` | expect X to be an attribute and return it casted into the given    |
--- a/misc/scripts/ldapuser2ldapfeed.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/misc/scripts/ldapuser2ldapfeed.py	Wed Jan 09 16:06:20 2013 +0100
@@ -80,7 +80,7 @@
     pprint(duplicates)
 
 print len(todelete), 'entities will be deleted'
-for etype, entities in todelete.values():
+for etype, entities in todelete.iteritems():
     print 'deleting', etype, [e.login for e in entities]
     system_source.delete_info_multi(session, entities, source_name)
 
--- a/server/cwzmq.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/cwzmq.py	Wed Jan 09 16:06:20 2013 +0100
@@ -32,6 +32,23 @@
 ctx = zmq.Context()
 
 class ZMQComm(object):
+    """
+    A simple ZMQ-based notification bus.
+
+    There should at most one instance of this class attached to a
+    Repository. A typical usage may be something like::
+
+        def callback(msg):
+            self.info('received message: %s', ' '.join(msg))
+        repo.app_instances_bus.subscribe('hello', callback)
+
+    to subsribe to the 'hello' kind of message. On the other side, to
+    emit a notification, call::
+
+       repo.app_instances_bus.publish(['hello', 'world'])
+
+    See http://docs.cubicweb.org for more details.
+    """
     def __init__(self):
         self.ioloop = ioloop.IOLoop()
         self._topics = {}
--- a/server/ldaputils.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/ldaputils.py	Wed Jan 09 16:06:20 2013 +0100
@@ -222,19 +222,6 @@
             raise AuthenticationError()
         return eid
 
-    def object_exists_in_ldap(self, dn):
-        cnx = self.get_connection().cnx #session.cnxset.connection(self.uri).cnx
-        if cnx is None:
-            self.warning('Could not establish connexion with LDAP server, assuming dn %s exists', dn)
-            return True # ldap unreachable, let's not touch it
-        try:
-            cnx.search_s(dn, self.user_base_scope)
-        except ldap.PARTIAL_RESULTS:
-            self.warning('PARTIAL RESULTS for dn %s', dn)
-        except ldap.NO_SUCH_OBJECT:
-            return False
-        return True
-
     def _connect(self, user=None, userpwd=None):
         protocol, hostport = self.connection_info()
         self.info('connecting %s://%s as %s', protocol, hostport,
--- a/server/migractions.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/migractions.py	Wed Jan 09 16:06:20 2013 +0100
@@ -1061,7 +1061,7 @@
                         rdef = copy(rschema.rdef(rschema.subjects(objtype)[0], objtype))
                         rdef.subject = etype
                         rdef.rtype = self.repo.schema.rschema(rschema)
-                        rdef.object = self.repo.schema.rschema(objtype)
+                        rdef.object = self.repo.schema.eschema(objtype)
                         ss.execschemarql(execute, rdef,
                                          ss.rdef2rql(rdef, cmap, gmap))
         if commit:
--- a/server/serverconfig.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/serverconfig.py	Wed Jan 09 16:06:20 2013 +0100
@@ -1,4 +1,4 @@
-# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
 #
 # This file is part of CubicWeb.
@@ -292,7 +292,7 @@
                 return True
             return source.uri in self.sources_mode
         if self.quick_start:
-            return False
+            return source.uri == 'system'
         return (not source.disabled and (
             not self.enabled_sources or source.uri in self.enabled_sources))
 
--- a/server/sources/ldapuser.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/sources/ldapuser.py	Wed Jan 09 16:06:20 2013 +0100
@@ -19,7 +19,7 @@
 
 this source is for now limited to a read-only CWUser source
 """
-from __future__ import division
+from __future__ import division, with_statement
 from base64 import b64decode
 
 import ldap
@@ -114,7 +114,8 @@
                                    self._query_cache.clear_expired)
 
     def synchronize(self):
-        self.pull_data(self.repo.internal_session())
+        with self.repo.internal_session() as session:
+            self.pull_data(session)
 
     def pull_data(self, session, force=False, raise_on_error=False):
         """synchronize content known by this repository with content in the
@@ -123,51 +124,47 @@
         self.info('synchronizing ldap source %s', self.uri)
         ldap_emailattr = self.user_rev_attrs['email']
         assert ldap_emailattr
-        session = self.repo.internal_session()
         execute = session.execute
-        try:
-            cursor = session.system_sql("SELECT eid, extid FROM entities WHERE "
-                                        "source='%s'" % self.uri)
-            for eid, b64extid in cursor.fetchall():
-                extid = b64decode(b64extid)
-                self.debug('ldap eid %s', eid)
-                # if no result found, _search automatically delete entity information
-                res = self._search(session, extid, BASE)
-                self.debug('ldap search %s', res)
-                if res:
-                    ldapemailaddr = res[0].get(ldap_emailattr)
-                    if ldapemailaddr:
-                        if isinstance(ldapemailaddr, list):
-                            ldapemailaddr = ldapemailaddr[0] # XXX consider only the first email in the list
-                        rset = execute('Any X,A WHERE '
-                                       'X address A, U use_email X, U eid %(u)s',
-                                       {'u': eid})
-                        ldapemailaddr = unicode(ldapemailaddr)
-                        for emaileid, emailaddr, in rset:
-                            if emailaddr == ldapemailaddr:
-                                break
+        cursor = session.system_sql("SELECT eid, extid FROM entities WHERE "
+                                    "source='%s'" % self.uri)
+        for eid, b64extid in cursor.fetchall():
+            extid = b64decode(b64extid)
+            self.debug('ldap eid %s', eid)
+            # if no result found, _search automatically delete entity information
+            res = self._search(session, extid, BASE)
+            self.debug('ldap search %s', res)
+            if res:
+                ldapemailaddr = res[0].get(ldap_emailattr)
+                if ldapemailaddr:
+                    if isinstance(ldapemailaddr, list):
+                        ldapemailaddr = ldapemailaddr[0] # XXX consider only the first email in the list
+                    rset = execute('Any X,A WHERE '
+                                   'X address A, U use_email X, U eid %(u)s',
+                                   {'u': eid})
+                    ldapemailaddr = unicode(ldapemailaddr)
+                    for emaileid, emailaddr, in rset:
+                        if emailaddr == ldapemailaddr:
+                            break
+                    else:
+                        self.debug('updating email address of user %s to %s',
+                                  extid, ldapemailaddr)
+                        emailrset = execute('EmailAddress A WHERE A address %(addr)s',
+                                            {'addr': ldapemailaddr})
+                        if emailrset:
+                            execute('SET U use_email X WHERE '
+                                    'X eid %(x)s, U eid %(u)s',
+                                    {'x': emailrset[0][0], 'u': eid})
+                        elif rset:
+                            if not execute('SET X address %(addr)s WHERE '
+                                           'U primary_email X, U eid %(u)s',
+                                           {'addr': ldapemailaddr, 'u': eid}):
+                                execute('SET X address %(addr)s WHERE '
+                                        'X eid %(x)s',
+                                        {'addr': ldapemailaddr, 'x': rset[0][0]})
                         else:
-                            self.debug('updating email address of user %s to %s',
-                                      extid, ldapemailaddr)
-                            emailrset = execute('EmailAddress A WHERE A address %(addr)s',
-                                                {'addr': ldapemailaddr})
-                            if emailrset:
-                                execute('SET U use_email X WHERE '
-                                        'X eid %(x)s, U eid %(u)s',
-                                        {'x': emailrset[0][0], 'u': eid})
-                            elif rset:
-                                if not execute('SET X address %(addr)s WHERE '
-                                               'U primary_email X, U eid %(u)s',
-                                               {'addr': ldapemailaddr, 'u': eid}):
-                                    execute('SET X address %(addr)s WHERE '
-                                            'X eid %(x)s',
-                                            {'addr': ldapemailaddr, 'x': rset[0][0]})
-                            else:
-                                # no email found, create it
-                                _insert_email(session, ldapemailaddr, eid)
-        finally:
-            session.commit()
-            session.close()
+                            # no email found, create it
+                            _insert_email(session, ldapemailaddr, eid)
+        session.commit()
 
     def ldap_name(self, var):
         if var.stinfo['relations']:
--- a/server/test/unittest_ldapuser.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/server/test/unittest_ldapuser.py	Wed Jan 09 16:06:20 2013 +0100
@@ -103,9 +103,9 @@
         session.create_entity('CWSource', name=u'ldapuser', type=u'ldapfeed', parser=u'ldapfeed',
                               url=URL, config=CONFIG)
         session.commit()
-        isession = session.repo.internal_session(safe=True)
-        lfsource = isession.repo.sources_by_uri['ldapuser']
-        stats = lfsource.pull_data(isession, force=True, raise_on_error=True)
+        with session.repo.internal_session(safe=True) as isession:
+            lfsource = isession.repo.sources_by_uri['ldapuser']
+            stats = lfsource.pull_data(isession, force=True, raise_on_error=True)
 
     def _pull(self):
         with self.session.repo.internal_session() as isession:
@@ -113,6 +113,34 @@
             stats = lfsource.pull_data(isession, force=True, raise_on_error=True)
             isession.commit()
 
+    def test_a_filter_inactivate(self):
+        """ filtered out people should be deactivated, unable to authenticate """
+        source = self.session.execute('CWSource S WHERE S type="ldapfeed"').get_entity(0,0)
+        config = source.repo_source.check_config(source)
+        # filter with adim's phone number
+        config['user-filter'] = u'(%s=%s)' % ('telephoneNumber', '109')
+        source.repo_source.update_config(source, config)
+        self.commit()
+        self._pull()
+        self.assertRaises(AuthenticationError, self.repo.connect, 'syt', password='syt')
+        self.assertEqual(self.execute('Any N WHERE U login "syt", '
+                                      'U in_state S, S name N').rows[0][0],
+                         'deactivated')
+        self.assertEqual(self.execute('Any N WHERE U login "adim", '
+                                      'U in_state S, S name N').rows[0][0],
+                         'activated')
+        # unfilter, syt should be activated again
+        config['user-filter'] = u''
+        source.repo_source.update_config(source, config)
+        self.commit()
+        self._pull()
+        self.assertEqual(self.execute('Any N WHERE U login "syt", '
+                                      'U in_state S, S name N').rows[0][0],
+                         'activated')
+        self.assertEqual(self.execute('Any N WHERE U login "adim", '
+                                      'U in_state S, S name N').rows[0][0],
+                         'activated')
+
     def test_delete(self):
         """ delete syt, pull, check deactivation, repull,
         readd syt, pull, check activation
@@ -132,10 +160,9 @@
         self.tearDownClass()
         self.setUpClass()
         self._pull()
-        # still deactivated, but a warning has been emitted ...
         self.assertEqual(self.execute('Any N WHERE U login "syt", '
                                       'U in_state S, S name N').rows[0][0],
-                         'deactivated')
+                         'activated')
         # test reactivating the user isn't enough to authenticate, as the native source
         # refuse to authenticate user from other sources
         os.system(deletecmd)
--- a/sobjects/ldapparser.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/sobjects/ldapparser.py	Wed Jan 09 16:06:20 2013 +0100
@@ -22,7 +22,7 @@
 """
 from __future__ import with_statement
 
-from logilab.common.decorators import cached
+from logilab.common.decorators import cached, cachedproperty
 from logilab.common.shellutils import generate_password
 
 from cubicweb import Binary, ConfigurationError
@@ -36,15 +36,27 @@
     # attributes of the cw user
     non_attribute_keys = set(('email',))
 
+    @cachedproperty
+    def searchfilterstr(self):
+        """ ldap search string, including user-filter """
+        return '(&%s)' % ''.join(self.source.base_filters)
+
+    @cachedproperty
+    def source_entities_by_extid(self):
+        source = self.source
+        return dict((userdict['dn'], userdict)
+                    for userdict in source._search(self._cw,
+                                                   source.user_base_dn,
+                                                   source.user_base_scope,
+                                                   self.searchfilterstr))
+
     def process(self, url, raise_on_error=False):
         """IDataFeedParser main entry point"""
-        source = self.source
-        searchstr = '(&%s)' % ''.join(source.base_filters)
-        self.warning('processing ldapfeed stuff %s %s', source, searchstr)
-        for userdict in source._search(self._cw, source.user_base_dn,
-                                       source.user_base_scope, searchstr):
+        self.debug('processing ldapfeed source %s %s', self.source, self.searchfilterstr)
+        for userdict in self.source_entities_by_extid.itervalues():
             self.warning('fetched user %s', userdict)
-            entity = self.extid2entity(userdict['dn'], 'CWUser', **userdict)
+            extid = userdict['dn']
+            entity = self.extid2entity(extid, 'CWUser', **userdict)
             if entity is not None and not self.created_during_pull(entity):
                 self.notify_updated(entity)
                 attrs = self.ldap2cwattrs(userdict)
@@ -78,7 +90,8 @@
         if entity.__regid__ == 'CWUser':
             wf = entity.cw_adapt_to('IWorkflowable')
             if wf.state == 'deactivated':
-                self.warning('update on deactivated user %s', entity.login)
+                wf.fire_transition('activate')
+                self.warning('user %s reactivated', entity.login)
         mdate = attrs.get('modification_date')
         if not mdate or mdate > entity.modification_date:
             attrs = dict( (k, v) for k, v in attrs.iteritems()
@@ -121,12 +134,14 @@
         entity.cw_set(in_group=groups)
         self._process_email(entity, sourceparams)
 
-    def is_deleted(self, extid, etype, eid):
+    def is_deleted(self, extidplus, etype, eid):
         try:
-            extid, _ = extid.rsplit('@@', 1)
+            extid, _ = extidplus.rsplit('@@', 1)
         except ValueError:
-            pass
-        return not self.source.object_exists_in_ldap(extid)
+            # for some reason extids here tend to come in both forms, e.g:
+            # dn, dn@@Babar
+            extid = extidplus
+        return extid not in self.source_entities_by_extid
 
     def _process_email(self, entity, userdict):
         try:
--- a/web/request.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/request.py	Wed Jan 09 16:06:20 2013 +0100
@@ -600,24 +600,35 @@
             name = bwcompat
         self.set_cookie(name, '', maxage=0, expires=date(2000, 1, 1))
 
-    def set_content_type(self, content_type, filename=None, encoding=None):
+    def set_content_type(self, content_type, filename=None, encoding=None,
+                         disposition='inline'):
         """set output content type for this request. An optional filename
-        may be given
+        may be given.
+
+        The disposition argument may be `attachement` or `inline` as specified
+        for the Content-disposition HTTP header. The disposition parameter have
+        no effect if no filename are specified.
         """
         if content_type.startswith('text/') and ';charset=' not in content_type:
             content_type += ';charset=' + (encoding or self.encoding)
         self.set_header('content-type', content_type)
         if filename:
-            header = ['attachment']
+            header = [disposition]
+            unicode_filename = None
             try:
-                filename = filename.encode('ascii')
-                header.append('filename=' + filename)
+                ascii_filename = filename.encode('ascii')
             except UnicodeEncodeError:
                 # fallback filename for very old browser
-                header.append('filename=' + filename.encode('ascii', 'ignore'))
+                unicode_filename = filename
+                ascii_filename = filename.encode('ascii', 'ignore')
+            # escape " and \
+            # see http://greenbytes.de/tech/tc2231/#attwithfilenameandextparamescaped
+            ascii_filename = ascii_filename.replace('\x5c', r'\\').replace('"', r'\"')
+            header.append('filename="%s"' % ascii_filename)
+            if unicode_filename is not None:
                 # encoded filename according RFC5987
-                filename = urllib.quote(filename.encode('utf-8'), '')
-                header.append("filename*=utf-8''" + filename)
+                urlquoted_filename = urllib.quote(unicode_filename.encode('utf-8'), '')
+                header.append("filename*=utf-8''" + urlquoted_filename)
             self.set_header('content-disposition', ';'.join(header))
 
     # high level methods for HTML headers management ##########################
--- a/web/test/unittest_idownloadable.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/test/unittest_idownloadable.py	Wed Jan 09 16:06:20 2013 +0100
@@ -58,12 +58,44 @@
         req.form['eid'] = str(req.user.eid)
         data = self.ctrl_publish(req,'view')
         get = req.headers_out.getRawHeaders
-        self.assertEqual(['attachment;filename=admin.txt'],
+        self.assertEqual(['attachment;filename="admin.txt"'],
                          get('content-disposition'))
         self.assertEqual(['text/plain;charset=ascii'],
                          get('content-type'))
         self.assertEqual('Babar is not dead!', data)
 
+    def test_header_with_space(self):
+        req = self.request()
+        self.create_user(req, login=u'c c l a', password='babar')
+        self.commit()
+        with self.login(u'c c l a', password='babar'):
+            req = self.request()
+            req.form['vid'] = 'download'
+            req.form['eid'] = str(req.user.eid)
+            data = self.ctrl_publish(req,'view')
+            get = req.headers_out.getRawHeaders
+            self.assertEqual(['attachment;filename="c c l a.txt"'],
+                             get('content-disposition'))
+            self.assertEqual(['text/plain;charset=ascii'],
+                             get('content-type'))
+            self.assertEqual('Babar is not dead!', data)
+
+    def test_header_with_space_and_comma(self):
+        req = self.request()
+        self.create_user(req, login=ur'c " l\ a', password='babar')
+        self.commit()
+        with self.login(ur'c " l\ a', password='babar'):
+            req = self.request()
+            req.form['vid'] = 'download'
+            req.form['eid'] = str(req.user.eid)
+            data = self.ctrl_publish(req,'view')
+            get = req.headers_out.getRawHeaders
+            self.assertEqual([r'attachment;filename="c \" l\\ a.txt"'],
+                             get('content-disposition'))
+            self.assertEqual(['text/plain;charset=ascii'],
+                             get('content-type'))
+            self.assertEqual('Babar is not dead!', data)
+
     def test_header_unicode_filename(self):
         req = self.request()
         self.create_user(req, login=u'cécilia', password='babar')
@@ -74,7 +106,7 @@
             req.form['eid'] = str(req.user.eid)
             self.ctrl_publish(req,'view')
             get = req.headers_out.getRawHeaders
-            self.assertEqual(["attachment;filename=ccilia.txt;filename*=utf-8''c%C3%A9cilia.txt"],
+            self.assertEqual(['''attachment;filename="ccilia.txt";filename*=utf-8''c%C3%A9cilia.txt'''],
                              get('content-disposition'))
 
     def test_header_unicode_long_filename(self):
@@ -88,7 +120,7 @@
             req.form['eid'] = str(req.user.eid)
             self.ctrl_publish(req,'view')
             get = req.headers_out.getRawHeaders
-            self.assertEqual(["attachment;filename=Brte_h_grand_nm_a_va_totallement_dborder_de_la_limite_l.txt;filename*=utf-8''B%C3%A8rte_h%C3%B4_grand_n%C3%B4m_%C3%A7a_va_totallement_d%C3%A9border_de_la_limite_l%C3%A0.txt"],
+            self.assertEqual(["""attachment;filename="Brte_h_grand_nm_a_va_totallement_dborder_de_la_limite_l.txt";filename*=utf-8''B%C3%A8rte_h%C3%B4_grand_n%C3%B4m_%C3%A7a_va_totallement_d%C3%A9border_de_la_limite_l%C3%A0.txt"""],
                              get('content-disposition'))
 
 if __name__ == '__main__':
--- a/web/test/unittest_views_json.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/test/unittest_views_json.py	Wed Jan 09 16:06:20 2013 +0100
@@ -37,6 +37,13 @@
         self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/json'])
         self.assertEqual(data, '[["guests", 1], ["managers", 1]]')
 
+    def test_json_rsetexport_empty_rset(self):
+        req = self.request()
+        rset = req.execute('Any X WHERE X is CWUser, X login "foobarbaz"')
+        data = self.view('jsonexport', rset)
+        self.assertEqual(req.headers_out.getRawHeaders('content-type'), ['application/json'])
+        self.assertEqual(data, '[]')
+
     def test_json_rsetexport_with_jsonp(self):
         req = self.request()
         req.form.update({'callback': 'foo',
--- a/web/test/unittest_viewselector.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/test/unittest_viewselector.py	Wed Jan 09 16:06:20 2013 +0100
@@ -111,8 +111,8 @@
     def test_possible_views_noresult(self):
         req = self.request()
         rset = req.execute('Any X WHERE X eid 999999')
-        self.assertListEqual(self.pviews(req, rset),
-                             [])
+        self.assertListEqual([('jsonexport', json.JsonRsetView)],
+                             self.pviews(req, rset))
 
     def test_possible_views_one_egroup(self):
         req = self.request()
--- a/web/views/idownloadable.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/views/idownloadable.py	Wed Jan 09 16:06:20 2013 +0100
@@ -100,7 +100,8 @@
             contenttype = adapter.download_content_type()
         self._cw.set_content_type(contenttype or self.content_type,
                                   filename=adapter.download_file_name(),
-                                  encoding=encoding)
+                                  encoding=encoding,
+                                  disposition='attachment')
 
     def call(self):
         entity = self.cw_rset.complete_entity(self.cw_row or 0, self.cw_col or 0)
--- a/web/views/json.py	Wed Jan 09 15:46:05 2013 +0100
+++ b/web/views/json.py	Wed Jan 09 16:06:20 2013 +0100
@@ -23,6 +23,7 @@
 _ = unicode
 
 from cubicweb.utils import json_dumps
+from cubicweb.predicates import any_rset
 from cubicweb.view import EntityView, AnyRsetView
 from cubicweb.web.application import anonymized_request
 from cubicweb.web.views import basecontrollers
@@ -90,6 +91,7 @@
 class JsonRsetView(JsonMixIn, AnyRsetView):
     """dumps raw result set in JSON format"""
     __regid__ = 'jsonexport'
+    __select__ = any_rset() # means rset might be empty or have any shape
     title = _('json-export-view')
 
     def call(self):