Repository: hedyorg/hedy

Issue:
Adventure mode
Add a Hedy mode where a particular challenge/problem is the axis of work ("challenge mode"), instead of the levels ("level mode", the currently implemented one).

Principles:
- Make it incremental. Don't ruin what already works!
- No forks in the road, no decisions to be imposed on first time or early users.
- Always be able to go back to "level mode" without hassle.
- A lot of similarity between "level mode" and "challenge mode".

Starting point for implementation:
- Make a list of problems.
- Add a button/switcher between levels and challenges.
- Show a list of Hedy-style drawn tiles (@Felienne, I need you here), one for each problem.
- Jump to a particular problem.
- Add data/hints for each combination of problem & level.

Project: https://github.com/Felienne/hedy/projects/1


Discussion:

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -21,7 +21,7 @@
 from werkzeug.urls import url_encode
 from config import config
 from auth import auth_templates, current_user, requires_login, is_admin, is_teacher
-from utils import db_get, db_get_many, db_set, timems, type_check, object_check, db_del
+from utils import db_get, db_get_many, db_set, timems, type_check, object_check, db_del, load_yaml
 import hashlib
 
 # app.py
@@ -60,16 +60,43 @@
 
 TRANSLATIONS = hedyweb.Translations()
 
+def load_adventures_in_all_languages():
+    adventures = {}
+    for lang in ALL_LANGUAGES.keys ():
+        adventures[lang] = load_yaml(f'coursedata/adventures/{lang}.yaml')
+    return adventures
+
+def load_adventure_for_language(lang):
+    adventures = load_adventures_in_all_languages()
+    if not lang in adventures or len (adventures [lang]) == 0:
+        return adventures ['en']
+    return adventures [lang]
+
+def load_adventure_assignments_per_level(lang, level):
+    assignments = []
+    adventures = load_adventure_for_language(lang)['adventures']
+    for short_name, adventure in adventures.items ():
+        if level in adventure['levels']:
+            assignments.append({
+                'short_name': short_name,
+                'name': adventure['name'],
+                'image': adventure.get('image', None),
+                'text': adventure['levels'][level].get('story_text', 'No Story Text')
+            })
+    return assignments
+
+
 # Load main menu (do it once, can be cached)
 with open(f'main/menu.json', 'r') as f:
     main_menu_json = json.load(f)
 
-
 logging.basicConfig(
     level=logging.DEBUG,
     format='[%(asctime)s] %(levelname)-8s: %(message)s')
 
 app = Flask(__name__, static_url_path='')
+# Ignore trailing slashes in URLs
+app.url_map.strict_slashes = False
 
 def hash_user_or_session (string):
     hash = hashlib.md5 (string.encode ('utf-8')).hexdigest ()
@@ -141,6 +168,12 @@ def before_request_https():
 Commonmark(app)
 logger = jsonbin.JsonBinLogger.from_env_vars()
 
+# Check that requested language is supported, otherwise return 404
+@app.before_request
+def check_language():
+    if requested_lang() not in ALL_LANGUAGES.keys ():
+        return "Language " + requested_lang () + " not supported", 404
+
 if not os.getenv('HEROKU_RELEASE_CREATED_AT'):
     logging.warning('Cannot determine release; enable Dyno metadata by running "heroku labs:enable runtime-dyno-metadata -a <APP_NAME>"')
 
@@ -153,6 +186,8 @@ def parse():
         return "body.code must be a string", 400
     if 'level' not in body:
         return "body.level must be a string", 400
+    if 'adventure_name' in body and not type_check (body ['adventure_name'], 'str'):
+        return "if present, body.adventure_name must be a string", 400
 
     code = body ['code']
     level = int(body ['level'])
@@ -198,7 +233,6 @@ def parse():
         except Exception as E:
             print(f"error transpiling {code}")
             response["Error"] = str(E)
-
     logger.log ({
         'session': session_id(),
         'date': str(datetime.datetime.now()),
@@ -208,7 +242,8 @@ def parse():
         'server_error': response.get('Error'),
         'version': version(),
         'username': username,
-        'is_test': 1 if os.getenv ('IS_TEST_ENV') else None
+        'is_test': 1 if os.getenv ('IS_TEST_ENV') else None,
+        'adventure_name': body.get('adventure_name', None)
     })
 
     return jsonify(response)
@@ -256,16 +291,12 @@ def programs_page (request):
     if not username:
         return "unauthorized", 403
 
-    lang = requested_lang()
-    query_lang = request.args.get('lang') or ''
-    if query_lang:
-        query_lang = '?lang=' + query_lang
-
     from_user = request.args.get('user') or None
     if from_user and not is_admin (request):
         return "unauthorized", 403
 
-    texts=TRANSLATIONS.data [lang] ['Programs']
+    texts=TRANSLATIONS.data [requested_lang ()] ['Programs']
+    ui=TRANSLATIONS.data [requested_lang ()] ['ui']
 
     result = db_get_many ('programs', {'username': from_user or username}, True)
     programs = []
@@ -281,12 +312,72 @@ def programs_page (request):
 
             date = round (date / 24)
 
-        programs.append ({'id': item ['id'], 'code': item ['code'], 'date': texts ['ago-1'] + ' ' + str (date) + ' ' + measure + ' ' + texts ['ago-2'], 'level': item ['level'], 'name': item ['name']})
-
-    return render_template('programs.html', lang=requested_lang(), menu=render_main_menu('programs'), texts=texts, auth=TRANSLATIONS.data [lang] ['Auth'], programs=programs, username=username, current_page='programs', query_lang=query_lang, from_user=from_user)
+        programs.append ({'id': item ['id'], 'code': item ['code'], 'date': texts ['ago-1'] + ' ' + str (date) + ' ' + measure + ' ' + texts ['ago-2'], 'level': item ['level'], 'name': item ['name'], 'adventure_name': item.get ('adventure_name')})
+
+    return render_template('programs.html', lang=requested_lang(), menu=render_main_menu('programs'), texts=texts, ui=ui, auth=TRANSLATIONS.data [lang] ['Auth'], programs=programs, username=username, current_page='programs', from_user=from_user)
+
+# Adventure mode
+@app.route('/hedy/adventures', methods=['GET'])
+def adventures_list():
+    return render_template('adventures.html', lang=lang, adventures=load_adventure_for_language (requested_lang ()), menu=render_main_menu('adventures'), username=current_user(request) ['username'], auth=TRANSLATIONS.data [lang] ['Auth'])
+
+@app.route('/hedy/adventures/<adventure_name>', methods=['GET'], defaults={'level': 1})
+@app.route('/hedy/adventures/<adventure_name>/<level>', methods=['GET'])
+def adventure_page(adventure_name, level):
+
+    user = current_user (request)
+    level = int (level)
+    adventures = load_adventure_for_language (requested_lang ())
+
+    # If requested adventure does not exist, return 404
+    if not adventure_name in adventures ['adventures']:
+        return 'No such Hedy adventure!', 404
+
+    adventure = adventures ['adventures'] [adventure_name]
+    loaded_program = ''
+
+    # If no level is specified (take last item of path and remove query parameter, if any, then compare to adventure_name)
+    if re.sub (r'\?.+', '', request.url.split ('/') [len (request.url.split ('/')) - 1]) == adventure_name:
+        # If user is logged in, check if they have a program for this adventure
+        # If there are many, note the highest level for which there is a saved program
+        desired_level = 0
+        if user ['username']:
+            existing_programs = db_get_many ('programs', {'username': user ['username']}, True)
+            for program in existing_programs:
+                if 'adventure_name' in program and program ['adventure_name'] == adventure_name and program ['level'] > desired_level:
+                    desired_level = program ['level']
+            # If the user has a saved program for this adventure, redirect them to the level with the highest adventure
+            if desired_level != 0:
+                return redirect(request.url.replace ('/' + adventure_name, '/' + adventure_name + '/' + str (desired_level)), code=302)
+        # If user is not logged in, or has no saved programs for this adventure, default to the lowest level available for the adventure
+        if desired_level == 0:
+            for key in adventure ['levels'].keys ():
+                if type_check (key, 'int') and (desired_level == 0 or desired_level > key):
+                    desired_level = key
+        level = desired_level
+
+    # If a level is specified and user is logged in, check if there's a stored program available for this level
+    elif user ['username']:
+        existing_programs = db_get_many ('programs', {'username': user ['username']}, True)
+        for program in existing_programs:
+            if 'adventure_name' in program and program ['adventure_name'] == adventure_name and program ['level'] == level:
+                loaded_program = program ['code']
+
+    # If requested level is not in adventure, return 404
+    if not level in adventure ['levels']:
+        abort(404)
 
-# @app.route('/post/', methods=['POST'])
-# for now we do not need a post but I am leaving it in for a potential future
+    return hedyweb.render_adventure(
+        adventure_name=adventure_name,
+        adventure=adventure,
+        course=HEDY_COURSE[requested_lang ()],
+        request=request,
+        lang=requested_lang (),
+        level_number=level,
+        menu=render_main_menu('hedy'),
+        translations=TRANSLATIONS,
+        version=version(),
+        loaded_program=loaded_program)
 
 # routing to index.html
 @app.route('/hedy', methods=['GET'], defaults={'level': 1, 'step': 1})
@@ -294,7 +385,10 @@ def programs_page (request):
 @app.route('/hedy/<level>/<step>', methods=['GET'])
 def index(level, step):
     session_id()  # Run this for the side effect of generating a session ID
-    g.level = level = int(level)
+    try:
+        g.level = level = int(level)
+    except:
+        return 'No such Hedy level!', 404
     g.lang = requested_lang()
     g.prefix = '/hedy'
 
@@ -311,7 +405,9 @@ def index(level, step):
         # We default to step 1 to provide a meaningful default assignment
         step = 1
     else:
-        loaded_program = None
+        loaded_program = ''
+
+    adventure_assignments = load_adventure_assignments_per_level(g.lang, level)
 
     return hedyweb.render_assignment_editor(
         request=request,
@@ -321,6 +417,7 @@ def index(level, step):
         menu=render_main_menu('hedy'),
         translations=TRANSLATIONS,
         version=version(),
+        adventure_assignments=adventure_assignments,
         loaded_program=loaded_program)
 
 @app.route('/onlinemasters', methods=['GET'], defaults={'level': 1, 'step': 1})
@@ -340,7 +437,7 @@ def onlinemasters(level, step):
         translations=TRANSLATIONS,
         version=version(),
         menu=None,
-        loaded_program=None)
+        loaded_program='')
 
 @app.route('/space_eu', methods=['GET'], defaults={'level': 1, 'step': 1})
 @app.route('/space_eu/<level>', methods=['GET'], defaults={'step': 1})
@@ -359,7 +456,7 @@ def space_eu(level, step):
         translations=TRANSLATIONS,
         version=version(),
         menu=None,
-        loaded_program=None)
+        loaded_program='')
 
 
 
@@ -452,6 +549,12 @@ def other_languages():
     cl = requested_lang()
     return [make_lang_obj(l) for l in ALL_LANGUAGES.keys() if l != cl]
 
+@app.template_global()
+def localize_link(url):
+    lang = requested_lang()
+    if not lang:
+        return url
+    return url + '?lang=' + lang
 
 def make_lang_obj(lang):
     """Make a language object for a given language."""
@@ -536,6 +639,9 @@ def save_program (user):
         return 'name must be a string', 400
     if not object_check (body, 'level', 'int'):
         return 'level must be an integer', 400
+    if 'adventure_name' in body:
+        if not object_check (body, 'adventure_name', 'str'):
+            return 'if present, adventure_name must be a string', 400
 
     # We execute the saved program to see if it would generate an error or not
     error = None
@@ -550,18 +656,20 @@ def save_program (user):
 
     name = body ['name']
 
-    # We check if a program with a name `xyz` exists in the database for the username. If it does, we exist whether `xyz (1)` exists, until we find a program `xyz (NN)` that doesn't exist yet.
-    # It'd be ideal to search by username & program name, but since DynamoDB doesn't allow searching for two indexes at the same time, this would require to create a special index to that effect, which is cumbersome.
-    # For now, we bring all existing programs for the user and then search within them for repeated names.
-    existing = db_get_many ('programs', {'username': user ['username']}, True)
-    name_counter = 0
-    for program in existing:
-        if re.match ('^' + re.escape (name) + '( \(\d+\))*', program ['name']):
-            name_counter = name_counter + 1
-    if name_counter:
-        name = name + ' (' + str (name_counter) + ')'
-
-    db_set('programs', {
+    # If this is not a saved program for an adventure, we check if there's already a program with that name for that user.
+    if not 'adventure_name' in body:
+        # We check if a program with a name `xyz` exists in the database for the username. If it does, we exist whether `xyz (1)` exists, until we find a program `xyz (NN)` that doesn't exist yet.
+        # It'd be ideal to search by username & program name, but since DynamoDB doesn't allow searching for two indexes at the same time, this would require to create a special index to that effect, which is cumbersome.
+        # For now, we bring all existing programs for the user and then search within them for repeated names.
+        existing = db_get_many ('programs', {'username': user ['username']}, True)
+        name_counter = 0
+        for program in existing:
+            if re.match ('^' + re.escape (name) + '( \(\d+\))*', program ['name']):
+                name_counter = name_counter + 1
+        if name_counter:
+            name = name + ' (' + str (name_counter) + ')'
+
+    stored_program = {
         'id': uuid.uuid4().hex,
         'session': session_id(),
         'date': timems (),
@@ -572,7 +680,13 @@ def save_program (user):
         'name': name,
         'server_error': error,
         'username': user ['username']
-    })
+    }
+
+    if 'adventure_name' in body:
+        stored_program ['adventure_name'] = body ['adventure_name']
+
+    db_set('programs', stored_program)
+
     program_count = 0
     if 'program_count' in user:
         program_count = user ['program_count']
diff --git a/hedyweb.py b/hedyweb.py
--- a/hedyweb.py
+++ b/hedyweb.py
@@ -24,7 +24,7 @@ def get_translations(self, language, section):
     return d
 
 
-def render_assignment_editor(request, course, level_number, assignment_number, menu, translations, version, loaded_program):
+def render_assignment_editor(request, course, level_number, assignment_number, menu, translations, version, loaded_program, adventure_assignments):
   assignment = course.get_assignment(level_number, assignment_number)
   if not assignment:
     abort(404)
@@ -47,7 +47,8 @@ def render_assignment_editor(request, course, level_number, assignment_number, m
   arguments_dict['docs'] = [attr.asdict(d) for d in assignment.docs]
   arguments_dict['auth'] = translations.data [course.language] ['Auth']
   arguments_dict['username'] = current_user(request) ['username']
-  arguments_dict['loaded_program'] = loaded_program or ''
+  arguments_dict['loaded_program'] = loaded_program
+  arguments_dict['adventure_assignments'] = adventure_assignments
 
   # Translations
   arguments_dict.update(**translations.get_translations(course.language, 'ui'))
@@ -60,3 +61,43 @@ def render_assignment_editor(request, course, level_number, assignment_number, m
     doc ['markdown'] = (course.docs.get(int(level_number), doc ['slug']) or {'markdown': ''}).markdown
 
   return render_template("code-page.html", **arguments_dict)
+
+def render_adventure(adventure_name, adventure, course, request, lang, level_number, menu, translations, version, loaded_program):
+
+  arguments_dict = {}
+
+  arguments_dict['lang'] = lang
+  arguments_dict['level_nr'] = str(level_number)
+  arguments_dict['level'] = level_number
+  arguments_dict['prev_level'] = level_number - 1 if level_number - 1 in adventure ['levels'] else None
+  arguments_dict['next_level'] = level_number + 1 if level_number + 1 in adventure ['levels'] else None
+  arguments_dict['menu'] = menu
+  arguments_dict['latest'] = version
+  arguments_dict['selected_page'] = 'code'
+  arguments_dict['page_title'] = f'Adventure mode: {adventure ["name"]} {level_number} – Hedy'
+  arguments_dict['auth'] = (translations.data.get (lang) or translations.data ['en']) ['Auth']
+  arguments_dict['username'] = current_user(request) ['username']
+  arguments_dict['loaded_program'] = loaded_program
+  arguments_dict['adventure_name'] = adventure_name
+  arguments_dict['full_adventure_name'] = adventure ['name']
+
+  # Translations
+  arguments_dict.update(**translations.get_translations(lang, 'ui'))
+
+  # Actual assignment
+  for key, value in adventure ['levels'] [level_number].items ():
+    arguments_dict [key] = value
+
+  if not 'story_text' in arguments_dict:
+    arguments_dict ['story_text'] = ''
+  if not 'story_commands' in arguments_dict:
+    arguments_dict ['story_commands'] = []
+
+  # We use the intro_text and commands from the corresponding Hedy level, if they are not present in the adventure
+  hedy_course = course.get_assignment(level_number, None)
+  if not 'intro_text' in arguments_dict:
+    arguments_dict ['intro_text'] = hedy_course.intro_text
+  if not 'commands' in arguments_dict:
+    arguments_dict ['commands'] = hedy_course.commands
+
+  return render_template("code-page.html", **arguments_dict)
diff --git a/utils.py b/utils.py
--- a/utils.py
+++ b/utils.py
@@ -3,6 +3,7 @@
 import boto3
 from boto3.dynamodb.conditions import Key, Attr
 import os
+import yaml
 
 def type_check (val, Type):
     if Type == 'dict':
@@ -21,9 +22,9 @@ def type_check (val, Type):
         return type (val) == bool
 
 def object_check (obj, key, Type):
-  if not type_check (obj, 'dict') or not key in obj:
-     return False
-  return type_check (obj [key], Type)
+    if not type_check (obj, 'dict') or not key in obj:
+        return False
+    return type_check (obj [key], Type)
 
 def timems ():
     return int (round (time.time () * 1000))
@@ -31,6 +32,13 @@ def timems ():
 def times ():
     return int (round (time.time ()))
 
+def load_yaml (filename):
+    try:
+        with open (filename, 'r') as f:
+            return yaml.safe_load (f)
+    except IOError:
+        return {}
+
 # *** DYNAMO DB ***
 
 # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html


Test patch: