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
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