diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 2958aaa2a4cc9a654716f624b78f0b47b9d180fe..5ce5d019bff9d252ef97d0e69cf67ac0e662b6c3 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -6,13 +6,10 @@ from __future__ import absolute_import import re from importlib import import_module -from django import forms from django.conf import settings from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator from django.core.exceptions import ValidationError -from django.core.validators import RegexValidator, slug_re -from django.forms import widgets from django.urls import reverse from django.utils.http import int_to_base36 from django.utils.translation import ugettext_lazy as _ @@ -28,7 +25,6 @@ from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_f from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from student.message_types import AccountRecovery as AccountRecoveryMessage from student.models import CourseEnrollmentAllowed, email_exists_or_retired -from util.password_policy_validators import validate_password def send_account_recovery_email_for_user(user, request, email=None): @@ -62,242 +58,3 @@ def send_account_recovery_email_for_user(user, request, email=None): user_context=message_context, ) ace.send(msg) - - -class TrueCheckbox(widgets.CheckboxInput): - """ - A checkbox widget that only accepts "true" (case-insensitive) as true. - """ - def value_from_datadict(self, data, files, name): - value = data.get(name, '') - return value.lower() == 'true' - - -class TrueField(forms.BooleanField): - """ - A boolean field that only accepts "true" (case-insensitive) as true - """ - widget = TrueCheckbox - - -def validate_username(username): - """ - Verifies a username is valid, raises a ValidationError otherwise. - Args: - username (unicode): The username to validate. - - This function is configurable with `ENABLE_UNICODE_USERNAME` feature. - """ - - username_re = slug_re - flags = None - message = accounts_settings.USERNAME_INVALID_CHARS_ASCII - - if settings.FEATURES.get("ENABLE_UNICODE_USERNAME"): - username_re = r"^{regex}$".format(regex=settings.USERNAME_REGEX_PARTIAL) - flags = re.UNICODE - message = accounts_settings.USERNAME_INVALID_CHARS_UNICODE - - validator = RegexValidator( - regex=username_re, - flags=flags, - message=message, - code='invalid', - ) - - validator(username) - - -def contains_html(value): - """ - Validator method to check whether name contains html tags - """ - regex = re.compile('(<|>)', re.UNICODE) - return bool(regex.search(value)) - - -def validate_name(name): - """ - Verifies a Full_Name is valid, raises a ValidationError otherwise. - Args: - name (unicode): The name to validate. - """ - if contains_html(name): - raise forms.ValidationError(_('Full Name cannot contain the following characters: < >')) - - -class UsernameField(forms.CharField): - """ - A CharField that validates usernames based on the `ENABLE_UNICODE_USERNAME` feature. - """ - - default_validators = [validate_username] - - def __init__(self, *args, **kwargs): - super(UsernameField, self).__init__( - min_length=accounts_settings.USERNAME_MIN_LENGTH, - max_length=accounts_settings.USERNAME_MAX_LENGTH, - error_messages={ - "required": accounts_settings.USERNAME_BAD_LENGTH_MSG, - "min_length": accounts_settings.USERNAME_BAD_LENGTH_MSG, - "max_length": accounts_settings.USERNAME_BAD_LENGTH_MSG, - } - ) - - def clean(self, value): - """ - Strips the spaces from the username. - - Similar to what `django.forms.SlugField` does. - """ - - value = self.to_python(value).strip() - return super(UsernameField, self).clean(value) - - -class AccountCreationForm(forms.Form): - """ - A form to for account creation data. It is currently only used for - validation, not rendering. - """ - - _EMAIL_INVALID_MSG = _("A properly formatted e-mail is required") - _NAME_TOO_SHORT_MSG = _("Your legal name must be a minimum of one character long") - - # TODO: Resolve repetition - - username = UsernameField() - - email = forms.EmailField( - max_length=accounts_settings.EMAIL_MAX_LENGTH, - min_length=accounts_settings.EMAIL_MIN_LENGTH, - error_messages={ - "required": _EMAIL_INVALID_MSG, - "invalid": _EMAIL_INVALID_MSG, - "max_length": _("Email cannot be more than %(limit_value)s characters long"), - } - ) - - password = forms.CharField() - - name = forms.CharField( - min_length=accounts_settings.NAME_MIN_LENGTH, - error_messages={ - "required": _NAME_TOO_SHORT_MSG, - "min_length": _NAME_TOO_SHORT_MSG, - }, - validators=[validate_name] - ) - - def __init__( - self, - data=None, - extra_fields=None, - extended_profile_fields=None, - do_third_party_auth=True, - tos_required=True - ): - super(AccountCreationForm, self).__init__(data) - - extra_fields = extra_fields or {} - self.extended_profile_fields = extended_profile_fields or {} - self.do_third_party_auth = do_third_party_auth - if tos_required: - self.fields["terms_of_service"] = TrueField( - error_messages={"required": _("You must accept the terms of service.")} - ) - - # TODO: These messages don't say anything about minimum length - error_message_dict = { - "level_of_education": _("A level of education is required"), - "gender": _("Your gender is required"), - "year_of_birth": _("Your year of birth is required"), - "mailing_address": _("Your mailing address is required"), - "goals": _("A description of your goals is required"), - "city": _("A city is required"), - "country": _("A country is required") - } - for field_name, field_value in extra_fields.items(): - if field_name not in self.fields: - if field_name == "honor_code": - if field_value == "required": - self.fields[field_name] = TrueField( - error_messages={ - "required": _("To enroll, you must follow the honor code.") - } - ) - else: - required = field_value == "required" - min_length = 1 if field_name in ("gender", "level_of_education") else 2 - error_message = error_message_dict.get( - field_name, - _("You are missing one or more required fields") - ) - self.fields[field_name] = forms.CharField( - required=required, - min_length=min_length, - error_messages={ - "required": error_message, - "min_length": error_message, - } - ) - - for field in self.extended_profile_fields: - if field not in self.fields: - self.fields[field] = forms.CharField(required=False) - - def clean_password(self): - """Enforce password policies (if applicable)""" - password = self.cleaned_data["password"] - if not self.do_third_party_auth: - # Creating a temporary user object to test password against username - # This user should NOT be saved - username = self.cleaned_data.get('username') - email = self.cleaned_data.get('email') - temp_user = User(username=username, email=email) if username else None - validate_password(password, temp_user) - return password - - def clean_email(self): - """ Enforce email restrictions (if applicable) """ - email = self.cleaned_data["email"] - if settings.REGISTRATION_EMAIL_PATTERNS_ALLOWED is not None: - # This Open edX instance has restrictions on what email addresses are allowed. - allowed_patterns = settings.REGISTRATION_EMAIL_PATTERNS_ALLOWED - # We append a '$' to the regexs to prevent the common mistake of using a - # pattern like '.*@edx\\.org' which would match 'bob@edx.org.badguy.com' - if not any(re.match(pattern + "$", email) for pattern in allowed_patterns): - # This email is not on the whitelist of allowed emails. Check if - # they may have been manually invited by an instructor and if not, - # reject the registration. - if not CourseEnrollmentAllowed.objects.filter(email=email).exists(): - raise ValidationError(_("Unauthorized email address.")) - if email_exists_or_retired(email): - raise ValidationError( - _( - "It looks like {email} belongs to an existing account. Try again with a different email address." - ).format(email=email) - ) - return email - - def clean_year_of_birth(self): - """ - Parse year_of_birth to an integer, but just use None instead of raising - an error if it is malformed - """ - try: - year_str = self.cleaned_data["year_of_birth"] - return int(year_str) if year_str is not None else None - except ValueError: - return None - - @property - def cleaned_extended_profile(self): - """ - Return a dictionary containing the extended_profile_fields and values - """ - return { - key: value - for key, value in self.cleaned_data.items() - if key in self.extended_profile_fields and value is not None - } diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py index 71904a45ea1a2e683105aab377eccef1809cc52d..e3bfb38bd81c88af58821d7fc91e9d07f7b33a22 100644 --- a/common/djangoapps/student/management/commands/create_random_users.py +++ b/common/djangoapps/student/management/commands/create_random_users.py @@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand from opaque_keys.edx.keys import CourseKey from six.moves import range -from student.forms import AccountCreationForm +from openedx.core.djangoapps.user_authn.views.registration_form import AccountCreationForm from student.helpers import do_create_account from student.models import CourseEnrollment diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index d4c481119ae9389b69106306e27c1e260e7e6bac..26cd9242225163243e22166a6fb50bad73fdecae 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -64,7 +64,6 @@ from openedx.core.djangoapps.user_api.models import UserRetirementRequest from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_authn.message_types import PasswordReset from openedx.core.djangolib.markup import HTML, Text -from student.forms import AccountCreationForm from student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info, generate_activation_email_context from student.message_types import AccountActivation, EmailChange, EmailChangeConfirmation, RecoveryEmailCreate from student.models import ( diff --git a/openedx/core/djangoapps/enrollments/forms.py b/openedx/core/djangoapps/enrollments/forms.py index d2530ae868a240b977ff234653e1e4ee2583f0c0..88dff93ee191b282f8a8fd79d14f817f85d94465 100644 --- a/openedx/core/djangoapps/enrollments/forms.py +++ b/openedx/core/djangoapps/enrollments/forms.py @@ -8,7 +8,7 @@ from django.forms import CharField, Form from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from student import forms as student_forms +from openedx.core.djangoapps.user_authn.views.registration_form import validate_username class CourseEnrollmentsApiListForm(Form): @@ -46,6 +46,6 @@ class CourseEnrollmentsApiListForm(Form): ) ) for username in usernames: - student_forms.validate_username(username) + validate_username(username) return usernames return usernames_csv_string diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 7385a72a80b24da990a48ffa6ecf8c3048c5ac48..2c24507eca50154ded052138c62a2cf0e75bb75b 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -29,8 +29,8 @@ from openedx.core.djangoapps.user_api.errors import ( ) from openedx.core.djangoapps.user_api.preferences.api import update_user_preferences from openedx.core.lib.api.view_utils import add_serializer_errors +from openedx.core.djangoapps.user_authn.views.registration_form import validate_name, validate_username from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields -from student import forms as student_forms from student import views as student_views from student.models import ( AccountRecovery, @@ -245,7 +245,7 @@ def _validate_name_change(user_profile, data, field_errors): old_name = user_profile.name try: - student_forms.validate_name(data['name']) + validate_name(data['name']) except ValidationError as err: field_errors["name"] = { "developer_message": u"Error thrown from validate_name: '{}'".format(err.message), @@ -538,7 +538,7 @@ def _validate_username(username): with override_language('en'): # `validate_username` provides a proper localized message, however the API needs only the English # message by convention. - student_forms.validate_username(username) + validate_username(username) except (UnicodeError, errors.AccountDataBadType, errors.AccountDataBadLength) as username_err: raise errors.AccountUsernameInvalid(text_type(username_err)) except ValidationError as validation_err: diff --git a/openedx/core/djangoapps/user_authn/views/auto_auth.py b/openedx/core/djangoapps/user_authn/views/auto_auth.py index 0ea8e7c7e05708852fd9227bf118b5a7d5bd3b2c..3db0f87cf2a7e7169e1b852bfef5d9e43cfb06d6 100644 --- a/openedx/core/djangoapps/user_authn/views/auto_auth.py +++ b/openedx/core/djangoapps/user_authn/views/auto_auth.py @@ -19,8 +19,8 @@ from opaque_keys.edx.locator import CourseLocator from lms.djangoapps.verify_student.models import ManualVerification from openedx.core.djangoapps.django_comment_common.models import assign_role from openedx.core.djangoapps.user_authn.utils import generate_password +from openedx.core.djangoapps.user_authn.views.registration_form import AccountCreationForm from openedx.features.course_experience import course_home_url_name -from student.forms import AccountCreationForm from student.helpers import ( AccountValidationError, authenticate_new_user, diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 3a5b659995be681f729c80327e050b8a8e0bb429..6a3446e6d1388a388df856669973abc64cb0c942 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -12,7 +12,7 @@ from django.conf import settings from django.contrib.auth import login as django_login from django.contrib.auth.models import User from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied -from django.core.validators import ValidationError, validate_email +from django.core.validators import ValidationError from django.db import transaction from django.dispatch import Signal from django.http import HttpResponse, HttpResponseForbidden @@ -53,9 +53,10 @@ from openedx.core.djangoapps.user_authn.utils import generate_password from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies from openedx.core.djangoapps.user_authn.views.registration_form import ( - get_registration_extension_form, RegistrationFormFactory + get_registration_extension_form, + AccountCreationForm, + RegistrationFormFactory ) -from student.forms import AccountCreationForm from student.helpers import ( authenticate_new_user, create_or_set_user_attribute_created_on_site, diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index d524c7fcfb78f538d8f202f0fa3136216ec9db8d..8d2bc5805a20a297bfb075f3578d32d8d53956f4 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -5,11 +5,16 @@ from __future__ import absolute_import import copy from importlib import import_module +import re import six +from django import forms from django.conf import settings +from django.contrib.auth.models import User from django.core.exceptions import ImproperlyConfigured +from django.core.validators import RegexValidator, ValidationError, slug_re +from django.forms import widgets from django.urls import reverse from django.utils.translation import ugettext as _ from django_countries import countries @@ -21,13 +26,257 @@ from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.helpers import FormDescription from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.api import enterprise_customer_for_request -from student.models import UserProfile +from student.models import ( + CourseEnrollmentAllowed, + UserProfile, + email_exists_or_retired, +) from util.password_policy_validators import ( password_validators_instruction_texts, - password_validators_restrictions + password_validators_restrictions, + validate_password, ) +class TrueCheckbox(widgets.CheckboxInput): + """ + A checkbox widget that only accepts "true" (case-insensitive) as true. + """ + def value_from_datadict(self, data, files, name): + value = data.get(name, '') + return value.lower() == 'true' + + +class TrueField(forms.BooleanField): + """ + A boolean field that only accepts "true" (case-insensitive) as true + """ + widget = TrueCheckbox + + +def validate_username(username): + """ + Verifies a username is valid, raises a ValidationError otherwise. + Args: + username (unicode): The username to validate. + + This function is configurable with `ENABLE_UNICODE_USERNAME` feature. + """ + + username_re = slug_re + flags = None + message = accounts.USERNAME_INVALID_CHARS_ASCII + + if settings.FEATURES.get("ENABLE_UNICODE_USERNAME"): + username_re = r"^{regex}$".format(regex=settings.USERNAME_REGEX_PARTIAL) + flags = re.UNICODE + message = accounts.USERNAME_INVALID_CHARS_UNICODE + + validator = RegexValidator( + regex=username_re, + flags=flags, + message=message, + code='invalid', + ) + + validator(username) + + +def contains_html(value): + """ + Validator method to check whether name contains html tags + """ + regex = re.compile('(<|>)', re.UNICODE) + return bool(regex.search(value)) + + +def validate_name(name): + """ + Verifies a Full_Name is valid, raises a ValidationError otherwise. + Args: + name (unicode): The name to validate. + """ + if contains_html(name): + raise forms.ValidationError(_('Full Name cannot contain the following characters: < >')) + + +class UsernameField(forms.CharField): + """ + A CharField that validates usernames based on the `ENABLE_UNICODE_USERNAME` feature. + """ + + default_validators = [validate_username] + + def __init__(self, *args, **kwargs): + super(UsernameField, self).__init__( + min_length=accounts.USERNAME_MIN_LENGTH, + max_length=accounts.USERNAME_MAX_LENGTH, + error_messages={ + "required": accounts.USERNAME_BAD_LENGTH_MSG, + "min_length": accounts.USERNAME_BAD_LENGTH_MSG, + "max_length": accounts.USERNAME_BAD_LENGTH_MSG, + } + ) + + def clean(self, value): + """ + Strips the spaces from the username. + + Similar to what `django.forms.SlugField` does. + """ + + value = self.to_python(value).strip() + return super(UsernameField, self).clean(value) + + +class AccountCreationForm(forms.Form): + """ + A form to for account creation data. It is currently only used for + validation, not rendering. + """ + + _EMAIL_INVALID_MSG = _(u"A properly formatted e-mail is required") + _NAME_TOO_SHORT_MSG = _(u"Your legal name must be a minimum of one character long") + + # TODO: Resolve repetition + + username = UsernameField() + + email = forms.EmailField( + max_length=accounts.EMAIL_MAX_LENGTH, + min_length=accounts.EMAIL_MIN_LENGTH, + error_messages={ + "required": _EMAIL_INVALID_MSG, + "invalid": _EMAIL_INVALID_MSG, + "max_length": _(u"Email cannot be more than %(limit_value)s characters long"), + } + ) + + password = forms.CharField() + + name = forms.CharField( + min_length=accounts.NAME_MIN_LENGTH, + error_messages={ + "required": _NAME_TOO_SHORT_MSG, + "min_length": _NAME_TOO_SHORT_MSG, + }, + validators=[validate_name] + ) + + def __init__( + self, + data=None, + extra_fields=None, + extended_profile_fields=None, + do_third_party_auth=True, + tos_required=True + ): + super(AccountCreationForm, self).__init__(data) + + extra_fields = extra_fields or {} + self.extended_profile_fields = extended_profile_fields or {} + self.do_third_party_auth = do_third_party_auth + if tos_required: + self.fields["terms_of_service"] = TrueField( + error_messages={"required": _("You must accept the terms of service.")} + ) + + # TODO: These messages don't say anything about minimum length + error_message_dict = { + "level_of_education": _("A level of education is required"), + "gender": _("Your gender is required"), + "year_of_birth": _("Your year of birth is required"), + "mailing_address": _("Your mailing address is required"), + "goals": _("A description of your goals is required"), + "city": _("A city is required"), + "country": _("A country is required") + } + for field_name, field_value in extra_fields.items(): + if field_name not in self.fields: + if field_name == "honor_code": + if field_value == "required": + self.fields[field_name] = TrueField( + error_messages={ + "required": _("To enroll, you must follow the honor code.") + } + ) + else: + required = field_value == "required" + min_length = 1 if field_name in ("gender", "level_of_education") else 2 + error_message = error_message_dict.get( + field_name, + _("You are missing one or more required fields") + ) + self.fields[field_name] = forms.CharField( + required=required, + min_length=min_length, + error_messages={ + "required": error_message, + "min_length": error_message, + } + ) + + for field in self.extended_profile_fields: + if field not in self.fields: + self.fields[field] = forms.CharField(required=False) + + def clean_password(self): + """Enforce password policies (if applicable)""" + password = self.cleaned_data["password"] + if not self.do_third_party_auth: + # Creating a temporary user object to test password against username + # This user should NOT be saved + username = self.cleaned_data.get('username') + email = self.cleaned_data.get('email') + temp_user = User(username=username, email=email) if username else None + validate_password(password, temp_user) + return password + + def clean_email(self): + """ Enforce email restrictions (if applicable) """ + email = self.cleaned_data["email"] + if settings.REGISTRATION_EMAIL_PATTERNS_ALLOWED is not None: + # This Open edX instance has restrictions on what email addresses are allowed. + allowed_patterns = settings.REGISTRATION_EMAIL_PATTERNS_ALLOWED + # We append a '$' to the regexs to prevent the common mistake of using a + # pattern like '.*@edx\\.org' which would match 'bob@edx.org.badguy.com' + if not any(re.match(pattern + "$", email) for pattern in allowed_patterns): + # This email is not on the whitelist of allowed emails. Check if + # they may have been manually invited by an instructor and if not, + # reject the registration. + if not CourseEnrollmentAllowed.objects.filter(email=email).exists(): + raise ValidationError(_(u"Unauthorized email address.")) + if email_exists_or_retired(email): + raise ValidationError( + _( + u"It looks like {email} belongs to an existing account. Try again with a different email address." + ).format(email=email) + ) + return email + + def clean_year_of_birth(self): + """ + Parse year_of_birth to an integer, but just use None instead of raising + an error if it is malformed + """ + try: + year_str = self.cleaned_data["year_of_birth"] + return int(year_str) if year_str is not None else None + except ValueError: + return None + + @property + def cleaned_extended_profile(self): + """ + Return a dictionary containing the extended_profile_fields and values + """ + return { + key: value + for key, value in self.cleaned_data.items() + if key in self.extended_profile_fields and value is not None + } + + def get_registration_extension_form(*args, **kwargs): """ Convenience function for getting the custom form set in settings.REGISTRATION_EXTENSION_FORM.