Repository: ocadotechnology/codeforlife-portal

Issue:
Student login form
So that students can log in :P


Discussion:

Resolving patch:
diff --git a/portal/forms/teach_new.py b/portal/forms/teach_new.py
--- a/portal/forms/teach_new.py
+++ b/portal/forms/teach_new.py
@@ -192,10 +192,10 @@ def check_password_errors(self, password, confirm_password, current_password):
 
 
 class TeacherLoginForm(forms.Form):
-    email = forms.EmailField(
+    teacher_email = forms.EmailField(
         label='Email address',
         widget=forms.EmailInput(attrs={'placeholder': "my.email@address.com"}))
-    password = forms.CharField(
+    teacher_password = forms.CharField(
         label='Password',
         widget=forms.PasswordInput)
 
@@ -203,8 +203,8 @@ def clean(self):
         if self.has_error('recaptcha'):
             raise forms.ValidationError('Incorrect email address, password or captcha')
 
-        email = self.cleaned_data.get('email', None)
-        password = self.cleaned_data.get('password', None)
+        email = self.cleaned_data.get('teacher_email', None)
+        password = self.cleaned_data.get('teacher_password', None)
 
         if email and password:
 
@@ -253,144 +253,6 @@ class ClassCreationForm(forms.Form):
         widget=forms.Select(attrs={'class': 'wide'}))
 
 
-class ClassEditForm(forms.Form):
-    classmate_choices = [('True', 'Yes'), ('False', 'No')]
-    # select dropdown choices for potentially limiting time in which external students may join
-    # class
-    # 0 value = don't allow
-    # n value = allow for next n hours, n < 1000 hours
-    # o/w = allow forever
-    join_choices = [('', "Don't change my current setting"),
-                    ('0', "Don't allow external requests to this class"),
-                    ('1', "Allow external requests to this class for the next hour")]
-    join_choices.extend([(str(hours), "Allow external requests to this class for the next " + str(hours) + " hours")
-                         for hours in range(4, 24, 4)])
-    join_choices.extend([(str(days*24), "Allow external requests to this class for the next " + str(days) + " days")
-                         for days in range(2, 5)])
-    join_choices.append(('1000', "Always allow external requests to this class (not recommended)"))
-    name = forms.CharField(
-        label='Class Name',
-        widget=forms.TextInput(attrs={'placeholder': 'Class Name'}))
-    classmate_progress = forms.ChoiceField(
-        label="Allow students to see their classmates' progress?",
-        choices=classmate_choices, widget=forms.Select(attrs={'class': 'wide'}))
-    external_requests = forms.ChoiceField(
-        label="Set up external requests to this class", required=False, choices=join_choices,
-        widget=forms.Select(attrs={'class': 'wide'}))
-
-
-class ClassMoveForm(forms.Form):
-    new_teacher = forms.ChoiceField(label='Teachers', widget=forms.Select(attrs={'class': 'wide'}))
-
-    def __init__(self, teachers, *args, **kwargs):
-        self.teachers = teachers
-        teacher_choices = []
-        for teacher in teachers:
-            teacher_choices.append((teacher.id, teacher.new_user.first_name + ' ' + teacher.new_user.last_name))
-        super(ClassMoveForm, self).__init__(*args, **kwargs)
-        self.fields['new_teacher'].choices = teacher_choices
-
-
-class TeacherEditStudentForm(forms.Form):
-    name = forms.CharField(label='Name', widget=forms.TextInput(attrs={'placeholder': 'Name'}))
-
-    def __init__(self, student, *args, **kwargs):
-        self.student = student
-        self.klass = student.class_field
-        super(TeacherEditStudentForm, self).__init__(*args, **kwargs)
-
-    def clean_name(self):
-        name = stripStudentName(self.cleaned_data.get('name', ''))
-
-        if name == '':
-            raise forms.ValidationError("'" + self.cleaned_data.get('name', '') + "' is not a valid name")
-
-        students = Student.objects.filter(class_field=self.klass,
-                                          new_user__first_name__iexact=name)
-        if students.exists() and students[0] != self.student:
-            raise forms.ValidationError("There is already a student called '" + name + "' in this class")
-
-        return name
-
-
-class TeacherSetStudentPass(forms.Form):
-    password = forms.CharField(
-        label='New password',
-        widget=forms.PasswordInput(attrs={'placeholder': "New password"}))
-    confirm_password = forms.CharField(
-        label='Confirm new password',
-        widget=forms.PasswordInput(attrs={'placeholder': "Confirm new password"}))
-
-    def clean_password(self):
-        password = self.cleaned_data.get('password', None)
-
-        if password and not password_strength_test(password, length=6, upper=False, lower=False,
-                                                   numbers=False):
-            raise forms.ValidationError(
-                "Password not strong enough, consider using at least 6 characters")
-
-        return password
-
-    def clean(self):
-        password = self.cleaned_data.get('password', None)
-        confirm_password = self.cleaned_data.get('confirm_password', None)
-
-        if password is not None and (password or confirm_password) and password != confirm_password:
-            raise forms.ValidationError('The new passwords do not match')
-
-        return self.cleaned_data
-
-
-class TeacherAddExternalStudentForm(forms.Form):
-    name = forms.CharField(
-        label='Student name',
-        widget=forms.TextInput(attrs={'placeholder': 'Name'}))
-
-    def __init__(self, klass, *args, **kwargs):
-        self.klass = klass
-        super(TeacherAddExternalStudentForm, self).__init__(*args, **kwargs)
-
-    def clean_name(self):
-        name = stripStudentName(self.cleaned_data.get('name', ''))
-
-        if name == '':
-            raise forms.ValidationError("'" + self.cleaned_data.get('name', '') + "' is not a valid name")
-
-        if Student.objects.filter(
-                class_field=self.klass, new_user__first_name__iexact=name).exists():
-            raise forms.ValidationError("There is already a student called '" + name + "' in this class")
-
-        return name
-
-
-class TeacherMoveStudentsDestinationForm(forms.Form):
-    new_class = forms.ChoiceField(label='Choose a new class from the drop down menu for the selected students.', widget=forms.Select(attrs={'class': 'wide'}))
-
-    def __init__(self, classes, *args, **kwargs):
-        self.classes = classes
-        class_choices = []
-        for klass in classes:
-            class_choices.append((klass.id, klass.name + ' (' + klass.access_code + '), ' + klass.teacher.new_user.first_name + ' ' + klass.teacher.new_user.last_name))
-        super(TeacherMoveStudentsDestinationForm, self).__init__(*args, **kwargs)
-        self.fields['new_class'].choices = class_choices
-
-
-class TeacherMoveStudentDisambiguationForm(forms.Form):
-    orig_name = forms.CharField(
-        label='Original Name',
-        widget=forms.TextInput(
-            attrs={'readonly': 'readonly', 'placeholder': 'Original Name', 'type': 'hidden'}))
-    name = forms.CharField(
-        label='Name',
-        widget=forms.TextInput(attrs={'placeholder': 'Name', 'style': 'margin : 0px'}))
-
-    def clean_name(self):
-        name = stripStudentName(self.cleaned_data.get('name', ''))
-        if name == '':
-            raise forms.ValidationError("'" + self.cleaned_data.get('name', '') + "' is not a valid name")
-        return name
-
-
 def validateStudentNames(klass, names):
     validationErrors = []
 
@@ -425,75 +287,6 @@ def find_duplicates(names, lower_names, validationErrors):
             duplicates_found.append(duplicate)
 
 
-class BaseTeacherMoveStudentsDisambiguationFormSet(forms.BaseFormSet):
-    def __init__(self, destination, *args, **kwargs):
-        self.destination = destination
-        super(BaseTeacherMoveStudentsDisambiguationFormSet, self).__init__(*args, **kwargs)
-
-    def clean(self):
-        if any(self.errors):
-            return
-
-        names = [form.cleaned_data['name'] for form in self.forms]
-
-        validationErrors = validateStudentNames(self.destination, names)
-
-        if len(validationErrors) > 0:
-            raise forms.ValidationError(validationErrors)
-
-        self.strippedNames = names
-
-
-class TeacherDismissStudentsForm(forms.Form):
-    orig_name = forms.CharField(
-        label='Original Name',
-        widget=forms.TextInput(
-            attrs={'readonly': 'readonly', 'placeholder': 'Original Name', 'type': 'hidden'}))
-    name = forms.CharField(
-        label='New Name',
-        widget=forms.TextInput(attrs={'placeholder': 'New Name', 'style': 'margin : 0px'}))
-    email = forms.EmailField(
-        label='Email',
-        widget=forms.EmailInput(attrs={'placeholder': 'Email Address', 'style': 'margin : 0px'}))
-    confirm_email = forms.EmailField(
-        label='Confirm Email',
-        widget=forms.EmailInput(
-            attrs={'placeholder': 'Confirm Email Address', 'style': 'margin : 0px'}))
-
-    def clean_name(self):
-        name = stripStudentName(self.cleaned_data.get('name', ''))
-
-        if name == '':
-            raise forms.ValidationError("'" + self.cleaned_data.get('name', '') + "' is not a valid name")
-
-        if User.objects.filter(username=name).exists():
-            raise forms.ValidationError('That username is already in use')
-
-        return name
-
-    def clean(self):
-        email = self.cleaned_data.get('email', None)
-        confirm_email = self.cleaned_data.get('confirm_email', None)
-
-        if (email or confirm_email) and email != confirm_email:
-            raise forms.ValidationError('Your new emails do not match')
-
-        return self.cleaned_data
-
-
-class BaseTeacherDismissStudentsFormSet(forms.BaseFormSet):
-    def clean(self):
-        if any(self.errors):
-            return
-
-        names = [form.cleaned_data['name'] for form in self.forms]
-
-        validationErrors = validateStudentNames(None, names)
-
-        if len(validationErrors) > 0:
-            raise forms.ValidationError(validationErrors)
-
-
 class StudentCreationForm(forms.Form):
     names = forms.CharField(label='names', widget=forms.Textarea)
 
diff --git a/portal/views/home_new.py b/portal/views/home_new.py
--- a/portal/views/home_new.py
+++ b/portal/views/home_new.py
@@ -46,7 +46,8 @@
 from django_recaptcha_field import create_form_subclass_with_recaptcha
 
 from portal.models import Teacher, Class
-from portal.forms.teach import TeacherSignupForm, TeacherLoginForm
+from portal.forms.teach_new import TeacherSignupForm, TeacherLoginForm
+from portal.forms.play import StudentLoginForm
 from portal.helpers.emails_new import send_verification_email, is_verified
 from portal.utils import using_two_factor
 from portal import app_settings
@@ -57,7 +58,18 @@
 
 def teach_email_labeller(request):
     if request.method == 'POST' and 'login' in request.POST:
-        return request.POST['login-email']
+        return request.POST['login-teacher_email']
+
+    return ''
+
+
+def play_name_labeller(request):
+    if request.method == 'POST':
+        if 'school_login' in request.POST:
+            return request.POST['login-name'] + ':' + request.POST['login-access_code']
+
+        if 'independent_student_login' in request.POST:
+            return request.POST['independent_student-username']
 
     return ''
 
@@ -77,29 +89,52 @@ def register_view(request):
 
 @ratelimit('ip', periods=['1m'], increment=lambda req, res: hasattr(res, 'count') and res.count)
 @ratelimit('email', labeller=teach_email_labeller, ip=False, periods=['1m'], increment=lambda req, res: hasattr(res, 'count') and res.count)
+@ratelimit('name', labeller=play_name_labeller, ip=False, periods=['1m'], increment=lambda req, res: hasattr(res, 'count') and res.count)
 def render_login_form(request):
     invalid_form = False
 
-    limits = getattr(request, 'limits', {'ip': [0], 'email': [0]})
-    captcha_limit = 5
+    teacher_limits = getattr(request, 'limits', {'ip': [0], 'email': [0]})
+    teacher_captcha_limit = 5
 
     LoginFormWithCaptcha = partial(create_form_subclass_with_recaptcha(TeacherLoginForm, recaptcha_client), request)
-    InputLoginForm = compute_input_login_form(LoginFormWithCaptcha, limits, captcha_limit)
-    OutputLoginForm = compute_output_login_form(LoginFormWithCaptcha, limits, captcha_limit)
+    InputLoginForm = compute_teacher_input_login_form(LoginFormWithCaptcha, teacher_limits, teacher_captcha_limit)
+    OutputLoginForm = compute_teacher_output_login_form(LoginFormWithCaptcha, teacher_limits, teacher_captcha_limit)
 
     login_form = OutputLoginForm(prefix='login')
 
+    student_limits = getattr(request, 'limits', {'ip': [0], 'name': [0]})
+    student_captcha_limit = 30
+    student_name_captcha_limit = 5
+
+    StudentLoginFormWithCaptcha = partial(create_form_subclass_with_recaptcha(StudentLoginForm, recaptcha_client), request)
+    InputStudentLoginForm = compute_student_input_login_form(StudentLoginFormWithCaptcha, student_limits, student_captcha_limit, student_name_captcha_limit)
+    OutputStudentLoginForm = compute_student_output_login_form(StudentLoginFormWithCaptcha, student_limits, student_captcha_limit, student_name_captcha_limit)
+
+    school_login_form = OutputStudentLoginForm(prefix='login')
+
     if request.method == 'POST':
-        login_form = InputLoginForm(request.POST, prefix='login')
-        if login_form.is_valid():
-            return process_login_form(request, login_form)
+        if 'school_login' in request.POST:
+            school_login_form = InputStudentLoginForm(request.POST, prefix='login')
+
+            if school_login_form.is_valid():
+                return process_student_login_form(request, school_login_form)
+
+            else:
+                school_login_form = OutputStudentLoginForm(request.POST, prefix='login')
+                invalid_form = True
 
         else:
-            login_form = OutputLoginForm(request.POST, prefix='login')
-            invalid_form = True
+            login_form = InputLoginForm(request.POST, prefix='login')
+            if login_form.is_valid():
+                return process_login_form(request, login_form)
+
+            else:
+                login_form = OutputLoginForm(request.POST, prefix='login')
+                invalid_form = True
 
     res = render(request, 'redesign/login.html', {
         'login_form': login_form,
+        'school_login_form': school_login_form,
         'logged_in_as_teacher': is_logged_in_as_teacher(request),
     })
 
@@ -128,26 +163,46 @@ def render_signup_form(request):
     return res
 
 
-def compute_use_captcha(limits, captcha_limit):
+def compute_teacher_use_captcha(limits, captcha_limit):
     using_captcha = (limits['ip'][0] > captcha_limit or limits['email'][0] > captcha_limit)
     return using_captcha
 
 
-def compute_should_use_captcha(limits, captcha_limit):
+def compute_teacher_should_use_captcha(limits, captcha_limit):
     should_use_captcha = (limits['ip'][0] >= captcha_limit or limits['email'][0] >= captcha_limit)
     return should_use_captcha
 
 
-def compute_input_login_form(LoginFormWithCaptcha, limits, captcha_limit):
-    InputLoginForm = LoginFormWithCaptcha if compute_use_captcha(limits, captcha_limit) else TeacherLoginForm
+def compute_student_use_captcha(limits, ip_captcha_limit, name_captcha_limit):
+    using_captcha = (limits['ip'][0] > ip_captcha_limit or limits['name'][0] >= name_captcha_limit)
+    return using_captcha
+
+
+def compute_student_should_use_captcha(limits, ip_captcha_limit, name_captcha_limit):
+    should_use_captcha = (limits['ip'][0] >= ip_captcha_limit or limits['name'][0] >= name_captcha_limit)
+    return should_use_captcha
+
+
+def compute_teacher_input_login_form(LoginFormWithCaptcha, limits, captcha_limit):
+    InputLoginForm = LoginFormWithCaptcha if compute_teacher_use_captcha(limits, captcha_limit) else TeacherLoginForm
     return InputLoginForm
 
 
-def compute_output_login_form(LoginFormWithCaptcha, limits, captcha_limit):
-    OutputLoginForm = LoginFormWithCaptcha if compute_should_use_captcha(limits, captcha_limit) else TeacherLoginForm
+def compute_teacher_output_login_form(LoginFormWithCaptcha, limits, captcha_limit):
+    OutputLoginForm = LoginFormWithCaptcha if compute_teacher_should_use_captcha(limits, captcha_limit) else TeacherLoginForm
     return OutputLoginForm
 
 
+def compute_student_input_login_form(StudentLoginFormWithCaptcha, limits, ip_captcha_limit, name_captcha_limit):
+    InputStudentLoginForm = StudentLoginFormWithCaptcha if compute_student_use_captcha(limits, ip_captcha_limit, name_captcha_limit) else StudentLoginForm
+    return InputStudentLoginForm
+
+
+def compute_student_output_login_form(StudentLoginFormWithCaptcha, limits, ip_captcha_limit, name_captcha_limit):
+    OutputStudentLoginForm = StudentLoginFormWithCaptcha if compute_student_should_use_captcha(limits, ip_captcha_limit, name_captcha_limit) else StudentLoginForm
+    return OutputStudentLoginForm
+
+
 def process_login_form(request, login_form):
     user = login_form.user
     if not is_verified(user):
@@ -180,6 +235,16 @@ def process_login_form(request, login_form):
     return redirect_user_to_correct_page(teacher)
 
 
+def process_student_login_form(request, school_login_form):
+    login(request, school_login_form.user)
+
+    next_url = request.GET.get('next', None)
+    if next_url:
+        return HttpResponseRedirect(next_url)
+
+    return HttpResponseRedirect(reverse_lazy('student_details'))
+
+
 def process_signup_form(request, data):
     teacher = Teacher.objects.factory(
         title=data['title'],
diff --git a/portal/views/teacher/teach_new.py b/portal/views/teacher/teach_new.py
--- a/portal/views/teacher/teach_new.py
+++ b/portal/views/teacher/teach_new.py
@@ -43,7 +43,6 @@
 from django.contrib import messages as messages
 from django.contrib.auth.decorators import login_required, user_passes_test
 from django.contrib.staticfiles.storage import staticfiles_storage
-from django.utils import timezone
 
 from reportlab.pdfgen import canvas
 from reportlab.lib.pagesizes import A4


Test patch:
diff --git a/portal/tests/pageObjects/portal/login_page.py b/portal/tests/pageObjects/portal/login_page.py
--- a/portal/tests/pageObjects/portal/login_page.py
+++ b/portal/tests/pageObjects/portal/login_page.py
@@ -72,15 +72,30 @@ def login_no_students(self, email, password):
 
         return teach.onboarding_students_page.OnboardingStudentsPage(self.browser)
 
+    def student_login(self, name, access_code, password):
+        self._student_login(name, access_code, password)
+
+        return play_page.PlayDashboardPage(self.browser)
+
     def login_failure(self, email, password):
         self._login(email, password)
         return self
 
+    def student_login_failure(self, name, access_code, password):
+        self._student_login(name, access_code, password)
+        return self
+
     def _login(self, email, password):
-        self.browser.find_element_by_id('id_login-email').send_keys(email)
-        self.browser.find_element_by_id('id_login-password').send_keys(password)
+        self.browser.find_element_by_id('id_login-teacher_email').send_keys(email)
+        self.browser.find_element_by_id('id_login-teacher_password').send_keys(password)
         self.browser.find_element_by_name('login').click()
 
+    def _student_login(self, name, access_code, password):
+        self.browser.find_element_by_id('id_login-name').send_keys(name)
+        self.browser.find_element_by_id('id_login-access_code').send_keys(access_code)
+        self.browser.find_element_by_id('id_login-password').send_keys(password)
+        self.browser.find_element_by_name('school_login').click()
+
     def has_login_failed(self):
         if not self.element_exists_by_css('.errorlist'):
             return False
@@ -88,3 +103,11 @@ def has_login_failed(self):
         errors = self.browser.find_element_by_id('form-login-teacher').find_element_by_class_name('errorlist').text
         error = 'Incorrect email address or password'
         return error in errors
+
+    def has_student_login_failed(self):
+        if not self.element_exists_by_css('.errorlist'):
+            return False
+
+        errors = self.browser.find_element_by_id('form-login-school').find_element_by_class_name('errorlist').text
+        error = 'Invalid name, class access code or password'
+        return error in errors
diff --git a/portal/tests/test_school_student_new.py b/portal/tests/test_school_student_new.py
new file mode 100644
--- /dev/null
+++ b/portal/tests/test_school_student_new.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# Code for Life
+#
+# Copyright (C) 2016, Ocado Innovation Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+# ADDITIONAL TERMS – Section 7 GNU General Public Licence
+#
+# This licence does not grant any right, title or interest in any “Ocado” logos,
+# trade names or the trademark “Ocado” or any other trademarks or domain names
+# owned by Ocado Innovation Limited or the Ocado group of companies or any other
+# distinctive brand features of “Ocado” as may be secured from time to time. You
+# must not distribute any modification of this program using the trademark
+# “Ocado” or claim any affiliation or association with Ocado or its employees.
+#
+# You are not authorised to use the name Ocado (or any of its trade names) or
+# the names of any author or contributor in advertising or for publicity purposes
+# pertaining to the distribution of this program, without the prior written
+# authorisation of Ocado.
+#
+# Any propagation, distribution or conveyance of this program must include this
+# copyright notice and these terms. You must not misrepresent the origins of this
+# program; modified versions of the program must be marked as such and not
+# identified as the original program.
+from base_test import BaseTest
+
+from portal.tests.pageObjects.portal.home_page_new import HomePage
+from utils.teacher_new import signup_teacher_directly
+from utils.organisation import create_organisation_directly
+from utils.classes import create_class_directly
+from utils.student import create_school_student_directly
+
+from django_selenium_clean import selenium
+
+
+class TestSchoolStudent(BaseTest):
+    def test_login(self):
+        email, password = signup_teacher_directly()
+        create_organisation_directly(email)
+        _, class_name, access_code = create_class_directly(email)
+        student_name, student_password, _ = create_school_student_directly(access_code)
+
+        selenium.get(self.live_server_url + "/portal/redesign/home")
+        page = HomePage(selenium)\
+            .go_to_login_page()\
+            .student_login(student_name, access_code, student_password)
+        assert self.is_dashboard(page)
+
+    def test_login_failure(self):
+        email, password = signup_teacher_directly()
+        create_organisation_directly(email)
+        _, class_name, access_code = create_class_directly(email)
+        student_name, student_password, _ = create_school_student_directly(access_code)
+
+        selenium.get(self.live_server_url + "/portal/redesign/home")
+        page = HomePage(selenium)\
+            .go_to_login_page()\
+            .student_login_failure(student_name, access_code, 'some other password')
+
+        assert page.has_student_login_failed()
+
+    def is_dashboard(self, page):
+        return page.__class__.__name__ == 'PlayDashboardPage'