Repository: ckan/ckan

Issue:
Redirect /dataset/[id] -> /dataset/[name] etc
Redirects:
* /dataset/[id] -> /dataset/[name]
* /group/[id] -> /group/[name]
* /organization/[id] -> /organization/[name]

Specifying these objects by *id* is hidden functionality really. There are no links to it. I've added it for two reasons:

1. As a developer it's occasionally I know a dataset's id and want to look at it, and it is useful to have it instead display on the normal url with the name in it. Why would you want the current functionality and keep it as the weird id url anyway? It's functionality I've wanted on many occasions over the years - maybe it's just me!

2. In Activity Stream the links to old versions of datasets/groups/organizations are currently given using the 'name' of those objects *as they were at the time of the change*. Obviously there are ways that these objects can change name, and in this case the link gets broken. So with my changes in PR https://github.com/ckan/ckan/pull/3972 I'm keen to change the link to be the id of the objects, so we avoid the broken link problem, and we don't add a whole bunch of joins on these queries that are potentially already quite big.



Discussion:

Resolving patch:
diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py
--- a/ckan/controllers/group.py
+++ b/ckan/controllers/group.py
@@ -225,6 +225,12 @@ def read(self, id, limit=20):
         except (NotFound, NotAuthorized):
             abort(404, _('Group not found'))
 
+        # if the user specified a group id, redirect to the group name
+        if data_dict['id'] == c.group_dict['id'] and \
+                data_dict['id'] != c.group_dict['name']:
+            h.redirect_to(controller=group_type, action='read',
+                          id=c.group_dict['name'])
+
         self._read(id, limit, group_type)
         return render(self._read_template(c.group_dict['type']),
                       extra_vars={'group_type': group_type})
@@ -860,6 +866,7 @@ def follow(self, id):
             group_dict = get_action('group_show')(context, data_dict)
             h.flash_success(_("You are now following {0}").format(
                 group_dict['title']))
+            id = group_dict['name']
         except ValidationError as e:
             error_message = (e.message or e.error_summary
                              or e.error_dict)
@@ -880,6 +887,7 @@ def unfollow(self, id):
             group_dict = get_action('group_show')(context, data_dict)
             h.flash_success(_("You are no longer following {0}").format(
                 group_dict['title']))
+            id = group_dict['name']
         except ValidationError as e:
             error_message = (e.message or e.error_summary
                              or e.error_dict)
diff --git a/ckan/views/dataset.py b/ckan/views/dataset.py
--- a/ckan/views/dataset.py
+++ b/ckan/views/dataset.py
@@ -412,6 +412,12 @@ def read(package_type, id):
     g.pkg_dict = pkg_dict
     g.pkg = pkg
 
+    # if the user specified a package id, redirect to the package name
+    if data_dict['id'] == pkg_dict['id'] and \
+            data_dict['id'] != pkg_dict['name']:
+        return h.redirect_to(u'dataset.read',
+                             id=pkg_dict['name'])
+
     # can the resources be previewed?
     for resource in pkg_dict[u'resources']:
         resource_views = get_action(u'resource_view_list')(
@@ -828,6 +834,7 @@ def follow(package_type, id):
     try:
         get_action(u'follow_dataset')(context, data_dict)
         package_dict = get_action(u'package_show')(context, data_dict)
+        id = package_dict['name']
     except ValidationError as e:
         error_message = (e.message or e.error_summary or e.error_dict)
         h.flash_error(error_message)
@@ -854,6 +861,7 @@ def unfollow(package_type, id):
     try:
         get_action(u'unfollow_dataset')(context, data_dict)
         package_dict = get_action(u'package_show')(context, data_dict)
+        id = package_dict['name']
     except ValidationError as e:
         error_message = (e.message or e.error_summary or e.error_dict)
         h.flash_error(error_message)


Test patch:
diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py
--- a/ckan/tests/controllers/test_group.py
+++ b/ckan/tests/controllers/test_group.py
@@ -189,21 +189,34 @@ def test_all_fields_saved(self):
 
 
 class TestGroupRead(helpers.FunctionalTestBase):
-    def setup(self):
-        super(TestGroupRead, self).setup()
-        self.app = helpers._get_test_app()
-        self.user = factories.User()
-        self.user_env = {'REMOTE_USER': self.user['name'].encode('ascii')}
-        self.group = factories.Group(user=self.user)
 
     def test_group_read(self):
-        response = self.app.get(url=url_for(controller='group',
-                                            action='read',
-                                            id=self.group['id']),
-                                status=200,
-                                extra_environ=self.user_env)
-        assert_in(self.group['title'], response)
-        assert_in(self.group['description'], response)
+        group = factories.Group()
+        app = helpers._get_test_app()
+        response = app.get(url=url_for(controller='group',
+                                       action='read',
+                                       id=group['name']))
+        assert_in(group['title'], response)
+        assert_in(group['description'], response)
+
+    def test_redirect_when_given_id(self):
+        group = factories.Group()
+        app = helpers._get_test_app()
+        response = app.get(url_for(controller='group', action='read',
+                                   id=group['id']),
+                           status=302)
+        # redirect replaces the ID with the name in the URL
+        redirected_response = response.follow()
+        expected_url = url_for(controller='group', action='read',
+                               id=group['name'])
+        assert_equal(redirected_response.request.path, expected_url)
+
+    def test_no_redirect_loop_when_name_is_the_same_as_the_id(self):
+        group = factories.Group(id='abc', name='abc')
+        app = helpers._get_test_app()
+        app.get(url_for(controller='group', action='read',
+                        id=group['id']),
+                status=200)  # ie no redirect
 
 
 class TestGroupDelete(helpers.FunctionalTestBase):
@@ -537,7 +550,8 @@ def test_group_unfollow_not_following(self):
                                id=group['id'])
         unfollow_response = app.post(unfollow_url, extra_environ=env,
                                      status=302)
-        unfollow_response = unfollow_response.follow()
+        unfollow_response = unfollow_response.follow()  # /group/[id] 302s to:
+        unfollow_response = unfollow_response.follow()  # /group/[name]
 
         assert_true('You are not following {0}'.format(group['id'])
                     in unfollow_response)
@@ -660,7 +674,7 @@ def test_group_search_within_org(self):
                           groups=[{'id': grp['id']}])
 
         grp_url = url_for(controller='group', action='read',
-                          id=grp['id'])
+                          id=grp['name'])
         grp_response = app.get(grp_url)
         grp_response_html = BeautifulSoup(grp_response.body)
 
@@ -688,7 +702,7 @@ def test_group_search_within_org_results(self):
                           groups=[{'id': grp['id']}])
 
         grp_url = url_for(controller='group', action='read',
-                          id=grp['id'])
+                          id=grp['name'])
         grp_response = app.get(grp_url)
         search_form = grp_response.forms['group-datasets-search-form']
         search_form['q'] = 'One'
@@ -721,7 +735,7 @@ def test_group_search_within_org_no_results(self):
                           groups=[{'id': grp['id']}])
 
         grp_url = url_for(controller='group', action='read',
-                          id=grp['id'])
+                          id=grp['name'])
         grp_response = app.get(grp_url)
         search_form = grp_response.forms['group-datasets-search-form']
         search_form['q'] = 'Nout'
diff --git a/ckan/tests/controllers/test_organization.py b/ckan/tests/controllers/test_organization.py
--- a/ckan/tests/controllers/test_organization.py
+++ b/ckan/tests/controllers/test_organization.py
@@ -82,21 +82,33 @@ def test_error_message_shown_when_no_organization_list_permission(self, mock_che
 
 
 class TestOrganizationRead(helpers.FunctionalTestBase):
-    def setup(self):
-        super(TestOrganizationRead, self).setup()
-        self.app = helpers._get_test_app()
-        self.user = factories.User()
-        self.user_env = {'REMOTE_USER': self.user['name'].encode('ascii')}
-        self.organization = factories.Organization(user=self.user)
+    def test_group_read(self):
+        org = factories.Organization()
+        app = helpers._get_test_app()
+        response = app.get(url=url_for(controller='organization',
+                                       action='read',
+                                       id=org['name']))
+        assert_in(org['title'], response)
+        assert_in(org['description'], response)
 
-    def test_organization_read(self):
-        response = self.app.get(url=url_for(controller='organization',
-                                            action='read',
-                                            id=self.organization['id']),
-                                status=200,
-                                extra_environ=self.user_env)
-        assert_in(self.organization['title'], response)
-        assert_in(self.organization['description'], response)
+    def test_read_redirect_when_given_id(self):
+        org = factories.Organization()
+        app = helpers._get_test_app()
+        response = app.get(url_for(controller='organization', action='read',
+                                   id=org['id']),
+                           status=302)
+        # redirect replaces the ID with the name in the URL
+        redirected_response = response.follow()
+        expected_url = url_for(controller='organization', action='read',
+                               id=org['name'])
+        assert_equal(redirected_response.request.path, expected_url)
+
+    def test_no_redirect_loop_when_name_is_the_same_as_the_id(self):
+        org = factories.Organization(id='abc', name='abc')
+        app = helpers._get_test_app()
+        app.get(url_for(controller='organization', action='read',
+                        id=org['id']),
+                status=200)  # ie no redirect
 
 
 class TestOrganizationEdit(helpers.FunctionalTestBase):
@@ -397,7 +409,7 @@ def test_organization_search_within_org(self):
                           owner_org=org['id'])
 
         org_url = url_for(controller='organization', action='read',
-                          id=org['id'])
+                          id=org['name'])
         org_response = app.get(org_url)
         org_response_html = BeautifulSoup(org_response.body)
 
@@ -426,7 +438,7 @@ def test_organization_search_within_org_results(self):
                           owner_org=org['id'])
 
         org_url = url_for(controller='organization', action='read',
-                          id=org['id'])
+                          id=org['name'])
         org_response = app.get(org_url)
         search_form = org_response.forms['organization-datasets-search-form']
         search_form['q'] = 'One'
@@ -459,7 +471,7 @@ def test_organization_search_within_org_no_results(self):
                           owner_org=org['id'])
 
         org_url = url_for(controller='organization', action='read',
-                          id=org['id'])
+                          id=org['name'])
         org_response = app.get(org_url)
         search_form = org_response.forms['organization-datasets-search-form']
         search_form['q'] = 'Nout'
diff --git a/ckan/tests/controllers/test_package.py b/ckan/tests/controllers/test_package.py
--- a/ckan/tests/controllers/test_package.py
+++ b/ckan/tests/controllers/test_package.py
@@ -9,12 +9,11 @@
     assert_in
 )
 
-from mock import patch, MagicMock
 from ckan.lib.helpers import url_for
 
 import ckan.model as model
 import ckan.plugins as p
-from ckan.lib import search
+from ckan.logic import get_action
 
 import ckan.tests.helpers as helpers
 import ckan.tests.factories as factories
@@ -1655,7 +1654,8 @@ def test_package_unfollow_not_following(self):
                                id=package['id'])
         unfollow_response = app.post(unfollow_url, extra_environ=env,
                                      status=302)
-        unfollow_response = unfollow_response.follow()
+        unfollow_response = unfollow_response.follow()  # /package/[id] 302s to
+        unfollow_response = unfollow_response.follow()  # /package/[name]
 
         assert_true('You are not following {0}'.format(package['id'])
                     in unfollow_response)
@@ -1703,6 +1703,22 @@ def test_dataset_read(self):
         dataset = factories.Dataset()
 
         url = url_for('dataset.read',
-                      id=dataset['id'])
+                      id=dataset['name'])
         response = app.get(url)
         assert_in(dataset['title'], response)
+
+    def test_redirect_when_given_id(self):
+        dataset = factories.Dataset()
+        app = helpers._get_test_app()
+        response = app.get(url_for('dataset.read', id=dataset['id']),
+                           status=302)
+        # redirect replaces the ID with the name in the URL
+        redirected_response = response.follow()
+        expected_url = url_for('dataset.read', id=dataset['name'])
+        assert_equal(redirected_response.request.path, expected_url)
+
+    def test_no_redirect_loop_when_name_is_the_same_as_the_id(self):
+        dataset = factories.Dataset(id='abc', name='abc')
+        app = helpers._get_test_app()
+        app.get(url_for('dataset.read', id=dataset['id']),
+                status=200)  # ie no redirect