diff options
| -rw-r--r-- | rest_framework/fields.py | 91 | ||||
| -rw-r--r-- | rest_framework/tests/fields.py | 78 | ||||
| -rw-r--r-- | rest_framework/tests/generics.py | 42 | ||||
| -rw-r--r-- | rest_framework/tests/permissions.py | 45 | ||||
| -rw-r--r-- | rest_framework/views.py | 50 |
5 files changed, 293 insertions, 13 deletions
diff --git a/rest_framework/fields.py b/rest_framework/fields.py index b5f99823..d6db3ebe 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -23,13 +23,44 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.datastructures import SortedDict from rest_framework import ISO_8601 -from rest_framework.compat import timezone, parse_date, parse_datetime, parse_time +from rest_framework.compat import (timezone, parse_date, parse_datetime, + parse_time) from rest_framework.compat import BytesIO from rest_framework.compat import six from rest_framework.compat import smart_text, force_text, is_non_str_iterable from rest_framework.settings import api_settings +HUMANIZED_FIELD_TYPES = { + 'BooleanField': u'Boolean', + 'CharField': u'Single Character', + 'ChoiceField': u'Single Choice', + 'ComboField': u'Single Choice', + 'DateField': u'Date', + 'DateTimeField': u'Date and Time', + 'DecimalField': u'Decimal', + 'EmailField': u'Email', + 'Field': u'Field', + 'FileField': u'File', + 'FilePathField': u'File Path', + 'FloatField': u'Float', + 'GenericIPAddressField': u'Generic IP Address', + 'IPAddressField': u'IP Address', + 'ImageField': u'Image', + 'IntegerField': u'Integer', + 'MultiValueField': u'Multiple Value', + 'MultipleChoiceField': u'Multiple Choice', + 'NullBooleanField': u'Nullable Boolean', + 'RegexField': u'Regular Expression', + 'SlugField': u'Slug', + 'SplitDateTimeField': u'Split Date and Time', + 'TimeField': u'Time', + 'TypedChoiceField': u'Typed Single Choice', + 'TypedMultipleChoiceField': u'Typed Multiple Choice', + 'URLField': u'URL', +} + + def is_simple_callable(obj): """ True if the object is a callable that takes no arguments. @@ -61,7 +92,8 @@ def get_component(obj, attr_name): def readable_datetime_formats(formats): - format = ', '.join(formats).replace(ISO_8601, 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]') + format = ', '.join(formats).replace(ISO_8601, + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HHMM|-HHMM|Z]') return humanize_strptime(format) @@ -70,6 +102,61 @@ def readable_date_formats(formats): return humanize_strptime(format) +def humanize_field_type(field_type): + """Return a human-readable name for a field type. + + :param field_type: Either a field type class (for example + django.forms.fields.DateTimeField), or the name of a field type + (for example "DateTimeField"). + + :return: unicode + + """ + if isinstance(field_type, basestring): + field_type_name = field_type + else: + field_type_name = field_type.__name__ + try: + return HUMANIZED_FIELD_TYPES[field_type_name] + except KeyError: + humanized = re.sub('([a-z0-9])([A-Z])', r'\1 \2', field_type_name) + return humanized.capitalize() + + +def humanize_field(field): + """Return a human-readable description of a field. + + :param field: A Django field. + + :return: A dictionary of the form {type: type name, required: bool, + label: field label: read_only: bool, + help_text: optional help text} + + """ + humanized = { + 'type': humanize_field_type(field.__class__), + 'required': getattr(field, 'required', False), + 'label': field.label, + } + optional_attrs = ['read_only', 'help_text'] + for attr in optional_attrs: + if hasattr(field, attr): + humanized[attr] = getattr(field, attr) + return humanized + + +def humanize_form_fields(form): + """Return a humanized description of all the fields in a form. + + :param form: A Django form. + :return: A dictionary of {field_label: humanized description} + + """ + fields = SortedDict([(name, humanize_field(field)) + for name, field in form.fields.iteritems()]) + return fields + + def readable_time_formats(formats): format = ', '.join(formats).replace(ISO_8601, 'hh:mm[:ss[.uuuuuu]]') return humanize_strptime(format) diff --git a/rest_framework/tests/fields.py b/rest_framework/tests/fields.py index dad69975..7a5ed718 100644 --- a/rest_framework/tests/fields.py +++ b/rest_framework/tests/fields.py @@ -4,6 +4,9 @@ General serializer field tests. from __future__ import unicode_literals from django.utils.datastructures import SortedDict import datetime +from rest_framework.fields import (humanize_field, humanize_field_type, + humanize_form_fields) +from django import forms from decimal import Decimal from django.db import models from django.test import TestCase @@ -11,6 +14,9 @@ from django.core import validators from rest_framework import serializers from rest_framework.serializers import Serializer from rest_framework.tests.models import RESTFrameworkModel +from rest_framework.fields import Field +from collections import namedtuple +from uuid import uuid4 class TimestampedModel(models.Model): @@ -809,3 +815,75 @@ class URLFieldTests(TestCase): serializer = URLFieldSerializer(data={}) self.assertEqual(serializer.is_valid(), True) self.assertEqual(getattr(serializer.fields['url_field'], 'max_length'), 20) + + +class HumanizedFieldType(TestCase): + def test_standard_type_classes(self): + for field_type_name in forms.fields.__all__: + field_type = getattr(forms.fields, field_type_name) + humanized = humanize_field_type(field_type) + self.assert_valid_name(humanized) + + def test_standard_type_names(self): + for field_type_name in forms.fields.__all__: + humanized = humanize_field_type(field_type_name) + self.assert_valid_name(humanized) + + def test_custom_type_name(self): + humanized = humanize_field_type('SomeCustomType') + self.assertEquals(humanized, u'Some custom type') + + def test_custom_type(self): + custom_type = namedtuple('SomeCustomType', []) + humanized = humanize_field_type(custom_type) + self.assertEquals(humanized, u'Some custom type') + + def assert_valid_name(self, humanized): + """A humanized field name is valid if it's a non-empty + unicode. + + """ + self.assertIsInstance(humanized, unicode) + self.assertTrue(humanized) + + +class HumanizedField(TestCase): + def setUp(self): + self.required_field = Field() + self.required_field.label = uuid4().hex + self.required_field.required = True + + self.optional_field = Field() + self.optional_field.label = uuid4().hex + self.optional_field.required = False + + def test_required(self): + self.assertEqual(humanize_field(self.required_field)['required'], True) + + def test_optional(self): + self.assertEqual(humanize_field(self.optional_field)['required'], + False) + + def test_label(self): + for field in (self.required_field, self.optional_field): + self.assertEqual(humanize_field(field)['label'], field.label) + + +class Form(forms.Form): + field1 = forms.CharField(max_length=3, label='field one') + field2 = forms.CharField(label='field two') + + +class HumanizedSerializer(TestCase): + def setUp(self): + self.serializer = TimestampedModelSerializer() + + def test_humanized(self): + humanized = humanize_form_fields(Form()) + self.assertEqual(humanized, { + 'field1': { + u'help_text': u'', u'required': True, + u'type': u'Single Character', u'label': 'field one'}, + 'field2': { + u'help_text': u'', u'required': True, + u'type': u'Single Character', u'label': 'field two'}}) diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index 15d87e86..d8556638 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -121,8 +121,27 @@ class TestRootView(TestCase): 'text/html' ], 'name': 'Root', - 'description': 'Example description for OPTIONS.' + 'description': 'Example description for OPTIONS.', + 'actions': {} } + # TODO: this is just a draft for fields' metadata - needs review and decision + for method in ('GET', 'POST',): + expected['actions'][method] = { + 'text': { + 'description': '', + 'label': '', + 'readonly': False, + 'required': True, + 'type': 'CharField', + }, + 'id': { + 'description': '', + 'label': '', + 'readonly': True, + 'required': True, + 'type': 'IntegerField', + }, + } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, expected) @@ -238,8 +257,27 @@ class TestInstanceView(TestCase): 'text/html' ], 'name': 'Instance', - 'description': 'Example description for OPTIONS.' + 'description': 'Example description for OPTIONS.', + 'actions': {} } + # TODO: this is just a draft idea for fields' metadata - needs review and decision + for method in ('GET', 'PATCH', 'PUT', 'DELETE'): + expected['actions'][method] = { + 'text': { + 'description': '', + 'label': '', + 'readonly': False, + 'required': True, + 'type': 'CharField', + }, + 'id': { + 'description': '', + 'label': '', + 'readonly': True, + 'required': True, + 'type': 'IntegerField', + }, + } self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, expected) diff --git a/rest_framework/tests/permissions.py b/rest_framework/tests/permissions.py index b3993be5..5a18182b 100644 --- a/rest_framework/tests/permissions.py +++ b/rest_framework/tests/permissions.py @@ -108,6 +108,51 @@ class ModelPermissionsIntegrationTests(TestCase): response = instance_view(request, pk='2') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_options_permitted(self): + request = factory.options('/', content_type='application/json', + HTTP_AUTHORIZATION=self.permitted_credentials) + response = root_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['POST', 'GET',]) + + request = factory.options('/1', content_type='application/json', + HTTP_AUTHORIZATION=self.permitted_credentials) + response = instance_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['PUT', 'PATCH', 'DELETE', 'GET',]) + + def test_options_disallowed(self): + request = factory.options('/', content_type='application/json', + HTTP_AUTHORIZATION=self.disallowed_credentials) + response = root_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['GET',]) + + request = factory.options('/1', content_type='application/json', + HTTP_AUTHORIZATION=self.disallowed_credentials) + response = instance_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['GET',]) + + def test_options_updateonly(self): + request = factory.options('/', content_type='application/json', + HTTP_AUTHORIZATION=self.updateonly_credentials) + response = root_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['GET',]) + + request = factory.options('/1', content_type='application/json', + HTTP_AUTHORIZATION=self.updateonly_credentials) + response = instance_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('actions', response.data) + self.assertEquals(response.data['actions'].keys(), ['PUT', 'PATCH', 'GET',]) + class OwnerModel(models.Model): text = models.CharField(max_length=100) diff --git a/rest_framework/views.py b/rest_framework/views.py index 555fa2f4..11d50e5d 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from rest_framework import status, exceptions from rest_framework.compat import View from rest_framework.response import Response -from rest_framework.request import Request +from rest_framework.request import clone_request, Request from rest_framework.settings import api_settings from rest_framework.utils.formatting import get_view_name, get_view_description @@ -52,19 +52,51 @@ class APIView(View): } def metadata(self, request): - return { + content = { 'name': get_view_name(self.__class__), 'description': get_view_description(self.__class__), 'renders': [renderer.media_type for renderer in self.renderer_classes], 'parses': [parser.media_type for parser in self.parser_classes], } - # TODO: Add 'fields', from serializer info, if it exists. - # serializer = self.get_serializer() - # if serializer is not None: - # field_name_types = {} - # for name, field in form.fields.iteritems(): - # field_name_types[name] = field.__class__.__name__ - # content['fields'] = field_name_types + action_metadata = self._generate_action_metadata(request) + if action_metadata is not None: + content['actions'] = action_metadata + + return content + + def _generate_action_metadata(self, request): + ''' + Helper for generating the fields metadata for allowed and permitted methods. + ''' + actions = {} + + for method in self.allowed_methods: + # skip HEAD and OPTIONS + if method in ('HEAD', 'OPTIONS'): + continue + + cloned_request = clone_request(request, method) + try: + self.check_permissions(cloned_request) + + # TODO: find right placement - APIView does not have get_serializer + serializer = self.get_serializer() + if serializer is not None: + field_name_types = {} + for name, field in serializer.fields.iteritems(): + from rest_framework.fields import humanize_field + humanize_field(field) + field_name_types[name] = field.__class__.__name__ + + actions[method] = field_name_types + except exceptions.PermissionDenied: + # don't add this method + pass + except exceptions.NotAuthenticated: + # don't add this method + pass + + return actions if len(actions) > 0 else None def http_method_not_allowed(self, request, *args, **kwargs): """ |
