Commit 9d956e03 by Christina Roberts

Merge pull request #7053 from edx/christina/account-api

User account API
parents 8bdd90a5 ae0333cb
...@@ -105,7 +105,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin): ...@@ -105,7 +105,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin):
""" """
def test_invalid_user(self): def test_invalid_user(self):
self.login_and_enroll() self.login_and_enroll()
self.api_response(expected_response_code=403, username='no_user') self.api_response(expected_response_code=404, username='no_user')
def test_other_user(self): def test_other_user(self):
# login and enroll as the test user # login and enroll as the test user
...@@ -120,7 +120,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin): ...@@ -120,7 +120,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin):
# now login and call the API as the test user # now login and call the API as the test user
self.login() self.login()
self.api_response(expected_response_code=403, username=other.username) self.api_response(expected_response_code=404, username=other.username)
@ddt.ddt @ddt.ddt
......
...@@ -10,6 +10,7 @@ from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2Au ...@@ -10,6 +10,7 @@ from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2Au
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from openedx.core.lib.api.permissions import IsUserInUrl
def mobile_course_access(depth=0, verify_enrolled=True): def mobile_course_access(depth=0, verify_enrolled=True):
...@@ -42,13 +43,6 @@ def mobile_view(is_user=False): ...@@ -42,13 +43,6 @@ def mobile_view(is_user=False):
""" """
Function and class decorator that abstracts the authentication and permission checks for mobile api views. Function and class decorator that abstracts the authentication and permission checks for mobile api views.
""" """
class IsUser(permissions.BasePermission):
"""
Permission that checks to see if the request user matches the user in the URL.
"""
def has_permission(self, request, view):
return request.user.username == request.parser_context.get('kwargs', {}).get('username', None)
def _decorator(func_or_class): def _decorator(func_or_class):
""" """
Requires either OAuth2 or Session-based authentication. Requires either OAuth2 or Session-based authentication.
...@@ -60,6 +54,6 @@ def mobile_view(is_user=False): ...@@ -60,6 +54,6 @@ def mobile_view(is_user=False):
) )
func_or_class.permission_classes = (permissions.IsAuthenticated,) func_or_class.permission_classes = (permissions.IsAuthenticated,)
if is_user: if is_user:
func_or_class.permission_classes += (IsUser,) func_or_class.permission_classes += (IsUserInUrl,)
return func_or_class return func_or_class
return _decorator return _decorator
...@@ -61,6 +61,8 @@ urlpatterns = ('', # nopep8 ...@@ -61,6 +61,8 @@ urlpatterns = ('', # nopep8
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')), url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')),
url(r'^api/user/', include('openedx.core.djangoapps.user_api.accounts.urls')),
url(r'^notifier_api/', include('notifier_api.urls')), url(r'^notifier_api/', include('notifier_api.urls')),
url(r'^lang_pref/', include('lang_pref.urls')), url(r'^lang_pref/', include('lang_pref.urls')),
......
from rest_framework import serializers
from django.contrib.auth.models import User
from student.models import UserProfile
class AccountUserSerializer(serializers.HyperlinkedModelSerializer):
"""
Class that serializes the portion of User model needed for account information.
"""
class Meta:
model = User
fields = ("username", "email", "date_joined")
read_only_fields = ("username", "email", "date_joined")
class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer):
"""
Class that serializes the portion of UserProfile model needed for account information.
"""
class Meta:
model = UserProfile
fields = (
"name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", "mailing_address"
)
read_only_fields = ("name",)
def transform_gender(self, obj, value):
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
def transform_country(self, obj, value):
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
def transform_level_of_education(self, obj, value):
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
@staticmethod
def convert_empty_to_None(value):
""" Helper method to convert empty string to None (other values pass through). """
return None if value == "" else value
import unittest
import ddt
import json
from datetime import datetime
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.conf import settings
from rest_framework.test import APITestCase, APIClient
from student.tests.factories import UserFactory
from student.models import UserProfile
TEST_PASSWORD = "test"
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestAccountAPI(APITestCase):
def setUp(self):
super(TestAccountAPI, self).setUp()
self.anonymous_client = APIClient()
self.different_user = UserFactory.create(password=TEST_PASSWORD)
self.different_client = APIClient()
self.staff_user = UserFactory(is_staff=True, password=TEST_PASSWORD)
self.staff_client = APIClient()
self.user = UserFactory.create(password=TEST_PASSWORD)
self.url = reverse("accounts_api", kwargs={'username': self.user.username})
def test_get_account_anonymous_user(self):
"""
Test that an anonymous client (not logged in) cannot call get.
"""
self.send_get(self.anonymous_client, expected_status=401)
def test_get_account_different_user(self):
"""
Test that a client (logged in) cannot get the account information for a different client.
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.send_get(self.different_client, expected_status=404)
def test_get_account_default(self):
"""
Test that a client (logged in) can get her own account information (using default legacy profile information,
as created by the test UserFactory).
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)
response = self.send_get(self.client)
data = response.data
self.assertEqual(11, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
for empty_field in ("year_of_birth", "level_of_education", "mailing_address"):
self.assertIsNone(data[empty_field])
self.assertIsNone(data["country"])
# TODO: what should the format of this be?
self.assertEqual("", data["language"])
self.assertEqual("m", data["gender"])
self.assertEqual("World domination", data["goals"])
self.assertEqual(self.user.email, data["email"])
self.assertIsNotNone(data["date_joined"])
@ddt.data(
("client", "user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_account(self, api_client, user):
"""
Test that a client (logged in) can get her own account information. Also verifies that a "is_staff"
user can get the account information for other users.
"""
# Create some test profile values.
legacy_profile = UserProfile.objects.get(id=self.user.id)
legacy_profile.country = "US"
legacy_profile.level_of_education = "m"
legacy_profile.year_of_birth = 1900
legacy_profile.goals = "world peace"
legacy_profile.mailing_address = "Park Ave"
legacy_profile.save()
client = self.login_client(api_client, user)
response = self.send_get(client)
data = response.data
self.assertEqual(11, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual("US", data["country"])
self.assertEqual("", data["language"])
self.assertEqual("m", data["gender"])
self.assertEqual(1900, data["year_of_birth"])
self.assertEqual("m", data["level_of_education"])
self.assertEqual("world peace", data["goals"])
self.assertEqual("Park Ave", data['mailing_address'])
self.assertEqual(self.user.email, data["email"])
self.assertIsNotNone(data["date_joined"])
def test_get_account_empty_string(self):
"""
Test the conversion of empty strings to None for certain fields.
"""
legacy_profile = UserProfile.objects.get(id=self.user.id)
legacy_profile.country = ""
legacy_profile.level_of_education = ""
legacy_profile.gender = ""
legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country"):
self.assertIsNone(response.data[empty_field])
@ddt.data(
(
"client", "user", "gender", "f", "not a gender",
"Select a valid choice. not a gender is not one of the available choices."
),
(
"client", "user", "level_of_education", "none", "x",
"Select a valid choice. x is not one of the available choices."
),
("client", "user", "country", "GB", "XY", "Select a valid choice. XY is not one of the available choices."),
("client", "user", "year_of_birth", 2009, "not_an_int", "Enter a whole number."),
("client", "user", "language", "Creole"),
("client", "user", "goals", "Smell the roses"),
("client", "user", "mailing_address", "Sesame Street"),
# All of the fields can be edited by is_staff, but iterating through all of them again seems like overkill.
# Just test a representative field.
("staff_client", "staff_user", "goals", "Smell the roses"),
)
@ddt.unpack
def test_patch_account(
self, api_client, user, field, value, fails_validation_value=None, developer_validation_message=None
):
"""
Test the behavior of patch, when using the correct content_type.
"""
client = self.login_client(api_client, user)
self.send_patch(client, {field: value})
get_response = self.send_get(client)
self.assertEqual(value, get_response.data[field])
if fails_validation_value:
error_response = self.send_patch(client, {field: fails_validation_value}, expected_status=400)
self.assertEqual(
"Value '{0}' is not valid for field '{1}'.".format(fails_validation_value, field),
error_response.data["field_errors"][field]["user_message"]
)
self.assertEqual(
developer_validation_message,
error_response.data["field_errors"][field]["developer_message"]
)
else:
# If there are no values that would fail validation, then empty string should be supported.
self.send_patch(client, {field: ""})
get_response = self.send_get(client)
self.assertEqual("", get_response.data[field])
@ddt.data(
("client", "user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_patch_account_noneditable(self, api_client, user):
"""
Tests the behavior of patch when a read-only field is attempted to be edited.
"""
client = self.login_client(api_client, user)
def verify_error_response(field_name, data):
self.assertEqual(
"This field is not editable via this API", data["field_errors"][field_name]["developer_message"]
)
self.assertEqual(
"Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"]
)
for field_name in ["username", "email", "date_joined", "name"]:
response = self.send_patch(client, {field_name: "will_error", "gender": "f"}, expected_status=400)
verify_error_response(field_name, response.data)
# Make sure that gender did not change.
response = self.send_get(client)
self.assertEqual("m", response.data["gender"])
# Test error message with multiple read-only items
response = self.send_patch(client, {"username": "will_error", "email": "xx"}, expected_status=400)
self.assertEqual(2, len(response.data["field_errors"]))
verify_error_response("username", response.data)
verify_error_response("email", response.data)
def test_patch_bad_content_type(self):
"""
Test the behavior of patch when an incorrect content_type is specified.
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.send_patch(self.client, {}, content_type="application/json", expected_status=415)
self.send_patch(self.client, {}, content_type="application/xml", expected_status=415)
def test_patch_account_empty_string(self):
"""
Tests the behavior of patch when attempting to set fields with a select list of options to the empty string.
Also verifies the behaviour when setting to None.
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)
for field_name in ["gender", "level_of_education", "country"]:
self.send_patch(self.client, {field_name: ""})
response = self.send_get(self.client)
# Although throwing a 400 might be reasonable, the default DRF behavior with ModelSerializer
# is to convert to None, which also seems acceptable (and is difficult to override).
self.assertIsNone(response.data[field_name])
# Verify that the behavior is the same for sending None.
self.send_patch(self.client, {field_name: ""})
response = self.send_get(self.client)
self.assertIsNone(response.data[field_name])
def login_client(self, api_client, user):
"""Helper method for getting the client and user and logging in. Returns client. """
client = getattr(self, api_client)
user = getattr(self, user)
client.login(username=user.username, password=TEST_PASSWORD)
return client
def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=204):
"""
Helper method for sending a patch to the server, defaulting to application/merge-patch+json content_type.
Verifies the expected status and returns the response.
"""
response = client.patch(self.url, data=json.dumps(json_data), content_type=content_type)
self.assertEqual(expected_status, response.status_code)
return response
def send_get(self, client, expected_status=200):
"""
Helper method for sending a GET to the server. Verifies the expected status and returns the response.
"""
response = client.get(self.url)
self.assertEqual(expected_status, response.status_code)
return response
from .views import AccountView
from django.conf.urls import include, patterns, url
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(
r'^v0/accounts/' + USERNAME_PATTERN + '$',
AccountView.as_view(),
name="accounts_api"
)
)
"""
NOTE: this API is WIP and has not yet been approved. Do not use this API without talking to Christina or Andy.
For more information, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework import permissions
from rest_framework import parsers
from student.models import UserProfile
from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer, AccountUserSerializer
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
from openedx.core.lib.api.parsers import MergePatchParser
class AccountView(APIView):
"""
**Use Cases**
Get or update the user's account information. Updates are only supported through merge patch.
**Example Requests**:
GET /api/user/v0/accounts/{username}/
PATCH /api/user/v0/accounts/{username}/ with content_type "application/merge-patch+json"
**Response Values for GET**
* username: username associated with the account (not editable)
* name: full name of the user (not editable through this API)
* email: email for the user (not editable through this API)
* date_joined: date this account was created (not editable), in the string format provided by
datetime (for example, "2014-08-26T17:52:11Z")
* gender: null (not set), "m", "f", or "o"
* year_of_birth: null or integer year
* level_of_education: null (not set), or one of the following choices:
* "p" signifying "Doctorate"
* "m" signifying "Master's or professional degree"
* "b" signifying "Bachelor's degree"
* "a" signifying "Associate's degree"
* "hs" signifying "Secondary/high school"
* "jhs" signifying "Junior secondary/junior high/middle school"
* "el" signifying "Elementary/primary school"
* "none" signifying "None"
* "o" signifying "Other"
* language: null or name of preferred language
* country: null (not set), or a Country corresponding to one of the ISO 3166-1 countries
* mailing_address: null or textual representation of mailing address
* goals: null or textual representation of goals
**Response for PATCH**
Returns a 204 status if successful, with no additional content.
If "application/merge-patch+json" is not the specified content_type, returns a 415 status.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
parser_classes = (MergePatchParser,)
def get(self, request, username):
"""
GET /api/user/v0/accounts/{username}/
"""
existing_user, existing_user_profile = self._get_user_and_profile(username)
user_serializer = AccountUserSerializer(existing_user)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile)
return Response(dict(user_serializer.data, **legacy_profile_serializer.data))
def patch(self, request, username):
"""
PATCH /api/user/v0/accounts/{username}/
Note that this implementation is the "merge patch" implementation proposed in
https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or
else an error response with status code 415 will be returned.
"""
existing_user, existing_user_profile = self._get_user_and_profile(username)
# Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400.
update = request.DATA
read_only_fields = set(update.keys()).intersection(
AccountUserSerializer.Meta.read_only_fields + AccountLegacyProfileSerializer.Meta.read_only_fields
)
if read_only_fields:
field_errors = {}
for read_only_field in read_only_fields:
field_errors[read_only_field] = {
"developer_message": "This field is not editable via this API",
"user_message": _("Field '{field_name}' cannot be edited.".format(field_name=read_only_field))
}
response_data = {"field_errors": field_errors}
return Response(response_data, status=status.HTTP_400_BAD_REQUEST)
user_serializer = AccountUserSerializer(existing_user, data=update)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile, data=update)
for serializer in user_serializer, legacy_profile_serializer:
validation_errors = self._get_validation_errors(update, serializer)
if validation_errors:
return Response(validation_errors, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def _get_user_and_profile(self, username):
"""
Helper method to return the legacy user and profile objects based on username.
"""
try:
existing_user = User.objects.get(username=username)
except ObjectDoesNotExist:
return Response({}, status=status.HTTP_404_NOT_FOUND)
existing_user_profile = UserProfile.objects.get(user=existing_user)
return existing_user, existing_user_profile
def _get_validation_errors(self, update, serializer):
"""
Helper method that returns any validation errors that are present.
"""
validation_errors = {}
if not serializer.is_valid():
field_errors = {}
errors = serializer.errors
for key, value in errors.iteritems():
if isinstance(value, list) and len(value) > 0:
developer_message = value[0]
else:
developer_message = "Invalid value: {field_value}'".format(field_value=update[key])
field_errors[key] = {
"developer_message": developer_message,
"user_message": _("Value '{field_value}' is not valid for field '{field_name}'.".format(
field_value=update[key], field_name=key)
)
}
validation_errors['field_errors'] = field_errors
return validation_errors
\ No newline at end of file
from rest_framework import parsers
class MergePatchParser(parsers.JSONParser):
"""
Custom parser to be used with the "merge patch" implementation (https://tools.ietf.org/html/rfc7396).
"""
media_type = 'application/merge-patch+json'
from django.conf import settings from django.conf import settings
from rest_framework import permissions from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from django.http import Http404
class ApiKeyHeaderPermission(permissions.BasePermission): class ApiKeyHeaderPermission(permissions.BasePermission):
...@@ -31,3 +32,26 @@ class IsAuthenticatedOrDebug(permissions.BasePermission): ...@@ -31,3 +32,26 @@ class IsAuthenticatedOrDebug(permissions.BasePermission):
user = getattr(request, 'user', None) user = getattr(request, 'user', None)
return user and user.is_authenticated() return user and user.is_authenticated()
class IsUserInUrl(permissions.BasePermission):
"""
Permission that checks to see if the request user matches the user in the URL.
"""
def has_permission(self, request, view):
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an account.
if request.user.username != request.parser_context.get('kwargs', {}).get('username', None):
raise Http404()
return True
class IsUserInUrlOrStaff(IsUserInUrl):
"""
Permission that checks to see if the request user matches the user in the URL or has is_staff access.
"""
def has_permission(self, request, view):
if request.user.is_staff:
return True
return super(IsUserInUrlOrStaff, self).has_permission(request, view)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment