diff --git a/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss b/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss new file mode 100644 index 0000000000..5342c985c2 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/foldit/leaderboard.scss @@ -0,0 +1,20 @@ +$leaderboard: #F4F4F4; + +section.foldit { + div.folditchallenge { + table { + border: 1px solid lighten($leaderboard, 10%); + border-collapse: collapse; + margin-top: 20px; + } + th { + background: $leaderboard; + color: darken($leaderboard, 25%); + } + td { + background: lighten($leaderboard, 3%); + border-bottom: 1px solid #fff; + padding: 8px; + } + } +} diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index ea16fee7f1..3990a61183 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor log = logging.getLogger(__name__) class FolditModule(XModule): + + css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]} + def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - # ooh look--I'm lazy, so hardcoding the 7.00x required level. - # If we need it generalized, can pull from the xml later - self.required_level = 4 - self.required_sublevel = 5 + """ + + Example: + + """ + req_level = self.metadata.get("required_level") + req_sublevel = self.metadata.get("required_sublevel") + + # default to what Spring_7012x uses + self.required_level = req_level if req_level else 4 + self.required_sublevel = req_sublevel if req_sublevel else 5 def parse_due_date(): """ @@ -66,6 +79,14 @@ class FolditModule(XModule): PuzzleComplete.completed_puzzles(self.system.anonymous_student_id), key=lambda d: (d['set'], d['subset'])) + def puzzle_leaders(self, n=10): + """ + Returns a list of n pairs (user, score) corresponding to the top + scores; the pairs are in descending order of score. + """ + from foldit.models import Score + + return [(e['username'], e['score']) for e in Score.get_tops_n(10)] def get_html(self): """ @@ -75,15 +96,47 @@ class FolditModule(XModule): self.required_level, self.required_sublevel) + showbasic = (self.metadata.get("show_basic_score").lower() == "true") + showleader = (self.metadata.get("show_leaderboard").lower() == "true") + context = { + 'due': self.due_str, + 'success': self.is_complete(), + 'goal_level': goal_level, + 'completed': self.completed_puzzles(), + 'top_scores': self.puzzle_leaders(), + 'show_basic': showbasic, + 'show_leader': showleader, + 'folditbasic': self.get_basicpuzzles_html(), + 'folditchallenge': self.get_challenge_html() + } + + return self.system.render_template('foldit.html', context) + + def get_basicpuzzles_html(self): + """ + Render html for the basic puzzle section. + """ + goal_level = '{0}-{1}'.format( + self.required_level, + self.required_sublevel) + context = { 'due': self.due_str, 'success': self.is_complete(), 'goal_level': goal_level, 'completed': self.completed_puzzles(), } + return self.system.render_template('folditbasic.html', context) - return self.system.render_template('foldit.html', context) + def get_challenge_html(self): + """ + Render html for challenge (i.e., the leaderboard) + """ + context = { + 'top_scores': self.puzzle_leaders()} + + return self.system.render_template('folditchallenge.html', context) def get_score(self): """ @@ -97,9 +150,10 @@ class FolditModule(XModule): return 1 + class FolditDescriptor(XmlDescriptor, EditingDescriptor): """ - Module for adding open ended response questions to courses + Module for adding Foldit problems to courses """ mako_template = "widgets/html-edit.html" module_class = FolditModule @@ -119,6 +173,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor): @classmethod def definition_from_xml(cls, xml_object, system): """ - For now, don't need anything from the xml + Get the xml_object's attributes. """ - return {} + return {'metadata': xml_object.attrib} diff --git a/lms/djangoapps/foldit/migrations/0001_initial.py b/lms/djangoapps/foldit/migrations/0001_initial.py new file mode 100644 index 0000000000..6c9edfeaa4 --- /dev/null +++ b/lms/djangoapps/foldit/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Score' + db.create_table('foldit_score', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_scores', to=orm['auth.User'])), + ('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('puzzle_id', self.gf('django.db.models.fields.IntegerField')()), + ('best_score', self.gf('django.db.models.fields.FloatField')(db_index=True)), + ('current_score', self.gf('django.db.models.fields.FloatField')(db_index=True)), + ('score_version', self.gf('django.db.models.fields.IntegerField')()), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('foldit', ['Score']) + + # Adding model 'PuzzleComplete' + db.create_table('foldit_puzzlecomplete', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='foldit_puzzles_complete', to=orm['auth.User'])), + ('unique_user_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('puzzle_id', self.gf('django.db.models.fields.IntegerField')()), + ('puzzle_set', self.gf('django.db.models.fields.IntegerField')(db_index=True)), + ('puzzle_subset', self.gf('django.db.models.fields.IntegerField')(db_index=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('foldit', ['PuzzleComplete']) + + # Adding unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'] + db.create_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset']) + + + def backwards(self, orm): + # Removing unique constraint on 'PuzzleComplete', fields ['user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'] + db.delete_unique('foldit_puzzlecomplete', ['user_id', 'puzzle_id', 'puzzle_set', 'puzzle_subset']) + + # Deleting model 'Score' + db.delete_table('foldit_score') + + # Deleting model 'PuzzleComplete' + db.delete_table('foldit_puzzlecomplete') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'foldit.puzzlecomplete': { + 'Meta': {'ordering': "['puzzle_id']", 'unique_together': "(('user', 'puzzle_id', 'puzzle_set', 'puzzle_subset'),)", 'object_name': 'PuzzleComplete'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'puzzle_id': ('django.db.models.fields.IntegerField', [], {}), + 'puzzle_set': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'puzzle_subset': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_puzzles_complete'", 'to': "orm['auth.User']"}) + }, + 'foldit.score': { + 'Meta': {'object_name': 'Score'}, + 'best_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'current_score': ('django.db.models.fields.FloatField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'puzzle_id': ('django.db.models.fields.IntegerField', [], {}), + 'score_version': ('django.db.models.fields.IntegerField', [], {}), + 'unique_user_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'foldit_scores'", 'to': "orm['auth.User']"}) + } + } + + complete_apps = ['foldit'] \ No newline at end of file diff --git a/lms/djangoapps/foldit/migrations/__init__.py b/lms/djangoapps/foldit/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/foldit/models.py b/lms/djangoapps/foldit/models.py index ea4f099216..7041be1446 100644 --- a/lms/djangoapps/foldit/models.py +++ b/lms/djangoapps/foldit/models.py @@ -25,6 +25,47 @@ class Score(models.Model): score_version = models.IntegerField() created = models.DateTimeField(auto_now_add=True) + @staticmethod + def display_score(score, sum_of=1): + """ + Argument: + score (float), as stored in the DB (i.e., "rosetta score") + sum_of (int): if this score is the sum of scores of individual + problems, how many elements are in that sum + + Returns: + score (float), as displayed to the user in the game and in the leaderboard + """ + return (-score) * 10 + 8000 * sum_of + + + @staticmethod + def get_tops_n(n, puzzles=['994559']): + """ + Arguments: + puzzles: a list of puzzle ids that we will use. If not specified, + defaults to puzzle used in 7012x. + n (int): number of top scores to return + + + Returns: + The top n sum of scores for puzzles in . Output is a list + of disctionaries, sorted by display_score: + [ {username: 'a_user', + score: 12000} ...] + """ + if not(type(puzzles) == list): + puzzles = [puzzles] + scores = Score.objects \ + .filter(puzzle_id__in=puzzles) \ + .annotate(total_score=models.Sum('best_score')) \ + .order_by('-total_score')[:n] + num = len(puzzles) + + return [{'username': s.user.username, + 'score': Score.display_score(s.total_score, num)} + for s in scores] + class PuzzleComplete(models.Model): """ diff --git a/lms/djangoapps/foldit/tests.py b/lms/djangoapps/foldit/tests.py index 4d387b44e8..7127651601 100644 --- a/lms/djangoapps/foldit/tests.py +++ b/lms/djangoapps/foldit/tests.py @@ -9,7 +9,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from foldit.views import foldit_ops, verify_code -from foldit.models import PuzzleComplete +from foldit.models import PuzzleComplete, Score from student.models import UserProfile, unique_id_for_user from datetime import datetime, timedelta @@ -25,92 +25,162 @@ class FolditTestCase(TestCase): pwd = 'abc' self.user = User.objects.create_user('testuser', 'test@test.com', pwd) + self.user2 = User.objects.create_user('testuser2', 'test2@test.com', pwd) self.unique_user_id = unique_id_for_user(self.user) + self.unique_user_id2 = unique_id_for_user(self.user2) now = datetime.now() self.tomorrow = now + timedelta(days=1) self.yesterday = now - timedelta(days=1) UserProfile.objects.create(user=self.user) + UserProfile.objects.create(user=self.user2) - def make_request(self, post_data): + def make_request(self, post_data, user=None): request = self.factory.post(self.url, post_data) - request.user = self.user + request.user = self.user if not user else user return request + def make_puzzle_score_request(self, puzzle_ids, best_scores, user=None): + """ + Given lists of puzzle_ids and best_scores (must have same length), make a + SetPlayerPuzzleScores request and return the response. + """ + if not(type(best_scores) == list): + best_scores = [best_scores] + if not(type(puzzle_ids) == list): + puzzle_ids = [puzzle_ids] + user = self.user if not user else user + + def score_dict(puzzle_id, best_score): + return {"PuzzleID": puzzle_id, + "ScoreType": "score", + "BestScore": best_score, + # current scores don't actually matter + "CurrentScore": best_score + 0.01, + "ScoreVersion": 23} + scores = [score_dict(pid, bs) for pid, bs in zip(puzzle_ids, best_scores)] + scores_str = json.dumps(scores) + + verify = {"Verify": verify_code(user.email, scores_str), + "VerifyMethod": "FoldItVerify"} + data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), + 'SetPlayerPuzzleScores': scores_str} + + request = self.make_request(data, user) + + response = foldit_ops(request) + self.assertEqual(response.status_code, 200) + return response + def test_SetPlayerPuzzleScores(self): - scores = [ {"PuzzleID": 994391, - "ScoreType": "score", - "BestScore": 0.078034, - "CurrentScore":0.080035, - "ScoreVersion":23}] - scores_str = json.dumps(scores) - - verify = {"Verify": verify_code(self.user.email, scores_str), - "VerifyMethod":"FoldItVerify"} - data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), - 'SetPlayerPuzzleScores': scores_str} - - request = self.make_request(data) - - response = foldit_ops(request) - self.assertEqual(response.status_code, 200) + puzzle_id = 994391 + best_score = 0.078034 + response = self.make_puzzle_score_request(puzzle_id, [best_score]) self.assertEqual(response.content, json.dumps( [{"OperationID": "SetPlayerPuzzleScores", "Value": [{ - "PuzzleID": 994391, + "PuzzleID": puzzle_id, "Status": "Success"}]}])) + # There should now be a score in the db. + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 1) + self.assertEqual(top_10[0]['score'], Score.display_score(best_score)) def test_SetPlayerPuzzleScores_many(self): - scores = [ {"PuzzleID": 994391, - "ScoreType": "score", - "BestScore": 0.078034, - "CurrentScore":0.080035, - "ScoreVersion":23}, - - {"PuzzleID": 994392, - "ScoreType": "score", - "BestScore": 0.078000, - "CurrentScore":0.080011, - "ScoreVersion":23}] - - scores_str = json.dumps(scores) - - verify = {"Verify": verify_code(self.user.email, scores_str), - "VerifyMethod":"FoldItVerify"} - data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify), - 'SetPlayerPuzzleScores': scores_str} - - request = self.make_request(data) - - response = foldit_ops(request) - self.assertEqual(response.status_code, 200) + response = self.make_puzzle_score_request([1, 2], [0.078034, 0.080000]) self.assertEqual(response.content, json.dumps( [{"OperationID": "SetPlayerPuzzleScores", "Value": [{ - "PuzzleID": 994391, + "PuzzleID": 1, "Status": "Success"}, - {"PuzzleID": 994392, + {"PuzzleID": 2, "Status": "Success"}]}])) + def test_SetPlayerPuzzleScores_multiple(self): + """ + Check that multiple posts with the same id are handled properly + (keep latest for each user, have multiple users work properly) + """ + orig_score = 0.07 + puzzle_id = '1' + response = self.make_puzzle_score_request([puzzle_id], [orig_score]) + + # There should now be a score in the db. + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 1) + self.assertEqual(top_10[0]['score'], Score.display_score(orig_score)) + + # Reporting a better score should overwrite + better_score = 0.06 + response = self.make_puzzle_score_request([1], [better_score]) + + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 1) + + # Floats always get in the way, so do almostequal + self.assertAlmostEqual(top_10[0]['score'], + Score.display_score(better_score), + delta=0.5) + + # reporting a worse score shouldn't + worse_score = 0.065 + response = self.make_puzzle_score_request([1], [worse_score]) + + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 1) + # should still be the better score + self.assertAlmostEqual(top_10[0]['score'], + Score.display_score(better_score), + delta=0.5) + + def test_SetPlayerPuzzleScores_manyplayers(self): + """ + Check that when we send scores from multiple users, the correct order + of scores is displayed. + """ + puzzle_id = ['1'] + player1_score = 0.07 + player2_score = 0.08 + response1 = self.make_puzzle_score_request(puzzle_id, player1_score, + self.user) + + # There should now be a score in the db. + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 1) + self.assertEqual(top_10[0]['score'], Score.display_score(player1_score)) + + response2 = self.make_puzzle_score_request(puzzle_id, player2_score, + self.user2) + + # There should now be two scores in the db + top_10 = Score.get_tops_n(10, puzzle_id) + self.assertEqual(len(top_10), 2) + + # Top score should be player2_score. Second should be player1_score + self.assertEqual(top_10[0]['score'], Score.display_score(player2_score)) + self.assertEqual(top_10[1]['score'], Score.display_score(player1_score)) + + # Top score user should be self.user2.username + self.assertEqual(top_10[0]['username'], self.user2.username) def test_SetPlayerPuzzleScores_error(self): - scores = [ {"PuzzleID": 994391, + scores = [{"PuzzleID": 994391, "ScoreType": "score", "BestScore": 0.078034, - "CurrentScore":0.080035, - "ScoreVersion":23}] + "CurrentScore": 0.080035, + "ScoreVersion": 23}] validation_str = json.dumps(scores) verify = {"Verify": verify_code(self.user.email, validation_str), - "VerifyMethod":"FoldItVerify"} + "VerifyMethod": "FoldItVerify"} # change the real string -- should get an error scores[0]['ScoreVersion'] = 22 diff --git a/lms/djangoapps/foldit/views.py b/lms/djangoapps/foldit/views.py index 8bd3684c04..988c113d23 100644 --- a/lms/djangoapps/foldit/views.py +++ b/lms/djangoapps/foldit/views.py @@ -10,6 +10,8 @@ from django.views.decorators.csrf import csrf_exempt from foldit.models import Score, PuzzleComplete from student.models import unique_id_for_user +import re + log = logging.getLogger(__name__) @@ -38,6 +40,13 @@ def foldit_ops(request): "user %s, scores json %r, verify %r", request.user, puzzle_scores_json, pz_verify_json) else: + # This is needed because we are not getting valid json - the + # value of ScoreType is an unquoted string. Right now regexes are + # quoting the string, but ideally the json itself would be fixed. + # To allow for fixes without breaking this, the regex should only + # match unquoted strings, + a = re.compile(r':([a-zA-Z]*),') + puzzle_scores_json = re.sub(a, ':"\g<1>",', puzzle_scores_json) puzzle_scores = json.loads(puzzle_scores_json) responses.append(save_scores(request.user, puzzle_scores)) @@ -98,10 +107,31 @@ def save_scores(user, puzzle_scores): # BestScore (energy), CurrentScore (Energy), ScoreVersion (int) puzzle_id = score['PuzzleID'] - - # TODO: save the score + best_score = score['BestScore'] + current_score = score['CurrentScore'] + score_version = score['ScoreVersion'] # SetPlayerPuzzleScoreResponse object + # Score entries are unique on user/unique_user_id/puzzle_id/score_version + try: + obj = Score.objects.get( + user=user, + unique_user_id=unique_id_for_user(user), + puzzle_id=puzzle_id, + score_version=score_version) + obj.current_score = current_score + obj.best_score = best_score + + except Score.DoesNotExist: + obj = Score( + user=user, + unique_user_id=unique_id_for_user(user), + puzzle_id=puzzle_id, + current_score=current_score, + best_score=best_score, + score_version=score_version) + obj.save() + score_responses.append({'PuzzleID': puzzle_id, 'Status': 'Success'}) diff --git a/lms/templates/foldit.html b/lms/templates/foldit.html index 2c16ebbfeb..2a8271cc62 100644 --- a/lms/templates/foldit.html +++ b/lms/templates/foldit.html @@ -1,28 +1,12 @@
-

Due: ${due} + + % if show_basic: + ${folditbasic} + % endif -

-Status: -% if success: -You have successfully gotten to level ${goal_level}. -% else: -You have not yet gotten to level ${goal_level}. -% endif -

-

Completed puzzles

+ % if show_leader: + ${folditchallenge} + % endif - - - - - - % for puzzle in completed: - - - - - % endfor -
LevelSubmitted
${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}${puzzle['created'].strftime('%Y-%m-%d %H:%M')}
- -
\ No newline at end of file + diff --git a/lms/templates/folditbasic.html b/lms/templates/folditbasic.html new file mode 100644 index 0000000000..0c79a53703 --- /dev/null +++ b/lms/templates/folditbasic.html @@ -0,0 +1,29 @@ +
+

Due: ${due} + +

+ Status: + % if success: + You have successfully gotten to level ${goal_level}. + % else: + You have not yet gotten to level ${goal_level}. + % endif +

+ +

Completed puzzles

+ + + + + + + % for puzzle in completed: + + + + + % endfor +
LevelSubmitted
${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}${puzzle['created'].strftime('%Y-%m-%d %H:%M')}
+ +
+
diff --git a/lms/templates/folditchallenge.html b/lms/templates/folditchallenge.html new file mode 100644 index 0000000000..677bc286c8 --- /dev/null +++ b/lms/templates/folditchallenge.html @@ -0,0 +1,16 @@ +
+

Puzzle Leaderboard

+ + + + + + + % for pair in top_scores: + + + + + % endfor +
UserScore
${pair[0]}${pair[1]}
+