diff --git a/common/djangoapps/user_api/api/profile.py b/common/djangoapps/user_api/api/profile.py index fc14e47273..4b18b6c1e6 100644 --- a/common/djangoapps/user_api/api/profile.py +++ b/common/djangoapps/user_api/api/profile.py @@ -65,9 +65,15 @@ def profile_info(username): return None profile_dict = { - u'username': profile.user.username, - u'email': profile.user.email, - u'full_name': profile.name, + "username": profile.user.username, + "email": profile.user.email, + "full_name": profile.name, + "level_of_education": profile.level_of_education, + "mailing_address": profile.mailing_address, + "year_of_birth": profile.year_of_birth, + "goals": profile.goals, + "city": profile.city, + "country": profile.country, } return profile_dict diff --git a/common/djangoapps/user_api/helpers.py b/common/djangoapps/user_api/helpers.py index 1cae378786..cd926983b9 100644 --- a/common/djangoapps/user_api/helpers.py +++ b/common/djangoapps/user_api/helpers.py @@ -4,6 +4,8 @@ This is NOT part of the public API. """ from functools import wraps import logging +import json + LOGGER = logging.getLogger(__name__) @@ -54,3 +56,242 @@ def intercept_errors(api_error, ignore_errors=[]): raise api_error(msg) return _wrapped return _decorator + + +class InvalidFieldError(Exception): + """The provided field definition is not valid. """ + + +class FormDescription(object): + """Generate a JSON representation of a form. """ + + ALLOWED_TYPES = ["text", "select", "textarea"] + + ALLOWED_RESTRICTIONS = { + "text": ["min_length", "max_length"], + } + + def __init__(self, method, submit_url): + """Configure how the form should be submitted. + + Args: + method (unicode): The HTTP method used to submit the form. + submit_url (unicode): The URL where the form should be submitted. + + """ + self.method = method + self.submit_url = submit_url + self.fields = [] + + def add_field( + self, name, label=u"", field_type=u"text", default=u"", + placeholder=u"", instructions=u"", required=True, restrictions=None, + options=None + ): + """Add a field to the form description. + + Args: + name (unicode): The name of the field, which is the key for the value + to send back to the server. + + Keyword Arguments: + label (unicode): The label for the field (e.g. "E-mail" or "Username") + + field_type (unicode): The type of the field. See `ALLOWED_TYPES` for + acceptable values. + + default (unicode): The default value for the field. + + placeholder (unicode): Placeholder text in the field + (e.g. "user@example.com" for an email field) + + instructions (unicode): Short instructions for using the field + (e.g. "This is the email address you used when you registered.") + + required (boolean): Whether the field is required or optional. + + restrictions (dict): Validation restrictions for the field. + See `ALLOWED_RESTRICTIONS` for acceptable values. + + options (list): For "select" fields, a list of tuples + (value, display_name) representing the options available to + the user. `value` is the value of the field to send to the server, + and `display_name` is the name to display to the user. + If the field type is "select", you *must* provide this kwarg. + + Raises: + InvalidFieldError + + """ + if field_type not in self.ALLOWED_TYPES: + msg = u"Field type '{field_type}' is not a valid type. Allowed types are: {allowed}.".format( + field_type=field_type, + allowed=", ".join(self.ALLOWED_TYPES) + ) + raise InvalidFieldError(msg) + + field_dict = { + "label": label, + "name": name, + "type": field_type, + "default": default, + "placeholder": placeholder, + "instructions": instructions, + "required": required, + "restrictions": {} + } + + if field_type == "select": + if options is not None: + field_dict["options"] = [ + {"value": option_value, "name": option_name} + for option_value, option_name in options + ] + else: + raise InvalidFieldError("You must provide options for a select field.") + + if restrictions is not None: + allowed_restrictions = self.ALLOWED_RESTRICTIONS.get(field_type, []) + for key, val in restrictions.iteritems(): + if key in allowed_restrictions: + field_dict["restrictions"][key] = val + else: + msg = "Restriction '{restriction}' is not allowed for field type '{field_type}'".format( + restriction=key, + field_type=field_type + ) + raise InvalidFieldError(msg) + + self.fields.append(field_dict) + + def to_json(self): + """Create a JSON representation of the form description. + + Here's an example of the output: + { + "method": "post", + "submit_url": "/submit", + "fields": [ + { + "name": "cheese_or_wine", + "label": "Cheese or Wine?", + "default": "cheese", + "type": "select", + "required": True, + "placeholder": "", + "instructions": "", + "options": [ + {"value": "cheese", "name": "Cheese"}, + {"value": "wine", "name": "Wine"} + ] + "restrictions": {}, + }, + { + "name": "comments", + "label": "comments", + "default": "", + "type": "text", + "required": False, + "placeholder": "Any comments?", + "instructions": "Please enter additional comments here." + "restrictions": { + "max_length": 200 + } + }, + ... + ] + } + + If the field is NOT a "select" type, then the "options" + key will be omitted. + + Returns: + unicode + """ + return json.dumps({ + "method": self.method, + "submit_url": self.submit_url, + "fields": self.fields + }) + + +def shim_student_view(view_func, check_logged_in=False): + """Create a "shim" view for a view function from the student Django app. + + Specifically, we need to: + * Strip out enrollment params, since the client for the new registration/login + page will communicate with the enrollment API to update enrollments. + + * Return responses with HTTP status codes indicating success/failure + (instead of always using status 200, but setting "success" to False in + the JSON-serialized content of the response) + + * Use status code 302 for redirects instead of + "redirect_url" in the JSON-serialized content of the response. + + * Use status code 403 to indicate a login failure. + + The shim will preserve any cookies set by the view. + + Arguments: + view_func (function): The view function from the student Django app. + + Keyword Args: + check_logged_in (boolean): If true, check whether the user successfully + authenticated and if not set the status to 403. + + Returns: + function + + """ + + @wraps(view_func) + def _inner(request): + + # Strip out enrollment action stuff, since we're handling that elsewhere + if "enrollment_action" in request.POST: + del request.POST["enrollment_action"] + if "course_id" in request.POST: + del request.POST["course_id"] + + # Actually call the function! + # TODO ^^ + response = view_func(request) + + # Most responses from this view are a JSON dict + # TODO -- explain this more + try: + response_dict = json.loads(response.content) + msg = response_dict.get("value", u"") + redirect_url = response_dict.get("redirect_url") + except (ValueError, TypeError): + msg = response.content + redirect_url = None + + # If the user could not be authenticated + if check_logged_in and not request.user.is_authenticated(): + response.status_code = 403 + response.content = msg + + # Handle redirects + # TODO -- explain why this is safe + elif redirect_url is not None: + response.status_code = 302 + response.content = redirect_url + + # Handle errors + elif response.status_code != 200 or not response_dict.get("success", False): + # TODO -- explain this + if response.status_code == 200: + response.status_code = 400 + response.content = msg + + # Otherwise, return the response + else: + response.content = msg + + # Return the response. + # IMPORTANT: this NEEDS to preserve session variables / cookies! + return response + + return _inner diff --git a/common/djangoapps/user_api/tests/test_views.py b/common/djangoapps/user_api/tests/test_views.py index ddebd1e5f4..a8f25b5085 100644 --- a/common/djangoapps/user_api/tests/test_views.py +++ b/common/djangoapps/user_api/tests/test_views.py @@ -1,12 +1,22 @@ -import base64 +"""Tests for the user API at the HTTP request level. """ -from django.test import TestCase -from django.test.utils import override_settings +import datetime +import base64 import json import re -from student.tests.factories import UserFactory + +from django.core.urlresolvers import reverse +from django.core import mail +from django.test import TestCase +from django.test.utils import override_settings from unittest import SkipTest -from user_api.models import UserPreference +import ddt +from pytz import UTC +from django_countries.countries import COUNTRIES + +from user_api.api import account as account_api, profile as profile_api + +from student.tests.factories import UserFactory from user_api.tests.factories import UserPreferenceFactory from django_comment_common import models from opaque_keys.edx.locations import SlashSeparatedCourseKey @@ -532,3 +542,554 @@ class PreferenceUsersListViewTest(UserApiTestCase): self.assertUserIsValid(user) all_user_uris = [user["url"] for user in first_page_users + second_page_users] self.assertEqual(len(set(all_user_uris)), 2) + + +class LoginSessionViewTest(ApiTestCase): + """Tests for the login end-points of the user API. """ + + USERNAME = "bob" + EMAIL = "bob@example.com" + PASSWORD = "password" + + def setUp(self): + super(LoginSessionViewTest, self).setUp() + self.url = reverse("user_api_login_session") + + def test_allowed_methods(self): + self.assertAllowedMethods(self.url, ["GET", "POST", "HEAD", "OPTIONS"]) + + def test_put_not_allowed(self): + response = self.client.put(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_delete_not_allowed(self): + response = self.client.delete(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_patch_not_allowed(self): + raise SkipTest("Django 1.4's test client does not support patch") + + def test_login_form(self): + # Retrieve the login form + response = self.client.get(self.url, content_type="application/json") + self.assertHttpOK(response) + + # Verify that the form description matches what we expect + form_desc = json.loads(response.content) + self.assertEqual(form_desc["method"], "post") + self.assertEqual(form_desc["submit_url"], self.url) + self.assertEqual(form_desc["fields"], [ + { + u"name": u"email", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"E-mail", + u"placeholder": u"example: username@domain.com", + u"instructions": u"This is the e-mail address you used to register with edX", + u"restrictions": { + u"min_length": 3, + u"max_length": 254 + }, + }, + { + u"name": u"password", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"Password", + u"placeholder": u"", + u"instructions": u"", + u"restrictions": { + u"min_length": 2, + u"max_length": 75 + }, + }, + ]) + + def test_login(self): + # Create a test user + UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + + # Login + response = self.client.post(self.url, { + "email": self.EMAIL, + "password": self.PASSWORD, + }) + self.assertHttpOK(response) + + # Verify that we logged in successfully by accessing + # a page that requires authentication. + response = self.client.get(reverse("dashboard")) + self.assertHttpOK(response) + + def test_invalid_credentials(self): + # Create a test user + UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + + # Invalid password + response = self.client.post(self.url, { + "email": self.EMAIL, + "password": "invalid" + }) + self.assertHttpForbidden(response) + + # Invalid email address + response = self.client.post(self.url, { + "email": "invalid@example.com", + "password": self.PASSWORD, + }) + self.assertHttpForbidden(response) + + def test_missing_login_params(self): + # Create a test user + UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD) + + # Missing password + response = self.client.post(self.url, { + "email": self.EMAIL, + }) + self.assertHttpBadRequest(response) + + # Missing email + response = self.client.post(self.url, { + "password": self.PASSWORD, + }) + self.assertHttpBadRequest(response) + + # Missing both email and password + response = self.client.post(self.url, {}) + + +@ddt.ddt +class RegistrationViewTest(ApiTestCase): + """Tests for the registration end-points of the User API. """ + + USERNAME = "bob" + EMAIL = "bob@example.com" + PASSWORD = "password" + NAME = "Bob Smith" + EDUCATION = "m" + YEAR_OF_BIRTH = "1998" + ADDRESS = "123 Fake Street" + CITY = "Springfield" + COUNTRY = "us" + GOALS = "Learn all the things!" + + def setUp(self): + super(RegistrationViewTest, self).setUp() + self.url = reverse("user_api_registration") + + def test_allowed_methods(self): + self.assertAllowedMethods(self.url, ["GET", "POST", "HEAD", "OPTIONS"]) + + def test_put_not_allowed(self): + response = self.client.put(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_delete_not_allowed(self): + response = self.client.delete(self.url) + self.assertHttpMethodNotAllowed(response) + + def test_patch_not_allowed(self): + raise SkipTest("Django 1.4's test client does not support patch") + + def test_register_form_default_fields(self): + no_extra_fields_setting = {} + + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"email", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"E-mail", + u"placeholder": u"example: username@domain.com", + u"instructions": u"This is the e-mail address you used to register with edX", + u"restrictions": { + u"min_length": 3, + u"max_length": 254 + }, + } + ) + + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"name", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"Full Name", + u"placeholder": u"", + u"instructions": u"Needed for any certificates you may earn", + u"restrictions": { + "max_length": 255, + } + } + ) + + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"username", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"Public Username", + u"placeholder": u"", + u"instructions": u"Will be shown in any discussions or forums you participate in (cannot be changed)", + u"restrictions": { + u"min_length": 2, + u"max_length": 30, + } + } + ) + + self._assert_reg_field( + no_extra_fields_setting, + { + u"name": u"password", + u"default": u"", + u"type": u"text", + u"required": True, + u"label": u"Password", + u"placeholder": u"", + u"instructions": u"", + u"restrictions": { + u"min_length": 2, + u"max_length": 75 + }, + } + ) + + def test_register_form_level_of_education(self): + self._assert_reg_field( + {"level_of_education": "optional"}, + { + "name": "level_of_education", + "default": "", + "type": "select", + "required": False, + "label": "Highest Level of Education Completed", + "placeholder": "", + "instructions": "", + "options": [ + {"value": "", "name": "--"}, + {"value": "p", "name": "Doctorate"}, + {"value": "m", "name": "Master's or professional degree"}, + {"value": "b", "name": "Bachelor's degree"}, + {"value": "a", "name": "Associate's degree"}, + {"value": "hs", "name": "Secondary/high school"}, + {"value": "jhs", "name": "Junior secondary/junior high/middle school"}, + {"value": "el", "name": "Elementary/primary school"}, + {"value": "none", "name": "None"}, + {"value": "other", "name": "Other"}, + ], + "restrictions": {}, + } + ) + + def test_register_form_gender(self): + self._assert_reg_field( + {"gender": "optional"}, + { + "name": "gender", + "default": "", + "type": "select", + "required": False, + "label": "Gender", + "placeholder": "", + "instructions": "", + "options": [ + {"value": "", "name": "--"}, + {"value": "m", "name": "Male"}, + {"value": "f", "name": "Female"}, + {"value": "o", "name": "Other"}, + ], + "restrictions": {}, + } + ) + + def test_register_form_year_of_birth(self): + this_year = datetime.datetime.now(UTC).year + year_options = ( + [{"value": "", "name": "--"}] + [ + {"value": unicode(year), "name": unicode(year)} + for year in range(this_year, this_year - 120, -1) + ] + ) + self._assert_reg_field( + {"year_of_birth": "optional"}, + { + "name": "year_of_birth", + "default": "", + "type": "select", + "required": False, + "label": "Year of Birth", + "placeholder": "", + "instructions": "", + "options": year_options, + "restrictions": {}, + } + ) + + def test_registration_form_mailing_address(self): + self._assert_reg_field( + {"mailing_address": "optional"}, + { + "name": "mailing_address", + "default": "", + "type": "textarea", + "required": False, + "label": "Mailing Address", + "placeholder": "", + "instructions": "", + "restrictions": {}, + } + ) + + def test_registration_form_goals(self): + self._assert_reg_field( + {"goals": "optional"}, + { + "name": "goals", + "default": "", + "type": "textarea", + "required": False, + "label": "Please share with us your reasons for registering with edX", + "placeholder": "", + "instructions": "", + "restrictions": {}, + } + ) + + def test_registration_form_city(self): + self._assert_reg_field( + {"city": "optional"}, + { + "name": "city", + "default": "", + "type": "text", + "required": False, + "label": "City", + "placeholder": "", + "instructions": "", + "restrictions": {}, + } + ) + + def test_registration_form_country(self): + country_options = ( + [{"name": "--", "value": ""}] + + [ + {"value": country_code, "name": unicode(country_name)} + for country_code, country_name in COUNTRIES + ] + ) + self._assert_reg_field( + {"country": "required"}, + { + "label": "Country", + "name": "country", + "default": "", + "type": "select", + "required": True, + "placeholder": "", + "instructions": "", + "options": country_options, + "restrictions": {}, + } + ) + + @override_settings(REGISTRATION_EXTRA_FIELDS={ + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "city": "optional", + "country": "required", + }) + def test_field_order(self): + response = self.client.get(self.url) + self.assertHttpOK(response) + + # Verify that all fields render in the correct order + form_desc = json.loads(response.content) + field_names = [field["name"] for field in form_desc["fields"]] + self.assertEqual(field_names, [ + "email", + "name", + "username", + "password", + "city", + "country", + "level_of_education", + "gender", + "year_of_birth", + "mailing_address", + "goals", + ]) + + def test_register(self): + # Create a new registration + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + }) + self.assertHttpOK(response) + + # Verify that the user exists + self.assertEqual( + account_api.account_info(self.USERNAME), + { + "username": self.USERNAME, + "email": self.EMAIL, + "is_active": False + } + ) + + # Verify that the user's full name is set + profile_info = profile_api.profile_info(self.USERNAME) + self.assertEqual(profile_info["full_name"], self.NAME) + + # 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) + + @override_settings(REGISTRATION_EXTRA_FIELDS={ + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "city": "optional", + "country": "required", + }) + def test_register_with_profile_info(self): + # Register, providing lots of demographic info + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "level_of_education": self.EDUCATION, + "mailing_address": self.ADDRESS, + "year_of_birth": self.YEAR_OF_BIRTH, + "goals": self.GOALS, + "city": self.CITY, + "country": self.COUNTRY + }) + self.assertHttpOK(response) + + # Verify the profile information + profile_info = profile_api.profile_info(self.USERNAME) + self.assertEqual(profile_info["level_of_education"], self.EDUCATION) + self.assertEqual(profile_info["mailing_address"], self.ADDRESS) + self.assertEqual(profile_info["year_of_birth"], int(self.YEAR_OF_BIRTH)) + self.assertEqual(profile_info["goals"], self.GOALS) + self.assertEqual(profile_info["city"], self.CITY) + self.assertEqual(profile_info["country"], self.COUNTRY) + + def test_activation_email(self): + # Register, which should trigger an activation email + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + }) + self.assertHttpOK(response) + + # Verify that the activation email was sent + self.assertEqual(len(mail.outbox), 1) + sent_email = mail.outbox[0] + self.assertEqual(sent_email.to, [self.EMAIL]) + self.assertEqual(sent_email.subject, "Activate Your edX Account") + self.assertIn("activate your account", sent_email.body) + + @ddt.data( + {"email": ""}, + {"email": "invalid"}, + {"name": ""}, + {"username": ""}, + {"username": "a"}, + {"password": ""}, + ) + def test_register_invalid_input(self, invalid_fields): + # Initially, the field values are all valid + data = { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + } + + # Override the valid fields, making the input invalid + data.update(invalid_fields) + + # Attempt to create the account, expecting an error response + response = self.client.post(self.url, data) + self.assertHttpBadRequest(response) + + + @override_settings(REGISTRATION_EXTRA_FIELDS={"country": "required"}) + @ddt.data("email", "name", "username", "password", "country") + def test_register_missing_required_field(self, missing_field): + data = { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "country": self.COUNTRY, + } + + del data[missing_field] + + # Send a request missing a field + response = self.client.post(self.url, data) + self.assertHttpBadRequest(response) + + def test_register_already_authenticated(self): + data = { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + } + + # Register once, which will also log us in + response = self.client.post(self.url, data) + self.assertHttpOK(response) + + # Try to register again + response = self.client.post(self.url, data) + self.assertHttpBadRequest(response) + + def _assert_reg_field(self, extra_fields_setting, expected_field): + """Retrieve the registration form description from the server and + verify that it contains the expected field. + + Args: + extra_fields_setting (dict): Override the Django setting controlling + which extra fields are displayed in the form. + + expected_field (dict): The field definition we expect to find in the form. + + Raises: + AssertionError + + """ + # Retrieve the registration form description + with override_settings(REGISTRATION_EXTRA_FIELDS=extra_fields_setting): + response = self.client.get(self.url) + self.assertHttpOK(response) + + # Verify that the form description matches what we'd expect + form_desc = json.loads(response.content) + self.assertIn(expected_field, form_desc["fields"]) diff --git a/common/djangoapps/user_api/urls.py b/common/djangoapps/user_api/urls.py index 9fd20194ea..f73c4969fa 100644 --- a/common/djangoapps/user_api/urls.py +++ b/common/djangoapps/user_api/urls.py @@ -10,6 +10,8 @@ user_api_router.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = patterns( '', url(r'^v1/', include(user_api_router.urls)), + url(r'^v1/account/login_session/$', user_api_views.LoginSessionView.as_view(), name="user_api_login_session"), + url(r'^v1/account/registration/$', user_api_views.RegistrationView.as_view(), name="user_api_registration"), url( r'^v1/preferences/(?P{})/users/$'.format(UserPreference.KEY_REGEX), user_api_views.PreferenceUsersListView.as_view() diff --git a/common/djangoapps/user_api/views.py b/common/djangoapps/user_api/views.py index d007d7a9aa..af3b63b177 100644 --- a/common/djangoapps/user_api/views.py +++ b/common/djangoapps/user_api/views.py @@ -1,18 +1,28 @@ +"""TODO""" + from django.conf import settings from django.contrib.auth.models import User +from django.http import HttpResponse, HttpResponseBadRequest +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie from rest_framework import authentication from rest_framework import filters from rest_framework import generics from rest_framework import permissions -from rest_framework import status from rest_framework import viewsets +from rest_framework.views import APIView from rest_framework.exceptions import ParseError -from rest_framework.response import Response +from django_countries.countries import COUNTRIES from user_api.serializers import UserSerializer, UserPreferenceSerializer -from user_api.models import UserPreference +from user_api.models import UserPreference, UserProfile from django_comment_common.models import Role from opaque_keys.edx.locations import SlashSeparatedCourseKey +from user_api.api import account as account_api, profile as profile_api +from user_api.helpers import FormDescription, shim_student_view + class ApiKeyHeaderPermission(permissions.BasePermission): def has_permission(self, request, view): @@ -31,6 +41,251 @@ class ApiKeyHeaderPermission(permissions.BasePermission): ) +class LoginSessionView(APIView): + """TODO""" + + def get(self, request): + """Render a form for allowing a user to log in. + + TODO + """ + form_desc = FormDescription("post", reverse("user_api_login_session")) + + form_desc.add_field( + "email", + label=_(u"E-mail"), + placeholder=_(u"example: username@domain.com"), + instructions=_( + u"This is the e-mail address you used to register with {platform}" + ).format(platform=settings.PLATFORM_NAME), + restrictions={ + "min_length": account_api.EMAIL_MIN_LENGTH, + "max_length": account_api.EMAIL_MAX_LENGTH, + } + ) + + form_desc.add_field( + "password", + label=_(u"Password"), + restrictions={ + "min_length": account_api.PASSWORD_MIN_LENGTH, + "max_length": account_api.PASSWORD_MAX_LENGTH, + } + ) + + return HttpResponse(form_desc.to_json(), content_type="application/json") + + @method_decorator(ensure_csrf_cookie) + def post(self, request): + """Authenticate a user and log them in. + + TODO + """ + # Validate the parameters + # If either param is missing, it's a malformed request + email = request.POST.get("email") + password = request.POST.get("password") + if email is None or password is None: + return HttpResponseBadRequest() + + return self._login_shim(request) + + def _login_shim(self, request): + # Initially, this should be a shim to student views, + # since it will be too much work to re-implement everything there. + # Eventually, we'll want to pull out that functionality into this Django app. + from student.views import login_user + return shim_student_view(login_user, check_logged_in=True)(request) + + +class RegistrationView(APIView): + """TODO""" + + DEFAULT_FIELDS = ["email", "name", "username", "password"] + EXTRA_FIELDS = [ + "city", "country", "level_of_education", "gender", + "year_of_birth", "mailing_address", "goals", + ] + + def __init__(self, *args, **kwargs): + super(RegistrationView, self).__init__(*args, **kwargs) + + self.field_handlers = {} + for field_name in (self.DEFAULT_FIELDS + self.EXTRA_FIELDS): + handler = getattr(self, "_add_{field_name}_field".format(field_name=field_name)) + self.field_handlers[field_name] = handler + + def get(self, request): + """Render a form for allowing the user to register. + + TODO + """ + form_desc = FormDescription("post", reverse("user_api_registration")) + + # Default fields are always required + for field_name in self.DEFAULT_FIELDS: + self.field_handlers[field_name](form_desc, required=True) + + # Extra fields from configuration may be required, optional, or hidden + # TODO -- explain error handling here + for field_name in self.EXTRA_FIELDS: + field_setting = settings.REGISTRATION_EXTRA_FIELDS.get(field_name) + handler = self.field_handlers[field_name] + + if field_setting in ["required", "optional"]: + handler(form_desc, required=(field_setting == "required")) + elif field_setting != "hidden": + # TODO -- warning here + pass + + return HttpResponse(form_desc.to_json(), content_type="application/json") + + def post(self, request): + """Create the user's account. + + TODO + """ + # Backwards compat: + # TODO -- explain this + request.POST["honor_code"] = "true" + request.POST["terms_of_service"] = "true" + + # Initially, this should be a shim to student views. + # Eventually, we'll want to pull that functionality into this API. + from student.views import create_account + return shim_student_view(create_account)(request) + + def _add_email_field(self, form_desc, required=True): + """TODO """ + form_desc.add_field( + "email", + label=_(u"E-mail"), + placeholder=_(u"example: username@domain.com"), + instructions=_( + u"This is the e-mail address you used to register with {platform}" + ).format(platform=settings.PLATFORM_NAME), + restrictions={ + "min_length": account_api.EMAIL_MIN_LENGTH, + "max_length": account_api.EMAIL_MAX_LENGTH, + }, + required=required + ) + + def _add_name_field(self, form_desc, required=True): + """TODO""" + form_desc.add_field( + "name", + label=_(u"Full Name"), + instructions=_(u"Needed for any certificates you may earn"), + restrictions={ + "max_length": profile_api.FULL_NAME_MAX_LENGTH, + }, + required=required + ) + + def _add_username_field(self, form_desc, required=True): + """TODO""" + form_desc.add_field( + "username", + label=_(u"Public Username"), + instructions=_(u"Will be shown in any discussions or forums you participate in (cannot be changed)"), + restrictions={ + "min_length": account_api.USERNAME_MIN_LENGTH, + "max_length": account_api.USERNAME_MAX_LENGTH, + }, + required=required + ) + + def _add_password_field(self, form_desc, required=True): + """TODO""" + form_desc.add_field( + "password", + label=_(u"Password"), + restrictions={ + "min_length": account_api.PASSWORD_MIN_LENGTH, + "max_length": account_api.PASSWORD_MAX_LENGTH, + }, + required=required + ) + + def _add_level_of_education_field(self, form_desc, required=True): + """ TODO """ + form_desc.add_field( + "level_of_education", + label=_("Highest Level of Education Completed"), + field_type="select", + options=self._options_with_default(UserProfile.LEVEL_OF_EDUCATION_CHOICES), + required=required + ) + + def _add_gender_field(self, form_desc, required=True): + """TODO """ + form_desc.add_field( + "gender", + label=_("Gender"), + field_type="select", + options=self._options_with_default(UserProfile.GENDER_CHOICES), + required=required + ) + + def _add_year_of_birth_field(self, form_desc, required=True): + """TODO """ + options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] + form_desc.add_field( + "year_of_birth", + label=_("Year of Birth"), + field_type="select", + options=self._options_with_default(options), + required=required + ) + + def _add_mailing_address_field(self, form_desc, required=True): + """TODO """ + form_desc.add_field( + "mailing_address", + label=_("Mailing Address"), + field_type="textarea", + required=required + ) + + def _add_goals_field(self, form_desc, required=True): + """TODO """ + form_desc.add_field( + "goals", + label=_("Please share with us your reasons for registering with edX"), + field_type="textarea", + required=required + ) + + def _add_city_field(self, form_desc, required=True): + """TODO """ + form_desc.add_field( + "city", + label=_("City"), + required=required + ) + + def _add_country_field(self, form_desc, required=True): + """TODO """ + options = [ + (country_code, unicode(country_name)) + for country_code, country_name in COUNTRIES + ] + form_desc.add_field( + "country", + label=_("Country"), + field_type="select", + options=self._options_with_default(options), + required=required + ) + + def _options_with_default(self, options): + """TODO """ + return ( + [("", "--")] + list(options) + ) + + class UserViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (authentication.SessionAuthentication,) permission_classes = (ApiKeyHeaderPermission,) diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 0b2447c3ea..0f6900adad 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -3,6 +3,7 @@ import re from urllib import urlencode +import json from mock import patch import ddt from django.test import TestCase @@ -60,6 +61,37 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): response = self.client.get(reverse('account_index')) self.assertContains(response, "Student Account") + @ddt.data( + ("login", "login"), + ("register", "register"), + ) + @ddt.unpack + def test_login_and_registration_form(self, url_name, initial_mode): + response = self.client.get(reverse(url_name)) + expected_data = u"data-initial-mode=\"{mode}\"".format(mode=initial_mode) + self.assertContains(response, expected_data) + + @ddt.data("login", "register") + def test_login_and_registration_third_party_auth_urls(self, url_name): + response = self.client.get(reverse(url_name)) + + # This relies on the THIRD_PARTY_AUTH configuration in the test settings + expected_data = u"data-third-party-auth-providers=\"{providers}\"".format( + providers=json.dumps([ + { + u'icon_class': u'icon-facebook', + u'login_url': u'/auth/login/facebook/?auth_entry=login', + u'name': u'Facebook' + }, + { + u'icon_class': u'icon-google-plus', + u'login_url': u'/auth/login/google-oauth2/?auth_entry=login', + u'name': u'Google' + } + ]) + ) + self.assertContains(response, expected_data) + def test_change_email(self): response = self._change_email(self.NEW_EMAIL, self.PASSWORD) self.assertEquals(response.status_code, 200) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index bb5a0d5690..d6e3b71233 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -1,8 +1,17 @@ from django.conf.urls import patterns, url +from django.conf import settings + urlpatterns = patterns( 'student_account.views', - url(r'^$', 'index', name='account_index'), - url(r'^email$', 'email_change_request_handler', name='email_change_request'), - url(r'^email/confirmation/(?P[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'), + url(r'^login/$', 'login_and_registration_form', {'initial_mode': 'login'}, name='login'), + url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='register'), ) + +if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'): + urlpatterns += patterns( + 'student_account.views', + url(r'^$', 'index', name='account_index'), + url(r'^email$', 'email_change_request_handler', name='email_change_request'), + url(r'^email/confirmation/(?P[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'), + ) \ No newline at end of file diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index b20e19d051..c64818cf21 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -1,15 +1,16 @@ """ Views for a student's account information. """ +import json from django.conf import settings from django.http import ( - QueryDict, HttpResponse, - HttpResponseBadRequest, HttpResponseServerError + HttpResponse, HttpResponseBadRequest, HttpResponseServerError ) from django.core.mail import send_mail from django_future.csrf import ensure_csrf_cookie from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from edxmako.shortcuts import render_to_response, render_to_string +import third_party_auth from microsite_configuration import microsite from user_api.api import account as account_api @@ -41,6 +42,38 @@ def index(request): ) +@require_http_methods(['GET']) +def login_and_registration_form(request, initial_mode="login"): + """Render the combined login/registration form, defaulting to login + + This relies on the JS to asynchronously load the actual form from + the user_api. + + Keyword Args: + initial_mode (string): Either "login" or "registration". + + """ + context = { + 'disable_courseware_js': True, + 'initial_mode': initial_mode, + 'third_party_auth_providers': json.dumps([]) + } + + if microsite.get_value("ENABLE_THIRD_PARTY_AUTH", settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH")): + context["third_party_auth_providers"] = json.dumps([ + { + "name": enabled.NAME, + "icon_class": enabled.ICON_CLASS, + "login_url": third_party_auth.pipeline.get_login_url( + enabled.NAME, third_party_auth.pipeline.AUTH_ENTRY_LOGIN + ), + } + for enabled in third_party_auth.provider.Registry.enabled() + ]) + + return render_to_response('student_account/login_and_register.html', context) + + @login_required @require_http_methods(['POST']) @ensure_csrf_cookie diff --git a/lms/djangoapps/student_profile/urls.py b/lms/djangoapps/student_profile/urls.py index ba4cebeba1..48d20711d9 100644 --- a/lms/djangoapps/student_profile/urls.py +++ b/lms/djangoapps/student_profile/urls.py @@ -1,8 +1,14 @@ from django.conf.urls import patterns, url +from django.conf import settings -urlpatterns = patterns( - 'student_profile.views', - url(r'^$', 'index', name='profile_index'), - url(r'^preferences$', 'preference_handler', name='preference_handler'), - url(r'^preferences/languages$', 'language_info', name='language_info'), -) + +urlpatterns = [] + + +if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'): + urlpatterns = patterns( + 'student_profile.views', + url(r'^$', 'index', name='profile_index'), + url(r'^preferences$', 'preference_handler', name='preference_handler'), + url(r'^preferences/languages$', 'language_info', name='language_info'), + ) diff --git a/lms/envs/test.py b/lms/envs/test.py index a2587ee7c5..c807101b9a 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -203,6 +203,17 @@ simplefilter('ignore') # Change to "default" to see the first instance of each ######### Third-party auth ########## FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True +THIRD_PARTY_AUTH = { + "Google": { + "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", + "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", + }, + "Facebook": { + "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test", + "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test", + }, +} + ################################## OPENID ##################################### FEATURES['AUTH_USE_OPENID'] = True FEATURES['AUTH_USE_OPENID_PROVIDER'] = True diff --git a/lms/templates/student_account/login_and_register.html b/lms/templates/student_account/login_and_register.html new file mode 100644 index 0000000000..19ed498fd8 --- /dev/null +++ b/lms/templates/student_account/login_and_register.html @@ -0,0 +1,52 @@ +<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='/static_content.html'/> + +<%inherit file="../main.html" /> + +<%block name="pagetitle">${_("Login and Register")} + +<%block name="js_extra"> + + + <%static:js group='student_account'/> + + +<%block name="header_extras"> +% for template_name in ["account"]: + +% endfor + + +

Login and Registration!

+ +

This is a placeholder for the combined login and registration form

+ +## TODO: Use JavaScript to populate this div with +## the actual registration/login forms (loaded asynchronously from the user API) +## The URLS for the forms are: +## - GET /user_api/v1/registration/ +## - GET /user_api/v1/login_session/ +## +## You can post back to those URLs with JSON-serialized +## data from the form fields in order to complete the registration +## or login. +## +## Also TODO: we need to figure out how to enroll students in +## a course if they got here from a course about page. +## +## third_party_auth_providers is a JSON-serialized list of +## dictionaries of the form: +## { +## "name": "Facebook", +## "icon_class": "facebook-icon", +## "login_url": "http://api.facebook.com/auth" +## } +## +## Note that this list may be empty. +## +
diff --git a/lms/urls.py b/lms/urls.py index 4f746e6fe6..5445900c65 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -375,6 +375,10 @@ if settings.COURSEWARE_ENABLED: # LTI endpoints listing url(r'^courses/{}/lti_rest_endpoints/'.format(settings.COURSE_ID_PATTERN), 'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'), + + # Student account and profile + url(r'^account/', include('student_account.urls')), + url(r'^profile/', include('student_profile.urls')), ) # allow course staff to change to student view of courseware @@ -537,12 +541,6 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): url(r'', include('third_party_auth.urls')), ) -# If enabled, expose the URLs for the new dashboard, account, and profile pages -if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'): - urlpatterns += ( - url(r'^profile/', include('student_profile.urls')), - url(r'^account/', include('student_account.urls')), - ) urlpatterns = patterns(*urlpatterns)