Commit c8a20df2 by cahrens

Combine account and profile into same API.

parent 450d9e37
...@@ -130,7 +130,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -130,7 +130,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
# Verify that the profile API has been called as expected # Verify that the profile API has been called as expected
if email_opt_in is not None: if email_opt_in is not None:
opt_in = email_opt_in == 'true' opt_in = email_opt_in == 'true'
mock_update_email_opt_in.assert_called_once_with(self.USERNAME, self.course.org, opt_in) mock_update_email_opt_in.assert_called_once_with(self.user, self.course.org, opt_in)
else: else:
self.assertFalse(mock_update_email_opt_in.called) self.assertFalse(mock_update_email_opt_in.called)
......
...@@ -797,7 +797,7 @@ def try_change_enrollment(request): ...@@ -797,7 +797,7 @@ def try_change_enrollment(request):
log.exception(u"Exception automatically enrolling after login: %s", exc) log.exception(u"Exception automatically enrolling after login: %s", exc)
def _update_email_opt_in(request, username, org): def _update_email_opt_in(request, org):
"""Helper function used to hit the profile API if email opt-in is enabled.""" """Helper function used to hit the profile API if email opt-in is enabled."""
# TODO: remove circular dependency on openedx from common # TODO: remove circular dependency on openedx from common
...@@ -806,7 +806,7 @@ def _update_email_opt_in(request, username, org): ...@@ -806,7 +806,7 @@ def _update_email_opt_in(request, username, org):
email_opt_in = request.POST.get('email_opt_in') email_opt_in = request.POST.get('email_opt_in')
if email_opt_in is not None: if email_opt_in is not None:
email_opt_in_boolean = email_opt_in == 'true' email_opt_in_boolean = email_opt_in == 'true'
profile_api.update_email_opt_in(username, org, email_opt_in_boolean) profile_api.update_email_opt_in(request.user, org, email_opt_in_boolean)
@require_POST @require_POST
...@@ -878,7 +878,7 @@ def change_enrollment(request, check_access=True): ...@@ -878,7 +878,7 @@ def change_enrollment(request, check_access=True):
# Record the user's email opt-in preference # Record the user's email opt-in preference
if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'): if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'):
_update_email_opt_in(request, user.username, course_id.org) _update_email_opt_in(request, course_id.org)
available_modes = CourseMode.modes_for_course_dict(course_id) available_modes = CourseMode.modes_for_course_dict(course_id)
......
...@@ -672,7 +672,7 @@ def change_enrollment(strategy, user=None, is_dashboard=False, *args, **kwargs): ...@@ -672,7 +672,7 @@ def change_enrollment(strategy, user=None, is_dashboard=False, *args, **kwargs):
# TODO: remove circular dependency on openedx from common # TODO: remove circular dependency on openedx from common
from openedx.core.djangoapps.user_api.api import profile from openedx.core.djangoapps.user_api.api import profile
opt_in = email_opt_in.lower() == 'true' opt_in = email_opt_in.lower() == 'true'
profile.update_email_opt_in(user.username, course_id.org, opt_in) profile.update_email_opt_in(user, course_id.org, opt_in)
# Check whether we're blocked from enrolling by a # Check whether we're blocked from enrolling by a
# country access rule. # country access rule.
......
...@@ -1149,7 +1149,7 @@ class TestSubmitPhotosForVerification(TestCase): ...@@ -1149,7 +1149,7 @@ class TestSubmitPhotosForVerification(TestCase):
AssertionError AssertionError
""" """
account_settings = AccountView.get_serialized_account(self.user.username) account_settings = AccountView.get_serialized_account(self.user)
self.assertEqual(account_settings['name'], full_name) self.assertEqual(account_settings['name'], full_name)
......
...@@ -714,13 +714,11 @@ def submit_photos_for_verification(request): ...@@ -714,13 +714,11 @@ def submit_photos_for_verification(request):
if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user): if SoftwareSecurePhotoVerification.user_has_valid_or_pending(request.user):
return HttpResponseBadRequest(_("You already have a valid or pending verification.")) return HttpResponseBadRequest(_("You already have a valid or pending verification."))
username = request.user.username
# If the user wants to change his/her full name, # If the user wants to change his/her full name,
# then try to do that before creating the attempt. # then try to do that before creating the attempt.
if request.POST.get('full_name'): if request.POST.get('full_name'):
try: try:
AccountView.update_account(username, {"name": request.POST.get('full_name')}) AccountView.update_account(request.user, {"name": request.POST.get('full_name')})
except AccountUserNotFound: except AccountUserNotFound:
return HttpResponseBadRequest(_("No profile found for user")) return HttpResponseBadRequest(_("No profile found for user"))
except AccountUpdateError: except AccountUpdateError:
...@@ -743,7 +741,7 @@ def submit_photos_for_verification(request): ...@@ -743,7 +741,7 @@ def submit_photos_for_verification(request):
attempt.mark_ready() attempt.mark_ready()
attempt.submit() attempt.submit()
account_settings = AccountView.get_serialized_account(username) account_settings = AccountView.get_serialized_account(request.user)
# Send a confirmation email to the user # Send a confirmation email to the user
context = { context = {
......
...@@ -2047,14 +2047,14 @@ SEARCH_ENGINE = None ...@@ -2047,14 +2047,14 @@ SEARCH_ENGINE = None
# Use the LMS specific result processor # Use the LMS specific result processor
SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor" SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor"
# The configuration for learner profiles # The configuration visibility of account fields.
PROFILE_CONFIGURATION = { ACCOUNT_VISIBILITY_CONFIGURATION = {
# Default visibility level for accounts without a specified value # Default visibility level for accounts without a specified value
# The value is one of: 'all_users', 'private' # The value is one of: 'all_users', 'private'
"default_visibility": "private", "default_visibility": "private",
# The list of all fields that can be shown on a learner's profile # The list of all fields that can be shared with other users
"all_fields": [ "shareable_fields": [
'username', 'username',
'profile_image', 'profile_image',
'country', 'country',
...@@ -2063,7 +2063,7 @@ PROFILE_CONFIGURATION = { ...@@ -2063,7 +2063,7 @@ PROFILE_CONFIGURATION = {
'bio', 'bio',
], ],
# The list of fields that are always public on a learner's profile # The list of account fields that are always public
"public_fields": [ "public_fields": [
'username', 'username',
'profile_image', 'profile_image',
......
"""
Account constants
"""
# The minimum acceptable length for the name account field # The minimum acceptable length for the name account field
NAME_MIN_LENGTH = 2 NAME_MIN_LENGTH = 2
ACCOUNT_VISIBILITY_PREF_KEY = 'account_privacy'
# Indicates the user's preference that all users can view the shareable fields in their account information.
ALL_USERS_VISIBILITY = 'all_users'
# Indicates the user's preference that all their account information be private.
PRIVATE_VISIBILITY = 'private'
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import unittest import unittest
import ddt import ddt
import json import json
from mock import patch
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -9,6 +10,9 @@ from rest_framework.test import APITestCase, APIClient ...@@ -9,6 +10,9 @@ from rest_framework.test import APITestCase, APIClient
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import UserProfile, PendingEmailChange from student.models import UserProfile, PendingEmailChange
from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY
from openedx.core.djangoapps.user_api.models import UserPreference
from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY
TEST_PASSWORD = "test" TEST_PASSWORD = "test"
...@@ -73,24 +77,66 @@ class TestAccountAPI(UserAPITestCase): ...@@ -73,24 +77,66 @@ class TestAccountAPI(UserAPITestCase):
""" """
Unit tests for the Account API. Unit tests for the Account API.
""" """
def setUp(self): def setUp(self):
super(TestAccountAPI, self).setUp() super(TestAccountAPI, self).setUp()
self.url = reverse("accounts_api", kwargs={'username': self.user.username}) self.url = reverse("accounts_api", kwargs={'username': self.user.username})
def test_get_account_anonymous_user(self): def _verify_full_shareable_account_response(self, response):
"""
Verify that the shareable fields from the account are returned
"""
data = response.data
self.assertEqual(6, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"])
self.assertIsNone(data["profile_image"])
self.assertIsNone(data["time_zone"])
self.assertIsNone(data["languages"])
self.assertIsNone(data["bio"])
def _verify_private_account_response(self, response):
"""
Verify that only the public fields are returned if a user does not want to share account fields
"""
data = response.data
self.assertEqual(2, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertIsNone(data["profile_image"])
def _verify_full_account_response(self, response):
"""
Verify that all account fields are returned (even those that are not shareable).
"""
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_anonymous_access(self):
""" """
Test that an anonymous client (not logged in) cannot call get. Test that an anonymous client (not logged in) cannot call GET or PATCH.
""" """
self.send_get(self.anonymous_client, expected_status=401) self.send_get(self.anonymous_client, expected_status=401)
self.send_patch(self.anonymous_client, {}, expected_status=401)
def test_get_account_different_user(self): def test_unsupported_methods(self):
""" """
Test that a client (logged in) cannot get the account information for a different client. Test that DELETE, POST, and PUT are not supported.
""" """
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.send_get(self.different_client, expected_status=404) self.assertEqual(405, self.client.put(self.url).status_code)
self.assertEqual(405, self.client.post(self.url).status_code)
self.assertEqual(405, self.client.delete(self.url).status_code)
@ddt.data( @ddt.data(
("client", "user"), ("client", "user"),
...@@ -105,6 +151,69 @@ class TestAccountAPI(UserAPITestCase): ...@@ -105,6 +151,69 @@ class TestAccountAPI(UserAPITestCase):
response = client.get(reverse("accounts_api", kwargs={'username': "does_not_exist"})) response = client.get(reverse("accounts_api", kwargs={'username': "does_not_exist"}))
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
# Note: using getattr so that the patching works even if there is no configuration.
# This is needed when testing CMS as the patching is still executed even though the
# suite is skipped.
@patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "all_users"})
def test_get_account_different_user_visible(self):
"""
Test that a client (logged in) can only get the shareable fields for a different user.
This is the case when default_visibility is set to "all_users".
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response)
# Note: using getattr so that the patching works even if there is no configuration.
# This is needed when testing CMS as the patching is still executed even though the
# suite is skipped.
@patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "private"})
def test_get_account_different_user_private(self):
"""
Test that a client (logged in) can only get the shareable fields for a different user.
This is the case when default_visibility is set to "private".
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
response = self.send_get(self.different_client)
self._verify_private_account_response(response)
@ddt.data(
("client", "user", PRIVATE_VISIBILITY),
("different_client", "different_user", PRIVATE_VISIBILITY),
("staff_client", "staff_user", PRIVATE_VISIBILITY),
("client", "user", ALL_USERS_VISIBILITY),
("different_client", "different_user", ALL_USERS_VISIBILITY),
("staff_client", "staff_user", ALL_USERS_VISIBILITY),
)
@ddt.unpack
def test_get_account_private_visibility(self, api_client, requesting_username, preference_visibility):
"""
Test the return from GET based on user visibility setting.
"""
def verify_fields_visible_to_all_users(response):
if preference_visibility == PRIVATE_VISIBILITY:
self._verify_private_account_response(response)
else:
self._verify_full_shareable_account_response(response)
client = self.login_client(api_client, requesting_username)
# Update user account visibility setting.
UserPreference.set_preference(self.user, ACCOUNT_VISIBILITY_PREF_KEY, preference_visibility)
self.create_mock_profile(self.user)
response = self.send_get(client)
if requesting_username == "different_user":
verify_fields_visible_to_all_users(response)
else:
self._verify_full_account_response(response)
# Verify how the view parameter changes the fields that are returned.
response = self.send_get(client, query_parameters='view=shared')
verify_fields_visible_to_all_users(response)
def test_get_account_default(self): def test_get_account_default(self):
""" """
Test that a client (logged in) can get her own account information (using default legacy profile information, Test that a client (logged in) can get her own account information (using default legacy profile information,
...@@ -126,33 +235,6 @@ class TestAccountAPI(UserAPITestCase): ...@@ -126,33 +235,6 @@ class TestAccountAPI(UserAPITestCase):
self.assertEqual(self.user.email, data["email"]) self.assertEqual(self.user.email, data["email"])
self.assertIsNotNone(data["date_joined"]) 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.
"""
self.create_mock_profile(self.user)
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): def test_get_account_empty_string(self):
""" """
Test the conversion of empty strings to None for certain fields. Test the conversion of empty strings to None for certain fields.
...@@ -168,25 +250,18 @@ class TestAccountAPI(UserAPITestCase): ...@@ -168,25 +250,18 @@ class TestAccountAPI(UserAPITestCase):
for empty_field in ("level_of_education", "gender", "country"): for empty_field in ("level_of_education", "gender", "country"):
self.assertIsNone(response.data[empty_field]) self.assertIsNone(response.data[empty_field])
def test_patch_account_anonymous_user(self): @ddt.data(
""" ("different_client", "different_user"),
Test that an anonymous client (not logged in) cannot call patch. ("staff_client", "staff_user"),
""" )
self.send_patch(self.anonymous_client, {}, expected_status=401) @ddt.unpack
def test_patch_account_disallowed_user(self, api_client, user):
def test_patch_account_different_user(self):
"""
Test that a client (logged in) cannot update the account information for a different client.
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.send_patch(self.different_client, {}, expected_status=404)
def test_patch_account_is_staff(self):
""" """
Test that a client (logged in) with is_staff privileges cannot account settings for other users. Test that a client cannot call PATCH on a different client's user account (even with
is_staff access).
""" """
self.staff_client.login(username=self.staff_user.username, password=TEST_PASSWORD) client = self.login_client(api_client, user)
self.send_patch(self.staff_client, {}, expected_status=404) self.send_patch(client, {}, expected_status=404)
@ddt.data( @ddt.data(
("client", "user"), ("client", "user"),
...@@ -217,9 +292,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -217,9 +292,7 @@ class TestAccountAPI(UserAPITestCase):
# Note that email is tested below, as it is not immediately updated. # Note that email is tested below, as it is not immediately updated.
) )
@ddt.unpack @ddt.unpack
def test_patch_account( def test_patch_account(self, field, value, fails_validation_value=None, developer_validation_message=None):
self, field, value, fails_validation_value=None, developer_validation_message=None
):
""" """
Test the behavior of patch, when using the correct content_type. Test the behavior of patch, when using the correct content_type.
""" """
...@@ -311,10 +384,10 @@ class TestAccountAPI(UserAPITestCase): ...@@ -311,10 +384,10 @@ class TestAccountAPI(UserAPITestCase):
self.assertEqual(expected_entries, len(name_change_info)) self.assertEqual(expected_entries, len(name_change_info))
return name_change_info return name_change_info
def verify_change_info(change_info, old_name, new_name): def verify_change_info(change_info, old_name, requester, new_name):
self.assertEqual(3, len(change_info)) self.assertEqual(3, len(change_info))
self.assertEqual(old_name, change_info[0]) self.assertEqual(old_name, change_info[0])
self.assertEqual("Name change requested through account API", change_info[1]) self.assertEqual("Name change requested through account API by {}".format(requester), change_info[1])
self.assertIsNotNone(change_info[2]) self.assertIsNotNone(change_info[2])
# Verify the new name was also stored. # Verify the new name was also stored.
get_response = self.send_get(self.client) get_response = self.send_get(self.client)
...@@ -328,13 +401,13 @@ class TestAccountAPI(UserAPITestCase): ...@@ -328,13 +401,13 @@ class TestAccountAPI(UserAPITestCase):
# First change the name as the user and verify meta information. # First change the name as the user and verify meta information.
self.send_patch(self.client, {"name": "Mickey Mouse"}) self.send_patch(self.client, {"name": "Mickey Mouse"})
name_change_info = get_name_change_info(1) name_change_info = get_name_change_info(1)
verify_change_info(name_change_info[0], old_name, "Mickey Mouse") verify_change_info(name_change_info[0], old_name, self.user.username, "Mickey Mouse")
# Now change the name again and verify meta information. # Now change the name again and verify meta information.
self.send_patch(self.client, {"name": "Donald Duck"}) self.send_patch(self.client, {"name": "Donald Duck"})
name_change_info = get_name_change_info(2) name_change_info = get_name_change_info(2)
verify_change_info(name_change_info[0], old_name, "Donald Duck",) verify_change_info(name_change_info[0], old_name, self.user.username, "Donald Duck",)
verify_change_info(name_change_info[1], "Mickey Mouse", "Donald Duck") verify_change_info(name_change_info[1], "Mickey Mouse", self.user.username, "Donald Duck")
def test_patch_email(self): def test_patch_email(self):
""" """
......
...@@ -7,6 +7,7 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API ...@@ -7,6 +7,7 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.conf import settings
import datetime import datetime
from pytz import UTC from pytz import UTC
...@@ -17,11 +18,14 @@ from rest_framework.authentication import OAuth2Authentication, SessionAuthentic ...@@ -17,11 +18,14 @@ from rest_framework.authentication import OAuth2Authentication, SessionAuthentic
from rest_framework import permissions from rest_framework import permissions
from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer, AccountUserSerializer from openedx.core.djangoapps.user_api.accounts.serializers import AccountLegacyProfileSerializer, AccountUserSerializer
from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError from openedx.core.djangoapps.user_api.api.account import AccountUserNotFound, AccountUpdateError, AccountNotAuthorized
from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
from student.models import UserProfile from student.models import UserProfile
from student.views import do_email_change_request from student.views import do_email_change_request
from ..models import UserPreference
from . import ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY
class AccountView(APIView): class AccountView(APIView):
...@@ -79,7 +83,7 @@ class AccountView(APIView): ...@@ -79,7 +83,7 @@ class AccountView(APIView):
""" """
authentication_classes = (OAuth2Authentication, SessionAuthentication) authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff) permission_classes = (permissions.IsAuthenticated,)
parser_classes = (MergePatchParser,) parser_classes = (MergePatchParser,)
def get(self, request, username): def get(self, request, username):
...@@ -87,34 +91,75 @@ class AccountView(APIView): ...@@ -87,34 +91,75 @@ class AccountView(APIView):
GET /api/user/v0/accounts/{username}/ GET /api/user/v0/accounts/{username}/
""" """
try: try:
account_settings = AccountView.get_serialized_account(username) account_settings = AccountView.get_serialized_account(
request.user, username,
view=request.QUERY_PARAMS.get('view')
)
except AccountUserNotFound: except AccountUserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
return Response(account_settings) return Response(account_settings)
@staticmethod @staticmethod
def get_serialized_account(username): def get_serialized_account(requesting_user, username=None, configuration=None, view=None):
"""Returns the user's account information serialized as JSON. """Returns account information for a user serialized as JSON.
Note: Note:
This method does not perform authentication so it is up to the caller If `requesting_user.username` != `username`, this method will return differing amounts of information
to ensure that only the user themselves or staff can access the account. based on who `requesting_user` is and the privacy settings of the user associated with `username`.
Args: Args:
username (str): The username for the desired account. requesting_user (User): The user requesting the account information. Only the user with username
`username` or users with "is_staff" privileges can get full account information.
Other users will get the account fields that the user has elected to share.
username (str): Optional username for the desired account information. If not specified,
`requesting_user.username` is assumed.
configuration (dict): an optional configuration specifying which fields in the account
can be shared, and the default visibility settings. If not present, the setting value with
key ACCOUNT_VISIBILITY_CONFIGURATION is used.
view (str): An optional string allowing "is_staff" users and users requesting their own
account information to get just the fields that are shared with everyone. If view is
"shared", only shared account information will be returned, regardless of `requesting_user`.
Returns: Returns:
A dict containing each of the account's fields. A dict containing account fields.
Raises: Raises:
AccountUserNotFound: raised if there is no account for the specified username. AccountUserNotFound: `username` was specified, but no user exists with that username.
""" """
if username is None:
username = requesting_user.username
has_full_access = requesting_user.username == username or requesting_user.is_staff
return_all_fields = has_full_access and view != 'shared'
existing_user, existing_user_profile = AccountView._get_user_and_profile(username) existing_user, existing_user_profile = AccountView._get_user_and_profile(username)
user_serializer = AccountUserSerializer(existing_user) user_serializer = AccountUserSerializer(existing_user)
legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile) legacy_profile_serializer = AccountLegacyProfileSerializer(existing_user_profile)
return dict(user_serializer.data, **legacy_profile_serializer.data) account_settings = dict(user_serializer.data, **legacy_profile_serializer.data)
if return_all_fields:
return account_settings
if not configuration:
configuration = settings.ACCOUNT_VISIBILITY_CONFIGURATION
visible_settings = {}
profile_privacy = UserPreference.get_preference(existing_user, ACCOUNT_VISIBILITY_PREF_KEY)
privacy_setting = profile_privacy if profile_privacy else configuration.get('default_visibility')
if privacy_setting == ALL_USERS_VISIBILITY:
field_names = configuration.get('shareable_fields')
else:
field_names = configuration.get('public_fields')
for field_name in field_names:
visible_settings[field_name] = account_settings.get(field_name, None)
return visible_settings
def patch(self, request, username): def patch(self, request, username):
""" """
...@@ -124,13 +169,9 @@ class AccountView(APIView): ...@@ -124,13 +169,9 @@ class AccountView(APIView):
https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or 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. else an error response with status code 415 will be returned.
""" """
# Disallow users with is_staff access from calling patch on any account.
if request.user.username != username:
return Response(status=status.HTTP_404_NOT_FOUND)
try: try:
AccountView.update_account(username, request.DATA) AccountView.update_account(request.user, request.DATA, username=username)
except AccountUserNotFound: except (AccountUserNotFound, AccountNotAuthorized):
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
except AccountUpdateError as err: except AccountUpdateError as err:
return Response(err.error_info, status=status.HTTP_400_BAD_REQUEST) return Response(err.error_info, status=status.HTTP_400_BAD_REQUEST)
...@@ -138,23 +179,33 @@ class AccountView(APIView): ...@@ -138,23 +179,33 @@ class AccountView(APIView):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@staticmethod @staticmethod
def update_account(username, update): def update_account(requesting_user, update, username=None):
"""Update the account for the given username. """Update user account information.
Note: Note:
No authorization or permissions checks are done in this method. It is up to the caller It is up to the caller of this method to enforce the contract that this method is only called
of this method to enforce the contract that this method is only called with the user who made the request.
by the user with the specified username.
Arguments: Arguments:
username (string): the username associated with the account to change requesting_user (User): The user requesting to modify account information. Only the user with username
update (dict): the updated account field values 'username' has permissions to modify account information.
update (dict): The updated account field values.
username (string): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
Raises: Raises:
AccountUserNotFound: no user exists with the specified username AccountUserNotFound: `username` was specified, but no user exists with that username.
AccountUpdateError: the update could not be completed, usually due to validation errors AccountUpdateError: the update could not be completed, usually due to validation errors
(for example, read-only fields were specified or field values are not legal) (for example, read-only fields were specified or field values are not legal)
AccountNotAuthorized: the requesting_user does not have access to change the account
associated with `username`.
""" """
if username is None:
username = requesting_user.username
if requesting_user.username != username:
raise AccountNotAuthorized()
existing_user, existing_user_profile = AccountView._get_user_and_profile(username) existing_user, existing_user_profile = AccountView._get_user_and_profile(username)
# If user has requested to change email, we must call the multi-step process to handle this. # If user has requested to change email, we must call the multi-step process to handle this.
...@@ -203,14 +254,15 @@ class AccountView(APIView): ...@@ -203,14 +254,15 @@ class AccountView(APIView):
raise AccountUpdateError(validation_errors) raise AccountUpdateError(validation_errors)
serializer.save() serializer.save()
# If the name was changed, store information about the change operation. # If the name was changed, store information about the change operation. This is outside of the
# serializer so that we can store who requested the change.
if old_name: if old_name:
meta = existing_user_profile.get_meta() meta = existing_user_profile.get_meta()
if 'old_names' not in meta: if 'old_names' not in meta:
meta['old_names'] = [] meta['old_names'] = []
meta['old_names'].append([ meta['old_names'].append([
old_name, old_name,
"Name change requested through account API", "Name change requested through account API by {0}".format(requesting_user.username),
datetime.datetime.now(UTC).isoformat() datetime.datetime.now(UTC).isoformat()
]) ])
existing_user_profile.set_meta(meta) existing_user_profile.set_meta(meta)
......
...@@ -90,14 +90,14 @@ def update_preferences(username, **kwargs): ...@@ -90,14 +90,14 @@ def update_preferences(username, **kwargs):
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError]) @intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
def update_email_opt_in(username, org, optin): def update_email_opt_in(user, org, optin):
"""Updates a user's preference for receiving org-wide emails. """Updates a user's preference for receiving org-wide emails.
Sets a User Org Tag defining the choice to opt in or opt out of organization-wide Sets a User Org Tag defining the choice to opt in or opt out of organization-wide
emails. emails.
Arguments: Arguments:
username (str): The user to set a preference for. user (User): The user to set a preference for.
org (str): The org is used to determine the organization this setting is related to. org (str): The org is used to determine the organization this setting is related to.
optin (Boolean): True if the user is choosing to receive emails for this organization. If the user is not optin (Boolean): True if the user is choosing to receive emails for this organization. If the user is not
the correct age to receive emails, email-optin is set to False regardless. the correct age to receive emails, email-optin is set to False regardless.
...@@ -105,11 +105,8 @@ def update_email_opt_in(username, org, optin): ...@@ -105,11 +105,8 @@ def update_email_opt_in(username, org, optin):
Returns: Returns:
None None
Raises:
AccountUserNotFound: Raised when the username specified is not associated with a user.
""" """
account_settings = AccountView.get_serialized_account(username) account_settings = AccountView.get_serialized_account(user)
year_of_birth = account_settings['year_of_birth'] year_of_birth = account_settings['year_of_birth']
of_age = ( of_age = (
year_of_birth is None or # If year of birth is not set, we assume user is of age. year_of_birth is None or # If year of birth is not set, we assume user is of age.
...@@ -118,7 +115,6 @@ def update_email_opt_in(username, org, optin): ...@@ -118,7 +115,6 @@ def update_email_opt_in(username, org, optin):
) )
try: try:
user = User.objects.get(username=username)
preference, _ = UserOrgTag.objects.get_or_create( preference, _ = UserOrgTag.objects.get_or_create(
user=user, org=org, key='email-optin' user=user, org=org, key='email-optin'
) )
......
...@@ -297,7 +297,7 @@ class EmailOptInListTest(ModuleStoreTestCase): ...@@ -297,7 +297,7 @@ class EmailOptInListTest(ModuleStoreTestCase):
None None
""" """
profile_api.update_email_opt_in(user.username, org, is_opted_in) profile_api.update_email_opt_in(user, org, is_opted_in)
def _latest_pref_set_datetime(self, user): def _latest_pref_set_datetime(self, user):
"""Retrieve the latest opt-in preference for the user, """Retrieve the latest opt-in preference for the user,
......
"""
Profile constants
"""
PROFILE_VISIBILITY_PREF_KEY = 'profile_privacy'
# Indicates the user's preference that all users can view their profile.
ALL_USERS_VISIBILITY = 'all_users'
# Indicates the user's preference that their profile be private.
PRIVATE_VISIBILITY = 'private'
"""
Unit tests for profile APIs.
"""
import ddt
import unittest
from django.conf import settings
from django.core.urlresolvers import reverse
from mock import patch
from openedx.core.djangoapps.user_api.accounts.tests.test_views import UserAPITestCase
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.profiles import PROFILE_VISIBILITY_PREF_KEY
from .. import PRIVATE_VISIBILITY
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile APIs are only supported in LMS')
class TestProfileAPI(UserAPITestCase):
"""
Unit tests for the profile API.
"""
def setUp(self):
super(TestProfileAPI, self).setUp()
self.url = reverse("profiles_api", kwargs={'username': self.user.username})
def test_get_profile_anonymous_user(self):
"""
Test that an anonymous client (not logged in) cannot call get.
"""
self.send_get(self.anonymous_client, expected_status=401)
def _verify_full_profile_response(self, response):
"""
Verify that all of the profile's fields are returned
"""
data = response.data
self.assertEqual(6, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual("US", data["country"])
self.assertIsNone(data["profile_image"])
self.assertIsNone(data["time_zone"])
self.assertIsNone(data["languages"])
self.assertIsNone(data["bio"])
def _verify_private_profile_response(self, response):
"""
Verify that only the public fields are returned for a private user's profile
"""
data = response.data
self.assertEqual(2, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertIsNone(data["profile_image"])
@ddt.data(
("client", "user"),
("different_client", "different_user"),
("staff_client", "staff_user"),
)
@ddt.unpack
# Note: using getattr so that the patching works even if there is no configuration.
# This is needed when testing CMS as the patching is still executed even though the
# suite is skipped.
@patch.dict(getattr(settings, "PROFILE_CONFIGURATION", {}), {"default_visibility": "all_users"})
def test_get_default_profile(self, api_client, username):
"""
Test that any logged in user can get the main test user's public profile information.
"""
client = self.login_client(api_client, username)
self.create_mock_profile(self.user)
response = self.send_get(client)
self._verify_full_profile_response(response)
@ddt.data(
("client", "user"),
("different_client", "different_user"),
("staff_client", "staff_user"),
)
@ddt.unpack
# Note: using getattr so that the patching works even if there is no configuration.
# This is needed when testing CMS as the patching is still executed even though the
# suite is skipped.
@patch.dict(getattr(settings, "PROFILE_CONFIGURATION", {}), {"default_visibility": "private"})
def test_get_default_private_profile(self, api_client, username):
"""
Test that any logged in user gets only the public fields for a profile
if the default visibility is 'private'.
"""
client = self.login_client(api_client, username)
self.create_mock_profile(self.user)
response = self.send_get(client)
self._verify_private_profile_response(response)
@ddt.data(
("client", "user"),
("different_client", "different_user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_private_profile(self, api_client, requesting_username):
"""
Test that private profile information is only available to the test user themselves.
"""
client = self.login_client(api_client, requesting_username)
# Verify that a user with a private profile only returns the public fields
UserPreference.set_preference(self.user, PROFILE_VISIBILITY_PREF_KEY, PRIVATE_VISIBILITY)
self.create_mock_profile(self.user)
response = self.send_get(client)
self._verify_private_profile_response(response)
# Verify that only the public fields are returned if 'include_all' parameter is specified as false
response = self.send_get(client, query_parameters='include_all=false')
self._verify_private_profile_response(response)
# Verify that all fields are returned for the user themselves if
# the 'include_all' parameter is specified as true.
response = self.send_get(client, query_parameters='include_all=true')
if requesting_username == "user":
self._verify_full_profile_response(response)
else:
self._verify_private_profile_response(response)
@ddt.data(
("client", "user"),
("staff_client", "staff_user"),
)
@ddt.unpack
def test_get_profile_unknown_user(self, api_client, username):
"""
Test that requesting a user who does not exist returns a 404.
"""
client = self.login_client(api_client, username)
response = client.get(reverse("profiles_api", kwargs={'username': "does_not_exist"}))
self.assertEqual(404, response.status_code)
"""
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.conf import settings
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework import permissions
from ..accounts.views import AccountView
from ..api.account import AccountUserNotFound
from ..models import UserPreference
from . import PROFILE_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY
class ProfileView(APIView):
"""
**Use Cases**
Get the user's public profile information.
**Example Requests**:
GET /api/user/v0/profiles/{username}/[?include_all={true | false}]
**Response Values for GET**
Returns the same responses as for the AccountView API for the
subset of fields that have been configured to be in a profile.
The fields are additionally filtered based upon the user's
specified privacy permissions.
If the parameter 'include_all' is passed as 'true' then a user
can receive all fields for their own account, ignoring
any field visibility preferences. If the parameter is not
specified or if the user is requesting information for a
different account then the privacy filtering will be applied.
"""
authentication_classes = (OAuth2Authentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated,)
def get(self, request, username):
"""
GET /api/user/v0/profiles/{username}/[?include_all={true | false}]
Note:
The include_all query parameter will only be honored if the user is making
the request for their own username. It defaults to false, but if true
then all the profile fields will be returned even for a user with
a private profile.
"""
if request.user.username == username:
include_all_fields = self.request.QUERY_PARAMS.get('include_all') == 'true'
else:
include_all_fields = False
try:
profile_settings = ProfileView.get_serialized_profile(
username,
settings.PROFILE_CONFIGURATION,
include_all_fields=include_all_fields,
)
except AccountUserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(profile_settings)
@staticmethod
def get_serialized_profile(username, configuration=None, include_all_fields=False):
"""Returns the user's public profile settings serialized as JSON.
The fields returned are by default governed by the user's privacy preference.
If the user has a private profile, then only the fields that are always
public are returned. If the user is sharing their profile with all users
then all profile fields are returned.
Note:
This method does not perform authentication so it is up to the caller
to ensure that only edX users can access the profile. In addition, only
the user themselves should be able to access all fields of a private
profile through 'include_all_fields' being true.
Args:
username (str): The username for the desired account.
configuration (dict): A dictionary specifying three profile configuration settings:
default_visibility: the default visibility level for user's with no preference
all_fields: the list of all fields that can be shown on a profile
public_fields: the list of profile fields that are public
include_all_fields (bool): If true, ignores the user's privacy setting.
Returns:
A dict containing each of the user's profile fields.
Raises:
AccountUserNotFound: raised if there is no account for the specified username.
"""
if not configuration:
configuration = settings.PROFILE_CONFIGURATION
account_settings = AccountView.get_serialized_account(username)
profile = {}
privacy_setting = ProfileView._get_user_profile_privacy(username, configuration)
if include_all_fields or privacy_setting == ALL_USERS_VISIBILITY:
field_names = configuration.get('all_fields')
else:
field_names = configuration.get('public_fields')
for field_name in field_names:
profile[field_name] = account_settings.get(field_name, None)
return profile
@staticmethod
def _get_user_profile_privacy(username, configuration):
"""
Returns the profile privacy preference for the specified user.
"""
user = User.objects.get(username=username)
profile_privacy = UserPreference.get_preference(user, PROFILE_VISIBILITY_PREF_KEY)
return profile_privacy if profile_privacy else configuration.get('default_visibility')
...@@ -29,7 +29,8 @@ class ProfileApiTest(ModuleStoreTestCase): ...@@ -29,7 +29,8 @@ class ProfileApiTest(ModuleStoreTestCase):
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
# Retrieve the account settings # Retrieve the account settings
account_settings = AccountView.get_serialized_account(self.USERNAME) user = User.objects.get(username=self.USERNAME)
account_settings = AccountView.get_serialized_account(user)
# Expect a date joined field but remove it to simplify the following comparison # Expect a date joined field but remove it to simplify the following comparison
self.assertIsNotNone(account_settings['date_joined']) self.assertIsNotNone(account_settings['date_joined'])
...@@ -87,7 +88,7 @@ class ProfileApiTest(ModuleStoreTestCase): ...@@ -87,7 +88,7 @@ class ProfileApiTest(ModuleStoreTestCase):
profile.year_of_birth = year_of_birth profile.year_of_birth = year_of_birth
profile.save() profile.save()
profile_api.update_email_opt_in(self.USERNAME, course.id.org, option) profile_api.update_email_opt_in(user, course.id.org, option)
result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin') result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin')
self.assertEqual(result_obj.value, expected_result) self.assertEqual(result_obj.value, expected_result)
...@@ -99,7 +100,7 @@ class ProfileApiTest(ModuleStoreTestCase): ...@@ -99,7 +100,7 @@ class ProfileApiTest(ModuleStoreTestCase):
user = User.objects.get(username=self.USERNAME) user = User.objects.get(username=self.USERNAME)
profile_api.update_email_opt_in(self.USERNAME, course.id.org, True) profile_api.update_email_opt_in(user, course.id.org, True)
result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin') result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin')
self.assertEqual(result_obj.value, u"True") self.assertEqual(result_obj.value, u"True")
...@@ -130,8 +131,8 @@ class ProfileApiTest(ModuleStoreTestCase): ...@@ -130,8 +131,8 @@ class ProfileApiTest(ModuleStoreTestCase):
profile.year_of_birth = year_of_birth profile.year_of_birth = year_of_birth
profile.save() profile.save()
profile_api.update_email_opt_in(self.USERNAME, course.id.org, option) profile_api.update_email_opt_in(user, course.id.org, option)
profile_api.update_email_opt_in(self.USERNAME, course.id.org, second_option) profile_api.update_email_opt_in(user, course.id.org, second_option)
result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin') result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin')
self.assertEqual(result_obj.value, expected_result) self.assertEqual(result_obj.value, expected_result)
......
...@@ -8,6 +8,7 @@ import re ...@@ -8,6 +8,7 @@ import re
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core import mail from django.core import mail
from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from unittest import skipUnless from unittest import skipUnless
...@@ -1247,7 +1248,8 @@ class RegistrationViewTest(ApiTestCase): ...@@ -1247,7 +1248,8 @@ class RegistrationViewTest(ApiTestCase):
) )
# Verify that the user's full name is set # Verify that the user's full name is set
account_settings = AccountView.get_serialized_account(self.USERNAME) user = User.objects.get(username=self.USERNAME)
account_settings = AccountView.get_serialized_account(user)
self.assertEqual(account_settings["name"], self.NAME) self.assertEqual(account_settings["name"], self.NAME)
# Verify that we've been logged in # Verify that we've been logged in
...@@ -1280,7 +1282,8 @@ class RegistrationViewTest(ApiTestCase): ...@@ -1280,7 +1282,8 @@ class RegistrationViewTest(ApiTestCase):
self.assertHttpOK(response) self.assertHttpOK(response)
# Verify the user's account # Verify the user's account
account_settings = AccountView.get_serialized_account(self.USERNAME) user = User.objects.get(username=self.USERNAME)
account_settings = AccountView.get_serialized_account(user)
self.assertEqual(account_settings["level_of_education"], self.EDUCATION) self.assertEqual(account_settings["level_of_education"], self.EDUCATION)
self.assertEqual(account_settings["mailing_address"], self.ADDRESS) self.assertEqual(account_settings["mailing_address"], self.ADDRESS)
self.assertEqual(account_settings["year_of_birth"], int(self.YEAR_OF_BIRTH)) self.assertEqual(account_settings["year_of_birth"], int(self.YEAR_OF_BIRTH))
......
...@@ -3,7 +3,6 @@ Defines the URL routes for this app. ...@@ -3,7 +3,6 @@ Defines the URL routes for this app.
""" """
from .accounts.views import AccountView from .accounts.views import AccountView
from .profiles.views import ProfileView
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
...@@ -16,9 +15,4 @@ urlpatterns = patterns( ...@@ -16,9 +15,4 @@ urlpatterns = patterns(
AccountView.as_view(), AccountView.as_view(),
name="accounts_api" name="accounts_api"
), ),
url(
r'^v0/profiles/' + USERNAME_PATTERN + '$',
ProfileView.as_view(),
name="profiles_api"
),
) )
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