diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 0a34abaa0..80c3b43db 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -427,6 +427,7 @@ class BrowsableAPIRenderer(BaseRenderer): content = self.get_content(renderer, data, accepted_media_type, renderer_context) put_form = self.get_form(view, 'PUT', request) + patch_form = self.get_form(view, 'PATCH', request) post_form = self.get_form(view, 'POST', request) delete_form = self.get_form(view, 'DELETE', request) options_form = self.get_form(view, 'OPTIONS', request) @@ -448,6 +449,7 @@ class BrowsableAPIRenderer(BaseRenderer): 'allowed_methods': view.allowed_methods, 'available_formats': [renderer.format for renderer in view.renderer_classes], 'put_form': put_form, + 'patch_form': patch_form, 'post_form': post_form, 'delete_form': delete_form, 'options_form': options_form, diff --git a/rest_framework/static/rest_framework/js/default.js b/rest_framework/static/rest_framework/js/default.js index ecaccc0f0..fc386d6b8 100644 --- a/rest_framework/static/rest_framework/js/default.js +++ b/rest_framework/static/rest_framework/js/default.js @@ -3,3 +3,11 @@ prettyPrint(); $('.js-tooltip').tooltip({ delay: 1000 }); + +$('#patch-form').find('.field-switcher').on('change', function() { + var $this = $(this); + $('#patch-form').find('#'+$this.attr('data-field-id')) + .prop('disabled', !$this.prop('checked')); +}); + +$('#form-method-switcher a:first').tab('show'); diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 092bf2e47..0c0b3351b 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -147,32 +147,64 @@ {% endif %} - {% if put_form %} + {% if put_form or patch_form %}
-
-
- - {% csrf_token %} - {{ put_form.non_field_errors }} - {% for field in put_form %} -
- {{ field.label_tag|add_class:"control-label" }} -
- {{ field }} - {{ field.help_text }} - + +
+ {% if put_form %} +
+ +
+ + {% csrf_token %} + {{ put_form.non_field_errors }} + {% for field in put_form %} +
+ {{ field.label_tag|add_class:"control-label" }} +
+ {{ field }} + {{ field.help_text }} + +
+
+ {% endfor %} +
+
-
- {% endfor %} -
- -
- -
-
+ + +
+ {% endif %} + {% if patch_form %} +
+
+
+ + {% csrf_token %} + {{ patch_form.non_field_errors }} + {% for field in patch_form %} +
+ {{ field.label_tag|add_class:"control-label" }} +
+ {{ field }}  + {{ field.help_text }} + +
+
+ {% endfor %} +
+ +
+
+
+
+ {% endif %} + {% endif %} - {% endif %} diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 1f17e8d28..be75b31fb 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -1,12 +1,13 @@ from django.contrib.auth.models import User from django.http import HttpResponse -from django.test import Client, TestCase +from django.test import TestCase from rest_framework import permissions from rest_framework.authtoken.models import Token from rest_framework.authentication import TokenAuthentication, BasicAuthentication, SessionAuthentication from rest_framework.compat import patterns from rest_framework.views import APIView +from rest_framework.tests.utils import Client import json import base64 @@ -18,6 +19,9 @@ class MockView(APIView): def post(self, request): return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + def patch(self, request): + return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + def put(self, request): return HttpResponse({'a': 1, 'b': 2, 'c': 3}) @@ -103,6 +107,14 @@ class SessionAuthTests(TestCase): response = self.non_csrf_client.put('/session/', {'example': 'example'}) self.assertEqual(response.status_code, 200) + def test_patch_form_session_auth_passing(self): + """ + Ensure PATCHting form over session authentication with logged in user and CSRF token passes. + """ + self.non_csrf_client.login(username=self.username, password=self.password) + response = self.non_csrf_client.patch('/', {'example': 'example'}) + self.assertEqual(response.status_code, 200) + def test_post_form_session_auth_failing(self): """ Ensure POSTing form over session authentication without logged in user fails. diff --git a/rest_framework/tests/renderers.py b/rest_framework/tests/renderers.py index c1b4e624b..02ef0a3ac 100644 --- a/rest_framework/tests/renderers.py +++ b/rest_framework/tests/renderers.py @@ -111,6 +111,9 @@ class POSTDeniedView(APIView): def put(self, request): return Response() + def patch(self, request): + return Response() + class DocumentingRendererTests(TestCase): def test_only_permitted_forms_are_displayed(self): @@ -119,6 +122,7 @@ class DocumentingRendererTests(TestCase): response = view(request).render() self.assertNotContains(response, '>POST<') self.assertContains(response, '>PUT<') + self.assertContains(response, '>PATCH<') class RendererEndToEndTests(TestCase): diff --git a/rest_framework/tests/utils.py b/rest_framework/tests/utils.py index 3906adb9a..0cff55234 100644 --- a/rest_framework/tests/utils.py +++ b/rest_framework/tests/utils.py @@ -1,9 +1,9 @@ -from django.test.client import RequestFactory, FakePayload +from django.test.client import Client as _Client, RequestFactory as _RequestFactory, FakePayload from django.test.client import MULTIPART_CONTENT from urlparse import urlparse -class RequestFactory(RequestFactory): +class RequestFactory(_RequestFactory): def __init__(self, **defaults): super(RequestFactory, self).__init__(**defaults) @@ -25,3 +25,15 @@ class RequestFactory(RequestFactory): } r.update(extra) return self.request(**r) + + +class Client(_Client, RequestFactory): + def patch(self, path, data={}, content_type=MULTIPART_CONTENT, + follow=False, **extra): + """ + Send a resource to the server using PATCH. + """ + response = super(Client, self).patch(path, data=data, content_type=content_type, **extra) + if follow: + response = self._handle_redirects(response, **extra) + return response