Commit 7af75531 by Kelketek

Merge pull request #10413 from edx/register-dropdown

Add custom fields to registration menu.
parents 56fe511a acc0161c
"""
Utility functions for validating forms
"""
from importlib import import_module
from django import forms
from django.forms import widgets
from django.core.exceptions import ValidationError
......@@ -246,3 +248,18 @@ class AccountCreationForm(forms.Form):
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.
An example form app for this can be found at http://github.com/open-craft/custom-form-app
"""
if not settings.FEATURES.get("ENABLE_COMBINED_LOGIN_REGISTRATION"):
return None
if not getattr(settings, 'REGISTRATION_EXTENSION_FORM', None):
return None
module, klass = settings.REGISTRATION_EXTENSION_FORM.rsplit('.', 1)
module = import_module(module)
return getattr(module, klass)(*args, **kwargs)
......@@ -54,9 +54,8 @@ from student.models import (
CourseEnrollmentAllowed, UserStanding, LoginFailures,
create_comments_service_user, PasswordHistory, UserSignupSource,
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED)
from student.forms import AccountCreationForm, PasswordResetFormNoActive
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
from certificates.models import CertificateStatuses, certificate_status_for_student
from certificates.api import ( # pylint: disable=import-error
get_certificate_url,
......@@ -1439,7 +1438,7 @@ def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument
log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id))
def _do_create_account(form):
def _do_create_account(form, custom_form=None):
"""
Given cleaned post variables, create the User and UserProfile objects, as well as the
registration for this user.
......@@ -1448,8 +1447,13 @@ def _do_create_account(form):
Note: this function is also used for creating test users.
"""
if not form.is_valid():
raise ValidationError(form.errors)
errors = {}
errors.update(form.errors)
if custom_form:
errors.update(custom_form.errors)
if errors:
raise ValidationError(errors)
user = User(
username=form.cleaned_data["username"],
......@@ -1464,6 +1468,10 @@ def _do_create_account(form):
try:
with transaction.atomic():
user.save()
if custom_form:
custom_model = custom_form.save(commit=False)
custom_model.user = user
custom_model.save()
except IntegrityError:
# Figure out the cause of the integrity error
if len(User.objects.filter(username=user.username)) > 0:
......@@ -1593,11 +1601,12 @@ def create_account_with_params(request, params):
enforce_password_policy=enforce_password_policy,
tos_required=tos_required,
)
custom_form = get_registration_extension_form(data=params)
# Perform operations within a transaction that are critical to account creation
with transaction.atomic():
# first, create the account
(user, profile, registration) = _do_create_account(form)
(user, profile, registration) = _do_create_account(form, custom_form)
# next, link the account with social auth, if provided via the API.
# (If the user is using the normal register page, the social auth pipeline does the linking, not this code)
......
......@@ -168,7 +168,10 @@ class CombinedLoginAndRegisterPage(PageObject):
"Finish toggling to the other form"
).fulfill()
def register(self, email="", password="", username="", full_name="", country="", terms_of_service=False):
def register(
self, email="", password="", username="", full_name="", country="", favorite_movie="",
terms_of_service=False
):
"""Fills in and submits the registration form.
Requires that the "register" form is visible.
......@@ -197,6 +200,8 @@ class CombinedLoginAndRegisterPage(PageObject):
self.q(css="#register-password").fill(password)
if country:
self.q(css="#register-country option[value='{country}']".format(country=country)).click()
if favorite_movie:
self.q(css="#register-favorite_movie").fill(favorite_movie)
if terms_of_service:
self.q(css="#register-honor_code").click()
......
......@@ -262,6 +262,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
username=username,
full_name="Test User",
country="US",
favorite_movie="Mad Max: Fury Road",
terms_of_service=True
)
......@@ -276,6 +277,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
# Enter a blank for the username field, which is required
# Don't agree to the terms of service / honor code.
# Don't specify a country code, which is required.
# Don't specify a favorite movie.
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
self.register_page.register(
......@@ -291,6 +293,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
self.assertIn(u'Please enter your Public username.', errors)
self.assertIn(u'You must agree to the edX Terms of Service and Honor Code.', errors)
self.assertIn(u'Please select your Country.', errors)
self.assertIn(u'Please tell us your favorite movie.', errors)
def test_toggle_to_login_form(self):
self.register_page.visit().toggle_form()
......@@ -317,7 +320,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
self.assertIn("Galactica1", self.register_page.username_value)
# Set country, accept the terms, and submit the form:
self.register_page.register(country="US", terms_of_service=True)
self.register_page.register(country="US", favorite_movie="Battlestar Galactica", terms_of_service=True)
# Expect that we reach the dashboard and we're auto-enrolled in the course
course_names = self.dashboard_page.wait_for_page().available_courses
......
......@@ -158,6 +158,7 @@ SESSION_COOKIE_SECURE = ENV_TOKENS.get('SESSION_COOKIE_SECURE', SESSION_COOKIE_S
SESSION_SAVE_EVERY_REQUEST = ENV_TOKENS.get('SESSION_SAVE_EVERY_REQUEST', SESSION_SAVE_EVERY_REQUEST)
REGISTRATION_EXTRA_FIELDS = ENV_TOKENS.get('REGISTRATION_EXTRA_FIELDS', REGISTRATION_EXTRA_FIELDS)
REGISTRATION_EXTENSION_FORM = ENV_TOKENS.get('REGISTRATION_EXTENSION_FORM', REGISTRATION_EXTENSION_FORM)
# Set the names of cookies shared with the marketing site
# These have the same cookie domain as the session, which in production
......
......@@ -99,6 +99,7 @@
"MEDIA_URL": "",
"MKTG_URL_LINK_MAP": {},
"PLATFORM_NAME": "edX",
"REGISTRATION_EXTENSION_FORM": "openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm",
"REGISTRATION_EXTRA_FIELDS": {
"level_of_education": "optional",
"gender": "optional",
......
......@@ -2662,3 +2662,14 @@ FINANCIAL_ASSISTANCE_MAX_LENGTH = 2500
# Course Content Bookmarks Settings
MAX_BOOKMARKS_PER_COURSE = 100
#### Registration form extension. ####
# Only used if combined login/registration is enabled.
# This can be used to add fields to the registration page.
# It must be a path to a valid form, in dot-separated syntax.
# IE: custom_form_app.forms.RegistrationExtensionForm
# Note: If you want to use a model to store the results of the form, you will
# need to add the model's app to the ADDL_INSTALLED_APPS array in your
# lms.env.json file.
REGISTRATION_EXTENSION_FORM = None
......@@ -6,8 +6,12 @@ from collections import defaultdict
from functools import wraps
import logging
import json
from django.http import HttpResponseBadRequest
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.http import HttpResponseBadRequest
from django.utils.encoding import force_text
from django.utils.functional import Promise
LOGGER = logging.getLogger(__name__)
......@@ -121,6 +125,16 @@ class FormDescription(object):
"email": ["min_length", "max_length"],
}
FIELD_TYPE_MAP = {
forms.CharField: "text",
forms.PasswordInput: "password",
forms.ChoiceField: "select",
forms.TypedChoiceField: "select",
forms.Textarea: "textarea",
forms.BooleanField: "checkbox",
forms.EmailField: "email",
}
OVERRIDE_FIELD_PROPERTIES = [
"label", "type", "defaultValue", "placeholder",
"instructions", "required", "restrictions",
......@@ -141,9 +155,9 @@ class FormDescription(object):
self._field_overrides = defaultdict(dict)
def add_field(
self, name, label=u"", field_type=u"text", default=u"",
placeholder=u"", instructions=u"", required=True, restrictions=None,
options=None, include_default_option=False, error_messages=None
self, name, label=u"", field_type=u"text", default=u"",
placeholder=u"", instructions=u"", required=True, restrictions=None,
options=None, include_default_option=False, error_messages=None,
):
"""Add a field to the form description.
......@@ -297,7 +311,7 @@ class FormDescription(object):
"method": self.method,
"submit_url": self.submit_url,
"fields": self.fields
})
}, cls=LocalizedJSONEncoder)
def override_field_properties(self, field_name, **kwargs):
"""Override properties of a field.
......@@ -330,6 +344,20 @@ class FormDescription(object):
})
class LocalizedJSONEncoder(DjangoJSONEncoder):
"""
JSON handler that evaluates ugettext_lazy promises.
"""
# pylint: disable=method-hidden
def default(self, obj):
"""
Forces evaluation of ugettext_lazy promises.
"""
if isinstance(obj, Promise):
return force_text(obj)
super(LocalizedJSONEncoder, self).default(obj)
def shim_student_view(view_func, check_logged_in=False):
"""Create a "shim" view for a view function from the student Django app.
......
......@@ -4,6 +4,7 @@ Tests for helper functions.
import json
import mock
import ddt
from django import forms
from django.http import HttpRequest, HttpResponse
from django.test import TestCase
from nose.tools import raises
......@@ -214,3 +215,62 @@ class StudentViewShimTest(TestCase):
self.captured_request = request
return response
return shim_student_view(stub_view, check_logged_in=check_logged_in)
class DummyRegistrationExtensionModel(object):
"""
Dummy registration object
"""
user = None
def save(self):
"""
Dummy save method for dummy model.
"""
return None
class TestCaseForm(forms.Form):
"""
Test registration extension form.
"""
DUMMY_STORAGE = {}
MOVIE_MIN_LEN = 3
MOVIE_MAX_LEN = 100
FAVORITE_EDITOR = (
('vim', 'Vim'),
('emacs', 'Emacs'),
('np', 'Notepad'),
('cat', 'cat > filename')
)
favorite_movie = forms.CharField(
label="Fav Flick", min_length=MOVIE_MIN_LEN, max_length=MOVIE_MAX_LEN, error_messages={
"required": u"Please tell us your favorite movie.",
"invalid": u"We're pretty sure you made that movie up."
}
)
favorite_editor = forms.ChoiceField(label="Favorite Editor", choices=FAVORITE_EDITOR, required=False, initial='cat')
def save(self, commit=None): # pylint: disable=unused-argument
"""
Store the result in the dummy storage dict.
"""
self.DUMMY_STORAGE.update({
'favorite_movie': self.cleaned_data.get('favorite_movie'),
'favorite_editor': self.cleaned_data.get('favorite_editor'),
})
dummy_model = DummyRegistrationExtensionModel()
return dummy_model
class Meta(object):
"""
Set options for fields which can't be conveyed in their definition.
"""
serialization_options = {
'favorite_editor': {
'default': 'vim',
},
}
......@@ -30,6 +30,7 @@ from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPart
from third_party_auth.tests.utils import (
ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle
)
from .test_helpers import TestCaseForm
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from ..accounts.api import get_account_settings
......@@ -932,6 +933,62 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase):
}
)
@override_settings(REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm')
def test_extension_form_fields(self):
no_extra_fields_setting = {}
# Verify other fields didn't disappear for some reason.
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"email",
u"type": u"email",
u"required": True,
u"label": u"Email",
u"placeholder": u"username@domain.com",
u"restrictions": {
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH
},
}
)
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"favorite_editor",
u"type": u"select",
u"required": False,
u"label": u"Favorite Editor",
u"placeholder": u"cat",
u"defaultValue": u"vim",
u"errorMessages": {
u'required': u'This field is required.',
u'invalid_choice': u'Select a valid choice. %(value)s is not one of the available choices.',
}
}
)
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"favorite_movie",
u"type": u"text",
u"required": True,
u"label": u"Fav Flick",
u"placeholder": None,
u"defaultValue": None,
u"errorMessages": {
u'required': u'Please tell us your favorite movie.',
u'invalid': u"We're pretty sure you made that movie up."
},
u"restrictions": {
"min_length": TestCaseForm.MOVIE_MIN_LEN,
"max_length": TestCaseForm.MOVIE_MAX_LEN,
}
}
)
def test_register_form_third_party_auth_running(self):
no_extra_fields_setting = {}
......@@ -1309,16 +1366,19 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase):
}
)
@override_settings(REGISTRATION_EXTRA_FIELDS={
"level_of_education": "optional",
"gender": "optional",
"year_of_birth": "optional",
"mailing_address": "optional",
"goals": "optional",
"city": "optional",
"country": "required",
"honor_code": "required",
})
@override_settings(
REGISTRATION_EXTRA_FIELDS={
"level_of_education": "optional",
"gender": "optional",
"year_of_birth": "optional",
"mailing_address": "optional",
"goals": "optional",
"city": "optional",
"country": "required",
"honor_code": "required",
},
REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm',
)
def test_field_order(self):
response = self.client.get(self.url)
self.assertHttpOK(response)
......@@ -1331,6 +1391,8 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase):
"name",
"username",
"password",
"favorite_movie",
"favorite_editor",
"city",
"country",
"gender",
......@@ -1405,6 +1467,47 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase):
self.assertEqual(account_settings["goals"], self.GOALS)
self.assertEqual(account_settings["country"], self.COUNTRY)
@override_settings(REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm')
@mock.patch('openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm.DUMMY_STORAGE', new_callable=dict)
@mock.patch(
'openedx.core.djangoapps.user_api.tests.test_helpers.DummyRegistrationExtensionModel',
)
def test_with_extended_form(self, dummy_model, storage_dict):
dummy_model_instance = mock.Mock()
dummy_model.return_value = dummy_model_instance
# Create a new registration
self.assertEqual(storage_dict, {})
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
"favorite_movie": "Inception",
"favorite_editor": "cat",
})
self.assertHttpOK(response)
self.assertIn(settings.EDXMKTG_LOGGED_IN_COOKIE_NAME, self.client.cookies)
self.assertIn(settings.EDXMKTG_USER_INFO_COOKIE_NAME, self.client.cookies)
user = User.objects.get(username=self.USERNAME)
request = RequestFactory().get('/url')
request.user = user
account_settings = get_account_settings(request)
self.assertEqual(self.USERNAME, account_settings["username"])
self.assertEqual(self.EMAIL, account_settings["email"])
self.assertFalse(account_settings["is_active"])
self.assertEqual(self.NAME, account_settings["name"])
self.assertEqual(storage_dict, {'favorite_movie': "Inception", "favorite_editor": "cat"})
self.assertEqual(dummy_model_instance.user, user)
# Verify that we've been logged in
# by trying to access a page that requires authentication
response = self.client.get(reverse("dashboard"))
self.assertHttpOK(response)
def test_activation_email(self):
# Register, which should trigger an activation email
response = self.client.post(self.url, {
......
"""HTTP end-points for the User API. """
import copy
from opaque_keys import InvalidKeyError
from django.conf import settings
from django.contrib.auth.models import User
......@@ -24,6 +25,7 @@ from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
import third_party_auth
from django_comment_common.models import Role
from edxmako.shortcuts import marketing_link
from student.forms import get_registration_extension_form
from student.views import create_account_with_params
from student.cookies import set_logged_in_cookies
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
......@@ -229,6 +231,37 @@ class RegistrationView(APIView):
for field_name in self.DEFAULT_FIELDS:
self.field_handlers[field_name](form_desc, required=True)
# Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM
custom_form = get_registration_extension_form()
if custom_form:
for field_name, field in custom_form.fields.items():
restrictions = {}
if getattr(field, 'max_length', None):
restrictions['max_length'] = field.max_length
if getattr(field, 'min_length', None):
restrictions['min_length'] = field.min_length
field_options = getattr(
getattr(custom_form, 'Meta', None), 'serialization_options', {}
).get(field_name, {})
field_type = field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__))
if not field_type:
raise ImproperlyConfigured(
"Field type '{}' not recognized for registration extension field '{}'.".format(
field_type,
field_name
)
)
form_desc.add_field(
field_name, label=field.label,
default=field_options.get('default'),
field_type=field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__)),
placeholder=field.initial, instructions=field.help_text, required=field.required,
restrictions=restrictions,
options=getattr(field, 'choices', None), error_messages=field.error_messages,
include_default_option=field_options.get('include_default_option'),
)
# Extra fields configured in Django settings
# may be required, optional, or hidden
for field_name in self.EXTRA_FIELDS:
......
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