Repository: ocadotechnology/codeforlife-portal

Issue:
Dashboard page: external requests table
When independent students submit a request to join a school/class, the external requests table in the dashboard needs to be updated accordingly.


Discussion:

Resolving patch:
diff --git a/portal/forms/teach.py b/portal/forms/teach.py
--- a/portal/forms/teach.py
+++ b/portal/forms/teach.py
@@ -493,3 +493,24 @@ def clean(self):
         self.strippedNames = names
 
         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
diff --git a/portal/urls.py b/portal/urls.py
--- a/portal/urls.py
+++ b/portal/urls.py
@@ -59,7 +59,8 @@
     teacher_delete_students, teacher_delete_class, teacher_class_password_reset, teacher_move_students,\
     teacher_move_students_to_class, default_solution, teacher_dismiss_students, teacher_level_solutions
 from portal.views.teacher.dashboard import dashboard_manage, organisation_allow_join, organisation_deny_join, \
-    organisation_kick, organisation_toggle_admin, teacher_disable_2FA
+    organisation_kick, organisation_toggle_admin, teacher_disable_2FA, teacher_reject_student_request, \
+    teacher_accept_student_request
 from portal.views.registration import teacher_password_reset, password_reset_done, student_password_reset, \
     password_reset_check_and_confirm, custom_2FA_login
 js_info_dict = {
@@ -140,6 +141,8 @@
     url(r'^teach/dashboard/allow_join/(?P<pk>[0-9]+)/$', organisation_allow_join, name='organisation_allow_join'),
     url(r'^teach/dashboard/deny_join/(?P<pk>[0-9]+)/$', organisation_deny_join, name='organisation_deny_join'),
     url(r'^teach/dashboard/school/leave/$', organisation_leave, name='organisation_leave'),
+    url(r'^teach/dashboard/student/accept/(?P<pk>[0-9]+)/$', teacher_accept_student_request, name='teacher_accept_student_request'),
+    url(r'^teach/dashboard/student/reject/(?P<pk>[0-9]+)/$', teacher_reject_student_request, name='teacher_reject_student_request'),
     url(r'^teach/class/(?P<access_code>[A-Z0-9]+)/$', teacher_view_class, name='view_class'),
     url(r'^teach/class/delete/(?P<access_code>[A-Z0-9]+)/$', teacher_delete_class, name='teacher_delete_class'),
     url(r'^teach/class/(?P<access_code>[A-Z0-9]+)/students/delete/$', teacher_delete_students, name='teacher_delete_students'),
diff --git a/portal/views/teacher/dashboard.py b/portal/views/teacher/dashboard.py
--- a/portal/views/teacher/dashboard.py
+++ b/portal/views/teacher/dashboard.py
@@ -47,12 +47,12 @@
 
 from portal import app_settings, emailMessages
 from portal.helpers.emails import send_email, NOTIFICATION_EMAIL
-from portal.models import Teacher, Class
+from portal.models import Teacher, Class, Student
 from portal.forms.organisation import OrganisationForm
-from portal.forms.teach import ClassCreationForm, TeacherEditAccountForm
+from portal.forms.teach import ClassCreationForm, TeacherEditAccountForm, TeacherAddExternalStudentForm
 from portal.permissions import logged_in_as_teacher
 from portal.helpers.emails import send_verification_email
-from portal.helpers.generators import generate_access_code
+from portal.helpers.generators import generate_access_code, get_random_username
 from portal.helpers.location import lookup_coord
 
 from portal.utils import using_two_factor
@@ -69,6 +69,7 @@ def dashboard_teacher_view(request, is_admin):
     coworkers = Teacher.objects.filter(school=school).order_by('new_user__last_name', 'new_user__first_name')
 
     join_requests = Teacher.objects.filter(pending_join_request=school).order_by('new_user__last_name', 'new_user__first_name')
+    requests = Student.objects.filter(pending_class_request__teacher=teacher)
 
     update_school_form = OrganisationForm(user=request.user, current_school=school)
     update_school_form.fields['name'].initial = school.name
@@ -117,6 +118,7 @@ def dashboard_teacher_view(request, is_admin):
         'is_admin': is_admin,
         'coworkers': coworkers,
         'join_requests': join_requests,
+        'requests': requests,
         'update_school_form': update_school_form,
         'create_class_form': create_class_form,
         'update_account_form': update_account_form,
@@ -353,3 +355,67 @@ def teacher_disable_2FA(request, pk):
         device.delete()
 
     return HttpResponseRedirect(reverse_lazy('dashboard'))
+
+
+@login_required(login_url=reverse_lazy('login_view'))
+@user_passes_test(logged_in_as_teacher, login_url=reverse_lazy('login_new'))
+def teacher_accept_student_request(request, pk):
+    student = get_object_or_404(Student, id=pk)
+
+    # check student is awaiting decision on request
+    if not student.pending_class_request:
+        raise Http404
+
+    # check user (teacher) has authority to accept student
+    if request.user.new_teacher != student.pending_class_request.teacher:
+        raise Http404
+
+    students = Student.objects.filter(class_field=student.pending_class_request).order_by('new_user__first_name')
+
+    if request.method == 'POST':
+        form = TeacherAddExternalStudentForm(student.pending_class_request, request.POST)
+        if form.is_valid():
+            data = form.cleaned_data
+            student.class_field = student.pending_class_request
+            student.pending_class_request = None
+            student.new_user.username = get_random_username()
+            student.new_user.first_name = data['name']
+            student.new_user.last_name = ''
+            student.new_user.email = ''
+            student.save()
+            student.new_user.save()
+            return render(request, 'portal/teach/teacher_added_external_student.html',
+                          {'student': student,
+                           'class': student.class_field})
+    else:
+        form = TeacherAddExternalStudentForm(student.pending_class_request, initial={'name': student.new_user.first_name})
+
+    return render(request, 'portal/teach/teacher_add_external_student.html',
+                  {'students': students,
+                   'class': student.pending_class_request,
+                   'student': student,
+                   'form': form})
+
+
+@login_required(login_url=reverse_lazy('login_view'))
+@user_passes_test(logged_in_as_teacher, login_url=reverse_lazy('login_view'))
+def teacher_reject_student_request(request, pk):
+    student = get_object_or_404(Student, id=pk)
+
+    # check student is awaiting decision on request
+    if not student.pending_class_request:
+        raise Http404
+
+    # check user (teacher) has authority to reject student
+    if request.user.new_teacher != student.pending_class_request.teacher:
+        raise Http404
+
+    emailMessage = emailMessages.studentJoinRequestRejectedEmail(request, student.pending_class_request.teacher.school.name, student.pending_class_request.access_code)
+    send_email(NOTIFICATION_EMAIL, [student.new_user.email], emailMessage['subject'], emailMessage['message'])
+
+    student.pending_class_request = None
+    student.save()
+
+    messages.success(request, 'Request from external/independent student has been rejected successfully.')
+
+    return HttpResponseRedirect(reverse_lazy('dashboard'))


Test patch:
diff --git a/portal/tests/pageObjects/portal/play/dashboard_page.py b/portal/tests/pageObjects/portal/play/dashboard_page.py
--- a/portal/tests/pageObjects/portal/play/dashboard_page.py
+++ b/portal/tests/pageObjects/portal/play/dashboard_page.py
@@ -35,6 +35,7 @@
 # program; modified versions of the program must be marked as such and not
 # identified as the original program.
 from play_base_page import PlayBasePage
+from portal.tests.pageObjects.portal.play.join_school_or_club_page import JoinSchoolOrClubPage
 
 
 class PlayDashboardPage(PlayBasePage):
@@ -42,3 +43,8 @@ def __init__(self, browser):
         super(PlayDashboardPage, self).__init__(browser)
 
         assert self.on_correct_page('play_dashboard_page')
+
+    def go_to_join_a_school_or_club_page(self):
+        self.browser.find_element_by_id('join_a_school_or_club').click()
+
+        return JoinSchoolOrClubPage(self.browser)
diff --git a/portal/tests/pageObjects/portal/play/join_school_or_club_page.py b/portal/tests/pageObjects/portal/play/join_school_or_club_page.py
new file mode 100644
--- /dev/null
+++ b/portal/tests/pageObjects/portal/play/join_school_or_club_page.py
@@ -0,0 +1,51 @@
+# * coding: utf8 *
+# Code for Life
+#
+# Copyright (C) 2017, 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 portal.tests.pageObjects.portal.play.play_base_page import PlayBasePage
+
+
+class JoinSchoolOrClubPage(PlayBasePage):
+    def __init__(self, browser):
+        super(JoinSchoolOrClubPage, self).__init__(browser)
+
+        assert self.on_correct_page('play_join_organisation_page')
+
+    def join_a_school_or_club(self, access_code):
+        self.browser.find_element_by_id('id_access_code').send_keys(access_code)
+        self.browser.find_element_by_id('request_join_a_school_or_club_button').click()
+        assert self.element_exists_by_css('.success')
+
+        return self
diff --git a/portal/tests/pageObjects/portal/play/play_base_page.py b/portal/tests/pageObjects/portal/play/play_base_page.py
--- a/portal/tests/pageObjects/portal/play/play_base_page.py
+++ b/portal/tests/pageObjects/portal/play/play_base_page.py
@@ -34,6 +34,7 @@
 # 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.
+import time
 from portal.tests.pageObjects.portal.base_page import BasePage
 
 
@@ -41,11 +42,14 @@ class PlayBasePage(BasePage):
     def __init__(self, browser):
         super(PlayBasePage, self).__init__(browser)
 
-    # def logout(self):
-    #     self.browser.find_element_by_id('logout_button').click()
-    #     from portal.tests.pageObjects.portal.home_page import HomePage
-    #
-    #     return HomePage(self.browser)
+    def logout(self):
+        self.browser.find_element_by_id('logout_menu').click()
+
+        time.sleep(1)
+        self.browser.find_element_by_id('logout_button').click()
+        from portal.tests.pageObjects.portal.home_page import HomePage
+
+        return HomePage(self.browser)
 
     def go_to_account_page(self):
         self.browser.find_element_by_id('student_account_button').click()
diff --git a/portal/tests/pageObjects/portal/teach/add_independent_student_to_class_page.py b/portal/tests/pageObjects/portal/teach/add_independent_student_to_class_page.py
new file mode 100644
--- /dev/null
+++ b/portal/tests/pageObjects/portal/teach/add_independent_student_to_class_page.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# Code for Life
+#
+# Copyright (C) 2017, 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 portal.tests.pageObjects.portal.teach.added_independent_student_to_class_page import \
+    AddedIndependentStudentToClassPage
+from portal.tests.pageObjects.portal.teach.teach_base_page import TeachBasePage
+
+from selenium.webdriver.common.by import By
+
+
+class AddIndependentStudentToClassPage(TeachBasePage):
+    def __init__(self, browser):
+        super(AddIndependentStudentToClassPage, self).__init__(browser)
+
+        assert self.on_correct_page('add_independent_student_to_class')
+
+    def _rename(self):
+        self.browser.find_element_by_id('id_name').clear()
+        self.browser.find_element_by_id('id_name').send_keys("Student2")
+
+    def save(self):
+        self._rename()
+        self.wait_for_element_to_be_clickable((By.ID, 'save_student_name_button'))
+        self.browser.find_element_by_id('save_student_name_button').click()
+
+        return AddedIndependentStudentToClassPage(self.browser)
diff --git a/portal/tests/pageObjects/portal/teach/added_independent_student_to_class_page.py b/portal/tests/pageObjects/portal/teach/added_independent_student_to_class_page.py
new file mode 100644
--- /dev/null
+++ b/portal/tests/pageObjects/portal/teach/added_independent_student_to_class_page.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Code for Life
+#
+# Copyright (C) 2017, 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 portal.tests.pageObjects.portal.teach.class_page import TeachClassPage
+from portal.tests.pageObjects.portal.teach.teach_base_page import TeachBasePage
+
+
+class AddedIndependentStudentToClassPage(TeachBasePage):
+    def __init__(self, browser):
+        super(AddedIndependentStudentToClassPage, self).__init__(browser)
+
+        assert self.on_correct_page('added_independent_student_to_class')
+
+    def return_to_class(self):
+        self.browser.find_element_by_id('return_button').click()
+
+        return TeachClassPage(self.browser)
diff --git a/portal/tests/pageObjects/portal/teach/dashboard_page.py b/portal/tests/pageObjects/portal/teach/dashboard_page.py
--- a/portal/tests/pageObjects/portal/teach/dashboard_page.py
+++ b/portal/tests/pageObjects/portal/teach/dashboard_page.py
@@ -37,7 +37,7 @@
 from teach_base_page import TeachBasePage
 from class_page import TeachClassPage
 from move_classes_page import TeachMoveClassesPage
-from onboarding_organisation_page import OnboardingOrganisationPage
+from add_independent_student_to_class_page import AddIndependentStudentToClassPage
 from selenium.webdriver.support.ui import Select
 
 import time
@@ -136,9 +136,26 @@ def deny_join_request(self):
         self.browser.find_element_by_id('deny_button').click()
         return self
 
+    def accept_independent_join_request(self):
+        self.browser.find_element_by_id('allow_independent_button').click()
+        return AddIndependentStudentToClassPage(self.browser)
+
+    def deny_independent_join_request(self):
+        self.browser.find_element_by_id('deny_independent_button').click()
+        return self
+
     def has_join_request(self, email):
         return self.element_exists_by_id('request_table') and (email in self.browser.find_element_by_id('request_table').text)
 
+    def has_no_join_requests(self):
+        return self.element_does_not_exist_by_id('request_table')
+
+    def has_independent_join_request(self, email):
+        return self.element_exists_by_id('independent_request_table') and (email in self.browser.find_element_by_id('independent_request_table').text)
+
+    def has_no_independent_join_requests(self):
+        return self.element_does_not_exist_by_id('independent_request_table')
+
     def is_teacher_in_school(self, name):
         return name in self.browser.find_element_by_id('teachers_table').text
 
diff --git a/portal/tests/test_independent_student.py b/portal/tests/test_independent_student.py
--- a/portal/tests/test_independent_student.py
+++ b/portal/tests/test_independent_student.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # Code for Life
 #
-# Copyright (C) 2017, Ocado Innovation Limited
+# 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
@@ -41,6 +41,11 @@
 from django_selenium_clean import selenium
 from selenium.webdriver.support.wait import WebDriverWait
 
+from portal.tests.utils.classes import create_class_directly
+from portal.tests.utils.organisation import create_organisation_directly
+from portal.tests.utils.teacher import signup_teacher_directly
+from portal.tests.utils.student import create_school_student_directly
+
 from base_test import BaseTest
 from pageObjects.portal.home_page import HomePage
 from utils.student import create_independent_student, submit_independent_student_signup_form
@@ -54,7 +59,7 @@ def test_signup(self):
         page, _, _, _, _ = create_independent_student(page)
         assert is_email_verified_message_showing(selenium)
 
-    def test_failed_signup(self):
+    def test_signup_failed(self):
         page = self.go_to_homepage()
         page = submit_independent_student_signup_form(page, password='test')
         assert page.has_independent_student_signup_failed()
@@ -116,6 +121,64 @@ def test_reset_password_fail(self):
 
         assert len(mail.outbox) == 0
 
+    def test_join_class_accepted(self):
+        teacher_email, teacher_password = signup_teacher_directly()
+        create_organisation_directly(teacher_email)
+        klass, class_name, accesss_code = create_class_directly(teacher_email)
+        create_school_student_directly(accesss_code)
+        klass.always_accept_requests = True
+        klass.save()
+
+        homepage = self.go_to_homepage()
+
+        play_page, student_name, student_username, student_email, password = create_independent_student(homepage)
+
+        page = play_page \
+            .independent_student_login(student_username, password) \
+            .go_to_join_a_school_or_club_page() \
+            .join_a_school_or_club(accesss_code)
+
+        page.logout()
+
+        page = self.go_to_homepage()
+
+        page = page \
+            .go_to_login_page() \
+            .login(teacher_email, teacher_password) \
+            .accept_independent_join_request() \
+            .save() \
+            .return_to_class()
+
+        assert page.student_exists(student_name)
+
+    def test_join_class_denied(self):
+        teacher_email, teacher_password = signup_teacher_directly()
+        create_organisation_directly(teacher_email)
+        klass, class_name, accesss_code = create_class_directly(teacher_email)
+        create_school_student_directly(accesss_code)
+        klass.always_accept_requests = True
+        klass.save()
+
+        homepage = self.go_to_homepage()
+
+        play_page, student_name, student_username, student_email, password = create_independent_student(homepage)
+
+        page = play_page \
+            .independent_student_login(student_username, password) \
+            .go_to_join_a_school_or_club_page() \
+            .join_a_school_or_club(accesss_code)
+
+        page.logout()
+
+        page = self.go_to_homepage()
+
+        dashboard_page = page \
+            .go_to_login_page() \
+            .login(teacher_email, teacher_password) \
+            .deny_independent_join_request()
+
+        assert dashboard_page.has_no_independent_join_requests()
+
     def get_to_forgotten_password_page(self):
         selenium.get(self.live_server_url)
         page = HomePage(selenium) \