From b5368f2a4ff71b835c083bd6f0aa45f43c9da1f2 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Tue, 19 Jun 2012 18:16:14 -0400 Subject: [PATCH] Initial progress display. * add module_from_xml param to I4xSystem * use it to implement xmodule.get_children() * fix a few comments here and there * Render-time progress display for seq and vertical modules. - Computes fraction of subproblems done. * Pass problem state back to js during ajax calls. * general cleanup in capa_module.py * add progress_changed and progress fields to json returned from each ajax handler * Coffeescript changes to hook up sequence tracking of problem progress * net result: sequence 'a' tags now have a progress class * properly set css class on initial load * fire event when progress changes after ajax calls * also save state in 'progress' property of problems-wrapper tag * event handler finds those tags, computes updated progress --- common/lib/capa/capa_problem.py | 3 +- common/lib/xmodule/capa_module.py | 240 +++++++++++------- common/lib/xmodule/progress.py | 2 + common/lib/xmodule/seq_module.py | 30 ++- common/lib/xmodule/vertical_module.py | 9 + common/lib/xmodule/x_module.py | 8 + lms/djangoapps/courseware/grades.py | 2 + lms/djangoapps/courseware/module_render.py | 38 ++- lms/static/coffee/src/modules/problem.coffee | 21 +- lms/static/coffee/src/modules/sequence.coffee | 59 ++++- 10 files changed, 309 insertions(+), 103 deletions(-) diff --git a/common/lib/capa/capa_problem.py b/common/lib/capa/capa_problem.py index 4ee2a2113d..a06ac1d7b6 100644 --- a/common/lib/capa/capa_problem.py +++ b/common/lib/capa/capa_problem.py @@ -169,7 +169,8 @@ class LoncapaProblem(object): def get_score(self): ''' Compute score for this problem. The score is the number of points awarded. - Returns an integer, from 0 to get_max_score(). + Returns a dictionary {'score': integer, from 0 to get_max_score(), + 'total': get_max_score()}. ''' correct = 0 for key in self.correct_map: diff --git a/common/lib/xmodule/capa_module.py b/common/lib/xmodule/capa_module.py index 55534f8a3e..7c75d1666a 100644 --- a/common/lib/xmodule/capa_module.py +++ b/common/lib/xmodule/capa_module.py @@ -11,6 +11,7 @@ from datetime import timedelta from lxml import etree from x_module import XModule, XModuleDescriptor +from progress import Progress from capa.capa_problem import LoncapaProblem from capa.responsetypes import StudentInputError @@ -79,24 +80,41 @@ class Module(XModule): def get_xml_tags(c): return ["problem"] + def get_state(self): state = self.lcp.get_state() state['attempts'] = self.attempts return json.dumps(state) + def get_score(self): return self.lcp.get_score() + def max_score(self): return self.lcp.get_max_score() + + def get_progress(self): + ''' For now, just return score / max_score + ''' + d = self.get_score() + score = d['score'] + total = d['total'] + return Progress(score, total) + + def get_html(self): return self.system.render_template('problem_ajax.html', { 'id': self.item_id, 'ajax_url': self.ajax_url, }) + def get_problem_html(self, encapsulate=True): + '''Return html for the problem. Adds check, reset, save buttons + as necessary based on the problem config and state.''' + html = self.lcp.get_html() content = {'name': self.name, 'html': html, @@ -109,7 +127,7 @@ class Module(XModule): reset_button = True save_button = True - # If we're after deadline, or user has exhuasted attempts, + # If we're after deadline, or user has exhausted attempts, # question is read-only. if self.closed(): check_button = False @@ -154,11 +172,13 @@ class Module(XModule): 'attempts_used': self.attempts, 'attempts_allowed': self.max_attempts, 'explain': explain, + 'progress': self.get_progress(), } html = self.system.render_template('problem.html', context) if encapsulate: - html = '
'.format(id=self.item_id, ajax_url=self.ajax_url) + html + "
" + html = '
'.format( + id=self.item_id, ajax_url=self.ajax_url) + html + "
" return html @@ -170,7 +190,8 @@ class Module(XModule): dom2 = etree.fromstring(xml) - self.explanation = "problems/" + only_one(dom2.xpath('/problem/@explain'), default="closed") + self.explanation = "problems/" + only_one(dom2.xpath('/problem/@explain'), + default="closed") # TODO: Should be converted to: self.explanation=only_one(dom2.xpath('/problem/@explain'), default="closed") self.explain_available = only_one(dom2.xpath('/problem/@explain_available')) @@ -190,19 +211,19 @@ class Module(XModule): self.grace_period = None self.close_date = self.display_due_date - self.max_attempts =only_one(dom2.xpath('/problem/@attempts')) - if len(self.max_attempts)>0: - self.max_attempts =int(self.max_attempts) + self.max_attempts = only_one(dom2.xpath('/problem/@attempts')) + if len(self.max_attempts) > 0: + self.max_attempts = int(self.max_attempts) else: - self.max_attempts =None + self.max_attempts = None - self.show_answer =only_one(dom2.xpath('/problem/@showanswer')) + self.show_answer = only_one(dom2.xpath('/problem/@showanswer')) - if self.show_answer =="": - self.show_answer ="closed" + if self.show_answer == "": + self.show_answer = "closed" - self.rerandomize =only_one(dom2.xpath('/problem/@rerandomize')) - if self.rerandomize =="" or self.rerandomize=="always" or self.rerandomize=="true": + self.rerandomize = only_one(dom2.xpath('/problem/@rerandomize')) + if self.rerandomize == "" or self.rerandomize=="always" or self.rerandomize=="true": self.rerandomize="always" elif self.rerandomize=="false" or self.rerandomize=="per_student": self.rerandomize="per_student" @@ -253,23 +274,33 @@ class Module(XModule): def handle_ajax(self, dispatch, get): ''' - This is called by courseware.module_render, to handle an AJAX call. "get" is request.POST + This is called by courseware.module_render, to handle an AJAX call. + "get" is request.POST. + + Returns a json dictionary: + { 'progress_changed' : True/False, + 'progress' : 'none'/'in_progress'/'done', + } ''' - if dispatch=='problem_get': - response = self.get_problem(get) - elif False: #self.close_date > - return json.dumps({"error":"Past due date"}) - elif dispatch=='problem_check': - response = self.check_problem(get) - elif dispatch=='problem_reset': - response = self.reset_problem(get) - elif dispatch=='problem_save': - response = self.save_problem(get) - elif dispatch=='problem_show': - response = self.get_answer(get) - else: - return "Error" - return response + handlers = { + 'problem_get': self.get_problem, + 'problem_check': self.check_problem, + 'problem_reset': self.reset_problem, + 'problem_save': self.save_problem, + 'problem_show': self.get_answer, + } + + if dispatch not in handlers: + return 'Error' + + before = self.get_progress() + d = handlers[dispatch](get) + after = self.get_progress() + d.update({ + 'progress_changed' : after != before, + 'progress' : after.ternary_str(), + }) + return json.dumps(d, cls=ComplexEncoder) def closed(self): ''' Is the student still allowed to submit answers? ''' @@ -283,24 +314,22 @@ class Module(XModule): def answer_available(self): ''' Is the user allowed to see an answer? - TODO: simplify. ''' if self.show_answer == '': return False + if self.show_answer == "never": return False - if self.show_answer == 'attempted' and self.attempts == 0: - return False - if self.show_answer == 'attempted' and self.attempts > 0: - return True - if self.show_answer == 'answered' and self.lcp.done: - return True - if self.show_answer == 'answered' and not self.lcp.done: - return False - if self.show_answer == 'closed' and self.closed(): - return True - if self.show_answer == 'closed' and not self.closed(): - return False + + if self.show_answer == 'attempted': + return self.attempts > 0 + + if self.show_answer == 'answered': + return self.lcp.done + + if self.show_answer == 'closed': + return self.closed() + if self.show_answer == 'always': return True raise self.system.exception404 #TODO: Not 404 @@ -310,45 +339,64 @@ class Module(XModule): For the "show answer" button. TODO: show answer events should be logged here, not just in the problem.js + + Returns the answers: {'answers' : answers} ''' if not self.answer_available(): raise self.system.exception404 else: answers = self.lcp.get_question_answers() - return json.dumps(answers, - cls=ComplexEncoder) + return {'answers' : answers} + # Figure out if we should move these to capa_problem? def get_problem(self, get): - ''' Same as get_problem_html -- if we want to reconfirm we - have the right thing e.g. after several AJAX calls.''' - return self.get_problem_html(encapsulate=False) + ''' Return results of get_problem_html, as a simple dict for json-ing. + { 'html': } + + Used if we want to reconfirm we have the right thing e.g. after + several AJAX calls. + ''' + return {'html' : self.get_problem_html(encapsulate=False)} + + @staticmethod + def make_dict_of_responses(get): + '''Make dictionary of student responses (aka "answers") + get is POST dictionary. + ''' + answers = dict() + for key in get: + # e.g. input_resistor_1 ==> resistor_1 + answers['_'.join(key.split('_')[1:])] = get[key] + + return answers def check_problem(self, get): ''' Checks whether answers to a problem are correct, and - returns a map of correct/incorrect answers''' + returns a map of correct/incorrect answers: + + {'success' : bool, + 'contents' : html} + ''' event_info = dict() event_info['state'] = self.lcp.get_state() event_info['filename'] = self.filename - # make a dict of all the student responses ("answers"). - answers=dict() - # input_resistor_1 ==> resistor_1 - for key in get: - answers['_'.join(key.split('_')[1:])]=get[key] + answers = self.make_dict_of_responses(get) - event_info['answers']=answers + event_info['answers'] = answers # Too late. Cannot submit if self.closed(): - event_info['failure']='closed' + event_info['failure'] = 'closed' self.tracker('save_problem_check_fail', event_info) + # TODO: probably not 404? raise self.system.exception404 # Problem submitted. Student should reset before checking # again. if self.lcp.done and self.rerandomize == "always": - event_info['failure']='unreset' + event_info['failure'] = 'unreset' self.tracker('save_problem_check_fail', event_info) raise self.system.exception404 @@ -357,89 +405,107 @@ class Module(XModule): lcp_id = self.lcp.problem_id correct_map = self.lcp.grade_answers(answers) except StudentInputError as inst: - self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system) + # TODO: why is this line here? + self.lcp = LoncapaProblem(self.filestore.open(self.filename), + id=lcp_id, state=old_state, system=self.system) traceback.print_exc() - return json.dumps({'success':inst.message}) + return {'success': inst.message} except: - self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system) + # TODO: why is this line here? + self.lcp = LoncapaProblem(self.filestore.open(self.filename), + id=lcp_id, state=old_state, system=self.system) traceback.print_exc() raise Exception,"error in capa_module" - return json.dumps({'success':'Unknown Error'}) + # TODO: Dead code... is this a bug, or just old? + return {'success':'Unknown Error'} self.attempts = self.attempts + 1 - self.lcp.done=True + self.lcp.done = True - success = 'correct' # success = correct if ALL questions in this problem are correct + # success = correct if ALL questions in this problem are correct + success = 'correct' for answer_id in correct_map: if not correct_map.is_correct(answer_id): success = 'incorrect' - event_info['correct_map']=correct_map.get_dict() # log this in the tracker - event_info['success']=success + event_info['correct_map'] = correct_map.get_dict() # log this in the tracker + event_info['success'] = success self.tracker('save_problem_check', event_info) try: html = self.get_problem_html(encapsulate=False) # render problem into HTML except Exception,err: log.error('failed to generate html') - raise Exception,err + raise Exception, err + + return {'success': success, + 'contents': html, + } - return json.dumps({'success': success, - 'contents': html, - }) def save_problem(self, get): + ''' + Save the passed in answers. + Returns a dict { 'success' : bool, ['error' : error-msg]}, + with the error key only present if success is False. + ''' event_info = dict() event_info['state'] = self.lcp.get_state() event_info['filename'] = self.filename - answers=dict() - for key in get: - answers['_'.join(key.split('_')[1:])]=get[key] + answers = self.make_dict_of_responses(get) event_info['answers'] = answers # Too late. Cannot submit if self.closed(): - event_info['failure']='closed' + event_info['failure'] = 'closed' self.tracker('save_problem_fail', event_info) - return "Problem is closed" + return {'success': False, + 'error': "Problem is closed"} # Problem submitted. Student should reset before saving # again. if self.lcp.done and self.rerandomize == "always": - event_info['failure']='done' + event_info['failure'] = 'done' self.tracker('save_problem_fail', event_info) - return "Problem needs to be reset prior to save." + return {'success' : False, + 'error' : "Problem needs to be reset prior to save."} - self.lcp.student_answers=answers + self.lcp.student_answers = answers + # TODO: should this be save_problem_fail? Looks like success to me... self.tracker('save_problem_fail', event_info) - return json.dumps({'success':True}) + return {'success': True} def reset_problem(self, get): ''' Changes problem state to unfinished -- removes student answers, - and causes problem to rerender itself. ''' + and causes problem to rerender itself. + + Returns problem html as { 'html' : html-string }. + ''' event_info = dict() - event_info['old_state']=self.lcp.get_state() - event_info['filename']=self.filename + event_info['old_state'] = self.lcp.get_state() + event_info['filename'] = self.filename if self.closed(): - event_info['failure']='closed' + event_info['failure'] = 'closed' self.tracker('reset_problem_fail', event_info) return "Problem is closed" if not self.lcp.done: - event_info['failure']='not_done' + event_info['failure'] = 'not_done' self.tracker('reset_problem_fail', event_info) return "Refresh the page and make an attempt before resetting." - self.lcp.do_reset() # call method in LoncapaProblem to reset itself + self.lcp.do_reset() if self.rerandomize == "always": - self.lcp.seed=None # reset random number generator seed (note the self.lcp.get_state() in next line) - - self.lcp=LoncapaProblem(self.filestore.open(self.filename), self.item_id, self.lcp.get_state(), system=self.system) + # reset random number generator seed (note the self.lcp.get_state() in next line) + self.lcp.seed=None + + self.lcp = LoncapaProblem(self.filestore.open(self.filename), + self.item_id, self.lcp.get_state(), system=self.system) - event_info['new_state']=self.lcp.get_state() + event_info['new_state'] = self.lcp.get_state() self.tracker('reset_problem', event_info) - return json.dumps(self.get_problem_html(encapsulate=False)) + return {'html' : self.get_problem_html(encapsulate=False)} diff --git a/common/lib/xmodule/progress.py b/common/lib/xmodule/progress.py index 1ce5d821f3..b9e242f2b2 100644 --- a/common/lib/xmodule/progress.py +++ b/common/lib/xmodule/progress.py @@ -13,6 +13,8 @@ class Progress(object): Progress can only represent Progress for modules where that makes sense. Other modules (e.g. html) should return None from get_progress(). + + TODO: add tag for module type? Would allow for smarter merging. ''' def __init__(self, a, b): diff --git a/common/lib/xmodule/seq_module.py b/common/lib/xmodule/seq_module.py index b394227aa7..598fc4443e 100644 --- a/common/lib/xmodule/seq_module.py +++ b/common/lib/xmodule/seq_module.py @@ -1,8 +1,12 @@ import json +import logging from lxml import etree from x_module import XModule, XModuleDescriptor +from xmodule.progress import Progress + +log = logging.getLogger("mitx.common.lib.seq_module") # HACK: This shouldn't be hard-coded to two types # OBSOLETE: This obsoletes 'type' @@ -37,6 +41,16 @@ class Module(XModule): self.render() return self.destroy_js + def get_progress(self): + ''' Return the total progress, adding total done and total available. + (assumes that each submodule uses the same "units" for progress.) + ''' + # TODO: Cache progress or children array? + children = self.get_children() + progresses = [child.get_progress() for child in children] + progress = reduce(Progress.add_counts, progresses) + return progress + def handle_ajax(self, dispatch, get): # TODO: bounds checking ''' get = request.POST instance ''' if dispatch=='goto_position': @@ -53,10 +67,15 @@ class Module(XModule): titles = ["\n".join([i.get("name").strip() for i in e.iter() if i.get("name") is not None]) \ for e in self.xmltree] + children = self.get_children() + progresses = [child.get_progress() for child in children] + self.contents = self.rendered_children() - for contents, title in zip(self.contents, titles): + for contents, title, progress in zip(self.contents, titles, progresses): contents['title'] = title + contents['progress_str'] = str(progress) if progress is not None else "" + contents['progress_stat'] = progress.ternary_str() if progress is not None else "" for (content, element_class) in zip(self.contents, child_classes): new_class = 'other' @@ -68,16 +87,17 @@ class Module(XModule): # Split tags -- browsers handle this as end # of script, even if it occurs mid-string. Do this after json.dumps()ing # so that we can be sure of the quotations being used - params={'items':json.dumps(self.contents).replace('', '<"+"/script>'), - 'id':self.item_id, + params={'items': json.dumps(self.contents).replace('', '<"+"/script>'), + 'id': self.item_id, 'position': self.position, - 'titles':titles, - 'tag':self.xmltree.tag} + 'titles': titles, + 'tag': self.xmltree.tag} if self.xmltree.tag in ['sequential', 'videosequence']: self.content = self.system.render_template('seq_module.html', params) if self.xmltree.tag == 'tab': self.content = self.system.render_template('tab_module.html', params) + log.debug("rendered content: %s", content) self.rendered = True def __init__(self, system, xml, item_id, state=None): diff --git a/common/lib/xmodule/vertical_module.py b/common/lib/xmodule/vertical_module.py index 7eb6dda0b8..b3feec8bae 100644 --- a/common/lib/xmodule/vertical_module.py +++ b/common/lib/xmodule/vertical_module.py @@ -1,12 +1,14 @@ import json from x_module import XModule, XModuleDescriptor +from xmodule.progress import Progress from lxml import etree class ModuleDescriptor(XModuleDescriptor): pass class Module(XModule): + ''' Layout module for laying out submodules vertically.''' id_attribute = 'id' def get_state(self): @@ -21,6 +23,13 @@ class Module(XModule): 'items': self.contents }) + def get_progress(self): + # TODO: Cache progress or children array? + children = self.get_children() + progresses = [child.get_progress() for child in children] + progress = reduce(Progress.add_counts, progresses) + return progress + def __init__(self, system, xml, item_id, state=None): XModule.__init__(self, system, xml, item_id, state) xmltree=etree.fromstring(xml) diff --git a/common/lib/xmodule/x_module.py b/common/lib/xmodule/x_module.py index 3a5bd05286..043a830500 100644 --- a/common/lib/xmodule/x_module.py +++ b/common/lib/xmodule/x_module.py @@ -59,6 +59,13 @@ class XModule(object): else: raise "We should iterate through children and find a default name" + def get_children(self): + ''' + Return module instances for all the children of this module. + ''' + children = [self.module_from_xml(e) for e in self.__xmltree] + return children + def rendered_children(self): ''' Render all children. @@ -92,6 +99,7 @@ class XModule(object): self.tracker = system.track_function self.filestore = system.filestore self.render_function = system.render_function + self.module_from_xml = system.module_from_xml self.DEBUG = system.DEBUG self.system = system diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 354cf5991f..4a11ec2d51 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -174,6 +174,8 @@ def get_score(user, problem, cache, coursename=None): else: ## HACK 1: We shouldn't specifically reference capa_module ## HACK 2: Backwards-compatibility: This should be written when a grade is saved, and removed from the system + # TODO: These are no longer correct params for I4xSystem -- figure out what this code + # does, clean it up. from module_render import I4xSystem system = I4xSystem(None, None, None, coursename=coursename) total=float(xmodule.capa_module.Module(system, etree.tostring(problem), "id").max_score()) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index af5fec6b85..9b5e7e4940 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -34,7 +34,8 @@ class I4xSystem(object): and user, or other environment-specific info. ''' def __init__(self, ajax_url, track_function, render_function, - render_template, request=None, filestore=None): + module_from_xml, render_template, request=None, + filestore=None): ''' Create a closure around the system environment. @@ -43,6 +44,8 @@ class I4xSystem(object): or otherwise tracking the event. TODO: Not used, and has inconsistent args in different files. Update or remove. + module_from_xml - function that takes (module_xml) and returns a corresponding + module instance object. render_function - function that takes (module_xml) and renders it, returning a dictionary with a context for rendering the module to html. Dictionary will contain keys 'content' @@ -62,6 +65,7 @@ class I4xSystem(object): if settings.DEBUG: log.info("[courseware.module_render.I4xSystem] filestore path = %s", filestore) + self.module_from_xml = module_from_xml self.render_function = render_function self.render_template = render_template self.exception404 = Http404 @@ -127,6 +131,18 @@ def grade_histogram(module_id): return [] return grades + +def make_module_from_xml_fn(user, request, student_module_cache, position): + '''Create the make_from_xml() function''' + def module_from_xml(xml): + '''Modules need a way to convert xml to instance objects. + Pass the rest of the context through.''' + (instance, sm, module_type) = get_module( + user, request, xml, student_module_cache, position) + return instance + return module_from_xml + + def get_module(user, request, module_xml, student_module_cache, position=None): ''' Get an instance of the xmodule class corresponding to module_xml, setting the state based on an existing StudentModule, or creating one if none @@ -165,6 +181,9 @@ def get_module(user, request, module_xml, student_module_cache, position=None): # Setup system context for module instance ajax_url = settings.MITX_ROOT_URL + '/modx/' + module_type + '/' + module_id + '/' + module_from_xml = make_module_from_xml_fn( + user, request, student_module_cache, position) + system = I4xSystem(track_function = make_track_function(request), render_function = lambda xml: render_x_module( user, request, xml, student_module_cache, position), @@ -172,6 +191,7 @@ def get_module(user, request, module_xml, student_module_cache, position=None): ajax_url = ajax_url, request = request, filestore = OSFS(data_root), + module_from_xml = module_from_xml, ) # pass position specified in URL to module through I4xSystem system.set('position', position) @@ -295,9 +315,17 @@ def modx_dispatch(request, module=None, dispatch=None, id=None): response = HttpResponse(json.dumps({'success': error_msg})) return response + # TODO: This doesn't have a cache of child student modules. Just + # passing the current one. If ajax calls end up needing children, + # this won't work (but fixing it may cause performance issues...) + # Figure out :) + module_from_xml = make_module_from_xml_fn( + request.user, request, [s], None) + # Create the module system = I4xSystem(track_function = make_track_function(request), - render_function = None, + render_function = None, + module_from_xml = module_from_xml, render_template = render_to_string, ajax_url = ajax_url, request = request, @@ -316,7 +344,11 @@ def modx_dispatch(request, module=None, dispatch=None, id=None): return response # Let the module handle the AJAX - ajax_return = instance.handle_ajax(dispatch, request.POST) + try: + ajax_return = instance.handle_ajax(dispatch, request.POST) + except: + log.exception("error processing ajax call") + raise # Save the state back to the database s.state = instance.get_state() diff --git a/lms/static/coffee/src/modules/problem.coffee b/lms/static/coffee/src/modules/problem.coffee index aad41f23d4..1b254d5d3f 100644 --- a/lms/static/coffee/src/modules/problem.coffee +++ b/lms/static/coffee/src/modules/problem.coffee @@ -17,12 +17,20 @@ class @Problem @$('section.action input.save').click @save @$('input.math').keyup(@refreshMath).each(@refreshMath) + update_progress: (response) => + if response.progress_changed + @element.attr progress: response.progress + @element.trigger('progressChanged') + render: (content) -> if content @element.html(content) @bind() else - @element.load @content_url, @bind + $.postWithPrefix "/modx/problem/#{@id}/problem_get", '', (response) => + @element.html(response.html) + @bind() + check: => Logger.log 'problem_check', @answers @@ -30,19 +38,22 @@ class @Problem switch response.success when 'incorrect', 'correct' @render(response.contents) + @update_progress response else alert(response.success) reset: => Logger.log 'problem_reset', @answers - $.postWithPrefix "/modx/problem/#{@id}/problem_reset", id: @id, (content) => - @render(content) + $.postWithPrefix "/modx/problem/#{@id}/problem_reset", id: @id, (response) => + @render(response.html) + @update_progress response show: => if !@element.hasClass 'showed' Logger.log 'problem_show', problem: @id $.postWithPrefix "/modx/problem/#{@id}/problem_show", (response) => - $.each response, (key, value) => + answers = response.answers + $.each answers, (key, value) => if $.isArray(value) for choice in value @$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true' @@ -51,6 +62,7 @@ class @Problem MathJax.Hub.Queue ["Typeset", MathJax.Hub] @$('.show').val 'Hide Answer' @element.addClass 'showed' + @update_progress response else @$('[id^=answer_], [id^=solution_]').text '' @$('[correct_answer]').attr correct_answer: null @@ -62,6 +74,7 @@ class @Problem $.postWithPrefix "/modx/problem/#{@id}/problem_save", @answers, (response) => if response.success alert 'Saved' + @update_progress response refreshMath: (event, element) => element = event.target unless element diff --git a/lms/static/coffee/src/modules/sequence.coffee b/lms/static/coffee/src/modules/sequence.coffee index 463bf419fc..72a1c82ab6 100644 --- a/lms/static/coffee/src/modules/sequence.coffee +++ b/lms/static/coffee/src/modules/sequence.coffee @@ -2,6 +2,7 @@ class @Sequence constructor: (@id, @elements, @tag, position) -> @element = $("#sequence_#{@id}") @buildNavigation() + @initProgress() @bind() @render position @@ -11,11 +12,52 @@ class @Sequence bind: -> @$('#sequence-list a').click @goto + initProgress: -> + @progressTable = {} # "#problem_#{id}" -> progress + + + hookUpProgressEvent: -> + $('.problems-wrapper').bind 'progressChanged', @updateProgress + + mergeProgress: (p1, p2) -> + if p1 == "done" and p2 == "done" + return "done" + # not done, so if any progress on either, in_progress + w1 = p1 == "done" or p1 == "in_progress" + w2 = p2 == "done" or p2 == "in_progress" + if w1 or w2 + return "in_progress" + + return "none" + + updateProgress: => + new_progress = "none" + _this = this + $('.problems-wrapper').each (index) -> + progress = $(this).attr 'progress' + new_progress = _this.mergeProgress progress, new_progress + + @progressTable[@position] = new_progress + @setProgress(new_progress, @link_for(@position)) + + setProgress: (progress, element) -> + element.removeClass('progress-none') + .removeClass('progress-some') + .removeClass('progress-done') + switch progress + when 'none' then element.addClass('progress-none') + when 'in_progress' then element.addClass('progress-some') + when 'done' then element.addClass('progress-done') + buildNavigation: -> $.each @elements, (index, item) => link = $('').attr class: "seq_#{item.type}_inactive", 'data-element': index + 1 title = $('

').html(item.title) + # TODO: add item.progress_str either to the title or somewhere else. + # Make sure it gets updated after ajax calls list_item = $('

  • ').append(link.append(title)) + @setProgress item.progress_stat, link + @$('#sequence-list').append list_item toggleArrows: => @@ -36,13 +78,14 @@ class @Sequence if @position != undefined @mark_visited @position $.postWithPrefix "/modx/#{@tag}/#{@id}/goto_position", position: new_position - + @mark_active new_position @$('#seq_content').html @elements[new_position - 1].content MathJax.Hub.Queue(["Typeset", MathJax.Hub]) @position = new_position @toggleArrows() + @hookUpProgressEvent() @element.trigger 'contentChanged' goto: (event) => @@ -67,7 +110,17 @@ class @Sequence @$("#sequence-list a[data-element=#{position}]") mark_visited: (position) -> - @link_for(position).attr class: "seq_#{@elements[position - 1].type}_visited" + # Don't overwrite class attribute to avoid changing Progress class + type = @elements[position - 1].type + element = @link_for(position) + element.removeClass("seq_#{type}_inactive") + .removeClass("seq_#{type}_active") + .addClass("seq_#{type}_visited") mark_active: (position) -> - @link_for(position).attr class: "seq_#{@elements[position - 1].type}_active" + # Don't overwrite class attribute to avoid changing Progress class + type = @elements[position - 1].type + element = @link_for(position) + element.removeClass("seq_#{type}_inactive") + .removeClass("seq_#{type}_visited") + .addClass("seq_#{type}_active")