diff --git a/cms/envs/common.py b/cms/envs/common.py index 6aff9c2546f499460b8cb9880d5878317aa7f046..b3d6bf1687006ee73784842f4354f9bfcc160afb 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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. diff --git a/cms/envs/test.py b/cms/envs/test.py index ddc48208b451d32b0bd75657e515d95efa7777de..2d10aaad4947a46f1077152869b8285dcb97db64 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -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 diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a42a06fee56af26963197bccb54db3fc9f05b166..ff8d294d6a3319e725697f10ab32a0f647be17ea 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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): """ diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 6cf29170fce88a3902fbd8747ceb071ce736c88b..2334c99b9593ca032efa1de4e50b2dd4192fe538 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -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 diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py new file mode 100644 index 0000000000000000000000000000000000000000..3a3623cf6c2e89c0eb7803e0ea6f917b4a0ac02e --- /dev/null +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -0,0 +1,86 @@ +"""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) diff --git a/lms/envs/common.py b/lms/envs/common.py index d399640d35444ebbb21f23061a32f0398ebf50d6..dbcbceb85e1c7ef23a7a81229bda920b45f66cf8 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 ########################################## diff --git a/lms/envs/test.py b/lms/envs/test.py index 7658867fef29635bf0bb98627e6e88827f829256..58e1e7fef41043fd217968d731334d05ce7780b9 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 7913aecd18e018457beb6c9cf589ab4e41684f52..009b5424898fb31339fc2a37e196c2b8be845549 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py index af8e2150e98eb200341ca3d89c7faa974024b22a..92abeaff01ed717d0d9239307afc6d3e678ee749 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_helpers.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/helpers.py b/openedx/core/djangoapps/user_api/helpers.py index b939a41b770470f270f3b7de7c368f868d2e2cf5..8fc9b6c69b466075919d8870c026d57c36e34546 100644 --- a/openedx/core/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -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()`. diff --git a/openedx/core/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py index f8f94648c0f9155baa88f2dcad6dde70915f55ac..e3642fa2e6825dc9da23d601e621c2a62b5c351d 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -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. diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py index 447282a4bd05276b15651ed1a7ee39cfad24a45d..2c40f8515e7b9efba9694f0bc51a73e92ba81187 100644 --- a/openedx/core/djangoapps/user_api/preferences/api.py +++ b/openedx/core/djangoapps/user_api/preferences/api.py @@ -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() - - 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. diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py index 13d05c64926c231ac492fff08df5361733c90502..3791bc33c421154a2c1d817f691a8f3194c1c6c4 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -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"), diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 89e52fe34e80b7dbfb6ce6fbe1574c2d82adadda..d1844b119fdc9b8219700594a8467d7d8077d146 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -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