Commit 8cb0c2e5 by E. Kolpakov

Theming support:

1. Machinery to load specified theme files
2. Uses settings service to get selected theme
3. LMS theme
4. Blank apros theme - mentoring default css is essentially an Apros theme. Likely to move some parts of it later.
5. Unit and integration tests for theming
parent 8c4d0dc5
...@@ -44,12 +44,18 @@ from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContain ...@@ -44,12 +44,18 @@ from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContain
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
loader = ResourceLoader(__name__) loader = ResourceLoader(__name__)
_default_theme_config = {
'package': 'mentoring',
'locations': ['public/themes/lms.css']
}
# Classes ########################################################### # Classes ###########################################################
Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"]) Score = namedtuple("Score", ["raw", "percentage", "correct", "incorrect", "partially_correct"])
@XBlock.needs("i18n") @XBlock.needs("i18n")
@XBlock.wants('settings')
class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioContainerXBlockMixin): class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioContainerXBlockMixin):
""" """
An XBlock providing mentoring capabilities An XBlock providing mentoring capabilities
...@@ -156,14 +162,31 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ...@@ -156,14 +162,31 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
has_score = True has_score = True
has_children = True has_children = True
block_settings_key = 'mentoring'
theme_key = 'theme'
def _(self, text): def _(self, text):
""" translate text """ """ translate text """
return self.runtime.service(self, "i18n").ugettext(text) return self.runtime.service(self, "i18n").ugettext(text)
@property @property
def is_assessment(self): def is_assessment(self):
""" Checks if mentoring XBlock is in assessment mode """
return self.mode == 'assessment' return self.mode == 'assessment'
def get_theme(self):
"""
Gets theme settings from settings service. Falls back to default (LMS) theme
if settings service is not available, xblock theme settings are not set or does
contain mentoring theme settings.
"""
settings_service = self.runtime.service(self, "settings")
if settings_service:
xblock_settings = settings_service.get_settings_bucket(self)
if xblock_settings and self.theme_key in xblock_settings:
return xblock_settings[self.theme_key]
return _default_theme_config
@property @property
def score(self): def score(self):
"""Compute the student score taking into account the weight of each step.""" """Compute the student score taking into account the weight of each step."""
...@@ -178,6 +201,12 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ...@@ -178,6 +201,12 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
return Score(score, int(round(score * 100)), correct, incorrect, partially_correct) return Score(score, int(round(score * 100)), correct, incorrect, partially_correct)
def include_theme_files(self, fragment):
theme = self.get_theme()
theme_package, theme_files = theme['package'], theme['locations']
for theme_file in theme_files:
fragment.add_css(ResourceLoader(theme_package).load_unicode(theme_file))
def student_view(self, context): def student_view(self, context):
# Migrate stored data if necessary # Migrate stored data if necessary
self.migrate_fields() self.migrate_fields()
...@@ -219,6 +248,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ...@@ -219,6 +248,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html")
fragment.add_resource(loader.load_unicode('templates/html/mentoring_grade.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_grade.html'), "text/html")
self.include_theme_files(fragment)
# Workbench doesn't have font awesome, so add it: # Workbench doesn't have font awesome, so add it:
try: try:
from workbench.runtime import WorkbenchRuntime from workbench.runtime import WorkbenchRuntime
...@@ -515,6 +545,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC ...@@ -515,6 +545,7 @@ class MentoringBlock(XBlock, StepParentMixin, StudioEditableXBlockMixin, StudioC
"url_name": self.url_name "url_name": self.url_name
})) }))
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring_edit.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring_edit.css'))
self.include_theme_files(fragment)
return fragment return fragment
def author_edit_view(self, context): def author_edit_view(self, context):
......
.themed-xblock.mentoring .checkmark-incorrect {
color: #c1373f;
}
.themed-xblock.mentoring .checkmark-incorrect::before {
content: "\f00d";
}
.themed-xblock.mentoring .grade .checkmark-incorrect {
margin-left: 0;
margin-right: 10px;
width: 29px;
}
div.course-wrapper section.course-content .themed-xblock.mentoring p:empty {
display: block;
margin-bottom: 1.41575em;
}
\ No newline at end of file
<div class="mentoring" data-mode="{{ self.mode }}" data-step="{{ self.step }}"> <div class="mentoring themed-xblock" data-mode="{{ self.mode }}" data-step="{{ self.step }}">
<div class="missing-dependency warning" data-missing="{{ self.has_missing_dependency }}"> <div class="missing-dependency warning" data-missing="{{ self.has_missing_dependency }}">
You need to complete <a href="{{ missing_dependency_url }}">the previous step</a> before You need to complete <a href="{{ missing_dependency_url }}">the previous step</a> before
attempting this step. attempting this step.
......
...@@ -34,3 +34,70 @@ MentoringBlock.url_name = String() ...@@ -34,3 +34,70 @@ MentoringBlock.url_name = String()
class MentoringBaseTest(SeleniumBaseTest): class MentoringBaseTest(SeleniumBaseTest):
module_name = __name__ module_name = __name__
default_css_selector = 'div.mentoring' default_css_selector = 'div.mentoring'
class MentoringAssessmentBaseTest(MentoringBaseTest):
@staticmethod
def question_text(number):
if number:
return "Question %s" % number
else:
return "Question"
def go_to_assessment(self, page):
""" Navigates to assessment page """
mentoring = self.go_to_page(page)
class Namespace(object):
pass
controls = Namespace()
controls.submit = mentoring.find_element_by_css_selector("input.input-main")
controls.next_question = mentoring.find_element_by_css_selector("input.input-next")
controls.review = mentoring.find_element_by_css_selector("input.input-review")
controls.try_again = mentoring.find_element_by_css_selector("input.input-try-again")
return mentoring, controls
def expect_question_visible(self, number, mentoring):
question_text = self.question_text(number)
self.wait_until_text_in(self.question_text(number), mentoring)
question_div = None
for xblock_div in mentoring.find_elements_by_css_selector('div.xblock-v1'):
header_text = xblock_div.find_elements_by_css_selector('h3.question-title')
if header_text and question_text in header_text[0].text:
question_div = xblock_div
self.assertTrue(xblock_div.is_displayed())
elif header_text:
self.assertFalse(xblock_div.is_displayed())
# else this is an HTML block or something else, not a question step
self.assertIsNotNone(question_div)
return question_div
class GetChoices(object):
""" Helper class for interacting with MCQ options """
def __init__(self, question, selector=".choices"):
self._mcq = question.find_element_by_css_selector(selector)
@property
def text(self):
return self._mcq.text
@property
def state(self):
return {
choice.text: choice.find_element_by_css_selector("input").is_selected()
for choice in self._mcq.find_elements_by_css_selector(".choice")}
def select(self, text):
choice_wrapper = self.get_option_element(text)
choice_wrapper.find_element_by_css_selector("input").click()
def get_option_element(self, text):
for choice in self._mcq.find_elements_by_css_selector(".choice"):
if choice.text == text:
return choice
raise AssertionError("Expected selectable item present: {}".format(text))
from .base_test import MentoringBaseTest from .base_test import MentoringAssessmentBaseTest, GetChoices
CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct" CORRECT, INCORRECT, PARTIAL = "correct", "incorrect", "partially-correct"
class MentoringAssessmentTest(MentoringBaseTest): class MentoringAssessmentTest(MentoringAssessmentBaseTest):
def _selenium_bug_workaround_scroll_to(self, mentoring, question): def _selenium_bug_workaround_scroll_to(self, mentoring, question):
"""Workaround for selenium bug: """Workaround for selenium bug:
...@@ -41,27 +41,6 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -41,27 +41,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.assertIn("A Simple Assessment", mentoring.text) self.assertIn("A Simple Assessment", mentoring.text)
self.assertIn("This paragraph is shared between all questions.", mentoring.text) self.assertIn("This paragraph is shared between all questions.", mentoring.text)
class _GetChoices(object):
def __init__(self, question, selector=".choices"):
self._mcq = question.find_element_by_css_selector(selector)
@property
def text(self):
return self._mcq.text
@property
def state(self):
return {
choice.text: choice.find_element_by_css_selector("input").is_selected()
for choice in self._mcq.find_elements_by_css_selector(".choice")}
def select(self, text):
for choice in self._mcq.find_elements_by_css_selector(".choice"):
if choice.text == text:
choice.find_element_by_css_selector("input").click()
return
raise AssertionError("Expected selectable item present: {}".format(text))
def _assert_checkmark(self, mentoring, result): def _assert_checkmark(self, mentoring, result):
"""Assert that only the desired checkmark is present.""" """Assert that only the desired checkmark is present."""
states = {CORRECT: 0, INCORRECT: 0, PARTIAL: 0} states = {CORRECT: 0, INCORRECT: 0, PARTIAL: 0}
...@@ -73,28 +52,6 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -73,28 +52,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
def go_to_workbench_main_page(self): def go_to_workbench_main_page(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
def go_to_assessment(self, number):
mentoring = self.go_to_page('Assessment %s' % number)
class Namespace(object):
pass
controls = Namespace()
controls.submit = mentoring.find_element_by_css_selector("input.input-main")
controls.next_question = mentoring.find_element_by_css_selector("input.input-next")
controls.review = mentoring.find_element_by_css_selector("input.input-review")
controls.try_again = mentoring.find_element_by_css_selector("input.input-try-again")
return mentoring, controls
@staticmethod
def question_text(number):
if number:
return "Question %s" % number
else:
return "Question"
def freeform_answer(self, number, mentoring, controls, text_input, result, saved_value="", last=False): def freeform_answer(self, number, mentoring, controls, text_input, result, saved_value="", last=False):
question = self.expect_question_visible(number, mentoring) question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring) self.assert_persistent_elements_present(mentoring)
...@@ -166,7 +123,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -166,7 +123,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.ending_controls(controls, last) self.ending_controls(controls, last)
self.assert_hidden(controls.try_again) self.assert_hidden(controls.try_again)
choices = self._GetChoices(question) choices = GetChoices(question)
expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False} expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False}
self.assertEquals(choices.state, expected_state) self.assertEquals(choices.state, expected_state)
...@@ -194,7 +151,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -194,7 +151,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.assert_hidden(controls.review) self.assert_hidden(controls.review)
self.assert_hidden(controls.try_again) self.assert_hidden(controls.try_again)
choices = self._GetChoices(mentoring, ".rating") choices = GetChoices(mentoring, ".rating")
expected_choices = { expected_choices = {
"1 - Not good at all": False, "1 - Not good at all": False,
"2": False, "3": False, "4": False, "2": False, "3": False, "4": False,
...@@ -214,22 +171,6 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -214,22 +171,6 @@ class MentoringAssessmentTest(MentoringBaseTest):
self._assert_checkmark(mentoring, result) self._assert_checkmark(mentoring, result)
self.do_post(controls, last) self.do_post(controls, last)
def expect_question_visible(self, number, mentoring):
question_text = self.question_text(number)
self.wait_until_text_in(self.question_text(number), mentoring)
question_div = None
for xblock_div in mentoring.find_elements_by_css_selector('div.xblock-v1'):
header_text = xblock_div.find_elements_by_css_selector('h3.question-title')
if header_text and question_text in header_text[0].text:
question_div = xblock_div
self.assertTrue(xblock_div.is_displayed())
elif header_text:
self.assertFalse(xblock_div.is_displayed())
# else this is an HTML block or something else, not a question step
self.assertIsNotNone(question_div)
return question_div
def peek_at_multiple_choice_question(self, number, mentoring, controls, last=False): def peek_at_multiple_choice_question(self, number, mentoring, controls, last=False):
question = self.expect_question_visible(number, mentoring) question = self.expect_question_visible(number, mentoring)
self.assert_persistent_elements_present(mentoring) self.assert_persistent_elements_present(mentoring)
...@@ -244,7 +185,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -244,7 +185,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
def multiple_choice_question(self, number, mentoring, controls, choice_names, result, last=False): def multiple_choice_question(self, number, mentoring, controls, choice_names, result, last=False):
question = self.peek_at_multiple_choice_question(number, mentoring, controls, last=last) question = self.peek_at_multiple_choice_question(number, mentoring, controls, last=last)
choices = self._GetChoices(question) choices = GetChoices(question)
expected_choices = { expected_choices = {
"Its elegance": False, "Its elegance": False,
"Its beauty": False, "Its beauty": False,
...@@ -282,7 +223,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -282,7 +223,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
self.assert_hidden(controls.review) self.assert_hidden(controls.review)
def test_assessment(self): def test_assessment(self):
mentoring, controls = self.go_to_assessment(1) mentoring, controls = self.go_to_assessment("Assessment 1")
self.freeform_answer(1, mentoring, controls, 'This is the answer', CORRECT) self.freeform_answer(1, mentoring, controls, 'This is the answer', CORRECT)
self.single_choice_question(2, mentoring, controls, 'Maybe not', INCORRECT) self.single_choice_question(2, mentoring, controls, 'Maybe not', INCORRECT)
...@@ -291,7 +232,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -291,7 +232,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
# see if assessment remembers the current step # see if assessment remembers the current step
self.go_to_workbench_main_page() self.go_to_workbench_main_page()
mentoring, controls = self.go_to_assessment(1) mentoring, controls = self.go_to_assessment("Assessment 1")
self.multiple_choice_question(4, mentoring, controls, ("Its beauty",), PARTIAL, last=True) self.multiple_choice_question(4, mentoring, controls, ("Its beauty",), PARTIAL, last=True)
...@@ -324,7 +265,7 @@ class MentoringAssessmentTest(MentoringBaseTest): ...@@ -324,7 +265,7 @@ class MentoringAssessmentTest(MentoringBaseTest):
""" """
No 'Next Question' button on single question assessment. No 'Next Question' button on single question assessment.
""" """
mentoring, controls = self.go_to_assessment(2) mentoring, controls = self.go_to_assessment("Assessment 2")
self.single_choice_question(0, mentoring, controls, 'Maybe not', INCORRECT, last=True) self.single_choice_question(0, mentoring, controls, 'Maybe not', INCORRECT, last=True)
expected_results = { expected_results = {
......
import re
import mock
import ddt
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException
from .base_test import MentoringBaseTest from .base_test import MentoringBaseTest, MentoringAssessmentBaseTest, GetChoices
class MentoringTest(MentoringBaseTest): class MentoringTest(MentoringBaseTest):
...@@ -7,3 +10,46 @@ class MentoringTest(MentoringBaseTest): ...@@ -7,3 +10,46 @@ class MentoringTest(MentoringBaseTest):
mentoring = self.go_to_page('No Display Submit') mentoring = self.go_to_page('No Display Submit')
with self.assertRaises(NoSuchElementException): with self.assertRaises(NoSuchElementException):
mentoring.find_element_by_css_selector('.submit input.input-main') mentoring.find_element_by_css_selector('.submit input.input-main')
def _get_mentoring_theme_settings(theme):
return {
'package': 'mentoring',
'locations': ['public/themes/{}.css'.format(theme)]
}
@ddt.ddt
class MentoringThemeTest(MentoringAssessmentBaseTest):
def rgb_to_hex(self, rgb):
r, g, b = map(int, re.search(r'rgba?\((\d+),\s*(\d+),\s*(\d+)', rgb).groups())
return '#%02x%02x%02x' % (r, g, b)
def assert_status_icon_color(self, color):
mentoring, controls = self.go_to_assessment('Theme 1')
question = self.expect_question_visible(0, mentoring)
choice_name = "Maybe not"
choices = GetChoices(question)
expected_state = {"Yes": False, "Maybe not": False, "I don't understand": False}
self.assertEquals(choices.state, expected_state)
choices.select(choice_name)
expected_state[choice_name] = True
self.assertEquals(choices.state, expected_state)
controls.submit.click()
self.wait_until_disabled(controls.submit)
answer_result = mentoring.find_element_by_css_selector(".assessment-checkmark")
self.assertEqual(self.rgb_to_hex(answer_result.value_of_css_property("color")), color)
@ddt.unpack
@ddt.data(
('lms', "#c1373f"),
('apros', "#ff0000")
)
def test_lms_theme_applied(self, theme, expected_color):
with mock.patch("mentoring.MentoringBlock.get_theme") as patched_theme:
patched_theme.return_value = _get_mentoring_theme_settings(theme)
self.assert_status_icon_color(expected_color)
<mentoring url_name="mentoring-assessment-2" display_name="A Simple Assessment" weight="1" mode="assessment" max_attempts="2">
<html_demo>
<p>This paragraph is shared between <strong>all</strong> questions.</p>
<p>Please answer the questions below.</p>
</html_demo>
<mcq name="mcq_1_1" question="Do you like this MCQ?" correct_choices="yes">
<choice value="yes">Yes</choice>
<choice value="maybenot">Maybe not</choice>
<choice value="understand">I don't understand</choice>
<tip values="yes">Great!</tip>
<tip values="maybenot">Ah, damn.</tip>
<tip values="understand"><div id="test-custom-html">Really?</div></tip>
</mcq>
</mentoring>
import unittest import unittest
import ddt
from mock import MagicMock, Mock, patch from mock import MagicMock, Mock, patch
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from mentoring import MentoringBlock from mentoring import MentoringBlock
from mentoring.mentoring import _default_theme_config
class TestMentoringBlock(unittest.TestCase): class TestMentoringBlock(unittest.TestCase):
...@@ -28,3 +30,81 @@ class TestMentoringBlock(unittest.TestCase): ...@@ -28,3 +30,81 @@ class TestMentoringBlock(unittest.TestCase):
block.student_view(context={}) block.student_view(context={})
self.assertFalse(patched_runtime.publish.called) self.assertFalse(patched_runtime.publish.called)
@ddt.ddt
class TestMentoringBlockTheming(unittest.TestCase):
def setUp(self):
self.service_mock = Mock()
self.runtime_mock = Mock()
self.runtime_mock.service = Mock(return_value=self.service_mock)
self.block = MentoringBlock(self.runtime_mock, DictFieldData({}), Mock())
def test_theme_uses_default_theme_if_settings_service_is_not_available(self):
self.runtime_mock.service = Mock(return_value=None)
self.assertEqual(self.block.get_theme(), _default_theme_config)
def test_theme_uses_default_theme_if_no_theme_is_set(self):
self.service_mock.get_settings_bucket = Mock(return_value=None)
self.assertEqual(self.block.get_theme(), _default_theme_config)
self.service_mock.get_settings_bucket.assert_called_once_with(self.block)
@ddt.data(123, object())
def test_theme_raises_if_theme_object_is_not_iterable(self, theme_config):
self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
with self.assertRaises(TypeError):
self.block.get_theme()
self.service_mock.get_settings_bucket.assert_called_once_with(self.block)
@ddt.data(
{}, {'mass': 123}, {'spin': {}}, {'parity': "1"}
)
def test_theme_uses_default_theme_if_no_mentoring_theme_is_set_up(self, theme_config):
self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
self.assertEqual(self.block.get_theme(), _default_theme_config)
self.service_mock.get_settings_bucket.assert_called_once_with(self.block)
@ddt.data(
{MentoringBlock.theme_key: 123},
{MentoringBlock.theme_key: [1, 2, 3]},
{MentoringBlock.theme_key: {'package': 'qwerty', 'locations': ['something_else.css']}},
)
def test_theme_correctly_returns_configured_theme(self, theme_config):
self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
self.assertEqual(self.block.get_theme(), theme_config[MentoringBlock.theme_key])
def test_theme_files_are_loaded_from_correct_package(self):
fragment = MagicMock()
package_name = 'some_package'
theme_config = {MentoringBlock.theme_key: {'package': package_name, 'locations': ['lms.css']}}
self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
with patch("mentoring.mentoring.ResourceLoader") as patched_resource_loader:
self.block.include_theme_files(fragment)
patched_resource_loader.assert_called_with(package_name)
@ddt.data(
('mentoring', ['public/themes/lms.css']),
('mentoring', ['public/themes/lms.css', 'public/themes/lms.part2.css']),
('my_app.my_rules', ['typography.css', 'icons.css']),
)
@ddt.unpack
def test_theme_files_are_added_to_fragment(self, package_name, locations):
fragment = MagicMock()
theme_config = {MentoringBlock.theme_key: {'package': package_name, 'locations': locations}}
self.service_mock.get_settings_bucket = Mock(return_value=theme_config)
with patch("mentoring.mentoring.ResourceLoader.load_unicode") as patched_load_unicode:
self.block.include_theme_files(fragment)
for location in locations:
patched_load_unicode.assert_any_call(location)
self.assertEqual(patched_load_unicode.call_count, len(locations))
def test_student_view_calls_include_theme_files(self):
with patch.object(self.block, 'include_theme_files') as patched_include_theme_files:
fragment = self.block.student_view({})
patched_include_theme_files.assert_called_with(fragment)
def test_author_preview_view_calls_include_theme_files(self):
with patch.object(self.block, 'include_theme_files') as patched_include_theme_files:
fragment = self.block.author_preview_view({})
patched_include_theme_files.assert_called_with(fragment)
ddt ddt
mock
unicodecsv==0.9.4 unicodecsv==0.9.4
-e git+https://github.com/open-craft/xblock-utils.git@387a3ed97725d167862014e2367548ff54e4c1b7#egg=xblock-utils -e git+https://github.com/open-craft/xblock-utils.git@387a3ed97725d167862014e2367548ff54e4c1b7#egg=xblock-utils
-e . -e .
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