Commit e89afa93 by Will Daly

WIP: add login and registration end-points to the user API.

parent 80613361
......@@ -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
......
......@@ -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
......@@ -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<pref_key>{})/users/$'.format(UserPreference.KEY_REGEX),
user_api_views.PreferenceUsersListView.as_view()
......
"""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,)
......
......@@ -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)
......
from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
'student_account.views',
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<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'),
)
)
\ No newline at end of file
""" 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
......
from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
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'),
)
)
......@@ -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
......
<%! 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>
<%block name="js_extra">
<script type="text/javascript" src="${static.url('js/vendor/underscore-min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/backbone-min.js')}"></script>
<%static:js group='student_account'/>
</%block>
<%block name="header_extras">
% for template_name in ["account"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" />
</script>
% endfor
</%block>
<h1>Login and Registration!</h1>
<p>This is a placeholder for the combined login and registration form</p>
## 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.
##
<div id="login-and-registration-container"
data-initial-mode="${initial_mode}"
data-third-party-auth-providers="${third_party_auth_providers}"
/>
......@@ -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)
......
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