Repository: ocadotechnology/codeforlife-portal

Issue:
Students / User names with accents are making some features break
The portal allows teachers to register students with accented characters in their names.
However due to an encoding issue some features like the RR scoreboard export doesn't work when students in the class have accented letters.
We need to fix the encoding problem, or alternatively prevent users from registering accented letters at the start.
Workaround for teachers currently is to rename the students in their class dashboard.


Discussion:

Resolving patch:
diff --git a/portal/forms/home.py b/portal/forms/home.py
--- a/portal/forms/home.py
+++ b/portal/forms/home.py
@@ -35,6 +35,7 @@
 # program; modified versions of the program must be marked as such and not
 # identified as the original program.
 from django import forms
+import re
 
 
 class ContactForm(forms.Form):
@@ -47,5 +48,31 @@ class ContactForm(forms.Form):
     message = forms.CharField(label='Message', max_length=250,
                               widget=forms.Textarea(attrs={'class': 'contactField'}))
     browser = forms.CharField(label='Browser', max_length=250, required=False,
-                              widget=forms.TextInput(attrs={'type': 'hidden', 'id': 'browserField'})
-                              )
+                              widget=forms.TextInput(attrs={'type': 'hidden', 'id': 'browserField'}))
+    view_options = {'is_recaptcha_valid': False, 'is_recaptcha_visible': False}
+
+    def clean(self):
+        if self.view_options['is_recaptcha_visible'] and not self.view_options['is_recaptcha_valid']:
+            raise forms.ValidationError('Incorrect captcha')
+        return self.cleaned_data
+
+    def clean_name(self):
+        name = self.cleaned_data.get("name", None)
+        if re.match(re.compile('^[\w ]+$'), name) is None:
+            raise forms.ValidationError("Names may only contain letters, numbers, dashes, underscores, and spaces.")
+
+        return name
+
+    def clean_message(self):
+        message = self.cleaned_data.get("message", None)
+        if re.match(re.compile('^[ -~]+$'), message) is None:
+            raise forms.ValidationError("Your message may not contain special characters.")
+
+        return message
+
+    def clean_telephone(self):
+        telephone = self.cleaned_data.get("telephone", None)
+        if re.match(re.compile('^[0-9()\-+ ]+$'), telephone) is None:
+            raise forms.ValidationError("Invalid phone number")
+
+        return telephone
diff --git a/portal/forms/play.py b/portal/forms/play.py
--- a/portal/forms/play.py
+++ b/portal/forms/play.py
@@ -35,6 +35,7 @@
 # program; modified versions of the program must be marked as such and not
 # identified as the original program.
 from datetime import timedelta
+import re
 
 from django import forms
 from django.contrib.auth import authenticate
@@ -116,11 +117,16 @@ def __init__(self, user, *args, **kwargs):
         self.user = user
         super(StudentEditAccountForm, self).__init__(*args, **kwargs)
 
-    def clean_first_name(self):
-        first_name = self.cleaned_data.get('first_name', None)
-        if not self.user.new_student.class_field and first_name == '':
-            raise forms.ValidationError("This field is required")
-        return first_name
+    def clean_name(self):
+        name = self.cleaned_data.get('name', None)
+        if not self.user.new_student.class_field:
+            if name == '':
+                raise forms.ValidationError("This field is required")
+
+            if re.match(re.compile('^[\w ]+$'), name) is None:
+                raise forms.ValidationError("Names may only contain letters, numbers, dashes, underscores, and spaces.")
+
+        return name
 
     def clean_password(self):
         password = self.cleaned_data.get('password', None)
@@ -163,11 +169,21 @@ class StudentSignupForm(forms.Form):
         label='Confirm Password',
         widget=forms.PasswordInput)
 
+    def clean_name(self):
+        name = self.cleaned_data.get('name', None)
+        if re.match(re.compile('^[\w ]+$'), name) is None:
+            raise forms.ValidationError("Names may only contain letters, numbers, dashes, underscores, and spaces.")
+
+        return name
+
     def clean_username(self):
         username = self.cleaned_data.get('username', None)
         if User.objects.filter(username=username).exists():
             raise forms.ValidationError("That username is already in use")
 
+        if re.match(re.compile('[\w]+'), username) is None:
+            raise forms.ValidationError("Usernames may only contain letters, numbers, dashes, and underscores.")
+
         return username
 
     def clean_password(self):
diff --git a/portal/forms/teach.py b/portal/forms/teach.py
--- a/portal/forms/teach.py
+++ b/portal/forms/teach.py
@@ -294,6 +294,9 @@ def clean_name(self):
         if name == '':
             raise forms.ValidationError("'" + self.cleaned_data.get('name', '') + "' is not a valid name")
 
+        if re.match(re.compile('^[\w ]+$'), name) is None:
+            raise forms.ValidationError("Names may only contain letters, numbers, dashes, underscores, and spaces.")
+
         students = Student.objects.filter(class_field=self.klass,
                                           new_user__first_name__iexact=name)
         if students.exists() and students[0] != self.student:
@@ -344,6 +347,8 @@ def validateStudentNames(klass, names):
     lower_names = [name.lower() for name in names]
     find_duplicates(names, lower_names, validationErrors)
 
+    find_illegal_characters(names, validationErrors)
+
     return validationErrors
 
 
@@ -363,6 +368,13 @@ def find_duplicates(names, lower_names, validationErrors):
             duplicates_found.append(duplicate)
 
 
+def find_illegal_characters(names, validationErrors):
+    for name in names:
+        if re.match(re.compile('^[\w ]+$'), name) is None:
+            validationErrors.append(forms.ValidationError(
+                "Names may only contain letters, numbers, dashes, underscores, and spaces. You must rename '" + name + "'."))
+
+
 def check_passwords(password, confirm_password):
     if password is not None and (password or confirm_password) and password != confirm_password:
         raise forms.ValidationError('The password and the confirmation password do not match')


Test patch:
diff --git a/portal/tests/pageObjects/portal/help_and_support_page.py b/portal/tests/pageObjects/portal/help_and_support_page.py
--- a/portal/tests/pageObjects/portal/help_and_support_page.py
+++ b/portal/tests/pageObjects/portal/help_and_support_page.py
@@ -59,3 +59,39 @@ def send_empty_message(self):
         self.browser.find_element_by_name('submit').click()
 
         return self
+
+    def send_message_accented_name(self):
+        self.browser.find_element_by_id('id_name').send_keys(u"Zoë")
+        self.browser.find_element_by_id('id_telephone').send_keys("0123456789")
+        self.browser.find_element_by_id('id_email').send_keys("zoe@email.com")
+        self.browser.find_element_by_id('id_message').send_keys("Hello friends")
+        self.browser.find_element_by_name('submit').click()
+
+        return self
+
+    def send_message_incorrect_phone(self):
+        self.browser.find_element_by_id('id_name').send_keys("Florian Aucomte")
+        self.browser.find_element_by_id('id_telephone').send_keys("(012)-abc-6789")
+        self.browser.find_element_by_id('id_email').send_keys("florian@email.com")
+        self.browser.find_element_by_id('id_message').send_keys("Hello friends")
+        self.browser.find_element_by_name('submit').click()
+
+        return self
+
+    def send_message_formatted_phone(self):
+        self.browser.find_element_by_id('id_name').send_keys("Florian Aucomte")
+        self.browser.find_element_by_id('id_telephone').send_keys("+1 (012)-345 6789")
+        self.browser.find_element_by_id('id_email').send_keys("florian@email.com")
+        self.browser.find_element_by_id('id_message').send_keys("Hello friends")
+        self.browser.find_element_by_name('submit').click()
+
+        return self
+
+    def send_accented_message(self):
+        self.browser.find_element_by_id('id_name').send_keys("Florian Aucomte")
+        self.browser.find_element_by_id('id_telephone').send_keys("0123456789")
+        self.browser.find_element_by_id('id_email').send_keys("florian@email.com")
+        self.browser.find_element_by_id('id_message').send_keys(u"Héllö friends")
+        self.browser.find_element_by_name('submit').click()
+
+        return self
diff --git a/portal/tests/test_contact.py b/portal/tests/test_contact.py
--- a/portal/tests/test_contact.py
+++ b/portal/tests/test_contact.py
@@ -59,3 +59,39 @@ def test_contact_fail(self):
         page = page.send_empty_message()
 
         assert page.was_form_empty("contact_form")
+
+    def test_message_accented_name(self):
+        selenium.get(self.live_server_url)
+        page = HomePage(selenium)
+        page = page.go_to_help_and_support_page()
+
+        page = page.send_message_accented_name()
+
+        assert (not is_contact_message_sent_message_showing(selenium))
+
+    def test_message_incorrect_phone(self):
+        selenium.get(self.live_server_url)
+        page = HomePage(selenium)
+        page = page.go_to_help_and_support_page()
+
+        page = page.send_message_incorrect_phone()
+
+        assert (not is_contact_message_sent_message_showing(selenium))
+
+    def test_message_formatted_phone(self):
+        selenium.get(self.live_server_url)
+        page = HomePage(selenium)
+        page = page.go_to_help_and_support_page()
+
+        page = page.send_message_formatted_phone()
+
+        assert is_contact_message_sent_message_showing(selenium)
+
+    def test_accented_message(self):
+        selenium.get(self.live_server_url)
+        page = HomePage(selenium)
+        page = page.go_to_help_and_support_page()
+
+        page = page.send_accented_message()
+
+        assert (not is_contact_message_sent_message_showing(selenium))