Commit 9d2a2cbc by David Ormsbee

Merge pull request #1555 from MITx/feature/jkarni/foldit-api

Feature/jkarni/foldit api
parents b0afbba5 b6f3042c
$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;
}
}
}
......@@ -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:
<foldit show_basic_score="true"
required_level="4"
required_sublevel="3"
show_leaderboard="false"/>
"""
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)
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}
# -*- 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
......@@ -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 <puzzles>. 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):
"""
......
......@@ -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 test_SetPlayerPuzzleScores(self):
scores = [ {"PuzzleID": 994391,
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": 0.078034,
"CurrentScore":0.080035,
"ScoreVersion":23}]
"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(self.user.email, scores_str),
"VerifyMethod":"FoldItVerify"}
verify = {"Verify": verify_code(user.email, scores_str),
"VerifyMethod": "FoldItVerify"}
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
'SetPlayerPuzzleScores': scores_str}
request = self.make_request(data)
request = self.make_request(data, user)
response = foldit_ops(request)
self.assertEqual(response.status_code, 200)
return response
def test_SetPlayerPuzzleScores(self):
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
......
......@@ -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'})
......
<section class="foldit">
<p><strong>Due:</strong> ${due}
% if show_basic:
${folditbasic}
% endif
<p>
<strong>Status:</strong>
% if success:
You have successfully gotten to level ${goal_level}.
% else:
You have not yet gotten to level ${goal_level}.
% endif
</p>
<h3>Completed puzzles</h3>
% if show_leader:
${folditchallenge}
% endif
<table>
<tr>
<th>Level</th>
<th>Submitted</th>
</tr>
% for puzzle in completed:
<tr>
<td>${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}</td>
<td>${puzzle['created'].strftime('%Y-%m-%d %H:%M')}</td>
</tr>
% endfor
</table>
</section>
\ No newline at end of file
</section>
<div class="folditbasic">
<p><strong>Due:</strong> ${due}
<p>
<strong>Status:</strong>
% if success:
You have successfully gotten to level ${goal_level}.
% else:
You have not yet gotten to level ${goal_level}.
% endif
</p>
<h3>Completed puzzles</h3>
<table>
<tr>
<th>Level</th>
<th>Submitted</th>
</tr>
% for puzzle in completed:
<tr>
<td>${'{0}-{1}'.format(puzzle['set'], puzzle['subset'])}</td>
<td>${puzzle['created'].strftime('%Y-%m-%d %H:%M')}</td>
</tr>
% endfor
</table>
</br>
</div>
<div class="folditchallenge">
<h3>Puzzle Leaderboard</h3>
<table>
<tr>
<th>User</th>
<th>Score</th>
</tr>
% for pair in top_scores:
<tr>
<td>${pair[0]}</td>
<td>${pair[1]}</td>
</tr>
% endfor
</table>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment