diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 982a77631d..43d970d898 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -52,6 +52,7 @@ setup( "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", "annotatable = xmodule.annotatable_module:AnnotatableDescriptor", "foldit = xmodule.foldit_module:FolditDescriptor", + "word_cloud = xmodule.word_cloud_module:WordCloudDescriptor", "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", ], diff --git a/common/lib/xmodule/xmodule/js/src/word_cloud/logme.js b/common/lib/xmodule/xmodule/js/src/word_cloud/logme.js new file mode 100644 index 0000000000..c045757044 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/word_cloud/logme.js @@ -0,0 +1,54 @@ +// Wrapper for RequireJS. It will make the standard requirejs(), require(), and +// define() functions from Require JS available inside the anonymous function. +(function (requirejs, require, define) { + +define('logme', [], function () { + var debugMode; + + // debugMode can be one of the following: + // + // true - All messages passed to logme will be written to the internal + // browser console. + // false - Suppress all output to the internal browser console. + // + // Obviously, if anywhere there is a direct console.log() call, we can't do + // anything about it. That's why use logme() - it will allow to turn off + // the output of debug information with a single change to a variable. + debugMode = true; + + return logme; + + /* + * function: logme + * + * A helper function that provides logging facilities. We don't want + * to call console.log() directly, because sometimes it is not supported + * by the browser. Also when everything is routed through this function. + * the logging output can be easily turned off. + * + * logme() supports multiple parameters. Each parameter will be passed to + * console.log() function separately. + * + */ + function logme() { + var i; + + if ( + (typeof debugMode === 'undefined') || + (debugMode !== true) || + (typeof window.console === 'undefined') + ) { + return; + } + + for (i = 0; i < arguments.length; i++) { + window.console.log(arguments[i]); + } + } // End-of: function logme +}); + +// End of wrapper for RequireJS. As you can see, we are passing +// namespaced Require JS variables to an anonymous function. Within +// it, you can use the standard requirejs(), require(), and define() +// functions as if they were in the global namespace. +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) diff --git a/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud.js b/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud.js new file mode 100644 index 0000000000..74f2a488d7 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud.js @@ -0,0 +1,323 @@ +(function (requirejs, require, define) { +define('PollMain', ['logme'], function (logme) { + +PollMain.prototype = { + +'showAnswerGraph': function (poll_answers, total) { + var _this, totalValue; + + totalValue = parseFloat(total); + if (isFinite(totalValue) === false) { + return; + } + + _this = this; + + $.each(poll_answers, function (index, value) { + var numValue, percentValue; + + numValue = parseFloat(value); + if (isFinite(numValue) === false) { + return; + } + + percentValue = (numValue / totalValue) * 100.0; + + _this.answersObj[index].statsEl.show(); + _this.answersObj[index].numberEl.html('' + value + ' (' + percentValue.toFixed(1) + '%)'); + _this.answersObj[index].percentEl.css({ + 'width': '' + percentValue.toFixed(1) + '%' + }); + }); +}, + +'submitAnswer': function (answer, answerObj) { + var _this; + + // Make sure that the user can answer a question only once. + if (this.questionAnswered === true) { + return; + } + this.questionAnswered = true; + + _this = this; + + console.log('submit answer'); + + answerObj.buttonEl.addClass('answered'); + + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.postWithPrefix( + _this.ajax_url + '/' + answer, {}, + function (response) { + console.log('success! response = '); + console.log(response); + + _this.showAnswerGraph(response.poll_answers, response.total); + + if (_this.canReset === true) { + _this.resetButton.show(); + } + + // Initialize Conditional constructors. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) { + new window.Conditional(value, _this.id.replace(/^poll_/, '')); + }); + } + } + ); + +}, // End-of: 'submitAnswer': function (answer, answerEl) { + + +'submitReset': function () { + var _this; + + _this = this; + + console.log('submit reset'); + + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.postWithPrefix( + this.ajax_url + '/' + 'reset_poll', + {}, + function (response) { + console.log('success! response = '); + console.log(response); + + if ( + (response.hasOwnProperty('status') !== true) || + (typeof response.status !== 'string') || + (response.status.toLowerCase() !== 'success')) { + return; + } + + _this.questionAnswered = false; + _this.questionEl.find('.button.answered').removeClass('answered'); + _this.questionEl.find('.stats').hide(); + _this.resetButton.hide(); + + // Initialize Conditional constructors. We will specify the third parameter as 'true' + // notifying the constructor that this is a reset operation. + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) { + new window.Conditional(value, _this.id.replace(/^poll_/, '')); + }); + } + } + ); +}, // End-of: 'submitAnswer': function (answer, answerEl) { + +'postInit': function () { + var _this; + + // Access this object inside inner functions. + _this = this; + + if ( + (this.jsonConfig.poll_answer.length > 0) && + (this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false) + ) { + this.questionEl.append( + '
XML data format changed. List of answers was modified, but poll data was not updated.
' + ); + + return; + } + + // Get the DOM id of the question. + this.id = this.questionEl.attr('id'); + + // Get the URL to which we will post the users answer to the question. + this.ajax_url = this.questionEl.data('ajax-url'); + + this.questionHtmlMarkup = $('').html(this.jsonConfig.question).text(); + this.questionEl.append(this.questionHtmlMarkup); + + // When the user selects and answer, we will set this flag to true. + this.questionAnswered = false; + + this.answersObj = {}; + this.shortVersion = true; + + $.each(this.jsonConfig.answers, function (index, value) { + if (value.length >= 18) { + _this.shortVersion = false; + } + }); + + $.each(this.jsonConfig.answers, function (index, value) { + var answer; + + answer = {}; + + _this.answersObj[index] = answer; + + answer.el = $(''); + + answer.questionEl = $(''); + answer.buttonEl = $(''); + answer.textEl = $(''); + answer.questionEl.append(answer.buttonEl); + answer.questionEl.append(answer.textEl); + + answer.el.append(answer.questionEl); + + answer.statsEl = $(''); + answer.barEl = $(''); + answer.percentEl = $(''); + answer.barEl.append(answer.percentEl); + answer.numberEl = $(''); + answer.statsEl.append(answer.barEl); + answer.statsEl.append(answer.numberEl); + + answer.statsEl.hide(); + + answer.el.append(answer.statsEl); + + answer.textEl.html(value); + + if (_this.shortVersion === true) { + $.each(answer, function (index, value) { + if (value instanceof jQuery) { + value.addClass('short'); + } + }); + } + + answer.el.appendTo(_this.questionEl); + + answer.textEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + answer.buttonEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + if (index === _this.jsonConfig.poll_answer) { + answer.buttonEl.addClass('answered'); + _this.questionAnswered = true; + } + }); + + console.log(this.jsonConfig.reset); + + if ((typeof this.jsonConfig.reset === 'string') && (this.jsonConfig.reset.toLowerCase() === 'true')) { + this.canReset = true; + + this.resetButton = $(''); + + if (this.questionAnswered === false) { + this.resetButton.hide(); + } + + this.resetButton.appendTo(this.questionEl); + + this.resetButton.on('click', function () { + _this.submitReset(); + }); + } else { + this.canReset = false; + } + + // If it turns out that the user already answered the question, show the answers graph. + if (this.questionAnswered === true) { + this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total); + } +} // End-of: 'postInit': function () { +}; // End-of: PollMain.prototype = { + +return PollMain; + +function PollMain(el) { + var _this; + + this.questionEl = $(el).find('.poll_question'); + if (this.questionEl.length !== 1) { + // We require one question DOM element. + logme('ERROR: PollMain constructor requires one question DOM element.'); + + return; + } + + // Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be + // attached to the same DOM elements. We don't want this to happen. + if (this.questionEl.attr('poll_main_processed') === 'true') { + logme( + 'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.' + ); + + return; + } + + // This element was not processed earlier. + // Make sure that next time we will not process this element a second time. + this.questionEl.attr('poll_main_processed', 'true'); + + // Access this object inside inner functions. + _this = this; + + // DOM element which contains the current poll along with any conditionals. By default we assume that such + // element is not present. We will try to find it. + this.wrapperSectionEl = null; + + (function (tempEl, c1) { + while (tempEl.tagName.toLowerCase() !== 'body') { + tempEl = $(tempEl).parent()[0]; + c1 += 1; + + if ( + (tempEl.tagName.toLowerCase() === 'section') && + ($(tempEl).hasClass('xmodule_WrapperModule') === true) + ) { + _this.wrapperSectionEl = tempEl; + + break; + } else if (c1 > 50) { + // In case something breaks, and we enter an endless loop, a sane + // limit for loop iterations. + + break; + } + } + }($(el)[0], 0)); + + try { + this.jsonConfig = JSON.parse(this.questionEl.children('.poll_question_div').html()); + + $.postWithPrefix( + '' + this.questionEl.data('ajax-url') + '/' + 'get_state', {}, + function (response) { + _this.jsonConfig.poll_answer = response.poll_answer; + _this.jsonConfig.total = response.total; + + $.each(response.poll_answers, function (index, value) { + _this.jsonConfig.poll_answers[index] = value; + }); + + _this.questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig)); + + _this.postInit(); + } + ); + + return; + } catch (err) { + logme( + 'ERROR: Invalid JSON config for poll ID "' + this.id + '".', + 'Error messsage: "' + err.message + '".' + ); + + return; + } +} // End-of: function PollMain(el) { + +}); // End-of: define('PollMain', ['logme'], function (logme) { + +// End-of: (function (requirejs, require, define) { +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud_main.js b/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud_main.js new file mode 100644 index 0000000000..a2ccbc7c03 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/word_cloud/word_cloud_main.js @@ -0,0 +1,5 @@ +window.Poll = function (el) { + RequireJS.require(['PollMain'], function (PollMain) { + new PollMain(el); + }); +}; diff --git a/common/lib/xmodule/xmodule/word_cloud_module.py b/common/lib/xmodule/xmodule/word_cloud_module.py new file mode 100644 index 0000000000..7c2dfc6ad2 --- /dev/null +++ b/common/lib/xmodule/xmodule/word_cloud_module.py @@ -0,0 +1,166 @@ +"""Word cloud is ungraded xblock used by students to +generate and view word cloud.. + +On the client side we show: +If student does not yet anwered - five text inputs. +If student have answered - words he entered and cloud. + +Stunent can change his answer. +""" + +import cgi +import json +import logging +from copy import deepcopy +from collections import OrderedDict + +from lxml import etree +from pkg_resources import resource_string + +from xmodule.x_module import XModule +from xmodule.stringify import stringify_children +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor +from xblock.core import Scope, String, Object, Boolean, List + +log = logging.getLogger(__name__) + + +class WordCloudFields(object): + # Name of poll to use in links to this poll + display_name = String(help="Display name for this module", scope=Scope.settings) + + submitted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False) + student_words= List(help="Student answer", scope=Scope.student_state, default=[]) + all_words = Object(help="All possible words from other students", scope=Scope.content) + top_words = Object(help="Top N words for word cloud", scope=Scope.content) + top_low_border = Int(help="Number to distinguish top from all words", scope=Scope.content) + +class WordCloudModule(WordCloudFields, XModule): + """WordCloud Module""" + js = { + 'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')], + 'js': [resource_string(__name__, 'js/src/word_cloud/logme.js'), + resource_string(__name__, 'js/src/word_cloud/word_cloud.js'), + resource_string(__name__, 'js/src/word_cloud/word_cloud_main.js')] + } + css = {'scss': [resource_string(__name__, 'css/word_cloud/display.scss')]} + js_module_name = "Word_Cloud" + + Number_of_top_words = 250 + + def handle_ajax(self, dispatch, get): + """Ajax handler. + + Args: + dispatch: string request slug + get: dict request get parameters + + Returns: + json string + """ + if dispatch == 'submit': + + # self.all_words[word] -= 1 + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + # speed issues + temp_all_words = self.all_words + temp_top_words = self.top_words + + if self.submitted: + + for word in self.student_words: + temp_all_words[word] -= 1 + + if word in temp_top_words: + temp_top_words -= 1 + + else: + self.submitted = True + + self.student_words = get['student_words'] + + question_words = {} + + for word in self.student_words: + temp_all_words[word] += 1 + + if word in temp_top_words: + temp_top_words += 1 + else: + if temp_all_words[word] > top_low_border: + question_words[word] = temp_all_words[word] + + + self.all_words = temp_all_words + + self.top_words = self.update_top_words(question_words, temp_top_words) + + + return json.dumps({'student_words': self.student_words, + 'top_words': self.top_words, + }) + elif dispatch == 'get_state': + return json.dumps({'student_answers': self.student_answers, + 'top_words': self.top_words) + }) + else: # return error message + return json.dumps({'error': 'Unknown Command!'}) + + + def update_top_words(question_words, top_words): + + for word, number in question_words: + for top_word, top_number in top_words[:]: + if top_number < number: + del top_words[top_word] + top_words[word] - number + break + + return top_words + + def get_html(self): + """Renders parameters to template.""" + params = { + 'element_id': self.location.html_id(), + 'element_class': self.location.category, + 'ajax_url': self.system.ajax_url, + 'configuration_json': json.dumps({}), + } + self.content = self.system.render_template('word_cloud.html', params) + return self.content + + + +class WordCloudDescriptor(WordCloudFields, MakoModuleDescriptor, XmlDescriptor): + _tag_name = 'word_cloud' + + module_class = WordCloudModule + template_dir_name = 'word_cloud' + stores_state = True + + @classmethod + def definition_from_xml(cls, xml_object, system): + """Pull out the data into dictionary. + + Args: + xml_object: xml from file. + system: `system` object. + + Returns: + (definition, children) - tuple + + """ + definition = {} + children = [] + + return (definition, children) + + def definition_to_xml(self, resource_fs): + """Return an xml element representing to this definition.""" + poll_str = '<{tag_name}/>'.format(tag_name=self._tag_name) + xml_object = etree.fromstring(poll_str) + xml_object.set('display_name', self.display_name) + + return xml_object diff --git a/lms/templates/word_cloud.html b/lms/templates/word_cloud.html new file mode 100644 index 0000000000..091d2b0317 --- /dev/null +++ b/lms/templates/word_cloud.html @@ -0,0 +1,8 @@ +