Repository: hedyorg/hedy

Issue:
Add question of programming experience when creating/updating account
Allow the user to provide optional information about their programming expertise, with two inputs:

- Do you have any experience with programming? (yes/no/unanswered).
- If so, with what? 1) Scratch; 2) another block language; 3) Python; 4) another text language. All of these options can be selected independently of the others.

These options are shown on account creation and on account update. They are to be stored in a column, for each of the users who respond it.


Discussion:

Resolving patch:
diff --git a/utils.py b/utils.py
--- a/utils.py
+++ b/utils.py
@@ -193,6 +193,11 @@ def db_encode (data):
         elif type_check (data [key], 'int'):
             # Note we convert the value into a string
             processed_data [key] = {'N': str (data [key])}
+        elif type_check (data [key], 'dict'):
+            processed_data [key] = {'M': data [key]}
+        # Note: we only support lists of strings.
+        elif type_check (data [key], 'list'):
+            processed_data [key] = {'SS': data [key]}
         elif data [key] == None:
             processed_data [key] = {'NULL': True}
         else:
@@ -208,6 +213,11 @@ def db_encode_updates (data):
         elif type_check (data [key], 'int'):
             # Note we convert the value into a string
             processed_data [key] = {'Value': {'N': str (data [key])}}
+        elif type_check (data [key], 'dict'):
+            processed_data [key] = {'Value': {'M': data [key]}}
+        # Note: we only support lists of strings.
+        elif type_check (data [key], 'list'):
+            processed_data [key] = {'Value': {'SS': data [key]}}
         elif data [key] == None:
             processed_data [key] = {'Action': 'DELETE'}
         else:
@@ -222,6 +232,10 @@ def db_decode (data):
             processed_data [key] = data [key] ['S']
         elif 'N' in data [key]:
             processed_data [key] = int (data [key] ['N'])
+        elif 'M' in data [key]:
+            processed_data [key] = data [key] ['M']
+        elif 'SS' in data [key]:
+            processed_data [key] = data [key] ['SS']
         elif 'NULL' in data [key]:
             processed_data [key] = None
         else:
diff --git a/website/auth.py b/website/auth.py
--- a/website/auth.py
+++ b/website/auth.py
@@ -154,6 +154,14 @@ def signup ():
         if 'gender' in body:
             if body ['gender'] != 'm' and body ['gender'] != 'f' and body ['gender'] != 'o':
                 return 'gender must be m/f/o', 400
+        if 'prog_experience' in body and body ['prog_experience'] not in ['yes', 'no']:
+            return 'If present, prog_experience must be "yes" or "no"', 400
+        if 'experience_languages' in body:
+            if not type_check (body ['experience_languages'], 'list'):
+                return 'If present, experience_languages must be an array', 400
+            for language in body ['experience_languages']:
+                if language not in ['scratch', 'other_block', 'python', 'other_text']:
+                    return 'Invalid language: ' + str (language), 400
 
         user = db_get ('users', {'username': body ['username'].strip ().lower ()})
         if user:
@@ -200,12 +208,11 @@ def signup ():
             'last_login': timems ()
         }
 
-        if 'country' in body:
-            user ['country'] = body ['country']
-        if 'birth_year' in body:
-            user ['birth_year'] = body ['birth_year']
-        if 'gender' in body:
-            user ['gender'] = body ['gender']
+        for field in ['country', 'birth_year', 'gender', 'prog_experience', 'experience_languages']:
+           if field in body:
+               if field == 'experience_languages' and len (body [field]) == 0:
+                   continue
+               user [field] = body [field]
 
         db_create ('users', user)
 
@@ -313,6 +320,14 @@ def update_profile (user):
         if 'gender' in body:
             if body ['gender'] != 'm' and body ['gender'] != 'f' and body ['gender'] != 'o':
                 return 'body.gender must be m/f/o', 400
+        if 'prog_experience' in body and body ['prog_experience'] not in ['yes', 'no']:
+            return 'If present, prog_experience must be "yes" or "no"', 400
+        if 'experience_languages' in body:
+            if not type_check (body ['experience_languages'], 'list'):
+                return 'If present, experience_languages must be an array', 400
+            for language in body ['experience_languages']:
+                if language not in ['scratch', 'other_block', 'python', 'other_text']:
+                    return 'Invalid language: ' + str (language), 400
 
         resp = {}
         if 'email' in body:
@@ -330,12 +345,16 @@ def update_profile (user):
                 else:
                     send_email_template ('welcome_verify', email, requested_lang (), os.getenv ('BASE_URL') + '/auth/verify?username=' + urllib.parse.quote_plus (user['username']) + '&token=' + urllib.parse.quote_plus (hashed_token))
 
-        if 'country' in body:
-            db_update ('users', {'username': user ['username'], 'country': body ['country']})
-        if 'birth_year' in body:
-            db_update ('users', {'username': user ['username'], 'birth_year': body ['birth_year']})
-        if 'gender' in body:
-            db_update ('users', {'username': user ['username'], 'gender': body ['gender']})
+        updates = {'username': user ['username']}
+
+        for field in ['country', 'birth_year', 'gender', 'prog_experience', 'experience_languages']:
+           if field in body:
+               if field == 'experience_languages' and len (body [field]) == 0:
+                   updates [field] = None
+               else:
+                   updates [field] = body [field]
+
+        db_update ('users', updates)
 
         return jsonify (resp)
 
@@ -343,12 +362,9 @@ def update_profile (user):
     @requires_login
     def get_profile (user):
         output = {'username': user ['username'], 'email': user ['email']}
-        if 'birth_year' in user:
-            output ['birth_year'] = user ['birth_year']
-        if 'country' in user:
-            output ['country'] = user ['country']
-        if 'gender' in user:
-            output ['gender'] = user ['gender']
+        for field in ['birth_year', 'country', 'gender', 'prog_experience', 'experience_languages']:
+            if field in user:
+                output [field] = user [field]
         if 'verification_pending' in user:
             output ['verification_pending'] = True
         output ['session_expires_at'] = timems () + session_length * 1000
@@ -539,7 +555,7 @@ def auth_templates (page, lang, menu, request):
         # After hitting 1k users, it'd be wise to add pagination.
         users = db_scan ('users')
         userdata = []
-        fields = ['username', 'email', 'birth_year', 'country', 'gender', 'created', 'last_login', 'verification_pending', 'is_teacher', 'program_count']
+        fields = ['username', 'email', 'birth_year', 'country', 'gender', 'created', 'last_login', 'verification_pending', 'is_teacher', 'program_count', 'prog_experience', 'experience_languages']
 
         for user in users:
             data = {}


Test patch:
diff --git a/e2e_tests.py b/e2e_tests.py
--- a/e2e_tests.py
+++ b/e2e_tests.py
@@ -123,16 +123,17 @@ def invalidMap (tag, method, path, bodies):
         counter += 1
     return output
 
-def successfulSignup (state, response, username):
-    if not 'token' in response ['body']:
-        raise Exception ('No token present')
-    state ['token'] = response ['body'] ['token']
-
 # We define apres functions here because multiline lambdas are not supported by python
 def setSentCookies (state, response, username):
     if 'Set-Cookie' in response ['headers']:
         state ['headers'] ['cookie'] = response ['headers'] ['Set-Cookie']
 
+def successfulSignup (state, response, username):
+    if not 'token' in response ['body']:
+        raise Exception ('No token present')
+    state ['token'] = response ['body'] ['token']
+    setSentCookies (state, response, username)
+
 def getProfile1 (state, response, username):
     profile = response ['body']
     if profile ['username'] != username:
@@ -162,6 +163,22 @@ def getProfile3 (state, response, username):
     if 'verification_pending' in profile:
         raise Exception ('Invalid verification_pending (getProfile3)')
 
+def getProfile4 (state, response, username):
+    profile = response ['body']
+    if not 'verification_pending' in profile or profile ['verification_pending'] != True:
+        raise Exception ('Invalid verification_pending (getProfile4)')
+    if not 'prog_experience' in profile or profile ['prog_experience'] != 'yes':
+        raise Exception ('Invalid prog_experience (getProfile4)')
+    if not 'experience_languages' in profile or not type_check (profile ['experience_languages'], 'list') or len (profile ['experience_languages']) != 1 or profile ['experience_languages'] [0] != 'python':
+        raise Exception ('Invalid experience_languages (getProfile4)')
+
+def getProfile5 (state, response, username):
+    profile = response ['body']
+    if not 'prog_experience' in profile or profile ['prog_experience'] != 'no':
+        raise Exception ('Invalid prog_experience (getProfile5)')
+    if not 'experience_languages' in profile or not type_check (profile ['experience_languages'], 'list') or len (profile ['experience_languages']) != 2 or profile ['experience_languages'] [0] not in ['scratch', 'other_text'] or profile ['experience_languages'] [1] not in ['scratch', 'other_text']:
+        raise Exception ('Invalid experience_languages (getProfile5)')
+
 def emailChange (state, response, username):
     if not type_check (response ['body'] ['token'], 'str'):
         raise Exception ('Invalid country (emailChange)')
@@ -239,7 +256,7 @@ def suite (username):
         ['get session vars from test', 'get', '/session_test', {}, {}, 200, checkTestSessionVars],
         ['get session vars from main again', 'get', '/session_main', {}, {}, 200, checkMainSessionVarsAgain],
         ['get root', 'get', '/', {}, '', 200],
-        invalidMap ('signup', 'post', '/auth/signup', ['', [], {}, {'username': 1}, {'username': 'user@me', 'password': 'foobar', 'email': 'a@a.com'}, {'username:': 'user: me', 'password': 'foobar', 'email': 'a@a.co'}, {'username': 't'}, {'username': '    t    '}, {'username': username}, {'username': username, 'password': 1}, {'username': username, 'password': 'foo'}, {'username': username, 'password': 'foobar'}, {'username': username, 'password': 'foobar', 'email': 'me@something'}]),
+        invalidMap ('signup', 'post', '/auth/signup', ['', [], {}, {'username': 1}, {'username': 'user@me', 'password': 'foobar', 'email': 'a@a.com'}, {'username:': 'user: me', 'password': 'foobar', 'email': 'a@a.co'}, {'username': 't'}, {'username': '    t    '}, {'username': username}, {'username': username, 'password': 1}, {'username': username, 'password': 'foo'}, {'username': username, 'password': 'foobar'}, {'username': username, 'password': 'foobar', 'email': 'me@something'}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience': [2]}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'prog_experience': 'foo'}, {'username': username, 'password': 'foobar', 'email': 'me@something.com', 'experience_languages': 'python'}]),
         ['valid signup', 'post', '/auth/signup', {}, {'username': username, 'password': 'foobar', 'email': username + '@e2e-testing.com'}, 200, successfulSignup],
         invalidMap ('login', 'post', '/auth/login', ['', [], {}, {'username': 1}, {'username': 'user@me'}, {'username:': 'user: me'}]),
         ['valid login, invalid credentials', 'post', '/auth/login', {}, {'username': username, 'password': 'password'}, 403],
@@ -257,7 +274,7 @@ def suite (username):
         ['login again', 'post', '/auth/login', {}, {'username': username, 'password': 'foobar2'}, 200, setSentCookies],
         invalidMap ('change password', 'post', '/auth/change_password', ['', [], {}, {'foo': 'bar'}, {'old_password': 1}, {'old_password': 'foobar'}, {'old_password': 'foobar', 'new_password': 1}]),
         ['get profile before profile update', 'get', '/profile', {}, {}, 200, getProfile1],
-        invalidMap ('update profile', 'post', '/profile', ['', [], {'email': 'foobar'}, {'birth_year': 'a'}, {'birth_year': 20}, {'country': 'Netherlands'}, {'gender': 0}, {'gender': 'a'}]),
+        invalidMap ('update profile', 'post', '/profile', ['', [], {'email': 'foobar'}, {'birth_year': 'a'}, {'birth_year': 20}, {'country': 'Netherlands'}, {'gender': 0}, {'gender': 'a'}, {'prog_experience': 1}, {'prog_experience': 'foo'}, {'experience_languages': ['python', 'foo']}]),
         ['change profile with same email', 'post', '/profile', {}, {'email': username + '@e2e-testing.com', 'country': 'US'}, 200],
         ['change profile with different email', 'post', '/profile', {}, {'email': username + '@e2e-testing2.com', 'country': 'NL'}, 200, emailChange],
         ['get profile after profile update', 'get', '/profile', {}, {}, 200, getProfile2],
@@ -280,7 +297,12 @@ def suite (username):
         ['delete program', 'get', lambda state: '/programs/delete/' + state ['program'] ['id'], {}, {}, 302],
         ['retrieve programs after deleting saved program', 'get', '/programs_list', {}, {}, 200, retrieveProgramsBefore],
         ['destroy account', 'post', '/auth/destroy', {}, {}, 200],
-        ['get programs without being logged in', 'get', '/programs', {}, {}, 302]
+        ['get programs without being logged in', 'get', '/programs', {}, {}, 302],
+        ['valid signup again', 'post', '/auth/signup', {}, {'username': username, 'password': 'foobar', 'email': username + '@e2e-testing.com', 'prog_experience': 'yes', 'experience_languages': ['python']}, 200, successfulSignup],
+        ['get profile after recreating account', 'get', '/profile', {}, {}, 200, getProfile4],
+        ['change profile with different programming experience', 'post', '/profile', {}, {'prog_experience': 'no', 'experience_languages': ['scratch', 'other_text']}, 200],
+        ['get profile after updating experience', 'get', '/profile', {}, {}, 200, getProfile5],
+        ['destroy account', 'post', '/auth/destroy', {}, {}, 200],
     ]
 
 if not args.concurrent: