Commit 650a9a9b by Andy Armstrong

Implement parental controls for user profiles

TNL-1606
parent 8e08ff52
......@@ -36,7 +36,7 @@ import lms.envs.common
# Although this module itself may not use these imported variables, other dependent modules may.
from lms.envs.common import (
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED,
update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR,
update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR, PARENTAL_CONSENT_AGE_LIMIT,
# The following PROFILE_IMAGE_* settings are included as they are
# indirectly accessed through the email opt-in API, which is
# technically accessible through the CMS via legacy URLs.
......
......@@ -249,6 +249,9 @@ FEATURES['USE_MICROSITES'] = True
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
......
......@@ -28,6 +28,7 @@ from django.contrib.auth.hashers import make_password
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.db import models, IntegrityError
from django.db.models import Count
from django.db.models.signals import pre_save
from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop
......@@ -278,6 +279,50 @@ class UserProfile(models.Model):
self.set_meta(meta)
self.save()
def requires_parental_consent(self, date=None, age_limit=None, default_requires_consent=True):
"""Returns true if this user requires parental consent.
Args:
date (Date): The date for which consent needs to be tested (defaults to now).
age_limit (int): The age limit at which parental consent is no longer required.
This defaults to the value of the setting 'PARENTAL_CONTROL_AGE_LIMIT'.
default_requires_consent (bool): True if users require parental consent if they
have no specified year of birth (default is True).
Returns:
True if the user requires parental consent.
"""
if age_limit is None:
age_limit = getattr(settings, 'PARENTAL_CONSENT_AGE_LIMIT', None)
if age_limit is None:
return False
# Return True if either:
# a) The user has a year of birth specified and that year is fewer years in the past than the limit.
# b) The user has no year of birth specified and the default is to require consent.
#
# Note: we have to be conservative using the user's year of birth as their birth date could be
# December 31st. This means that if the number of years since their birth year is exactly equal
# to the age limit then we have to assume that they might still not be old enough.
year_of_birth = self.year_of_birth
if year_of_birth is None:
return default_requires_consent
if date is None:
date = datetime.now(UTC)
return date.year - year_of_birth <= age_limit # pylint: disable=maybe-no-member
@receiver(pre_save, sender=UserProfile)
def user_profile_pre_save_callback(sender, **kwargs):
"""
Ensure consistency of a user profile before saving it.
"""
user_profile = kwargs['instance']
# Remove profile images for users who require parental consent
if user_profile.requires_parental_consent() and user_profile.has_profile_image:
user_profile.has_profile_image = False
class UserSignupSource(models.Model):
"""
......
......@@ -194,7 +194,7 @@ class EnrollmentTest(UrlResetMixin, ModuleStoreTestCase):
"""Change the student's enrollment status in a course.
Args:
action (string): The action to perform (either "enroll" or "unenroll")
action (str): The action to perform (either "enroll" or "unenroll")
Keyword Args:
course_id (unicode): If provided, use this course ID. Otherwise, use the
......
"""Unit tests for parental controls."""
import datetime
from django.test import TestCase
from django.test.utils import override_settings
from student.models import UserProfile
from student.tests.factories import UserFactory
class ProfileParentalControlsTest(TestCase):
"""Unit tests for requires_parental_consent."""
password = "test"
def setUp(self):
super(ProfileParentalControlsTest, self).setUp()
self.user = UserFactory.create(password=self.password)
self.profile = UserProfile.objects.get(id=self.user.id)
def set_year_of_birth(self, year_of_birth):
"""
Helper method that creates a mock profile for the specified user.
"""
self.profile.year_of_birth = year_of_birth
self.profile.save()
def test_no_year_of_birth(self):
"""Verify the behavior for users with no specified year of birth."""
self.assertTrue(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(default_requires_consent=True))
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=False))
@override_settings(PARENTAL_CONSENT_AGE_LIMIT=None)
def test_no_parental_controls(self):
"""Verify the behavior for all users when parental controls are not enabled."""
self.assertFalse(self.profile.requires_parental_consent())
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=True))
self.assertFalse(self.profile.requires_parental_consent(default_requires_consent=False))
# Verify that even a child does not require parental consent
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 10)
self.assertFalse(self.profile.requires_parental_consent())
def test_adult_user(self):
"""Verify the behavior for an adult."""
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 20)
self.assertFalse(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(age_limit=21))
def test_child_user(self):
"""Verify the behavior for a child."""
current_year = datetime.datetime.now().year
# Verify for a child born 13 years agp
self.set_year_of_birth(current_year - 13)
self.assertTrue(self.profile.requires_parental_consent())
self.assertTrue(self.profile.requires_parental_consent(date=datetime.date(current_year, 12, 31)))
self.assertFalse(self.profile.requires_parental_consent(date=datetime.date(current_year + 1, 1, 1)))
# Verify for a child born 14 years ago
self.set_year_of_birth(current_year - 14)
self.assertFalse(self.profile.requires_parental_consent())
self.assertFalse(self.profile.requires_parental_consent(date=datetime.date(current_year, 1, 1)))
def test_profile_image(self):
"""Verify that a profile's image obeys parental controls."""
# Verify that an image cannot be set for a user with no year of birth set
self.profile.has_profile_image = True
self.profile.save()
self.assertFalse(self.profile.has_profile_image)
# Verify that an image can be set for an adult user
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 20)
self.profile.has_profile_image = True
self.profile.save()
self.assertTrue(self.profile.has_profile_image)
# verify that a user's profile image is removed when they switch to requiring parental controls
self.set_year_of_birth(current_year - 10)
self.profile.save()
self.assertFalse(self.profile.has_profile_image)
......@@ -987,6 +987,12 @@ EDXNOTES_INTERFACE = {
'url': 'http://localhost:8120/api/v1',
}
########################## Parental controls config #######################
# The age at which a learner no longer requires parental consent, or None
# if parental consent is never required.
PARENTAL_CONSENT_AGE_LIMIT = 13
################################# Jasmine ##################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
......@@ -1544,7 +1550,7 @@ BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02
############################# Email Opt In ####################################
# Minimum age for organization-wide email opt in
EMAIL_OPTIN_MINIMUM_AGE = 13
EMAIL_OPTIN_MINIMUM_AGE = PARENTAL_CONSENT_AGE_LIMIT
############################## Video ##########################################
......
......@@ -74,6 +74,9 @@ FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Makes the tests run much faster...
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
......
......@@ -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
......
......@@ -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
......
......@@ -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()`.
......
......@@ -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.
......
"""
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()
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)
)
if user_profile.requires_parental_consent(
age_limit=getattr(settings, 'EMAIL_OPTIN_MINIMUM_AGE', 13),
default_requires_consent=False,
):
opt_in = False
# 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.
......
......@@ -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"),
......
......@@ -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
......
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