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):
"""
def test_invalid_user(self):
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):
# login and enroll as the test user
......@@ -120,7 +120,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin):
# now login and call the API as the test user
self.login()
self.api_response(expected_response_code=403, username=other.username)
self.api_response(expected_response_code=404, username=other.username)
@ddt.ddt
......
......@@ -10,6 +10,7 @@ from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2Au
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
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):
......@@ -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.
"""
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):
"""
Requires either OAuth2 or Session-based authentication.
......@@ -60,6 +54,6 @@ def mobile_view(is_user=False):
)
func_or_class.permission_classes = (permissions.IsAuthenticated,)
if is_user:
func_or_class.permission_classes += (IsUser,)
func_or_class.permission_classes += (IsUserInUrl,)
return func_or_class
return _decorator
......@@ -61,6 +61,8 @@ urlpatterns = ('', # nopep8
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'^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 rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
from django.http import Http404
class ApiKeyHeaderPermission(permissions.BasePermission):
......@@ -31,3 +32,26 @@ class IsAuthenticatedOrDebug(permissions.BasePermission):
user = getattr(request, 'user', None)
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