Repository: encode/django-rest-framework

Issue:
Browsable API can't resolve URLs for HyperlinkedRelatedFields when using namespaced versioning
My POST requests to a ModelViewSet using a HyperlinkedModelSerializer don't work due to `HyperlinkedRelatedField.to_internal_value()` failing with 

``` python
ValidationError: [u'Invalid hyperlink - Incorrect URL match.']
```

This is using NamespaceVersioning, and the failure originates at [line 225](https://github.com/tomchristie/django-rest-framework/blob/version-3.1/rest_framework/relations.py#L225) of relations.py since `match.view_name = 'v1:snippet-detail'` but `self.view_name = 'snippet-detail'`.

I have a failing unit test for this but can't decide how to best solve it. When we reverse URLs, if a versioning scheme is in place then the scheme's reverse function handles it (in particular, `NamespaceVersioning.reverse()` adds on the version to the view name).

Is there a way to get at the request from the HyperlinkedRelatedField? If I can get pointed in the right direction then I'll make up a pull request for this.

Thanks!



Discussion:

Resolving patch:
diff --git a/rest_framework/relations.py b/rest_framework/relations.py
--- a/rest_framework/relations.py
+++ b/rest_framework/relations.py
@@ -1,7 +1,7 @@
 # coding: utf-8
 from __future__ import unicode_literals
 from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured
-from django.core.urlresolvers import resolve, get_script_prefix, NoReverseMatch, Resolver404
+from django.core.urlresolvers import get_script_prefix, NoReverseMatch, Resolver404
 from django.db.models.query import QuerySet
 from django.utils import six
 from django.utils.encoding import smart_text
@@ -9,7 +9,7 @@
 from django.utils.translation import ugettext_lazy as _
 from rest_framework.compat import OrderedDict
 from rest_framework.fields import get_attribute, empty, Field
-from rest_framework.reverse import reverse
+from rest_framework.reverse import reverse, resolve
 from rest_framework.utils import html
 
 
@@ -205,6 +205,7 @@ def get_url(self, obj, view_name, request, format):
         return self.reverse(view_name, kwargs=kwargs, request=request, format=format)
 
     def to_internal_value(self, data):
+        request = self.context.get('request', None)
         try:
             http_prefix = data.startswith(('http:', 'https:'))
         except AttributeError:
@@ -218,7 +219,7 @@ def to_internal_value(self, data):
                 data = '/' + data[len(prefix):]
 
         try:
-            match = self.resolve(data)
+            match = self.resolve(data, request=request)
         except Resolver404:
             self.fail('no_match')
 
diff --git a/rest_framework/reverse.py b/rest_framework/reverse.py
--- a/rest_framework/reverse.py
+++ b/rest_framework/reverse.py
@@ -1,12 +1,25 @@
 """
-Provide reverse functions that return fully qualified URLs
+Provide urlresolver functions that return fully qualified URLs or view names
 """
 from __future__ import unicode_literals
 from django.core.urlresolvers import reverse as django_reverse
+from django.core.urlresolvers import resolve as django_resolve
 from django.utils import six
 from django.utils.functional import lazy
 
 
+def resolve(path, urlconf=None, request=None):
+    """
+    If versioning is being used then we pass any `resolve` calls through
+    to the versioning scheme instance, so that the resulting view name
+    can be modified if needed.
+    """
+    scheme = getattr(request, 'versioning_scheme', None)
+    if scheme is not None:
+        return scheme.resolve(path, urlconf, request)
+    return django_resolve(path, urlconf)
+
+
 def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
     """
     If versioning is being used then we pass any `reverse` calls through
diff --git a/rest_framework/versioning.py b/rest_framework/versioning.py
--- a/rest_framework/versioning.py
+++ b/rest_framework/versioning.py
@@ -1,6 +1,8 @@
 # coding: utf-8
 from __future__ import unicode_literals
 from django.utils.translation import ugettext_lazy as _
+from django.core.urlresolvers import resolve as django_resolve
+from django.core.urlresolvers import ResolverMatch
 from rest_framework import exceptions
 from rest_framework.compat import unicode_http_header
 from rest_framework.reverse import _reverse
@@ -24,6 +26,9 @@ def determine_version(self, request, *args, **kwargs):
     def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, **extra):
         return _reverse(viewname, args, kwargs, request, format, **extra)
 
+    def resolve(self, path, urlconf=None):
+        return django_resolve(path, urlconf)
+
     def is_allowed_version(self, version):
         if not self.allowed_versions:
             return True
@@ -127,6 +132,17 @@ def reverse(self, viewname, args=None, kwargs=None, request=None, format=None, *
             viewname, args, kwargs, request, format, **extra
         )
 
+    def resolve(self, path, urlconf=None, request=None):
+        match = django_resolve(path, urlconf)
+        if match.namespace:
+            _, view_name = match.view_name.split(':')
+            return ResolverMatch(func=match.func,
+                                 args=match.args,
+                                 kwargs=match.kwargs,
+                                 url_name=view_name,
+                                 app_name=match.app_name)
+        return match
+
 
 class HostNameVersioning(BaseVersioning):
     """


Test patch:
diff --git a/tests/test_versioning.py b/tests/test_versioning.py
--- a/tests/test_versioning.py
+++ b/tests/test_versioning.py
@@ -1,9 +1,13 @@
+from .utils import MockObject, MockQueryset
 from django.conf.urls import include, url
+from django.core.exceptions import ObjectDoesNotExist
+from rest_framework import serializers
 from rest_framework import status, versioning
 from rest_framework.decorators import APIView
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
-from rest_framework.test import APIRequestFactory, APITestCase
+from rest_framework.test import APIRequestFactory, APITestCase, APISimpleTestCase
+from rest_framework.versioning import NamespaceVersioning
 
 
 class RequestVersionView(APIView):
@@ -29,15 +33,18 @@ def get(self, request, *args, **kwargs):
 factory = APIRequestFactory()
 
 mock_view = lambda request: None
+dummy_view = lambda request, pk: None
 
 included_patterns = [
     url(r'^namespaced/$', mock_view, name='another'),
+    url(r'^example/(?P<pk>\d+)/$', dummy_view, name='example-detail')
 ]
 
 urlpatterns = [
     url(r'^v1/', include(included_patterns, namespace='v1')),
     url(r'^another/$', mock_view, name='another'),
-    url(r'^(?P<version>[^/]+)/another/$', mock_view, name='another')
+    url(r'^(?P<version>[^/]+)/another/$', mock_view, name='another'),
+    url(r'^example/(?P<pk>\d+)/$', dummy_view, name='example-detail')
 ]
 
 
@@ -221,3 +228,33 @@ class FakeResolverMatch:
         request.resolver_match = FakeResolverMatch
         response = view(request, version='v3')
         assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+class TestHyperlinkedRelatedField(APISimpleTestCase):
+    urls = 'tests.test_versioning'
+
+    def setUp(self):
+
+        class HyperlinkedMockQueryset(MockQueryset):
+            def get(self, **lookup):
+                for item in self.items:
+                    if item.pk == int(lookup.get('pk', -1)):
+                        return item
+                raise ObjectDoesNotExist()
+
+        self.queryset = HyperlinkedMockQueryset([
+            MockObject(pk=1, name='foo'),
+            MockObject(pk=2, name='bar'),
+            MockObject(pk=3, name='baz')
+        ])
+        self.field = serializers.HyperlinkedRelatedField(
+            view_name='example-detail',
+            queryset=self.queryset
+        )
+        request = factory.post('/', urlconf='tests.test_versioning')
+        request.versioning_scheme = NamespaceVersioning()
+        self.field._context = {'request': request}
+
+    def test_bug_2489(self):
+        self.field.to_internal_value('/example/3/')
+        self.field.to_internal_value('/v1/example/3/')