Repository: hedyorg/hedy

Issue:
[UI idea] Notify kids of class invite in Hedy site
**Idea**
When teachers make a link, they have to somehow get the link to the kids. We envisioned teachers would do that via an LMS (on highschool) or email, but it turns out some elementary schools either do not have email addresses that kids use (they exist but kids don't log in) or block emails that contain links or block emails form third parties.

So, it would be better if kids get a notification that they are invited that they can accept from within Hedy, without the need for explicitly sharing the link.





Discussion:
I think a nice solution would be to create some "invitations mailbox" for each user. The teacher can invite children on their username, after which they receive the link in their "mailbox" which they can accept. This implementation also offers possibilities later on for sending adventures to students.  We can also simply create an additional banner in the profile page with:

`Hi! You just have been invited for class {{name}}, would you like to join? Click here!`

I'm not sure what would be the best solution for now. In both cases we need some additional db to keep track of the invitations and add some (invited/accepted) overview to the class overview page of the teacher. I think I'll be able to implement one of both options before Monday!
Few things we have to keep in mind while implementing this:

- Return an error if the user doesn't exist or is already in your class
- Remove pending invitations when the class is removed
- Where do we show the invitation? Profile page? Overlay banner?

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -300,6 +300,7 @@ def enrich_context_with_user_info():
     data = {'username': user.get('username', ''), 'is_teacher': is_teacher(user), 'is_admin': is_admin(user)}
     if len(data['username']) > 0: #If so, there is a user -> Retrieve all relevant info
         user_data = DATABASE.user_by_username(user.get('username'))
+        data['user_messages'] = 0
         if 'language' in user_data:
             if user_data['language'] in ALL_LANGUAGES.keys():
                 g.lang = session['lang'] = user_data['language']
@@ -309,6 +310,16 @@ def enrich_context_with_user_info():
         user_achievements = DATABASE.achievements_by_username(user.get('username'))
         if user_achievements:
             data['user_achievements'] = user_achievements
+        user_invites = DATABASE.get_username_invite(user.get('username'))
+        if user_invites:
+            Class = DATABASE.get_class(user_invites['class_id'])
+            if Class:
+                invite_data = user_invites.copy()
+                invite_data['class_name'] = Class.get('name')
+                invite_data['teacher'] = Class.get('teacher')
+                invite_data['join_link'] = Class.get('link')
+                data['invite_data'] = invite_data
+                data['user_messages'] += 1
     return data
 
 @app.context_processor
diff --git a/website/database.py b/website/database.py
--- a/website/database.py
+++ b/website/database.py
@@ -33,6 +33,7 @@
 #       "hide_prev_level": false,
 #       "hide_next_level": false
 #     }
+INVITATIONS = dynamo.Table(storage, 'class_invitations', partition_key='username', indexed_fields=[dynamo.IndexKey('class_id')])
 CUSTOMIZATIONS = dynamo.Table(storage, 'class_customizations', partition_key='id', sort_key='level')
 ACHIEVEMENTS = dynamo.Table(storage, 'achievements', partition_key='username')
 PUBLIC_PROFILES = dynamo.Table(storage, 'public_profiles', partition_key='username')
@@ -191,6 +192,7 @@ def forget_user(self, username):
         """Forget the given user."""
         classes = USERS.get({'username': username}).get ('classes') or []
         USERS.delete({'username': username})
+        INVITATIONS.delete({'username': username})
         # The recover password token may exist, so we delete it
         TOKENS.delete({'id': username})
         PROGRAMS.del_many({'username': username})
@@ -300,11 +302,24 @@ def delete_class(self, Class):
             Database.remove_student_from_class (self, Class ['id'], student_id)
 
         CUSTOMIZATIONS.del_many({'id': Class['id']})
+        INVITATIONS.del_many({'class_id': Class['id']})
         CLASSES.delete({'id': Class['id']})
 
     def resolve_class_link(self, link_id):
         return CLASSES.get({'link': link_id})
 
+    def get_username_invite(self, username):
+        return INVITATIONS.get({'username': username}) or None
+
+    def add_class_invite(self, username, class_id):
+        INVITATIONS.put({'username': username, 'class_id': class_id, 'timestamp': timems ()})
+
+    def remove_class_invite(self, username):
+        INVITATIONS.delete({'username': username})
+
+    def get_class_invites(self, class_id):
+        return INVITATIONS.get_many({'class_id': class_id}) or []
+
     def all_classes(self):
         return CLASSES.scan()
 
diff --git a/website/teacher.py b/website/teacher.py
--- a/website/teacher.py
+++ b/website/teacher.py
@@ -60,9 +60,13 @@ def get_class (user, class_id):
         teachers = os.getenv('BETA_TEACHERS', '').split(',')
         is_beta_teacher = user['username'] in teachers
 
+        invites = []
+        for invite in DATABASE.get_class_invites(Class['id']):
+            invites.append({'username': invite['username'], 'timestamp': utils.datetotimeordate (utils.mstoisostring (invite['timestamp']))})
+
         return render_template ('class-overview.html', current_page='my-profile',
                                 page_title=hedyweb.get_page_title('class overview'),
-                                achievement=achievement,
+                                achievement=achievement, invites=invites,
                                 is_beta_teacher=is_beta_teacher,
                                 class_info={'students': students, 'link': os.getenv('BASE_URL') + '/hedy/l/' + Class ['link'],
                                             'name': Class ['name'], 'id': Class ['id']})
@@ -175,6 +179,7 @@ def join_class(user):
             return utils.error_page(error=404,  ui_message='invalid_class_link')
 
         DATABASE.add_student_to_class(Class['id'], user['username'])
+        DATABASE.remove_class_invite(user['username'])
         achievement = ACHIEVEMENTS.add_single_achievement(user['username'], "epic_education")
         if achievement:
             return {'achievement': achievement}, 200
@@ -198,7 +203,7 @@ def leave_class (user, class_id, student_id):
     @requires_login
     def get_class_info(user, class_id):
         if not is_teacher(user):
-            return utils.error_page_403(error=403, ui_message='retrieve_class')
+            return utils.error_page(error=403, ui_message='retrieve_class')
         Class = DATABASE.get_class(class_id)
         if not Class or Class['teacher'] != user['username']:
             return utils.error_page(error=404,  ui_message='no_such_class')
@@ -255,6 +260,63 @@ def update_level_preferences(user, class_id):
             return {'achievement': achievement}, 200
         return {}, 200
 
+    @app.route('/invite_student', methods=['POST'])
+    @requires_login
+    def invite_student(user):
+        body = request.json
+        # Validations
+        if not isinstance(body, dict):
+            return g.auth_texts.get('ajax_error'), 400
+        if not isinstance(body.get('username'), str):
+            return g.auth_texts.get('username_invalid'), 400
+        if not isinstance(body.get('class_id'), str):
+            return 'class id must be a string', 400
+
+        username = body.get('username').lower()
+        class_id = body.get('class_id')
+
+        if not is_teacher(user):
+            return utils.error_page(error=403, ui_message='retrieve_class')
+        Class = DATABASE.get_class(class_id)
+        if not Class or Class['teacher'] != user['username']:
+            return utils.error_page(error=404, ui_message='no_such_class')
+
+        user = DATABASE.user_by_username(username)
+        if not user:
+            return g.auth_texts.get('student_not_existing'), 400
+        if 'students' in Class and user['username'] in Class['students']:
+            return g.auth_texts.get('student_already_in_class'), 400
+        if DATABASE.get_username_invite(user['username']):
+            return g.auth_texts.get('student_already_invite'), 400
+
+        # So: The class and student exist and are currently not a combination -> invite!
+        DATABASE.add_class_invite(username, class_id)
+        return {}, 200
+
+    @app.route('/remove_student_invite', methods=['POST'])
+    @requires_login
+    def remove_invite(user):
+        body = request.json
+        # Validations
+        if not isinstance(body, dict):
+            return g.auth_texts.get('ajax_error'), 400
+        if not isinstance(body.get('username'), str):
+            return g.auth_texts.get('username_invalid'), 400
+        if not isinstance(body.get('class_id'), str):
+            return 'class id must be a string', 400
+
+        username = body.get('username')
+        class_id = body.get('class_id')
+
+        if not is_teacher(user) and username != user.get('username'):
+            return utils.error_page(error=403, ui_message='retrieve_class')
+        Class = DATABASE.get_class(class_id)
+        if not Class or (Class['teacher'] != user['username'] and username != user.get('username')):
+            return utils.error_page(error=404, ui_message='no_such_class')
+
+        DATABASE.remove_class_invite(username)
+        return {}, 200
+
     @app.route('/hedy/l/<link_id>', methods=['GET'])
     def resolve_class_link (link_id):
         Class = DATABASE.resolve_class_link (link_id)


Test patch: