Repository: hedyorg/hedy

Issue:
Improvements & fixes to load/save with adventures
Upcoming work here: https://github.com/Felienne/hedy/tree/adventure-mode-improvements

   - For logged in users, load adventure saved programs together with adventures, so they are available.
   - Remove render_adventure in hedy.py, just use new unified level+adventure view to render adventures.
   - Cleanup templates from the standalone adventure view.
   - Add adventure_name to window.State, so it's available to js. 
   - Refactor saveit to simplify the way in which adventure_name is passed.
   - When in an adventure, hide the input to specify the saved program name.
   - When clicking on tabs, set or clear window.State.adventure_name for properly saving programs.
   - When loading an adventure (through a link or through a saved game that belongs to it), open the proper tab.
   - Load saved adventures (if any) when changing tabs
   - When saving a program for an adventure+level, overwrite the old one if it exists
   - Rework:
      - Allow multiple programs for a given adventure+level to be saved (in a global name namespace).
      - Include program name again on saveit.
      - Always show the input in adventure.
      - Load programs: use ids always, but if id belongs to adventure_name show the right adventure tab!
      - Improvement: put the name of the loaded program on the input
      - Improvement: use adventure_name + level or Untitled + level as default program names



Discussion:

Resolving patch:
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -81,19 +81,43 @@ def load_adventure_for_language(lang):
 
 
 def load_adventure_assignments_per_level(lang, level):
+
+    loaded_programs = {}
+    # If user is logged in, we iterate their programs that belong to the current level. Out of these, we keep the latest created program for both the level mode (no adventure) and for each of the adventures.
+    if current_user (request) ['username']:
+        user_programs = db_get_many ('programs', {'username': current_user (request) ['username']}, True)
+        for program in user_programs:
+            if program ['level'] != level:
+                continue
+            program_key = 'level' if not program.get ('adventure_name') else program ['adventure_name']
+            if not program_key in loaded_programs:
+                loaded_programs [program_key] = program
+            elif loaded_programs [program_key] ['date'] < program ['date']:
+                loaded_programs [program_key] = program
+
     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')
-            })
+        if not level in adventure['levels']:
+            continue
+        assignments.append({
+            'short_name': short_name,
+            'name': adventure['name'],
+            'image': adventure.get('image', None),
+            'default_save_name': adventure['default_save_name'],
+            'text': adventure['levels'][level].get('story_text', 'No Story Text'),
+            'start_code': adventure['levels'][level].get ('start_code', ''),
+            'loaded_program': '' if not loaded_programs.get (short_name) else loaded_programs.get (short_name) ['code'],
+            'loaded_program_name': '' if not loaded_programs.get (short_name) else loaded_programs.get (short_name) ['name']
+        })
+    # We create a 'level' pseudo assignment to store the loaded program for level mode, if any.
+    assignments.append({
+        'short_name': 'level',
+        'loaded_program': '' if not loaded_programs.get ('level') else loaded_programs.get ('level') ['code'],
+        'loaded_program_name': '' if not loaded_programs.get ('level') else loaded_programs.get ('level') ['name']
+    })
     return assignments
 
-
 # Load main menu (do it once, can be cached)
 with open(f'main/menu.json', 'r', encoding='utf-8') as f:
     main_menu_json = json.load(f)
@@ -342,6 +366,7 @@ def programs_page (request):
 
     texts=TRANSLATIONS.data [requested_lang ()] ['Programs']
     ui=TRANSLATIONS.data [requested_lang ()] ['ui']
+    adventures = load_adventure_for_language(requested_lang ())['adventures']
 
     result = db_get_many ('programs', {'username': from_user or username}, True)
     programs = []
@@ -359,7 +384,7 @@ def programs_page (request):
 
         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 [requested_lang ()] ['Auth'], programs=programs, username=username, current_page='programs', from_user=from_user)
+    return render_template('programs.html', lang=requested_lang(), menu=render_main_menu('programs'), texts=texts, ui=ui, auth=TRANSLATIONS.data [requested_lang ()] ['Auth'], programs=programs, username=username, current_page='programs', from_user=from_user, adventures=adventures)
 
 # Adventure mode
 @app.route('/hedy/adventures', methods=['GET'])
@@ -379,9 +404,8 @@ def adventure_page(adventure_name, level):
         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 no level is specified (this will happen if the last element of the path (minus the query parameter) is the same as the 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
@@ -401,28 +425,25 @@ def adventure_page(adventure_name, level):
                     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)
 
-    return hedyweb.render_adventure(
-        adventure_name=adventure_name,
-        adventure=adventure,
-        course=HEDY_COURSE[requested_lang ()],
+    adventure_assignments = load_adventure_assignments_per_level(requested_lang(), level)
+    g.prefix = '/hedy'
+    return hedyweb.render_assignment_editor(
         request=request,
-        lang=requested_lang (),
+        course=HEDY_COURSE[requested_lang()],
         level_number=level,
+        assignment_number=1,
         menu=render_main_menu('hedy'),
         translations=TRANSLATIONS,
         version=version(),
-        loaded_program=loaded_program)
+        adventure_assignments=adventure_assignments,
+        # The relevant loaded program will be available to client-side js and it will be loaded by js.
+        loaded_program='',
+        loaded_program_name='',
+        adventure_name=adventure_name)
 
 # routing to index.html
 @app.route('/hedy', methods=['GET'], defaults={'level': 1, 'step': 1})
@@ -437,6 +458,10 @@ def index(level, step):
     g.lang = requested_lang()
     g.prefix = '/hedy'
 
+    loaded_program = ''
+    loaded_program_name = ''
+    adventure_name = ''
+
     # If step is a string that has more than two characters, it must be an id of a program
     if step and type_check (step, 'str') and len (step) > 2:
         result = db_get ('programs', {'id': step})
@@ -447,10 +472,11 @@ def index(level, step):
         if user ['username'] != result ['username'] and not is_admin (request) and not is_teacher (request):
             return 'No such program!', 404
         loaded_program = result ['code']
+        loaded_program_name = result ['name']
+        if 'adventure_name' in result:
+            adventure_name = result ['adventure_name']
         # We default to step 1 to provide a meaningful default assignment
         step = 1
-    else:
-        loaded_program = ''
 
     adventure_assignments = load_adventure_assignments_per_level(g.lang, level)
 
@@ -463,7 +489,9 @@ def index(level, step):
         translations=TRANSLATIONS,
         version=version(),
         adventure_assignments=adventure_assignments,
-        loaded_program=loaded_program)
+        loaded_program=loaded_program,
+        loaded_program_name=loaded_program_name,
+        adventure_name=adventure_name)
 
 @app.route('/onlinemasters', methods=['GET'], defaults={'level': 1, 'step': 1})
 @app.route('/onlinemasters/<level>', methods=['GET'], defaults={'step': 1})
@@ -474,6 +502,8 @@ def onlinemasters(level, step):
     g.lang = lang = requested_lang()
     g.prefix = '/onlinemasters'
 
+    adventure_assignments = load_adventure_assignments_per_level(g.lang, level)
+
     return hedyweb.render_assignment_editor(
         request=request,
         course=ONLINE_MASTERS_COURSE,
@@ -482,7 +512,10 @@ def onlinemasters(level, step):
         translations=TRANSLATIONS,
         version=version(),
         menu=None,
-        loaded_program='')
+        adventure_assignments=adventure_assignments,
+        loaded_program='',
+        loaded_program_name='',
+        adventure_name='')
 
 @app.route('/space_eu', methods=['GET'], defaults={'level': 1, 'step': 1})
 @app.route('/space_eu/<level>', methods=['GET'], defaults={'step': 1})
@@ -493,6 +526,8 @@ def space_eu(level, step):
     g.lang = requested_lang()
     g.prefix = '/space_eu'
 
+    adventure_assignments = load_adventure_assignments_per_level(g.lang, level)
+
     return hedyweb.render_assignment_editor(
         request=request,
         course=SPACE_EU_COURSE[g.lang],
@@ -501,7 +536,10 @@ def space_eu(level, step):
         translations=TRANSLATIONS,
         version=version(),
         menu=None,
-        loaded_program='')
+        adventure_assignments=adventure_assignments,
+        loaded_program='',
+        loaded_program_name='',
+        adventure_name='')
 
 
 
@@ -705,18 +743,18 @@ def save_program (user):
 
     name = body ['name']
 
-    # 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) + ')'
+    # If name ends with (N) or (NN), we strip them since it's very likely these addenda were added by our server to avoid overwriting existing programs.
+    name = re.sub (' \(\d+\)$', '', 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) + ')'
 
     stored_program = {
         'id': uuid.uuid4().hex,
@@ -741,7 +779,7 @@ def save_program (user):
         program_count = user ['program_count']
     db_set('users', {'username': user ['username'], 'program_count': program_count + 1})
 
-    return jsonify({})
+    return jsonify({'name': name})
 
 @app.route('/translate/<source>/<target>')
 def translate_fromto(source, target):
diff --git a/hedyweb.py b/hedyweb.py
--- a/hedyweb.py
+++ b/hedyweb.py
@@ -33,7 +33,7 @@ def get_translations(self, language, section):
     return d
 
 
-def render_assignment_editor(request, course, level_number, assignment_number, menu, translations, version, loaded_program, adventure_assignments):
+def render_assignment_editor(request, course, level_number, assignment_number, menu, translations, version, loaded_program, loaded_program_name, adventure_assignments, adventure_name):
   assignment = course.get_assignment(level_number, assignment_number)
   if not assignment:
     abort(404)
@@ -57,7 +57,9 @@ def render_assignment_editor(request, course, level_number, assignment_number, m
   arguments_dict['auth'] = translations.data [course.language] ['Auth']
   arguments_dict['username'] = current_user(request) ['username']
   arguments_dict['loaded_program'] = loaded_program
+  arguments_dict['loaded_program_name'] = loaded_program_name
   arguments_dict['adventure_assignments'] = adventure_assignments
+  arguments_dict['adventure_name'] = adventure_name
 
   # Translations
   arguments_dict.update(**translations.get_translations(course.language, 'ui'))
@@ -70,43 +72,3 @@ 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)


Test patch: