Run tests for both the built-in and extracted WordCloud block. The tests are mostly compatible with both versions of the block, except for a few places where the XBlock framework and the built-in XModule system differ which we've had to handle using conditionals. This moves us closer to enabling the extracted WordCloud block by default and eventually removing the built-in block. Part of: https://github.com/openedx/edx-platform/issues/34840
333 lines
10 KiB
Python
333 lines
10 KiB
Python
"""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 answered - `num_inputs` numbers of text inputs.
|
|
If student have answered - words he entered and cloud.
|
|
"""
|
|
|
|
from xblocks_contrib.word_cloud import WordCloudBlock as _ExtractedWordCloudBlock
|
|
|
|
import json
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
from web_fragments.fragment import Fragment
|
|
from xblock.core import XBlock
|
|
from xblock.fields import Boolean, Dict, Integer, List, Scope, String
|
|
|
|
from xmodule.editing_block import EditingMixin
|
|
from xmodule.raw_block import EmptyDataRawMixin
|
|
from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment
|
|
from xmodule.x_module import (
|
|
ResourceTemplates,
|
|
shim_xmodule_js,
|
|
XModuleMixin,
|
|
XModuleToXBlockMixin,
|
|
)
|
|
from xmodule.xml_block import XmlMixin
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
|
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
|
_ = lambda text: text
|
|
|
|
|
|
def pretty_bool(value):
|
|
"""Check value for possible `True` value.
|
|
|
|
Using this function we can manage different type of Boolean value
|
|
in xml files.
|
|
"""
|
|
bool_dict = [True, "True", "true", "T", "t", "1"]
|
|
return value in bool_dict
|
|
|
|
|
|
@XBlock.needs('mako')
|
|
class _BuiltInWordCloudBlock( # pylint: disable=abstract-method
|
|
EmptyDataRawMixin,
|
|
XmlMixin,
|
|
EditingMixin,
|
|
XModuleToXBlockMixin,
|
|
ResourceTemplates,
|
|
XModuleMixin,
|
|
):
|
|
"""
|
|
Word Cloud XBlock.
|
|
"""
|
|
|
|
is_extracted = False
|
|
|
|
display_name = String(
|
|
display_name=_("Display Name"),
|
|
help=_("The display name for this component."),
|
|
scope=Scope.settings,
|
|
default="Word cloud"
|
|
)
|
|
instructions = String(
|
|
display_name=_("Instructions"),
|
|
help=_("Add instructions to help learners understand how to use the word cloud. Clear instructions are important, especially for learners who have accessibility requirements."), # nopep8 pylint: disable=C0301
|
|
scope=Scope.settings,
|
|
)
|
|
num_inputs = Integer(
|
|
display_name=_("Inputs"),
|
|
help=_("The number of text boxes available for learners to add words and sentences."),
|
|
scope=Scope.settings,
|
|
default=5,
|
|
values={"min": 1}
|
|
)
|
|
num_top_words = Integer(
|
|
display_name=_("Maximum Words"),
|
|
help=_("The maximum number of words displayed in the generated word cloud."),
|
|
scope=Scope.settings,
|
|
default=250,
|
|
values={"min": 1}
|
|
)
|
|
display_student_percents = Boolean(
|
|
display_name=_("Show Percents"),
|
|
help=_("Statistics are shown for entered words near that word."),
|
|
scope=Scope.settings,
|
|
default=True
|
|
)
|
|
|
|
# Fields for descriptor.
|
|
submitted = Boolean(
|
|
help=_("Whether this learner has posted words to the cloud."),
|
|
scope=Scope.user_state,
|
|
default=False
|
|
)
|
|
student_words = List(
|
|
help=_("Student answer."),
|
|
scope=Scope.user_state,
|
|
default=[]
|
|
)
|
|
all_words = Dict(
|
|
help=_("All possible words from all learners."),
|
|
scope=Scope.user_state_summary
|
|
)
|
|
top_words = Dict(
|
|
help=_("Top num_top_words words for word cloud."),
|
|
scope=Scope.user_state_summary
|
|
)
|
|
|
|
resources_dir = 'assets/word_cloud'
|
|
template_dir_name = 'word_cloud'
|
|
|
|
studio_js_module_name = "MetadataOnlyEditingDescriptor"
|
|
mako_template = "widgets/metadata-only-edit.html"
|
|
|
|
def get_state(self):
|
|
"""Return success json answer for client."""
|
|
if self.submitted:
|
|
total_count = sum(self.all_words.values())
|
|
return json.dumps({
|
|
'status': 'success',
|
|
'submitted': True,
|
|
'display_student_percents': pretty_bool(
|
|
self.display_student_percents
|
|
),
|
|
'student_words': {
|
|
word: self.all_words[word] for word in self.student_words
|
|
},
|
|
'total_count': total_count,
|
|
'top_words': self.prepare_words(self.top_words, total_count)
|
|
})
|
|
else:
|
|
return json.dumps({
|
|
'status': 'success',
|
|
'submitted': False,
|
|
'display_student_percents': False,
|
|
'student_words': {},
|
|
'total_count': 0,
|
|
'top_words': {}
|
|
})
|
|
|
|
def good_word(self, word):
|
|
"""Convert raw word to suitable word."""
|
|
return word.strip().lower()
|
|
|
|
def prepare_words(self, top_words, total_count):
|
|
"""Convert words dictionary for client API.
|
|
|
|
:param top_words: Top words dictionary
|
|
:type top_words: dict
|
|
:param total_count: Total number of words
|
|
:type total_count: int
|
|
|
|
:rtype: list of dicts. Every dict is 3 keys: text - actual word,
|
|
size - counter of word, percent - percent in top_words dataset.
|
|
|
|
Calculates corrected percents for every top word:
|
|
|
|
For every word except last, it calculates rounded percent.
|
|
For the last is 100 - sum of all other percents.
|
|
|
|
"""
|
|
list_to_return = []
|
|
percents = 0
|
|
sorted_top_words = sorted(top_words.items(), key=lambda x: x[0].lower())
|
|
for num, word_tuple in enumerate(sorted_top_words):
|
|
if num == len(top_words) - 1:
|
|
percent = 100 - percents
|
|
else:
|
|
percent = round((100.0 * word_tuple[1]) / total_count)
|
|
percents += percent
|
|
list_to_return.append(
|
|
{
|
|
'text': word_tuple[0],
|
|
'size': word_tuple[1],
|
|
'percent': percent
|
|
}
|
|
)
|
|
return list_to_return
|
|
|
|
def top_dict(self, dict_obj, amount):
|
|
"""Return top words from all words, filtered by number of
|
|
occurences
|
|
|
|
:param dict_obj: all words
|
|
:type dict_obj: dict
|
|
:param amount: number of words to be in top dict
|
|
:type amount: int
|
|
:rtype: dict
|
|
"""
|
|
return dict(
|
|
sorted(
|
|
list(dict_obj.items()),
|
|
key=lambda x: x[1],
|
|
reverse=True
|
|
)[:amount]
|
|
)
|
|
|
|
def handle_ajax(self, dispatch, data):
|
|
"""Ajax handler.
|
|
|
|
Args:
|
|
dispatch: string request slug
|
|
data: dict request get parameters
|
|
|
|
Returns:
|
|
json string
|
|
"""
|
|
if dispatch == 'submit':
|
|
if self.submitted:
|
|
return json.dumps({
|
|
'status': 'fail',
|
|
'error': 'You have already posted your data.'
|
|
})
|
|
|
|
# Student words from client.
|
|
# FIXME: we must use raw JSON, not a post data (multipart/form-data)
|
|
raw_student_words = data.getall('student_words[]')
|
|
student_words = [word for word in map(self.good_word, raw_student_words) if word]
|
|
|
|
self.student_words = student_words
|
|
|
|
# FIXME: fix this, when xblock will support mutable types.
|
|
# Now we use this hack.
|
|
# speed issues
|
|
temp_all_words = self.all_words
|
|
|
|
self.submitted = True
|
|
|
|
# Save in all_words.
|
|
for word in self.student_words:
|
|
temp_all_words[word] = temp_all_words.get(word, 0) + 1
|
|
|
|
# Update top_words.
|
|
self.top_words = self.top_dict(
|
|
temp_all_words,
|
|
self.num_top_words
|
|
)
|
|
|
|
# Save all_words in database.
|
|
self.all_words = temp_all_words
|
|
|
|
return self.get_state()
|
|
elif dispatch == 'get_state':
|
|
return self.get_state()
|
|
else:
|
|
return json.dumps({
|
|
'status': 'fail',
|
|
'error': 'Unknown Command!'
|
|
})
|
|
|
|
@XBlock.supports('multi_device')
|
|
def student_view(self, context): # lint-amnesty, pylint: disable=unused-argument
|
|
"""
|
|
Renders the output that a student will see.
|
|
"""
|
|
fragment = Fragment()
|
|
fragment.add_content(self.runtime.service(self, 'mako').render_lms_template('word_cloud.html', {
|
|
'ajax_url': self.ajax_url,
|
|
'display_name': self.display_name,
|
|
'instructions': self.instructions,
|
|
'element_class': self.location.block_type,
|
|
'element_id': self.location.html_id(),
|
|
'num_inputs': self.num_inputs,
|
|
'submitted': self.submitted,
|
|
}))
|
|
add_css_to_fragment(fragment, 'WordCloudBlockDisplay.css')
|
|
add_webpack_js_to_fragment(fragment, 'WordCloudBlockDisplay')
|
|
shim_xmodule_js(fragment, 'WordCloud')
|
|
|
|
return fragment
|
|
|
|
def author_view(self, context):
|
|
"""
|
|
Renders the output that an author will see.
|
|
"""
|
|
return self.student_view(context)
|
|
|
|
def studio_view(self, _context):
|
|
"""
|
|
Return the studio view.
|
|
"""
|
|
fragment = Fragment(
|
|
self.runtime.service(self, 'mako').render_cms_template(self.mako_template, self.get_context())
|
|
)
|
|
add_webpack_js_to_fragment(fragment, 'WordCloudBlockEditor')
|
|
shim_xmodule_js(fragment, self.studio_js_module_name)
|
|
return fragment
|
|
|
|
def index_dictionary(self):
|
|
"""
|
|
Return dictionary prepared with block content and type for indexing.
|
|
"""
|
|
# return key/value fields in a Python dict object
|
|
# values may be numeric / string or dict
|
|
# default implementation is an empty dict
|
|
|
|
xblock_body = super().index_dictionary()
|
|
|
|
index_body = {
|
|
"display_name": self.display_name,
|
|
"instructions": self.instructions,
|
|
}
|
|
|
|
if "content" in xblock_body:
|
|
xblock_body["content"].update(index_body)
|
|
else:
|
|
xblock_body["content"] = index_body
|
|
|
|
xblock_body["content_type"] = "Word Cloud"
|
|
|
|
return xblock_body
|
|
|
|
|
|
WordCloudBlock = None
|
|
|
|
|
|
def reset_class():
|
|
"""Reset class as per django settings flag"""
|
|
global WordCloudBlock
|
|
WordCloudBlock = (
|
|
_ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK
|
|
else _BuiltInWordCloudBlock
|
|
)
|
|
return WordCloudBlock
|
|
|
|
reset_class()
|
|
WordCloudBlock.__name__ = "WordCloudBlock"
|