diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 009b542489..9cf4db14d2 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -19,7 +19,7 @@ from ..helpers import intercept_errors from ..models import UserPreference from . import ( - ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, + ACCOUNT_VISIBILITY_PREF_KEY, ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) @@ -76,12 +76,8 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie visible_settings = {} - # Calling UserPreference directly because the requesting user may be different from existing_user - # (and does not have to be is_staff). - profile_privacy = UserPreference.get_value(existing_user, ACCOUNT_VISIBILITY_PREF_KEY) - privacy_setting = profile_privacy if profile_privacy else configuration.get('default_visibility') - - if privacy_setting == ALL_USERS_VISIBILITY: + profile_visibility = _get_profile_visibility(existing_user_profile, configuration) + if profile_visibility == ALL_USERS_VISIBILITY: field_names = configuration.get('shareable_fields') else: field_names = configuration.get('public_fields') @@ -92,6 +88,17 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie return visible_settings +def _get_profile_visibility(user_profile, configuration): + """Returns the visibility level for the specified user profile.""" + if user_profile.requires_parental_consent(): + return PRIVATE_VISIBILITY + + # Calling UserPreference directly because the requesting user may be different from existing_user + # (and does not have to be is_staff). + profile_privacy = UserPreference.get_value(user_profile.user, ACCOUNT_VISIBILITY_PREF_KEY) + return profile_privacy if profile_privacy else configuration.get('default_visibility') + + @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) def update_account_settings(requesting_user, update, username=None): """Update user account information. diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 2de8ac0319..76d0b6950e 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -25,16 +25,17 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea Class that serializes the portion of UserProfile model needed for account information. """ profile_image = serializers.SerializerMethodField("get_profile_image") + requires_parental_consent = serializers.SerializerMethodField("get_requires_parental_consent") class Meta: model = UserProfile fields = ( "name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", - "mailing_address", "bio", "profile_image" + "mailing_address", "bio", "profile_image", "requires_parental_consent", ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () - explicit_read_only_fields = ("profile_image",) + explicit_read_only_fields = ("profile_image", "requires_parental_consent") def validate_name(self, attrs, source): """ Enforce minimum length for name. """ @@ -48,15 +49,15 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea return attrs - def transform_gender(self, obj, value): + def transform_gender(self, user_profile, 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): + def transform_country(self, user_profile, 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): + def transform_level_of_education(self, user_profile, value): """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """ return AccountLegacyProfileSerializer.convert_empty_to_None(value) @@ -65,12 +66,16 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea """ Helper method to convert empty string to None (other values pass through). """ return None if value == "" else value - def get_profile_image(self, obj): + def get_profile_image(self, user_profile): """ Returns metadata about a user's profile image. """ - data = {'has_image': obj.has_profile_image} + data = {'has_image': user_profile.has_profile_image} data.update({ '{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): - get_profile_image_url_for_user(obj.user, size_value) + get_profile_image_url_for_user(user_profile.user, size_value) for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items() }) return data + + def get_requires_parental_consent(self, user_profile): + """ Returns a boolean representing whether the user requires parental controls. """ + return user_profile.requires_parental_consent() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 63c43cc280..0e4b484f16 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -220,6 +220,7 @@ class AccountSettingsOnCreationTest(TestCase): 'image_url_full': 'http://example-storage.com/profile_images/default_50.jpg', 'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg', }, + 'requires_parental_consent': True, }) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 1e139b599e..1d02b56de8 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import datetime import ddt import hashlib import json @@ -84,9 +85,10 @@ class UserAPITestCase(APITestCase): legacy_profile = UserProfile.objects.get(id=user.id) legacy_profile.country = "US" legacy_profile.level_of_education = "m" - legacy_profile.year_of_birth = 1900 + legacy_profile.year_of_birth = 2000 legacy_profile.goals = "world peace" legacy_profile.mailing_address = "Park Ave" + legacy_profile.gender = "f" legacy_profile.bio = "Tired mother of twins" legacy_profile.has_profile_image = True legacy_profile.save() @@ -139,27 +141,27 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNone(data["languages"]) self.assertEqual("Tired mother of twins", data["bio"]) - def _verify_private_account_response(self, response): + def _verify_private_account_response(self, response, requires_parental_consent=False): """ 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._verify_profile_image_data(data, True) + self._verify_profile_image_data(data, not requires_parental_consent) - def _verify_full_account_response(self, response): + def _verify_full_account_response(self, response, requires_parental_consent=False): """ Verify that all account fields are returned (even those that are not shareable). """ data = response.data - self.assertEqual(14, len(data)) + self.assertEqual(15, 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("f", data["gender"]) + self.assertEqual(2000, data["year_of_birth"]) self.assertEqual("m", data["level_of_education"]) self.assertEqual("world peace", data["goals"]) self.assertEqual("Park Ave", data['mailing_address']) @@ -167,7 +169,8 @@ class TestAccountAPI(UserAPITestCase): self.assertTrue(data["is_active"]) self.assertIsNotNone(data["date_joined"]) self.assertEqual("Tired mother of twins", data["bio"]) - self._verify_profile_image_data(data, True) + self._verify_profile_image_data(data, not requires_parental_consent) + self.assertEquals(requires_parental_consent, data["requires_parental_consent"]) def test_anonymous_access(self): """ @@ -269,7 +272,7 @@ class TestAccountAPI(UserAPITestCase): def verify_get_own_information(): response = self.send_get(self.client) data = response.data - self.assertEqual(14, len(data)) + self.assertEqual(15, 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", "bio"): @@ -283,6 +286,7 @@ class TestAccountAPI(UserAPITestCase): self.assertIsNotNone(data["date_joined"]) self.assertEqual(self.user.is_active, data["is_active"]) self._verify_profile_image_data(data, False) + self.assertTrue(data["requires_parental_consent"]) self.client.login(username=self.user.username, password=self.test_password) verify_get_own_information() @@ -406,8 +410,8 @@ class TestAccountAPI(UserAPITestCase): "Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"] ) - for field_name in ["username", "date_joined", "is_active"]: - response = self.send_patch(client, {field_name: "will_error", "gender": "f"}, expected_status=400) + for field_name in ["username", "date_joined", "is_active", "profile_image", "requires_parental_consent"]: + response = self.send_patch(client, {field_name: "will_error", "gender": "o"}, expected_status=400) verify_error_response(field_name, response.data) # Make sure that gender did not change. diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index efaab726ff..f964062f4c 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -80,9 +80,24 @@ class AccountView(APIView): * goals: The textual representation of the user's goals, or null. * bio: null or textural representation of user biographical - information ("about me") + information ("about me"). - For all text fields, clients rendering the values should take care + * profile_image: a dict with the following keys describing + the user's profile image: + * "has_image": true if the user has a profile image + * "image_url_full": an absolute URL to the user's full + profile image + * "image_url_large": an absolute URL to a large thumbnail + of the profile image + * "image_url_medium": an absolute URL to a medium thumbnail + of the profile image + * "image_url_small": an absolute URL to a small thumbnail + of the profile image + + * requires_parental_consent: true if the user is a minor + requiring parental consent. + +> For all text fields, clients rendering the values should take care to HTML escape them to avoid script injections, as the data is stored exactly as specified. The intention is that plain text is supported, not HTML.