Commit e710a5b2 by Andy Armstrong

Implement parental controls for the User API

TNL-1739
parent 650a9a9b
...@@ -19,7 +19,7 @@ from ..helpers import intercept_errors ...@@ -19,7 +19,7 @@ from ..helpers import intercept_errors
from ..models import UserPreference from ..models import UserPreference
from . import ( 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, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH
) )
...@@ -76,12 +76,8 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie ...@@ -76,12 +76,8 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie
visible_settings = {} visible_settings = {}
# Calling UserPreference directly because the requesting user may be different from existing_user profile_visibility = _get_profile_visibility(existing_user_profile, configuration)
# (and does not have to be is_staff). if profile_visibility == ALL_USERS_VISIBILITY:
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:
field_names = configuration.get('shareable_fields') field_names = configuration.get('shareable_fields')
else: else:
field_names = configuration.get('public_fields') field_names = configuration.get('public_fields')
...@@ -92,6 +88,17 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie ...@@ -92,6 +88,17 @@ def get_account_settings(requesting_user, username=None, configuration=None, vie
return visible_settings 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]) @intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
def update_account_settings(requesting_user, update, username=None): def update_account_settings(requesting_user, update, username=None):
"""Update user account information. """Update user account information.
......
...@@ -25,16 +25,17 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea ...@@ -25,16 +25,17 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
Class that serializes the portion of UserProfile model needed for account information. Class that serializes the portion of UserProfile model needed for account information.
""" """
profile_image = serializers.SerializerMethodField("get_profile_image") profile_image = serializers.SerializerMethodField("get_profile_image")
requires_parental_consent = serializers.SerializerMethodField("get_requires_parental_consent")
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ( fields = (
"name", "gender", "goals", "year_of_birth", "level_of_education", "language", "country", "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. # Currently no read-only field, but keep this so view code doesn't need to know.
read_only_fields = () read_only_fields = ()
explicit_read_only_fields = ("profile_image",) explicit_read_only_fields = ("profile_image", "requires_parental_consent")
def validate_name(self, attrs, source): def validate_name(self, attrs, source):
""" Enforce minimum length for name. """ """ Enforce minimum length for name. """
...@@ -48,15 +49,15 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea ...@@ -48,15 +49,15 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return attrs 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. """ """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value) 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. """ """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value) 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. """ """ Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
return AccountLegacyProfileSerializer.convert_empty_to_None(value) return AccountLegacyProfileSerializer.convert_empty_to_None(value)
...@@ -65,12 +66,16 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea ...@@ -65,12 +66,16 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
""" Helper method to convert empty string to None (other values pass through). """ """ Helper method to convert empty string to None (other values pass through). """
return None if value == "" else value 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. """ """ 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({ data.update({
'{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): '{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() for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items()
}) })
return data 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()
...@@ -220,6 +220,7 @@ class AccountSettingsOnCreationTest(TestCase): ...@@ -220,6 +220,7 @@ class AccountSettingsOnCreationTest(TestCase):
'image_url_full': 'http://example-storage.com/profile_images/default_50.jpg', 'image_url_full': 'http://example-storage.com/profile_images/default_50.jpg',
'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg', 'image_url_small': 'http://example-storage.com/profile_images/default_10.jpg',
}, },
'requires_parental_consent': True,
}) })
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime
import ddt import ddt
import hashlib import hashlib
import json import json
...@@ -84,9 +85,10 @@ class UserAPITestCase(APITestCase): ...@@ -84,9 +85,10 @@ class UserAPITestCase(APITestCase):
legacy_profile = UserProfile.objects.get(id=user.id) legacy_profile = UserProfile.objects.get(id=user.id)
legacy_profile.country = "US" legacy_profile.country = "US"
legacy_profile.level_of_education = "m" 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.goals = "world peace"
legacy_profile.mailing_address = "Park Ave" legacy_profile.mailing_address = "Park Ave"
legacy_profile.gender = "f"
legacy_profile.bio = "Tired mother of twins" legacy_profile.bio = "Tired mother of twins"
legacy_profile.has_profile_image = True legacy_profile.has_profile_image = True
legacy_profile.save() legacy_profile.save()
...@@ -139,27 +141,27 @@ class TestAccountAPI(UserAPITestCase): ...@@ -139,27 +141,27 @@ class TestAccountAPI(UserAPITestCase):
self.assertIsNone(data["languages"]) self.assertIsNone(data["languages"])
self.assertEqual("Tired mother of twins", data["bio"]) 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 Verify that only the public fields are returned if a user does not want to share account fields
""" """
data = response.data data = response.data
self.assertEqual(2, len(data)) self.assertEqual(2, len(data))
self.assertEqual(self.user.username, data["username"]) 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). Verify that all account fields are returned (even those that are not shareable).
""" """
data = response.data data = response.data
self.assertEqual(14, len(data)) self.assertEqual(15, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual("US", data["country"]) self.assertEqual("US", data["country"])
self.assertEqual("", data["language"]) self.assertEqual("", data["language"])
self.assertEqual("m", data["gender"]) self.assertEqual("f", data["gender"])
self.assertEqual(1900, data["year_of_birth"]) self.assertEqual(2000, data["year_of_birth"])
self.assertEqual("m", data["level_of_education"]) self.assertEqual("m", data["level_of_education"])
self.assertEqual("world peace", data["goals"]) self.assertEqual("world peace", data["goals"])
self.assertEqual("Park Ave", data['mailing_address']) self.assertEqual("Park Ave", data['mailing_address'])
...@@ -167,7 +169,8 @@ class TestAccountAPI(UserAPITestCase): ...@@ -167,7 +169,8 @@ class TestAccountAPI(UserAPITestCase):
self.assertTrue(data["is_active"]) self.assertTrue(data["is_active"])
self.assertIsNotNone(data["date_joined"]) self.assertIsNotNone(data["date_joined"])
self.assertEqual("Tired mother of twins", data["bio"]) 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): def test_anonymous_access(self):
""" """
...@@ -269,7 +272,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -269,7 +272,7 @@ class TestAccountAPI(UserAPITestCase):
def verify_get_own_information(): def verify_get_own_information():
response = self.send_get(self.client) response = self.send_get(self.client)
data = response.data data = response.data
self.assertEqual(14, len(data)) self.assertEqual(15, len(data))
self.assertEqual(self.user.username, data["username"]) self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"]) 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"): for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
...@@ -283,6 +286,7 @@ class TestAccountAPI(UserAPITestCase): ...@@ -283,6 +286,7 @@ class TestAccountAPI(UserAPITestCase):
self.assertIsNotNone(data["date_joined"]) self.assertIsNotNone(data["date_joined"])
self.assertEqual(self.user.is_active, data["is_active"]) self.assertEqual(self.user.is_active, data["is_active"])
self._verify_profile_image_data(data, False) self._verify_profile_image_data(data, False)
self.assertTrue(data["requires_parental_consent"])
self.client.login(username=self.user.username, password=self.test_password) self.client.login(username=self.user.username, password=self.test_password)
verify_get_own_information() verify_get_own_information()
...@@ -406,8 +410,8 @@ class TestAccountAPI(UserAPITestCase): ...@@ -406,8 +410,8 @@ class TestAccountAPI(UserAPITestCase):
"Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"] "Field '{0}' cannot be edited.".format(field_name), data["field_errors"][field_name]["user_message"]
) )
for field_name in ["username", "date_joined", "is_active"]: for field_name in ["username", "date_joined", "is_active", "profile_image", "requires_parental_consent"]:
response = self.send_patch(client, {field_name: "will_error", "gender": "f"}, expected_status=400) response = self.send_patch(client, {field_name: "will_error", "gender": "o"}, expected_status=400)
verify_error_response(field_name, response.data) verify_error_response(field_name, response.data)
# Make sure that gender did not change. # Make sure that gender did not change.
......
...@@ -80,9 +80,24 @@ class AccountView(APIView): ...@@ -80,9 +80,24 @@ class AccountView(APIView):
* goals: The textual representation of the user's goals, or null. * goals: The textual representation of the user's goals, or null.
* bio: null or textural representation of user biographical * 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 to HTML escape them to avoid script injections, as the data is
stored exactly as specified. The intention is that plain text is stored exactly as specified. The intention is that plain text is
supported, not HTML. supported, not HTML.
......
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