Implement parental controls for user profiles

TNL-1606
This commit is contained in:
Andy Armstrong
2015-03-18 13:36:15 -04:00
parent 8e08ff52ff
commit 650a9a9b85
14 changed files with 211 additions and 60 deletions

View File

@@ -104,7 +104,7 @@ def update_account_settings(requesting_user, update, username=None):
requesting_user (User): The user requesting to modify account information. Only the user with username
'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,
username (str): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
Raises:
@@ -372,9 +372,9 @@ def request_password_change(email, orig_host, is_secure):
Users must confirm the password change before we update their information.
Args:
email (string): An email address
orig_host (string): An originating host, extracted from a request with get_host
is_secure (Boolean): Whether the request was made with HTTPS
email (str): An email address
orig_host (str): An originating host, extracted from a request with get_host
is_secure (bool): Whether the request was made with HTTPS
Returns:
None

View File

@@ -9,9 +9,11 @@ from unittest import skipUnless
from django.conf import settings
from django.test import TestCase
from openedx.core.djangoapps.user_api.accounts.helpers import get_profile_image_url_for_user
from student.tests.factories import UserFactory
from ...models import UserProfile
from ..helpers import get_profile_image_url_for_user
@ddt
@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
@@ -27,6 +29,10 @@ class ProfileImageUrlTestCase(TestCase):
super(ProfileImageUrlTestCase, self).setUp()
self.user = UserFactory()
# Ensure that parental controls don't apply to this user
self.user.profile.year_of_birth = 1980
self.user.profile.save()
def verify_url(self, user, pixels, filename):
"""
Helper method to verify that we're correctly generating profile

View File

@@ -308,7 +308,7 @@ class FormDescription(object):
Field properties not in `OVERRIDE_FIELD_PROPERTIES` will be ignored.
Arguments:
field_name (string): The name of the field to override.
field_name (str): The name of the field to override.
Keyword Args:
Same as to `add_field()`.

View File

@@ -35,7 +35,7 @@ class UserPreference(models.Model):
Arguments:
user (User): The user whose preference should be set.
preference_key (string): The key for the user preference.
preference_key (str): The key for the user preference.
Returns:
The user preference value, or None if one is not set.

View File

@@ -1,22 +1,17 @@
"""
API for managing user preferences.
"""
import datetime
import logging
import string
import analytics
from eventtracking import tracker
from pytz import UTC
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db import IntegrityError
from django.utils.translation import ugettext as _
from student.models import User, UserProfile
from django.utils.translation import ugettext_noop
from student.models import UserProfile
from ..errors import (
UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized,
PreferenceValidationError, PreferenceUpdateError
@@ -35,7 +30,7 @@ def get_user_preference(requesting_user, preference_key, username=None):
Args:
requesting_user (User): The user requesting the user preferences. Only the user with username
`username` or users with "is_staff" privileges can access the preferences.
preference_key (string): The key for the user preference.
preference_key (str): The key for the user preference.
username (str): Optional username for which to look up the preferences. If not specified,
`requesting_user.username` is assumed.
@@ -92,7 +87,7 @@ def update_user_preferences(requesting_user, update, username=None):
Some notes:
Values are expected to be strings. Non-string values will be converted to strings.
Null values for a preference will be treated as a request to delete the key in question.
username (string): Optional username specifying which account should be updated. If not specified,
username (str): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
Raises:
@@ -148,9 +143,9 @@ def set_user_preference(requesting_user, preference_key, preference_value, usern
Arguments:
requesting_user (User): The user requesting to modify account information. Only the user with username
'username' has permissions to modify account information.
preference_key (string): The key for the user preference.
preference_value (string): The value to be stored. Non-string values will be converted to strings.
username (string): Optional username specifying which account should be updated. If not specified,
preference_key (str): The key for the user preference.
preference_value (str): The value to be stored. Non-string values will be converted to strings.
username (str): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
Raises:
@@ -182,8 +177,8 @@ def delete_user_preference(requesting_user, preference_key, username=None):
Arguments:
requesting_user (User): The user requesting to delete the preference. Only the user with username
'username' has permissions to delete their own preference.
preference_key (string): The key for the user preference.
username (string): Optional username specifying which account should be updated. If not specified,
preference_key (str): The key for the user preference.
username (str): Optional username specifying which account should be updated. If not specified,
`requesting_user.username` is assumed.
Returns:
@@ -218,7 +213,7 @@ def delete_user_preference(requesting_user, preference_key, username=None):
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError])
def update_email_opt_in(user, org, optin):
def update_email_opt_in(user, org, opt_in):
"""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
@@ -227,48 +222,48 @@ def update_email_opt_in(user, org, optin):
Arguments:
user (User): The user to set a preference for.
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
the correct age to receive emails, email-optin is set to False regardless.
opt_in (bool): True if the user is choosing to receive emails for this organization.
If the user requires parental consent then email-optin is set to False regardless.
Returns:
None
Raises:
UserNotFound: no user profile exists for the specified user.
"""
# Avoid calling get_account_settings because it introduces circularity for many callers who need both
# preferences and account information.
preference, _ = UserOrgTag.objects.get_or_create(
user=user, org=org, key='email-optin'
)
# If the user requires parental consent, then don't allow opt-in
try:
user_profile = UserProfile.objects.get(user=user)
except ObjectDoesNotExist:
raise UserNotFound()
if user_profile.requires_parental_consent(
age_limit=getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13),
default_requires_consent=False,
):
opt_in = False
year_of_birth = user_profile.year_of_birth
of_age = (
year_of_birth is None or # If year of birth is not set, we assume user is of age.
datetime.datetime.now(UTC).year - year_of_birth > # pylint: disable=maybe-no-member
getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13)
)
# Update the preference and save it
preference.value = str(opt_in)
try:
preference, _ = UserOrgTag.objects.get_or_create(
user=user, org=org, key='email-optin'
)
preference.value = str(optin and of_age)
preference.save()
if settings.FEATURES.get('SEGMENT_IO_LMS') and settings.SEGMENT_IO_LMS_KEY:
_track_update_email_opt_in(user.id, org, optin)
_track_update_email_opt_in(user.id, org, opt_in)
except IntegrityError as err:
log.warn(u"Could not update organization wide preference due to IntegrityError: {}".format(err.message))
def _track_update_email_opt_in(user_id, organization, opt_in):
"""Track an email opt-in preference change.
Arguments:
user_id (str): The ID of the user making the preference change.
organization (str): The organization whose emails are being opted into or out of by the user.
opt_in (Boolean): Whether the user has chosen to opt-in to emails from the organization.
opt_in (bool): Whether the user has chosen to opt-in to emails from the organization.
Returns:
None
@@ -317,8 +312,8 @@ def create_user_preference_serializer(user, preference_key, preference_value):
Arguments:
user (User): The user whose preference is being serialized.
preference_key (string): The key for the user preference.
preference_value (string): The value to be stored. Non-string values will be converted to strings.
preference_key (str): The key for the user preference.
preference_value (str): The value to be stored. Non-string values will be converted to strings.
Returns:
A serializer that can be used to save the user preference.
@@ -344,8 +339,8 @@ def validate_user_preference_serializer(serializer, preference_key, preference_v
Arguments:
serializer (UserPreferenceSerializer): The serializer to be validated.
preference_key (string): The key for the user preference.
preference_value (string): The value to be stored. Non-string values will be converted to strings.
preference_key (str): The key for the user preference.
preference_value (str): The value to be stored. Non-string values will be converted to strings.
Raises:
PreferenceValidationError: the supplied key and/or value for a user preference are invalid.

View File

@@ -344,6 +344,13 @@ class UpdateEmailOptInTests(ModuleStoreTestCase):
result_obj = UserOrgTag.objects.get(user=user, org=course.id.org, key='email-optin')
self.assertEqual(result_obj.value, u"True")
def test_update_email_optin_anonymous_user(self):
"""Verify that the API raises an exception for a user with no profile."""
course = CourseFactory.create()
no_profile_user, __ = User.objects.get_or_create(username="no_profile_user", password=self.PASSWORD)
with self.assertRaises(UserNotFound):
update_email_opt_in(no_profile_user, course.id.org, True)
@ddt.data(
# Check that a 27 year old can opt-in, then out.
(27, True, False, u"False"),

View File

@@ -309,7 +309,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -339,7 +339,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -372,7 +372,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -409,7 +409,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -434,7 +434,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
@@ -457,7 +457,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
@@ -480,7 +480,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
@@ -504,7 +504,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -525,7 +525,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This phrase appears above a field on the registration form
@@ -548,7 +548,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
@@ -568,7 +568,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
@@ -604,7 +604,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Separate terms of service and honor code checkboxes
@@ -658,7 +658,7 @@ class RegistrationView(APIView):
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
required (bool): Whether this field is required; defaults to True
"""
# Translators: This is a legal document users must agree to