Commit 2388770c by Victor Shnayder

Merge pull request #1502 from MITx/feature/victor/foldit-api

Feature/victor/foldit api
parents f9664284 1108137d
......@@ -45,7 +45,8 @@ setup(
"static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor"
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor",
]
}
)
import logging
from lxml import etree
from dateutil import parser
from pkg_resources import resource_string
from xmodule.editing_module import EditingDescriptor
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
log = logging.getLogger(__name__)
class FolditModule(XModule):
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
def parse_due_date():
"""
Pull out the date, or None
"""
s = self.metadata.get("due")
if s:
return parser.parse(s)
else:
return None
self.due_str = self.metadata.get("due", "None")
self.due = parse_due_date()
def is_complete(self):
"""
Did the user get to the required level before the due date?
"""
# We normally don't want django dependencies in xmodule. foldit is
# special. Import this late to avoid errors with things not yet being
# initialized.
from foldit.models import PuzzleComplete
complete = PuzzleComplete.is_level_complete(
self.system.anonymous_student_id,
self.required_level,
self.required_sublevel,
self.due)
return complete
def completed_puzzles(self):
"""
Return a list of puzzles that this user has completed, as an array of
dicts:
[ {'set': int,
'subset': int,
'created': datetime} ]
The list is sorted by set, then subset
"""
from foldit.models import PuzzleComplete
return sorted(
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
key=lambda d: (d['set'], d['subset']))
def get_html(self):
"""
Render the html for the module.
"""
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('foldit.html', context)
def get_score(self):
"""
0 / 1 based on whether student has gotten far enough.
"""
score = 1 if self.is_complete() else 0
return {'score': score,
'total': self.max_score()}
def max_score(self):
return 1
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
"""
Module for adding open ended response questions to courses
"""
mako_template = "widgets/html-edit.html"
module_class = FolditModule
filename_extension = "xml"
stores_state = True
has_score = True
template_dir_name = "foldit"
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor"
# The grade changes without any student interaction with the edx website,
# so always need to actually check.
always_recalculate_grades = True
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
For now, don't need anything from the xml
"""
return {}
......@@ -3,7 +3,11 @@ from xmodule.raw_module import RawDescriptor
class HiddenModule(XModule):
pass
def get_html(self):
if self.system.user_is_staff:
return "ERROR: This module is unknown--students will not see it at all"
else:
return ""
class HiddenDescriptor(RawDescriptor):
......
......@@ -515,6 +515,16 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
self._child_instances = None
self._inherited_metadata = set()
# Class level variable
always_recalculate_grades = False
"""
Return whether this descriptor always requires recalculation of grades, for
example if the score can change via an extrnal service, not just when the
student interacts with the module on the page. A specific example is
FoldIt, which posts grade-changing updates through a separate API.
"""
@property
def display_name(self):
'''
......
......@@ -343,6 +343,14 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul
Can return None if user doesn't have access, or if something else went wrong.
cache: A StudentModuleCache
"""
if problem_descriptor.always_recalculate_grades:
problem = module_creator(problem_descriptor)
d = problem.get_score()
if d is not None:
return (d['score'], d['total'])
else:
return (None, None)
if not (problem_descriptor.stores_state and problem_descriptor.has_score):
# These are not problems, and do not have a score
return (None, None)
......
......@@ -33,7 +33,7 @@ from xmodule_modifiers import replace_course_urls, replace_static_urls, add_hist
from xmodule.modulestore.exceptions import ItemNotFoundError
from statsd import statsd
log = logging.getLogger("mitx.courseware")
log = logging.getLogger(__name__)
if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
......
import logging
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
from student.models import unique_id_for_user
log = logging.getLogger(__name__)
class Score(models.Model):
"""
This model stores the scores of different users on FoldIt problems.
"""
user = models.ForeignKey(User, db_index=True,
related_name='foldit_scores')
# The XModule that wants to access this doesn't have access to the real
# userid. Save the anonymized version so we can look up by that.
unique_user_id = models.CharField(max_length=50, db_index=True)
puzzle_id = models.IntegerField()
best_score = models.FloatField(db_index=True)
current_score = models.FloatField(db_index=True)
score_version = models.IntegerField()
created = models.DateTimeField(auto_now_add=True)
class PuzzleComplete(models.Model):
"""
This keeps track of the sets of puzzles completed by each user.
e.g. PuzzleID 1234, set 1, subset 3. (Sets and subsets correspond to levels
in the intro puzzles)
"""
class Meta:
# there should only be one puzzle complete entry for any particular
# puzzle for any user
unique_together = ('user', 'puzzle_id', 'puzzle_set', 'puzzle_subset')
ordering = ['puzzle_id']
user = models.ForeignKey(User, db_index=True,
related_name='foldit_puzzles_complete')
# The XModule that wants to access this doesn't have access to the real
# userid. Save the anonymized version so we can look up by that.
unique_user_id = models.CharField(max_length=50, db_index=True)
puzzle_id = models.IntegerField()
puzzle_set = models.IntegerField(db_index=True)
puzzle_subset = models.IntegerField(db_index=True)
created = models.DateTimeField(auto_now_add=True)
def __unicode__(self):
return "PuzzleComplete({0}, id={1}, set={2}, subset={3}, created={4})".format(
self.user.username, self.puzzle_id,
self.puzzle_set, self.puzzle_subset,
self.created)
@staticmethod
def completed_puzzles(anonymous_user_id):
"""
Return a list of puzzles that this user has completed, as an array of
dicts:
[ {'set': int,
'subset': int,
'created': datetime} ]
"""
complete = PuzzleComplete.objects.filter(unique_user_id=anonymous_user_id)
return [{'set': c.puzzle_set,
'subset': c.puzzle_subset,
'created': c.created} for c in complete]
@staticmethod
def is_level_complete(anonymous_user_id, level, sub_level, due=None):
"""
Return True if this user completed level--sub_level by due.
Users see levels as e.g. 4-5.
Args:
level: int
sub_level: int
due (optional): If specified, a datetime. Ignored if None.
"""
complete = PuzzleComplete.objects.filter(unique_user_id=anonymous_user_id,
puzzle_set=level,
puzzle_subset=sub_level)
if due is not None:
complete = complete.filter(created__lte=due)
return complete.exists()
import json
import logging
from functools import partial
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.client import RequestFactory
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 student.models import UserProfile, unique_id_for_user
from datetime import datetime, timedelta
log = logging.getLogger(__name__)
class FolditTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.url = reverse('foldit_ops')
pwd = 'abc'
self.user = User.objects.create_user('testuser', 'test@test.com', pwd)
self.unique_user_id = unique_id_for_user(self.user)
now = datetime.now()
self.tomorrow = now + timedelta(days=1)
self.yesterday = now - timedelta(days=1)
UserProfile.objects.create(user=self.user)
def make_request(self, post_data):
request = self.factory.post(self.url, post_data)
request.user = self.user
return request
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)
self.assertEqual(response.content, json.dumps(
[{"OperationID": "SetPlayerPuzzleScores",
"Value": [{
"PuzzleID": 994391,
"Status": "Success"}]}]))
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)
self.assertEqual(response.content, json.dumps(
[{"OperationID": "SetPlayerPuzzleScores",
"Value": [{
"PuzzleID": 994391,
"Status": "Success"},
{"PuzzleID": 994392,
"Status": "Success"}]}]))
def test_SetPlayerPuzzleScores_error(self):
scores = [ {"PuzzleID": 994391,
"ScoreType": "score",
"BestScore": 0.078034,
"CurrentScore":0.080035,
"ScoreVersion":23}]
validation_str = json.dumps(scores)
verify = {"Verify": verify_code(self.user.email, validation_str),
"VerifyMethod":"FoldItVerify"}
# change the real string -- should get an error
scores[0]['ScoreVersion'] = 22
scores_str = json.dumps(scores)
data = {'SetPlayerPuzzleScoresVerify': json.dumps(verify),
'SetPlayerPuzzleScores': scores_str}
request = self.make_request(data)
response = foldit_ops(request)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response.content,
json.dumps([{
"OperationID": "SetPlayerPuzzleScores",
"Success": "false",
"ErrorString": "Verification failed",
"ErrorCode": "VerifyFailed"}]))
def make_puzzles_complete_request(self, puzzles):
"""
Make a puzzles complete request, given an array of
puzzles. E.g.
[ {"PuzzleID": 13, "Set": 1, "SubSet": 2},
{"PuzzleID": 53524, "Set": 1, "SubSet": 1} ]
"""
puzzles_str = json.dumps(puzzles)
verify = {"Verify": verify_code(self.user.email, puzzles_str),
"VerifyMethod":"FoldItVerify"}
data = {'SetPuzzlesCompleteVerify': json.dumps(verify),
'SetPuzzlesComplete': puzzles_str}
request = self.make_request(data)
response = foldit_ops(request)
self.assertEqual(response.status_code, 200)
return response
@staticmethod
def set_puzzle_complete_response(values):
return json.dumps([{"OperationID":"SetPuzzlesComplete",
"Value": values}])
def test_SetPlayerPuzzlesComplete(self):
puzzles = [ {"PuzzleID": 13, "Set": 1, "SubSet": 2},
{"PuzzleID": 53524, "Set": 1, "SubSet": 1} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertEqual(response.content,
self.set_puzzle_complete_response([13, 53524]))
def test_SetPlayerPuzzlesComplete_multiple(self):
"""Check that state is stored properly"""
puzzles = [ {"PuzzleID": 13, "Set": 1, "SubSet": 2},
{"PuzzleID": 53524, "Set": 1, "SubSet": 1} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertEqual(response.content,
self.set_puzzle_complete_response([13, 53524]))
puzzles = [ {"PuzzleID": 14, "Set": 1, "SubSet": 3},
{"PuzzleID": 15, "Set": 1, "SubSet": 1} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertEqual(response.content,
self.set_puzzle_complete_response([13, 14, 15, 53524]))
def test_SetPlayerPuzzlesComplete_level_complete(self):
"""Check that the level complete function works"""
puzzles = [ {"PuzzleID": 13, "Set": 1, "SubSet": 2},
{"PuzzleID": 53524, "Set": 1, "SubSet": 1} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertEqual(response.content,
self.set_puzzle_complete_response([13, 53524]))
puzzles = [ {"PuzzleID": 14, "Set": 1, "SubSet": 3},
{"PuzzleID": 15, "Set": 1, "SubSet": 1} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertEqual(response.content,
self.set_puzzle_complete_response([13, 14, 15, 53524]))
is_complete = partial(
PuzzleComplete.is_level_complete, self.unique_user_id)
self.assertTrue(is_complete(1, 1))
self.assertTrue(is_complete(1, 3))
self.assertTrue(is_complete(1, 2))
self.assertFalse(is_complete(4, 5))
puzzles = [ {"PuzzleID": 74, "Set": 4, "SubSet": 5} ]
response = self.make_puzzles_complete_request(puzzles)
self.assertTrue(is_complete(4, 5))
# Now check due dates
self.assertTrue(is_complete(1, 1, due=self.tomorrow))
self.assertFalse(is_complete(1, 1, due=self.yesterday))
def test_SetPlayerPuzzlesComplete_error(self):
puzzles = [ {"PuzzleID": 13, "Set": 1, "SubSet": 2},
{"PuzzleID": 53524, "Set": 1, "SubSet": 1} ]
puzzles_str = json.dumps(puzzles)
verify = {"Verify": verify_code(self.user.email, puzzles_str + "x"),
"VerifyMethod":"FoldItVerify"}
data = {'SetPuzzlesCompleteVerify': json.dumps(verify),
'SetPuzzlesComplete': puzzles_str}
request = self.make_request(data)
response = foldit_ops(request)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content)
self.assertEqual(response.content,
json.dumps([{
"OperationID": "SetPuzzlesComplete",
"Success": "false",
"ErrorString": "Verification failed",
"ErrorCode": "VerifyFailed"}]))
import hashlib
import json
import logging
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.views.decorators.http import require_POST
from foldit.models import Score, PuzzleComplete
from student.models import unique_id_for_user
log = logging.getLogger(__name__)
@login_required
@require_POST
def foldit_ops(request):
log.debug(request.POST)
responses = []
if "SetPlayerPuzzleScores" in request.POST:
puzzle_scores_json = request.POST.get("SetPlayerPuzzleScores")
pz_verify_json = request.POST.get("SetPlayerPuzzleScoresVerify")
puzzle_score_verify = json.loads(pz_verify_json)
if not verifies_ok(request.user.email,
puzzle_scores_json, puzzle_score_verify):
responses.append({"OperationID": "SetPlayerPuzzleScores",
"Success": "false",
"ErrorString": "Verification failed",
"ErrorCode": "VerifyFailed"})
log.info("Verification of SetPlayerPuzzleScores failed:" +
"user %s, scores json %r, verify %r",
request.user, puzzle_scores_json, pz_verify_json)
else:
puzzle_scores = json.loads(puzzle_scores_json)
responses.append(save_scores(request.user, puzzle_scores))
if "SetPuzzlesComplete" in request.POST:
puzzles_complete_json = request.POST.get("SetPuzzlesComplete")
pc_verify_json = request.POST.get("SetPuzzlesCompleteVerify")
puzzles_complete_verify = json.loads(pc_verify_json)
if not verifies_ok(request.user.email,
puzzles_complete_json, puzzles_complete_verify):
responses.append({"OperationID": "SetPuzzlesComplete",
"Success": "false",
"ErrorString": "Verification failed",
"ErrorCode": "VerifyFailed"})
log.info("Verification of SetPuzzlesComplete failed:" +
" user %s, puzzles json %r, verify %r",
request.user, puzzles_complete_json, pc_verify_json)
else:
puzzles_complete = json.loads(puzzles_complete_json)
responses.append(save_complete(request.user, puzzles_complete))
return HttpResponse(json.dumps(responses))
def verify_code(email, val):
"""
Given the email and passed in value (str), return the expected
verification code.
"""
# TODO: is this the right string?
verification_string = email.lower() + '|' + val
return hashlib.md5(verification_string).hexdigest()
def verifies_ok(email, val, verification):
"""
Check that the hash_str matches the expected hash of val.
Returns True if verification ok, False otherwise
"""
if verification.get("VerifyMethod") != "FoldItVerify":
log.debug("VerificationMethod in %r isn't FoldItVerify", verification)
return False
hash_str = verification.get("Verify")
return verify_code(email, val) == hash_str
def save_scores(user, puzzle_scores):
score_responses = []
for score in puzzle_scores:
log.debug("score: %s", score)
# expected keys ScoreType, PuzzleID (int),
# BestScore (energy), CurrentScore (Energy), ScoreVersion (int)
puzzle_id = score['PuzzleID']
# TODO: save the score
# SetPlayerPuzzleScoreResponse object
score_responses.append({'PuzzleID': puzzle_id,
'Status': 'Success'})
return {"OperationID": "SetPlayerPuzzleScores", "Value": score_responses}
def save_complete(user, puzzles_complete):
"""
Returned list of PuzzleIDs should be in sorted order (I don't think client
cares, but tests do)
"""
for complete in puzzles_complete:
log.debug("Puzzle complete: %s", complete)
puzzle_id = complete['PuzzleID']
puzzle_set = complete['Set']
puzzle_subset = complete['SubSet']
# create if not there
PuzzleComplete.objects.get_or_create(
user=user,
unique_user_id=unique_id_for_user(user),
puzzle_id=puzzle_id,
puzzle_set=puzzle_set,
puzzle_subset=puzzle_subset)
# List of all puzzle ids of intro-level puzzles completed ever, including on this
# request
# TODO: this is just in this request...
complete_responses = list(pc.puzzle_id
for pc in PuzzleComplete.objects.filter(user=user))
return {"OperationID": "SetPuzzlesComplete", "Value": complete_responses}
......@@ -575,6 +575,9 @@ INSTALLED_APPS = (
'wiki.plugins.notifications',
'course_wiki.plugins.markdownedx',
# foldit integration
'foldit',
# For testing
'django.contrib.admin', # only used in DEBUG mode
......
......@@ -248,3 +248,17 @@ section.self-assessment {
font-weight: bold;
}
}
section.foldit {
table {
margin-top: 10px;
}
th {
text-align: center;
}
td {
padding-left: 5px;
padding-right: 5px;
}
}
\ No newline at end of file
<section class="foldit">
<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>
</section>
\ No newline at end of file
......@@ -357,6 +357,12 @@ if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'):
url(r'^event_logs/(?P<args>.+)$', 'track.views.view_tracking_log'),
)
# FoldIt views
urlpatterns += (
# The path is hardcoded into their app...
url(r'^comm/foldit_ops', 'foldit.views.foldit_ops', name="foldit_ops"),
)
urlpatterns = patterns(*urlpatterns)
if settings.DEBUG:
......
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