diff --git a/.gitignore b/.gitignore index 0bcd497..0e46e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -217,3 +217,6 @@ __marimo__/ # Sphinx documentation docs/_build/ + +# macOS system files +**/.DS_Store diff --git a/pyproject.toml b/pyproject.toml index 77bc520..957f524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ keywords = [ dependencies = [ "XBlock", "Django>=4.2", + "django-crum", + "openedx-filters", ] [project.urls] @@ -41,6 +43,7 @@ Documentation = "https://xblocks-extra.readthedocs.io" dev = [ "build", "ruff", + "edx-i18n-tools", ] test = [ "pytest>=7.0", @@ -54,6 +57,13 @@ docs = [ [project.entry-points."xblock.v1"] audio = "audio:AudioXBlock" +feedback = "feedback.feedback:FeedbackXBlock" + +[project.entry-points."xblock.test.v0"] +feedbacktest = "feedback.feedbacktests:feedbacktests" + +[project.entry-points."lms.djangoapp"] +feedback = "feedback.apps:FeedbackConfig" # Packages live in src/ but are installed without the src prefix # e.g., src/foo_xblock/ is installed as foo_xblock diff --git a/src/feedback/.DS_Store b/src/feedback/.DS_Store new file mode 100644 index 0000000..f9bb98f Binary files /dev/null and b/src/feedback/.DS_Store differ diff --git a/src/feedback/README.rst b/src/feedback/README.rst new file mode 100644 index 0000000..c7eb5b0 --- /dev/null +++ b/src/feedback/README.rst @@ -0,0 +1,137 @@ +############## +FeedbackXBlock +############## +| |License: AGPL v3| |Status| |Python CI| |Publish package to PyPi| + +.. |License: AGPL v3| image:: https://img.shields.io/badge/License-AGPL_v3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0 + +.. |Python CI| image:: https://github.com/openedx/FeedbackXBlock/actions/workflows/ci.yml/badge.svg + :target: https://github.com/openedx/FeedbackXBlock/actions/workflows/ci.yml + +.. |Publish package to PyPi| image:: https://github.com/openedx/FeedbackXBlock/actions/workflows/pypi-release.yml/badge.svg + :target: https://github.com/openedx/FeedbackXBlock/actions/workflows/pypi-release.yml + +.. |Status| image:: https://img.shields.io/badge/status-maintained-31c653 + +Purpose +======= + +`XBlock`_ is the Open edX component architecture for building custom +learning interactives. + +.. _XBlock: https://openedx.org/r/xblock + +The FeedbackXBlock encourages learners to reflect on their learning experiences and allows instructors to capture feedback from learners. Feedback is provided as sentiment on a predefined scale and free text feedback. Feedback can be aggregated by instructors to understand which parts of a course work well and which parts work poorly. + +The block can be placed anywhere in the courseware, and students can +provide feedback related to those sections. With just a few database queries, +we can compile that feedback into useful insights. ;) We do provide +aggregate statistics to instructors, but not yet the text of the +feedback. + +.. |Good to bad scale| image:: happy_sad_example.png +.. |Scale where good is in the middle| image:: happy_sad_happy_example.png +.. |Numberical scale| image:: numerical_example.png + +The instructors can view reports in their course instructor dashboard. The reports shows the count for every score, the average sentiment score, and the last 10 feedback comments. + +Tutor configuration +------------------- + +To enable the FeedbackXBlock report in the instructor dashboard, you can use the following tutor inline plugins: + +.. code-block:: yaml + + name: feedback-xblock-settings + version: 0.1.0 + patches: + openedx-common-settings: | + FEATURES["ENABLE_FEEDBACK_INSTRUCTOR_VIEW"] = True + OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.instructor.dashboard.render.started.v1": { + "fail_silently": False, + "pipeline": [ + "feedback.extensions.filters.AddFeedbackTab", + ] + }, + } + +To enable this plugin you need to create a file called *feedback-xblock-settings.yml* in your tutor plugins directory of your tutor instance +with the content of the previous code block, and run the following commands. + +.. code-block:: bash + + tutor plugins enable feedback-xblock-settings + tutor config save + + +You can find more information about tutor plugins in the Tutor `plugins`_ documentation. + +.. _plugins: https://docs.tutor.edly.io/tutorials/plugin.html + +Getting Started +=============== + +.. TODO Make it possible to run in the Workbench. + +For details regarding how to deploy this or any other XBlock in the lms instance, see the `installing-the-xblock`_ documentation. + +.. _installing-the-xblock: https://docs.tutor.edly.io/configuration.html#installing-extra-xblocks-and-requirements + +Getting Help +============ + +If you're having trouble, we have discussion forums at +https://discuss.openedx.org where you can connect with others in the +community. + +Our real-time conversations are on Slack. You can request a `Slack +invitation`_, then join our `community Slack workspace`_. + +For anything non-trivial, the best path is to open an issue in this +repository with as many details about the issue you are facing as you +can provide. + +https://github.com/openedx/FeedbackXBlock/issues + +For more information about these options, see the `Getting Help`_ page. + +.. _Slack invitation: https://openedx.org/slack +.. _community Slack workspace: https://openedx.slack.com/ +.. _Getting Help: https://openedx.org/getting-help + +How to Contribute +================= + +Details about how to become a contributor to the Open edX project may +be found in the wiki at `How to contribute`_ + +.. _How to contribute: https://openedx.org/r/how-to-contribute + +The Open edX Code of Conduct +---------------------------- + +All community members should familarize themselves with the `Open edX Code of Conduct`_. + +.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/ + +People +====== + +The assigned maintainers for this component and other project details +may be found in `Backstage`_ or groked from inspecting catalog-info.yaml. + +.. _Backstage: https://open-edx-backstage.herokuapp.com/catalog/default/component/FeedbackXBlock + +Reporting Security Issues +========================= + +Please do not report security issues in public. Please email security@openedx.org. + +History +======= + +This is a basic clone of Dropthought for use in Open edX. This used to +be called the RateXBlock. We renamed it for better consistency. We are +keeping the old one around for backwards-compatibility. diff --git a/src/feedback/__init__.py b/src/feedback/__init__.py new file mode 100644 index 0000000..0a2388e --- /dev/null +++ b/src/feedback/__init__.py @@ -0,0 +1,10 @@ +""" +An edX XBlock designed to allow people to provide feedback on our +course resources, and to think and synthesize about their experience +in the course. +""" + +import os +from pathlib import Path + +ROOT_DIRECTORY = Path(os.path.dirname(os.path.abspath(__file__))) diff --git a/src/feedback/apps.py b/src/feedback/apps.py new file mode 100644 index 0000000..dfc9de6 --- /dev/null +++ b/src/feedback/apps.py @@ -0,0 +1,28 @@ +""" +forum_email_notifier Django application initialization. +""" + +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + """ + Configuration for the feedback Django application. + """ + + name = "feedback" + + plugin_app = { + "settings_config": { + "lms.djangoapp": { + "common": {"relative_path": "settings.common"}, + "test": {"relative_path": "settings.test"}, + "production": {"relative_path": "settings.production"}, + }, + "cms.djangoapp": { + "common": {"relative_path": "settings.common"}, + "test": {"relative_path": "settings.test"}, + "production": {"relative_path": "settings.production"}, + }, + }, + } diff --git a/src/feedback/conf/locale/config.yaml b/src/feedback/conf/locale/config.yaml new file mode 100644 index 0000000..a968d94 --- /dev/null +++ b/src/feedback/conf/locale/config.yaml @@ -0,0 +1,4 @@ +# Configuration for i18n workflow. + +locales: + - en # English - Source Language diff --git a/src/feedback/extensions/__init__.py b/src/feedback/extensions/__init__.py new file mode 100644 index 0000000..23dfa8c --- /dev/null +++ b/src/feedback/extensions/__init__.py @@ -0,0 +1,3 @@ +""" +Open edX filters extensions module. +""" diff --git a/src/feedback/extensions/filters.py b/src/feedback/extensions/filters.py new file mode 100644 index 0000000..b015e87 --- /dev/null +++ b/src/feedback/extensions/filters.py @@ -0,0 +1,184 @@ +""" +Open edX Filters needed for instructor dashboard integration. +""" + +import importlib.resources + +from crum import get_current_request +from django.conf import settings +from django.template import Context, Template +from openedx_filters import PipelineStep +from web_fragments.fragment import Fragment + +try: + from cms.djangoapps.contentstore.utils import get_lms_link_for_item + from lms.djangoapps.courseware.block_render import get_block_by_usage_id, load_single_xblock + from openedx.core.djangoapps.enrollments.data import get_user_enrollments + from xmodule.modulestore.django import modulestore +except ImportError: + load_single_xblock = None + get_block_by_usage_id = None + modulestore = None + get_user_enrollments = None + get_lms_link_for_item = None + +TEMPLATE_ABSOLUTE_PATH = "/instructor_dashboard/" +BLOCK_CATEGORY = "feedback" +TEMPLATE_CATEGORY = "feedback_instructor" + + +class AddFeedbackTab(PipelineStep): + """Add forum_notifier tab to instructor dashboard by adding a new context with feedback data.""" + + def run_filter(self, context, template_name): # pylint: disable=unused-argument, arguments-differ + """Execute filter that modifies the instructor dashboard context. + Args: + context (dict): the context for the instructor dashboard. + _ (str): instructor dashboard template name. + """ + if not settings.FEATURES.get("ENABLE_FEEDBACK_INSTRUCTOR_VIEW", False): + return { + "context": context, + } + + course = context["course"] + template = Template(self.resource_string(f"static/html/{TEMPLATE_CATEGORY}.html")) + + request = get_current_request() + + context.update( + { + "blocks": load_blocks(request, course), + } + ) + + html = template.render(Context(context)) + frag = Fragment(html) + frag.add_css(self.resource_string(f"static/css/{TEMPLATE_CATEGORY}.css")) + frag.add_javascript(self.resource_string(f"static/js/src/{TEMPLATE_CATEGORY}.js")) + + section_data = { + "fragment": frag, + "section_key": TEMPLATE_CATEGORY, + "section_display_name": "Course Feedback", + "course_id": str(course.id), + "template_path_prefix": TEMPLATE_ABSOLUTE_PATH, + } + context["sections"].append(section_data) + + return {"context": context} + + def resource_string(self, path): + """Handy helper for getting resources from our kit.""" + return importlib.resources.files("feedback").joinpath(path).read_text(encoding="utf-8") + + +def load_blocks(request, course): + """ + Load feedback blocks for a given course for all enrolled students. + + Arguments: + request (HttpRequest): Django request object. + course (CourseLocator): Course locator object. + """ + course_id = str(course.id) + + feedback_blocks = modulestore().get_items(course.id, qualifiers={"category": BLOCK_CATEGORY}) + + blocks = [] + + if not feedback_blocks: + return [] + + students = get_user_enrollments(course_id).values_list("user_id", "user__username") + for feedback_block in feedback_blocks: + block, _ = get_block_by_usage_id( + request, + str(course.id), + str(feedback_block.location), + disable_staff_debug_info=True, + course=course, + ) + answers = load_xblock_answers( + request, + students, + str(course.location.course_key), + str(feedback_block.location), + course, + ) + + vote_aggregate = [] + total_votes = 0 + total_answers = 0 + + if not block.vote_aggregate: + block.vote_aggregate = [0] * len(block.get_prompt()["scale_text"]) + for index, vote in enumerate(block.vote_aggregate): + vote_aggregate.append( + { + "scale_text": block.get_prompt()["scale_text"][index], + "count": vote, + } + ) + total_answers += vote + # We have an inverted scale, so we need to invert the index + # to get the correct average rating. + # Excellent = 1, Very Good = 2, Good = 3, Fair = 4, Poor = 5 + # So Excellent = 5, Very Good = 4, Good = 3, Fair = 2, Poor = 1 + total_votes += vote * (5 - index) + + try: + average_rating = round(total_votes / total_answers, 2) + except ZeroDivisionError: + average_rating = 0 + + unit = block.get_parent() + subsection = unit.get_parent() + section = subsection.get_parent() + + blocks.append( + { + "display_name": block.display_name, + "prompts": block.prompts, + "vote_aggregate": vote_aggregate, + "answers": answers[-10:], + "unit_display_name": unit.display_name, + "subsection_display_name": subsection.display_name, + "section_display_name": section.display_name, + "average_rating": average_rating, + "url": get_lms_link_for_item(block.location), + } + ) + return blocks + + +def load_xblock_answers(request, students, course_id, block_id, course): + """ + Load answers for a given feedback xblock instance. + + Arguments: + request (HttpRequest): Django request object. + students (list): List of enrolled students. + course_id (str): Course ID. + block_id (str): Block ID. + course (CourseDescriptor): Course descriptor. + """ + answers = [] + for user_id, username in students: + student_xblock_instance = load_single_xblock(request, user_id, course_id, block_id, course) + if student_xblock_instance: + prompt = student_xblock_instance.get_prompt() + if student_xblock_instance.user_freeform: + if student_xblock_instance.user_vote != -1: + vote = prompt["scale_text"][student_xblock_instance.user_vote] + else: + vote = "No vote" + answers.append( + { + "username": username, + "user_vote": vote, + "user_freeform": student_xblock_instance.user_freeform, + } + ) + + return answers diff --git a/src/feedback/feedback.py b/src/feedback/feedback.py new file mode 100644 index 0000000..360def8 --- /dev/null +++ b/src/feedback/feedback.py @@ -0,0 +1,419 @@ +# pylint: disable=E1101 +""" +This is an XBlock designed to allow people to provide feedback on our +course resources, and to think and synthesize about their experience +in the course. +""" + +import html +import importlib.resources +import random + +import six +from web_fragments.fragment import Fragment +from xblock.core import XBlock +from xblock.fields import Boolean, Float, Integer, List, Scope, String + +from feedback.utils import _ + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: # For backward compatibility with releases older than Quince. + from xblockutils.resources import ResourceLoader + +resource_loader = ResourceLoader(__name__) + +# We provide default text which is designed to elicit student thought. We'd +# like instructors to customize this to something highly structured (not +# "What did you think?" and "How did you like it?". +DEFAULT_FREEFORM = _("What did you learn from this? What was missing?") +DEFAULT_LIKERT = _("How would you rate this as a learning experience?") +DEFAULT_DEFAULT = _( + "Think about the material, and try to synthesize key lessons learned, as well as key gaps in our presentation." +) +DEFAULT_PLACEHOLDER = _( + "Take a little bit of time to reflect here. " + "Research shows that a meaningful synthesis will help " + "you better understand and remember material from " + "this course." +) +DEFAULT_ICON = "face" +DEFAULT_SCALETEXT = [_("Excellent"), _("Good"), _("Average"), _("Fair"), _("Poor")] + +# Unicode alt faces are cute, but we do nulls instead for a11y. +ICON_SETS = { + "face": [""] * 5, # u"😁😊😐😞😭", + "num": "12345", + "midface": [""] * 5, # u"😞😐😊😐😞" + "star": [""] * 5, # u "☆☆☆☆☆" +} + + +@XBlock.needs("i18n") +class FeedbackXBlock(XBlock): + """ + This is an XBlock -- eventually, hopefully an aside -- which + allows you to feedback content in the course. We've wanted this for a + long time, but Dartmouth finally encourage me to start to build + this. + """ + + # This is a list of prompts. If we have multiple elements in the + # list, one will be chosen at random. This is currently not + # exposed in the UX. If the prompt is missing any portions, we + # will default to the ones in default_prompt. + prompts = List( + default=[ + { + "freeform": DEFAULT_FREEFORM, + "default_text": DEFAULT_DEFAULT, + "likert": DEFAULT_LIKERT, + "placeholder": DEFAULT_PLACEHOLDER, + "scale_text": DEFAULT_SCALETEXT, + "icon_set": DEFAULT_ICON, + } + ], + scope=Scope.settings, + help=_("Freeform user prompt"), + xml_node=True, + ) + + prompt_choice = Integer( + default=-1, scope=Scope.user_state, help=_("Random number generated for p. -1 if uninitialized") + ) + + user_vote = Integer(default=-1, scope=Scope.user_state, help=_("How user voted. -1 if didn't vote")) + + # pylint: disable=invalid-name + p = Float(default=100, scope=Scope.settings, help=_("What percent of the time should this show?")) + + p_user = Float(default=-1, scope=Scope.user_state, help=_("Random number generated for p. -1 if uninitialized")) + + vote_aggregate = List(default=None, scope=Scope.user_state_summary, help=_("A list of user votes")) + + user_freeform = String(default="", scope=Scope.user_state, help=_("Feedback")) + + display_name = String(display_name=_("Display Name"), default=_("Provide Feedback"), scopde=Scope.settings) + + voting_message = String(display_name=_("Voting message"), default=_("Thank you for voting!"), scope=Scope.settings) + + feedback_message = String( + display_name=_("Feedback message"), default=_("Thank you for your feedback!"), scope=Scope.settings + ) + + show_aggregate_to_students = Boolean( + display_name=_("Show aggregate to students"), default=False, scope=Scope.settings + ) + + @classmethod + def resource_string(cls, path): + """Handy helper for getting resources from our kit.""" + return importlib.resources.files(__package__).joinpath(path).read_text(encoding="utf-8") + + def get_prompt(self, index=-1): + """ + Return the current prompt dictionary, doing appropriate + randomization if necessary, and falling back to defaults when + necessary. + """ + if index == -1: + index = self.prompt_choice + + _ = self.runtime.service(self, "i18n").ugettext + # This is the default prompt if something is not specified in the + # settings dictionary. Note that this is not the same as the default + # above. The default above is the prompt the instructor starts from + # in a tool like Studio. This is a fallback in case some JSON fields + # are left unpopulated (e.g. if someone manually tweaks the database, + # in case of OLX authoring, and similar). The examplar above is + # intended as a well-structured, coherent response. This is designed + # as generic, to work with any content as a safe fallback. + prompt = { + "freeform": _("Please reflect on this course material"), + "default_text": _("Please take time to meaningfully reflect on your experience with this course material."), + "likert": _("Please rate your overall experience"), + "scale_text": [_("Excellent"), _("Good"), _("Average"), _("Fair"), _("Poor")], + "icon_set": "num", + "placeholder": _("Please take a moment to thoughtfully reflect."), + } + + prompt.update(self.prompts[index]) + return prompt + + def student_view(self, context=None): # pylint: disable=unused-argument + """ + The primary view of the FeedbackXBlock, shown to students + when viewing courses. + """ + # Figure out which prompt we show. We set self.prompt_choice to + # the index of the prompt. We set it if it is out of range (either + # uninitiailized, or incorrect due to changing list length). Then, + # we grab the prompt, prepopulated with defaults. + if self.prompt_choice < 0 or self.prompt_choice >= len(self.prompts): + self.prompt_choice = random.randint(0, len(self.prompts) - 1) + prompt = self.get_prompt() + + # Staff see vote totals, so we have slightly different HTML here. + item_templates_file = "templates/html/scale_item.html" + + # We have five Likert fields right now, but we'd like this to + # be dynamic + indexes = range(5) + + # If the user voted before, we'd like to show that + active_vote = ["checked" if i == self.user_vote else "" for i in indexes] + + # Confirm that we do have vote totals (this may be uninitialized + # otherwise). This should probably go into __init__ or similar. + self.init_vote_aggregate() + votes = self.vote_aggregate + + # We grab the icons. This should move to a Filesystem field so + # instructors can upload new ones + def get_url(icon_type, i): + """ + Helper function to generate the URL for the icons shown in the + tool. Takes the type of icon (active, inactive, etc.) and + the number of the icon. + + Note that some icon types may not be actively used in the + styling. For example, at the time of this writing, we do + selected through CSS, rather than by using those icons. + """ + templates = { + "inactive": "public/default_icons/i{set}{i}.png", + "active": "public/default_icons/a{set}{i}.png", + } + template = templates[icon_type] + icon_file = template.format(i=i, set=prompt["icon_set"]) + return self.runtime.local_resource_url(self, icon_file) + + ina_urls = [get_url("inactive", i) for i in range(1, 6)] + act_urls = [get_url("active", i) for i in range(1, 6)] + + # Prepare the Likert scale fragment to be embedded into the feedback form + scale = "".join( + resource_loader.render_django_template( + item_templates_file, + { + "scale_text": scale_text, + "unicode_icon": unicode_icon, + "idx": idx, + "active": active, + "vote_cnt": vote_cnt, + "ina_icon": ina_icon, + "act_icon": act_icon, + "is_display_vote_cnt": self.vote_aggregate and (self.show_aggregate_to_students or self.is_staff()), + }, + i18n_service=self.runtime.service(self, "i18n"), + ) + for ( + scale_text, + unicode_icon, + idx, + active, + vote_cnt, + act_icon, + ina_icon, + ) in zip( + prompt["scale_text"], + ICON_SETS[(prompt["icon_set"])], + indexes, + active_vote, + votes, + act_urls, + ina_urls, + strict=False, + ) + ) + if self.user_vote != -1: + _ = self.runtime.service(self, "i18n").ugettext + response = self.voting_message + else: + response = "" + + # We initialize self.p_user if not initialized -- this sets whether + # or not we show it. From there, if it is less than odds of showing, + # we set the fragment to the rendered XBlock. Otherwise, we return + # empty HTML. There ought to be a way to return None, but XBlocks + # doesn't support that. + if self.p_user == -1: + self.p_user = random.uniform(0, 100) + if self.p_user < self.p: + frag = Fragment() + frag.add_content( + resource_loader.render_django_template( + "templates/html/feedback.html", + context={ + "self": self, + "scale": scale, + "freeform_prompt": prompt["freeform"], + "likert_prompt": prompt["likert"], + "response": response, + "placeholder": prompt["placeholder"], + }, + i18n_service=self.runtime.service(self, "i18n"), + ) + ) + else: + frag = Fragment("") + + # Finally, we do the standard JS+CSS boilerplate. Honestly, XBlocks + # ought to have a sane default here. + frag.add_css(self.resource_string("static/css/feedback.css")) + frag.add_javascript(self.resource_string("static/js/src/feedback.js")) + frag.initialize_js("FeedbackXBlock") + return frag + + def studio_view(self, context): # pylint: disable=unused-argument + """ + Create a fragment used to display the edit view in the Studio. + """ + prompt = self.get_prompt(0) + for idx in range(len(prompt["scale_text"])): + prompt[f"likert{idx}"] = prompt["scale_text"][idx] + frag = Fragment() + + prompt.update( + { + "display_name": self.display_name, + "voting_message": self.voting_message, + "feedback_message": self.feedback_message, + "show_aggregate_to_students": self.show_aggregate_to_students, + } + ) + frag.add_content( + resource_loader.render_django_template( + "templates/html/studio_view.html", prompt, i18n_service=self.runtime.service(self, "i18n") + ) + ) + js_str = self.resource_string("static/js/src/studio.js") + frag.add_javascript(six.text_type(js_str)) + frag.initialize_js("FeedbackBlock", {"icon_set": prompt["icon_set"]}) + return frag + + @XBlock.json_handler + def studio_submit(self, data, suffix=""): # pylint: disable=unused-argument + """ + Called when submitting the form in Studio. + """ + for item in ["freeform", "likert", "placeholder", "icon_set"]: + item_submission = data.get(item, None) + if item_submission and len(item_submission) > 0: + self.prompts[0][item] = html.escape(item_submission) + for i in range(5): + likert = data.get(f"likert{i}", None) + if likert and len(likert) > 0: + self.prompts[0]["scale_text"][i] = html.escape(likert) + + self.display_name = data.get("display_name") + self.voting_message = data.get("voting_message") + self.feedback_message = data.get("feedback_message") + self.show_aggregate_to_students = data.get("show_aggregate_to_students") + + return {"result": "success"} + + def init_vote_aggregate(self): + """ + There are a lot of places we read the aggregate vote counts. We + start out with these uninitialized. This guarantees they are + initialized. We'd prefer to do it this way, rather than default + value, since we do plan to not force scale length to be 5 in the + future. + """ + if not self.vote_aggregate: + self.vote_aggregate = [0] * (len(self.get_prompt()["scale_text"])) + + def vote(self, data): + """ + Handle voting + """ + # prompt_choice is initialized by student view. + # Ideally, we'd break this out into a function. + _prompt = self.get_prompt(self.prompt_choice) # pylint: disable=unused-variable + # Make sure we're initialized + self.init_vote_aggregate() + + # Remove old vote if we voted before + if self.user_vote != -1: + self.vote_aggregate[self.user_vote] -= 1 + + self.user_vote = data["vote"] + self.vote_aggregate[self.user_vote] += 1 + + @XBlock.json_handler + def feedback(self, data, suffix=""): # pylint: disable=unused-argument + """ + Allow students to submit feedback, both numerical and + qualitative. We only update the specific type of feedback + submitted. + + We return the current state. While this is not used by the + client code, it is helpful for testing. For staff users, we + also return the aggregate results. + """ + _ = self.runtime.service(self, "i18n").ugettext + + if "freeform" not in data and "vote" not in data: + response = {"success": False, "response": _("Please vote!")} + self.runtime.publish(self, "edx.feedbackxblock.nothing_provided", {}) + if "vote" in data: + response = {"success": True, "response": self.voting_message} + self.runtime.publish( + self, "edx.feedbackxblock.likert_provided", {"old_vote": self.user_vote, "new_vote": data["vote"]} + ) + self.vote(data) + if "freeform" in data: + response = {"success": True, "response": self.feedback_message} + self.runtime.publish( + self, + "edx.feedbackxblock.freeform_provided", + {"old_freeform": self.user_freeform, "new_freeform": data["freeform"]}, + ) + self.user_freeform = data["freeform"] + + response.update( + { # pylint: disable=possibly-used-before-assignment + "freeform": self.user_freeform, + "vote": self.user_vote, + } + ) + + if self.show_aggregate_to_students or self.is_staff(): + response["aggregate"] = self.vote_aggregate + + return response + + @staticmethod + def workbench_scenarios(): + """ + A canned scenario for display in the workbench. + + We have three blocks. One shows up all the time (for testing). The + other two show up 50% of the time. + """ + return [ + ( + "FeedbackXBlock", + """ + + + + + """, + ), + ] + + def is_staff(self): + """ + Return self.xmodule_runtime.user_is_staff if available + + This is not a supported part of the XBlocks API in all + runtimes, and this is a workaround so something reasonable + happens in both workbench and edx-platform + """ + if hasattr(self, "xmodule_runtime") and hasattr(self.xmodule_runtime, "user_is_staff"): + return self.xmodule_runtime.user_is_staff + else: + # In workbench and similar settings, always return true + return True diff --git a/src/feedback/feedbacktests/__init__.py b/src/feedback/feedbacktests/__init__.py new file mode 100644 index 0000000..b6763b4 --- /dev/null +++ b/src/feedback/feedbacktests/__init__.py @@ -0,0 +1,5 @@ +"""FeedbackXBlock test entry point exports.""" + +from .test_feedback import FeedbackTestCase as feedbacktests + +__all__ = ["feedbacktests"] diff --git a/src/feedback/feedbacktests/conftest.py b/src/feedback/feedbacktests/conftest.py new file mode 100644 index 0000000..43a9ea2 --- /dev/null +++ b/src/feedback/feedbacktests/conftest.py @@ -0,0 +1,27 @@ +from unittest.mock import Mock + +import pytest +from workbench.runtime import WorkbenchRuntime +from xblock.fields import ScopeIds +from xblock.runtime import DictKeyValueStore, KvsFieldData + +from feedback.feedback import FeedbackXBlock + + +def generate_scope_ids(runtime, block_type): + """helper to generate scope IDs for an XBlock""" + def_id = runtime.id_generator.create_definition(block_type) + usage_id = runtime.id_generator.create_usage(def_id) + return ScopeIds("user", block_type, def_id, usage_id) + + +@pytest.fixture +def feedback_xblock(): + """Feedback XBlock pytest fixture.""" + runtime = WorkbenchRuntime() + key_store = DictKeyValueStore() + db_model = KvsFieldData(key_store) + ids = generate_scope_ids(runtime, "feedback") + feedback_xblock = FeedbackXBlock(runtime, db_model, scope_ids=ids) + feedback_xblock.usage_id = Mock() + return feedback_xblock diff --git a/src/feedback/feedbacktests/test_feedback.py b/src/feedback/feedbacktests/test_feedback.py new file mode 100644 index 0000000..63c0ea9 --- /dev/null +++ b/src/feedback/feedbacktests/test_feedback.py @@ -0,0 +1,119 @@ +""" +Tests for the FeedbackXBlock that needs to run in Open edX. +""" + +from unittest import mock + + +class PatchRandomMixin: + """ + This is a class which will patch random.uniform so that we can + confirm whether randomization works. + """ + + def setUp(self): + super().setUp() + self.random_patch_value = None + + def patched_uniform(min, max): + return self.random_patch_value + + patcher = mock.patch("feedback.feedback.random.uniform", patched_uniform) + patcher.start() + self.addCleanup(patcher.stop) + + def set_random(self, random_patch_value): + self.random_patch_value = random_patch_value + + +# pylint: disable=abstract-method +class FeedbackTestCase(PatchRandomMixin): + """ + Basic tests for the FeedbackXBlock. We set up a page with two + of the block, make sure the page renders, toggle a few ratings, + and call it quits. + """ + + olx_scenarios = { # Currently not used + "two_feedback_block_test_case": """ + + + """ + } + + # This is a stop-gap until we can load OLX and/or OLX from + # normal workbench scenarios + test_configuration = [ + { + "urlname": "feedback_block_test_case_0", + "xblocks": [ # Stopgap until we handle OLX + {"blocktype": "feedback", "urlname": "feedback_0", "parameters": {"p": 100}} + ], + }, + { + "urlname": "feedback_block_test_case_1", + "xblocks": [{"blocktype": "feedback", "urlname": "feedback_1", "parameters": {"p": 50}}], + }, + ] + + def submit_feedback(self, block, data, desired_state): + """ + Make an AJAX call to the XBlock, and assert the state is as + desired. + """ + resp = self.ajax("feedback", block, data) + self.assertEqual(resp.status_code, 200) + # pylint: disable=no-member + self.assertEqual(resp.data, desired_state) + + # pylint: disable=unused-argument + def check_response(self, block_urlname, rendered): + """ + Confirm that we have a 200 response code (no server error) + + Confirm that we do this stochastically based no `p` + """ + response = self.render_block(block_urlname) + self.assertEqual(response.status_code, 200) + if rendered: + self.assertTrue("feedback_likert_scale" in response.content) + else: + self.assertFalse("feedback_likert_scale" in response.content) + + def test_feedback(self): + """ + Walk through a few ratings. Make sure the blocks don't mix up + state between them, initial state is correct, and final state + is correct. + """ + self.select_student(0) + # We confirm we don't have errors rendering the student view + self.check_response("feedback_0", True) + # At 45, feedback_1 should render + self.set_random(45) + self.check_response("feedback_1", True) + vote_str = "Thank you for voting!" + feedback_str = "Thank you for your feedback!" + self.submit_feedback( + "feedback_0", + {"freeform": "Worked well", "vote": 3}, + {"freeform": "Worked well", "vote": 3, "response": feedback_str, "success": True}, + ) + self.submit_feedback( + "feedback_0", {"vote": 4}, {"freeform": "Worked well", "vote": 4, "response": vote_str, "success": True} + ) + self.submit_feedback( + "feedback_0", + {"freeform": "Worked great"}, + {"freeform": "Worked great", "vote": 4, "response": feedback_str, "success": True}, + ) + # And confirm we render correctly + self.check_response("feedback_0", True) + # Feedback 1 should render again; this should be stored in a + # field + self.set_random(55) + self.check_response("feedback_1", True) + + # But it should not render for a new student + self.select_student(1) + self.check_response("feedback_1", False) diff --git a/src/feedback/feedbacktests/test_feedback_unit.py b/src/feedback/feedbacktests/test_feedback_unit.py new file mode 100644 index 0000000..bd28c3b --- /dev/null +++ b/src/feedback/feedbacktests/test_feedback_unit.py @@ -0,0 +1,60 @@ +""" +Tests for the Feedback XBlock with heavy mocking. +""" + +from unittest.mock import Mock + + +def test_template_content(feedback_xblock): + """Test content of FeedbackXBlock's student view""" + student_fragment = feedback_xblock.render("student_view", Mock()) + assert "feedback" in student_fragment.content + + +def test_studio_view(feedback_xblock): + """Test content of FeedbackXBlock's author view""" + student_fragment = feedback_xblock.render("studio_view", Mock()) + assert "feedback" in student_fragment.content + + +def test_studio_submit(feedback_xblock): + """Test the FeedbackXBlock's save action""" + request_body = b"""{ + "display_name": "foo", + "voting_message": "bar", + "feedback_message": "baz", + "show_aggregate_to_students": true + }""" + request = Mock(method="POST", body=request_body) + response = feedback_xblock.studio_submit(request) + + assert feedback_xblock.display_name == "foo" + assert feedback_xblock.voting_message == "bar" + assert feedback_xblock.feedback_message == "baz" + assert feedback_xblock.show_aggregate_to_students is True + assert response.status_code == 200 and {"result": "success"} == response.json, response.json + + +def test_vote(feedback_xblock): + """Test content of FeedbackXBlock's vote() method""" + feedback_xblock.vote({"vote": 1}) + + +def test_feedback_method(feedback_xblock): + """Test content of FeedbackXBlock's feedback() method""" + request_body = b"""{ + "freeform": "yes", + "vote": 1 + }""" + request = Mock(method="POST", body=request_body) + response = feedback_xblock.feedback(request) + + expected_response_json = { + "aggregate": [0, 1, 0, 0, 0], + "freeform": "yes", + "response": "Thank you for your feedback!", + "success": True, + "vote": 1, + } + + assert response.status_code == 200 and response.json == expected_response_json, response.json diff --git a/src/feedback/feedbacktests/test_filters.py b/src/feedback/feedbacktests/test_filters.py new file mode 100644 index 0000000..6ee112a --- /dev/null +++ b/src/feedback/feedbacktests/test_filters.py @@ -0,0 +1,142 @@ +""" +Test for the instructor dashboard filters. +""" + +from unittest import TestCase +from unittest.mock import Mock, patch + +from django.test.utils import override_settings + +from feedback.extensions.filters import AddFeedbackTab, load_xblock_answers + + +class TestFilters(TestCase): + """ + Test suite for the FeedbackXBlock filters. + """ + + def setUp(self) -> None: + """ + Set up the test suite. + """ + self.filter = AddFeedbackTab(filter_type=Mock(), running_pipeline=Mock()) + + @patch("feedback.extensions.filters.get_user_enrollments") + @patch("feedback.extensions.filters.get_block_by_usage_id") + @patch("feedback.extensions.filters.modulestore") + def test_run_filter_without_blocks(self, modulestore_mock, get_block_by_usage_id_mock, get_user_enrollments_mock): + """ + Check the filter is not executed when there are no Feedback blocks in the course. + + Expected result: + - The context is returned without modifications. + """ + modulestore_mock().get_items.return_value = [] + context = {"course": Mock(id="test-course-id"), "sections": []} + template_name = "test-template-name" + + self.filter.run_filter(context, template_name) + + get_block_by_usage_id_mock.assert_not_called() + get_user_enrollments_mock.assert_not_called() + + @patch("feedback.extensions.filters.get_lms_link_for_item") + @patch("feedback.extensions.filters.get_user_enrollments") + @patch("feedback.extensions.filters.get_block_by_usage_id") + @patch("feedback.extensions.filters.load_single_xblock") + @patch("feedback.extensions.filters.modulestore") + def test_run_filter( + self, + modulestore_mock, + load_single_xblock_mock, + get_block_by_usage_id_mock, + get_user_enrollments_mock, + get_lms_link_for_item_mock, + ): + """ + Check the filter is executed when there are Feedback blocks in the course. + + Expected result: + - The context is returned with the Feedback blocks information. + """ + modulestore_mock().get_items.return_value = [Mock(location="test-location")] + context = {"course": Mock(id="test-course-id"), "sections": []} + template_name = "test-template-name" + get_user_enrollments_mock.value_list = [(1, "test-username")] + block_mock = Mock( + vote_aggregate=[], + ) + block_mock.get_prompt.return_value = {"scale_text": ["test-scale-text"]} + get_block_by_usage_id_mock.return_value = block_mock, None + get_lms_link_for_item_mock.return_value = "test-url" + load_single_xblock_mock.return_value = Mock( + user_vote=1, + user_freeform="test-user-freeform", + ) + + result = self.filter.run_filter(context, template_name) + + get_block_by_usage_id_mock.assert_called() + get_user_enrollments_mock.assert_called_once() + self.assertEqual(1, len(result.get("context", {})["sections"])) + + @override_settings(FEATURES={"ENABLE_FEEDBACK_INSTRUCTOR_VIEW": False}) + def test_run_filter_disable(self): + context = {"course": Mock(id="test-course-id"), "sections": []} + template_name = "test-template-name" + + new_context = self.filter.run_filter(context, template_name)["context"] + + self.assertEqual(context, new_context) + + @patch("feedback.extensions.filters.load_single_xblock") + def test_load_xblock_answers(self, load_single_xblock_mock): + request_mock = Mock() + students = [(1, "test-username")] + course_id = "test-course-id" + block_id = "test-block-id" + course = Mock() + + single_block = Mock( + user_vote=0, + user_freeform="test-user-freeform", + ) + single_block.get_prompt.return_value = {"scale_text": ["test-scale-text"]} + + load_single_xblock_mock.return_value = single_block + + answers = load_xblock_answers(request_mock, students, course_id, block_id, course) + + self.assertEqual( + [ + { + "username": "test-username", + "user_vote": "test-scale-text", + "user_freeform": "test-user-freeform", + } + ], + answers, + ) + + @patch("feedback.extensions.filters.load_single_xblock") + def test_load_xblock_answers_skip_empty(self, load_single_xblock_mock): + request_mock = Mock() + students = [(1, "test-username")] + course_id = "test-course-id" + block_id = "test-block-id" + course = Mock() + + single_block = Mock( + user_vote=-1, + user_freeform="", + ) + single_block.get_prompt.return_value = {"scale_text": ["test-scale-text"]} + + load_single_xblock_mock.return_value = single_block + + answers = load_xblock_answers(request_mock, students, course_id, block_id, course) + + self.assertEqual( + [], + answers, + ) diff --git a/src/feedback/happy_sad_example.png b/src/feedback/happy_sad_example.png new file mode 100644 index 0000000..2114144 Binary files /dev/null and b/src/feedback/happy_sad_example.png differ diff --git a/src/feedback/happy_sad_happy_example.png b/src/feedback/happy_sad_happy_example.png new file mode 100644 index 0000000..2d0b06c Binary files /dev/null and b/src/feedback/happy_sad_happy_example.png differ diff --git a/src/feedback/numerical_example.png b/src/feedback/numerical_example.png new file mode 100644 index 0000000..6acc304 Binary files /dev/null and b/src/feedback/numerical_example.png differ diff --git a/src/feedback/public/default_icons/aface1.png b/src/feedback/public/default_icons/aface1.png new file mode 100644 index 0000000..360c1f3 Binary files /dev/null and b/src/feedback/public/default_icons/aface1.png differ diff --git a/src/feedback/public/default_icons/aface2.png b/src/feedback/public/default_icons/aface2.png new file mode 100644 index 0000000..dd2fc95 Binary files /dev/null and b/src/feedback/public/default_icons/aface2.png differ diff --git a/src/feedback/public/default_icons/aface3.png b/src/feedback/public/default_icons/aface3.png new file mode 100644 index 0000000..73e05cc Binary files /dev/null and b/src/feedback/public/default_icons/aface3.png differ diff --git a/src/feedback/public/default_icons/aface4.png b/src/feedback/public/default_icons/aface4.png new file mode 100644 index 0000000..4d9c73b Binary files /dev/null and b/src/feedback/public/default_icons/aface4.png differ diff --git a/src/feedback/public/default_icons/aface5.png b/src/feedback/public/default_icons/aface5.png new file mode 100644 index 0000000..aaa354f Binary files /dev/null and b/src/feedback/public/default_icons/aface5.png differ diff --git a/src/feedback/public/default_icons/amidface1.png b/src/feedback/public/default_icons/amidface1.png new file mode 100644 index 0000000..4d9c73b Binary files /dev/null and b/src/feedback/public/default_icons/amidface1.png differ diff --git a/src/feedback/public/default_icons/amidface2.png b/src/feedback/public/default_icons/amidface2.png new file mode 100644 index 0000000..73e05cc Binary files /dev/null and b/src/feedback/public/default_icons/amidface2.png differ diff --git a/src/feedback/public/default_icons/amidface3.png b/src/feedback/public/default_icons/amidface3.png new file mode 100644 index 0000000..dd2fc95 Binary files /dev/null and b/src/feedback/public/default_icons/amidface3.png differ diff --git a/src/feedback/public/default_icons/amidface4.png b/src/feedback/public/default_icons/amidface4.png new file mode 100644 index 0000000..73e05cc Binary files /dev/null and b/src/feedback/public/default_icons/amidface4.png differ diff --git a/src/feedback/public/default_icons/amidface5.png b/src/feedback/public/default_icons/amidface5.png new file mode 100644 index 0000000..4d9c73b Binary files /dev/null and b/src/feedback/public/default_icons/amidface5.png differ diff --git a/src/feedback/public/default_icons/anum1.png b/src/feedback/public/default_icons/anum1.png new file mode 100644 index 0000000..ab0ce3f Binary files /dev/null and b/src/feedback/public/default_icons/anum1.png differ diff --git a/src/feedback/public/default_icons/anum2.png b/src/feedback/public/default_icons/anum2.png new file mode 100644 index 0000000..08d7750 Binary files /dev/null and b/src/feedback/public/default_icons/anum2.png differ diff --git a/src/feedback/public/default_icons/anum3.png b/src/feedback/public/default_icons/anum3.png new file mode 100644 index 0000000..dcf1714 Binary files /dev/null and b/src/feedback/public/default_icons/anum3.png differ diff --git a/src/feedback/public/default_icons/anum4.png b/src/feedback/public/default_icons/anum4.png new file mode 100644 index 0000000..c77db0b Binary files /dev/null and b/src/feedback/public/default_icons/anum4.png differ diff --git a/src/feedback/public/default_icons/anum5.png b/src/feedback/public/default_icons/anum5.png new file mode 100644 index 0000000..f65fb67 Binary files /dev/null and b/src/feedback/public/default_icons/anum5.png differ diff --git a/src/feedback/public/default_icons/astar1.png b/src/feedback/public/default_icons/astar1.png new file mode 100644 index 0000000..ff94352 Binary files /dev/null and b/src/feedback/public/default_icons/astar1.png differ diff --git a/src/feedback/public/default_icons/astar2.png b/src/feedback/public/default_icons/astar2.png new file mode 100644 index 0000000..3b65505 Binary files /dev/null and b/src/feedback/public/default_icons/astar2.png differ diff --git a/src/feedback/public/default_icons/astar3.png b/src/feedback/public/default_icons/astar3.png new file mode 100644 index 0000000..6df0232 Binary files /dev/null and b/src/feedback/public/default_icons/astar3.png differ diff --git a/src/feedback/public/default_icons/astar4.png b/src/feedback/public/default_icons/astar4.png new file mode 100644 index 0000000..126f605 Binary files /dev/null and b/src/feedback/public/default_icons/astar4.png differ diff --git a/src/feedback/public/default_icons/astar5.png b/src/feedback/public/default_icons/astar5.png new file mode 100644 index 0000000..1d818f3 Binary files /dev/null and b/src/feedback/public/default_icons/astar5.png differ diff --git a/src/feedback/public/default_icons/iface1.png b/src/feedback/public/default_icons/iface1.png new file mode 100644 index 0000000..11cb5bf Binary files /dev/null and b/src/feedback/public/default_icons/iface1.png differ diff --git a/src/feedback/public/default_icons/iface2.png b/src/feedback/public/default_icons/iface2.png new file mode 100644 index 0000000..78bb27e Binary files /dev/null and b/src/feedback/public/default_icons/iface2.png differ diff --git a/src/feedback/public/default_icons/iface3.png b/src/feedback/public/default_icons/iface3.png new file mode 100644 index 0000000..4ff2a76 Binary files /dev/null and b/src/feedback/public/default_icons/iface3.png differ diff --git a/src/feedback/public/default_icons/iface4.png b/src/feedback/public/default_icons/iface4.png new file mode 100644 index 0000000..1a70456 Binary files /dev/null and b/src/feedback/public/default_icons/iface4.png differ diff --git a/src/feedback/public/default_icons/iface5.png b/src/feedback/public/default_icons/iface5.png new file mode 100644 index 0000000..27eeade Binary files /dev/null and b/src/feedback/public/default_icons/iface5.png differ diff --git a/src/feedback/public/default_icons/imidface1.png b/src/feedback/public/default_icons/imidface1.png new file mode 100644 index 0000000..1a70456 Binary files /dev/null and b/src/feedback/public/default_icons/imidface1.png differ diff --git a/src/feedback/public/default_icons/imidface2.png b/src/feedback/public/default_icons/imidface2.png new file mode 100644 index 0000000..4ff2a76 Binary files /dev/null and b/src/feedback/public/default_icons/imidface2.png differ diff --git a/src/feedback/public/default_icons/imidface3.png b/src/feedback/public/default_icons/imidface3.png new file mode 100644 index 0000000..78bb27e Binary files /dev/null and b/src/feedback/public/default_icons/imidface3.png differ diff --git a/src/feedback/public/default_icons/imidface4.png b/src/feedback/public/default_icons/imidface4.png new file mode 100644 index 0000000..4ff2a76 Binary files /dev/null and b/src/feedback/public/default_icons/imidface4.png differ diff --git a/src/feedback/public/default_icons/imidface5.png b/src/feedback/public/default_icons/imidface5.png new file mode 100644 index 0000000..1a70456 Binary files /dev/null and b/src/feedback/public/default_icons/imidface5.png differ diff --git a/src/feedback/public/default_icons/inum1.png b/src/feedback/public/default_icons/inum1.png new file mode 100644 index 0000000..4ee2a4e Binary files /dev/null and b/src/feedback/public/default_icons/inum1.png differ diff --git a/src/feedback/public/default_icons/inum2.png b/src/feedback/public/default_icons/inum2.png new file mode 100644 index 0000000..f98357b Binary files /dev/null and b/src/feedback/public/default_icons/inum2.png differ diff --git a/src/feedback/public/default_icons/inum3.png b/src/feedback/public/default_icons/inum3.png new file mode 100644 index 0000000..174fd84 Binary files /dev/null and b/src/feedback/public/default_icons/inum3.png differ diff --git a/src/feedback/public/default_icons/inum4.png b/src/feedback/public/default_icons/inum4.png new file mode 100644 index 0000000..80a0411 Binary files /dev/null and b/src/feedback/public/default_icons/inum4.png differ diff --git a/src/feedback/public/default_icons/inum5.png b/src/feedback/public/default_icons/inum5.png new file mode 100644 index 0000000..edef13b Binary files /dev/null and b/src/feedback/public/default_icons/inum5.png differ diff --git a/src/feedback/public/default_icons/istar1.png b/src/feedback/public/default_icons/istar1.png new file mode 100644 index 0000000..d036f94 Binary files /dev/null and b/src/feedback/public/default_icons/istar1.png differ diff --git a/src/feedback/public/default_icons/istar2.png b/src/feedback/public/default_icons/istar2.png new file mode 100644 index 0000000..aa00625 Binary files /dev/null and b/src/feedback/public/default_icons/istar2.png differ diff --git a/src/feedback/public/default_icons/istar3.png b/src/feedback/public/default_icons/istar3.png new file mode 100644 index 0000000..f7d34af Binary files /dev/null and b/src/feedback/public/default_icons/istar3.png differ diff --git a/src/feedback/public/default_icons/istar4.png b/src/feedback/public/default_icons/istar4.png new file mode 100644 index 0000000..bd6eea6 Binary files /dev/null and b/src/feedback/public/default_icons/istar4.png differ diff --git a/src/feedback/public/default_icons/istar5.png b/src/feedback/public/default_icons/istar5.png new file mode 100644 index 0000000..f52d314 Binary files /dev/null and b/src/feedback/public/default_icons/istar5.png differ diff --git a/src/feedback/settings/__init__.py b/src/feedback/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/feedback/settings/common.py b/src/feedback/settings/common.py new file mode 100644 index 0000000..2ca4307 --- /dev/null +++ b/src/feedback/settings/common.py @@ -0,0 +1,21 @@ +""" +Common Django settings for eox_hooks project. +For more information on this file, see +https://docs.djangoproject.com/en/2.22/topics/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.22/ref/settings/ +""" + +from feedback import ROOT_DIRECTORY + +INSTALLED_APPS = [ + "feedback", +] + + +def plugin_settings(settings): + """ + Set of plugin settings used by the Open Edx platform. + More info: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst + """ + settings.MAKO_TEMPLATE_DIRS_BASE.append(ROOT_DIRECTORY / "templates") diff --git a/src/feedback/settings/production.py b/src/feedback/settings/production.py new file mode 100644 index 0000000..b0fccf0 --- /dev/null +++ b/src/feedback/settings/production.py @@ -0,0 +1,14 @@ +""" +Common Django settings for eox_hooks project. +For more information on this file, see +https://docs.djangoproject.com/en/2.22/topics/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.22/ref/settings/ +""" + + +def plugin_settings(settings): # pylint: disable=unused-argument + """ + Set of plugin settings used by the Open Edx platform. + More info: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst + """ diff --git a/src/feedback/settings/test.py b/src/feedback/settings/test.py new file mode 100644 index 0000000..f537396 --- /dev/null +++ b/src/feedback/settings/test.py @@ -0,0 +1,23 @@ +""" +Common Test settings for eox_hooks project. +For more information on this file, see +https://docs.djangoproject.com/en/2.22/topics/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.22/ref/settings/ +""" + +from workbench.settings import * # noqa: F403 # pylint: disable=wildcard-import + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "feedback", + "workbench", +] + +FEATURES = { + "ENABLE_FEEDBACK_INSTRUCTOR_VIEW": True, +} + +SECRET_KEY = "fake-key" diff --git a/src/feedback/static/.DS_Store b/src/feedback/static/.DS_Store new file mode 100644 index 0000000..6b971a7 Binary files /dev/null and b/src/feedback/static/.DS_Store differ diff --git a/src/feedback/static/README.txt b/src/feedback/static/README.txt new file mode 100644 index 0000000..0472ef6 --- /dev/null +++ b/src/feedback/static/README.txt @@ -0,0 +1,19 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) + diff --git a/src/feedback/static/css/feedback.css b/src/feedback/static/css/feedback.css new file mode 100644 index 0000000..9c5981a --- /dev/null +++ b/src/feedback/static/css/feedback.css @@ -0,0 +1,123 @@ +/* CSS for FeedbackXBlock */ + +/* Overall block. We limit width, and put a very faint + border around it. */ +.feedback_block { + display: inline-block; + border: 1px solid rgba(0, 0, 0, .1); + padding: 10px; + max-width: 100%; +} + +/* Little thank you message div after people vote */ +.feedback_thank_you { + color: green; +} + +/* Label for the freeform text input. We want a little + space between this and the Likert input.*/ +.feedback_block .feedback_header_div { + margin-top: 1em; +} + +/* Fieldset for the Likert radio buttons */ +.feedback_block .feedback_likert_field { + padding: 0; + margin: 0; +} + +/* The div around everything with a radio button */ +.feedback_block .feedback_likert_rating { + cursor: pointer; + border-radius: 5px; + display: inline-block; + text-align: center; + padding: 0 10px; +} + +/* Hide checked icon */ +.feedback_icon_active { + display: none; +} + +.feedback_icon_inactive { + display: inline-block; +} + +/* But show it if we are checked... */ +.feedback_block input[type="radio"]:checked ~ .feedback_icon_active { + display: inline-block; +} + +/* ... while hiding the unchecked icon */ +.feedback_block input[type="radio"]:checked ~ .feedback_icon_inactive { + display: none; +} + +.feedback_icon { + border: 1px solid rgba(255, 255, 255, 0); + padding: 1px; + height: 60px; + width: 60px; +} + +.feedback_block input[type="radio"]:focus ~ .feedback_icon, +.feedback_block input[type="radio"]:hover ~ .feedback_icon { + border-color: #999; +} + +.feedback_block .feedback_likert_label { + cursor: pointer; +} + +.feedback_block .feedback_freeform_input { + margin-bottom: 1em; +} + +.feedback_block .feedback_freeform_area { + height: inherit; + width: 100%; +} + +.feedback_block .feedback_rating_active { + color: blue; + font-weight: bold; + background-color: blanchedalmond; +} + +.feedback_block .feedback_radio { + opacity: 0; + width: 1px; + padding: 0; + margin: 0; + position: absolute; +} + +.feedback_block .feedback_sr_text { + opacity: 0; + width: 1px; + height: 1px; + padding: 0; + margin: 0; + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + left: -10000px; + overflow: hidden; +} + +.feedback_block label { + display: inline; +} + +.feedback_block .feedback_likert_field { + border-style: none; +} + +.feedback_block .feedback_submit_feedback { + width: 100%; +} + +.feedback_likert_scale { + display: flex; + flex-flow: row wrap; +} diff --git a/src/feedback/static/css/feedback_instructor.css b/src/feedback/static/css/feedback_instructor.css new file mode 100644 index 0000000..881dc04 --- /dev/null +++ b/src/feedback/static/css/feedback_instructor.css @@ -0,0 +1,9 @@ +.feedback-instructor-wrapper { + width: 100%; + display: flex; + flex-direction: column; +} + +.go-back { + margin: 16px 0px 16px 0px; +} diff --git a/src/feedback/static/html/feedback_instructor.html b/src/feedback/static/html/feedback_instructor.html new file mode 100644 index 0000000..1668e55 --- /dev/null +++ b/src/feedback/static/html/feedback_instructor.html @@ -0,0 +1,52 @@ +{% load i18n %} + +
+
+
+ + + + + + + + + + + {% for block in blocks %} + + + + + + + {% endfor %} + +
+ {% trans "Component" %} + + {% trans "Number of ratings" %} + + {% trans "Average rating" %} + + {% trans "Feedback " %} +
+ + +
    + {% for vote_aggregate in block.vote_aggregate %} +
  • {{ vote_aggregate.scale_text }}: {{ vote_aggregate.count }}
  • + {% endfor %} +
+
{{ block.average_rating }} +
    + {% for feedback in block.answers %} +
  • {% trans "Comment" %}: {{ feedback.user_freeform }}. {% trans "Vote" %}: {% if feedback.user_vote != -1 %} {{ feedback.user_vote }} {% else %} {% trans "No vote" %} {% endif %}
  • + {% endfor %} +
+
+
+
+ + + diff --git a/src/feedback/static/js/src/feedback.js b/src/feedback/static/js/src/feedback.js new file mode 100644 index 0000000..5353d5b --- /dev/null +++ b/src/feedback/static/js/src/feedback.js @@ -0,0 +1,65 @@ +/* Javascript for FeedbackXBlock. */ +// Work-around so we can log in edx-platform, but not fail in Workbench +if (typeof Logger === 'undefined') { + var Logger = { + log: function (a) { + console.log(JSON.stringify(a) + '/' + JSON.stringify(a)); + } + }; +} + +function FeedbackXBlock(runtime, element) { + function getLikedVote() { + if ($('.feedback_radio:checked', element).length === 0) { + return -1; + } + + return parseInt($('.feedback_radio:checked', element).attr('data-id').split('_')[1]); + } + + function getFeedbackMessage() { + return $('.feedback_freeform_area', element).val(); + } + + function updateVoteCount(data) { + if (data.success && data.aggregate && $('.feedback_vote_count', element).length) { + $('.feedback_vote_count', element).each(function (idx) { + $(this).text('(' + data.aggregate[idx] + ')'); + }); + } + } + + function submit_feedback(freeform, vote) { + var feedback = {}; + if (freeform) { + feedback['freeform'] = freeform; + } + if (vote !== -1) { + feedback['vote'] = vote; + } + + Logger.log('edx.feedbackxblock.submitted', feedback); + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(element, 'feedback'), + data: JSON.stringify(feedback), + success: function (data) { + $('.feedback_thank_you', element).text(data.response || ''); + updateVoteCount(data); + } + }); + } + + $('.feedback_submit_feedback', element).click(function () { + submit_feedback(getFeedbackMessage(), -1); + }); + + $('.feedback_radio', element).change(function () { + Logger.log('edx.feedbackxblock.likert_changed', { vote: getLikedVote() }); + submit_feedback(false, getLikedVote()); + }); + + $('.feedback_freeform_area', element).change(function () { + Logger.log('edx.feedbackxblock.freeform_changed', { freeform: getFeedbackMessage() }); + }); +} diff --git a/src/feedback/static/js/src/feedback_instructor.js b/src/feedback/static/js/src/feedback_instructor.js new file mode 100644 index 0000000..7107cb9 --- /dev/null +++ b/src/feedback/static/js/src/feedback_instructor.js @@ -0,0 +1,10 @@ +$(function () { + + const cssUrl = "https://cdn.jsdelivr.net/npm/@edx/paragon@20.45.5/dist/paragon.min.css"; + + $('').attr({ + href: cssUrl, + rel: "stylesheet" + }).appendTo('head'); + +}); diff --git a/src/feedback/static/js/src/studio.js b/src/feedback/static/js/src/studio.js new file mode 100644 index 0000000..56a8f6e --- /dev/null +++ b/src/feedback/static/js/src/studio.js @@ -0,0 +1,35 @@ +function FeedbackBlock(runtime, element, data) { + // When the user asks to save, read the form data and send it via AJAX + $(element).find('.save-button').bind('click', function() { + var handlerUrl = runtime.handlerUrl(element, 'studio_submit'); + + var form_data = { + likert: $(element).find('input[name=likert]').val(), + likert0: $(element).find('input[name=likert0]').val(), + likert1: $(element).find('input[name=likert1]').val(), + likert2: $(element).find('input[name=likert2]').val(), + likert3: $(element).find('input[name=likert3]').val(), + likert4: $(element).find('input[name=likert4]').val(), + freeform: $(element).find('input[name=freeform]').val(), + placeholder: $(element).find('input[name=placeholder]').val(), + display_name: $(element).find('input[name=display_name]').val(), + voting_message: $(element).find('input[name=voting_message]').val(), + feedback_message: $(element).find('input[name=feedback_message]').val(), + show_aggregate_to_students: $(element).find('select[name=show_aggregate_to_students]').val() === 'True', + icon_set: $(element).find('select[name=icon_set]').val() + }; + runtime.notify('save', {state: 'start'}); + $.post(handlerUrl, JSON.stringify(form_data)).done(function(response) { + runtime.notify('save', {state: 'end'}); + }); + }); + + // When the user hits cancel, use Studio's proprietary notify() + // extension + $(element).find('.cancel-button').bind('click', function() { + runtime.notify('cancel', {}); + }); + + // Select the right icon set in the dropdown + $(element).find('select[name=icon_set]').val(data['icon_set']); +} diff --git a/src/feedback/templates/html/feedback.html b/src/feedback/templates/html/feedback.html new file mode 100644 index 0000000..8a3c1fa --- /dev/null +++ b/src/feedback/templates/html/feedback.html @@ -0,0 +1,19 @@ +{% load i18n %} + +
+
+ + + + + +
+
diff --git a/src/feedback/templates/html/scale_item.html b/src/feedback/templates/html/scale_item.html new file mode 100644 index 0000000..aafd399 --- /dev/null +++ b/src/feedback/templates/html/scale_item.html @@ -0,0 +1,19 @@ +{% load i18n %} + +
+ +
diff --git a/src/feedback/templates/html/studio_view.html b/src/feedback/templates/html/studio_view.html new file mode 100644 index 0000000..6249731 --- /dev/null +++ b/src/feedback/templates/html/studio_view.html @@ -0,0 +1,130 @@ +{% load i18n %} + +
+ + +
+ +
+
diff --git a/src/feedback/templates/instructor_dashboard/feedback_instructor.html b/src/feedback/templates/instructor_dashboard/feedback_instructor.html new file mode 100644 index 0000000..c060c55 --- /dev/null +++ b/src/feedback/templates/instructor_dashboard/feedback_instructor.html @@ -0,0 +1,8 @@ +<%page args="section_data" expression_filter="h"/> +<%! from openedx.core.djangolib.markup import HTML %> + +<%include file="/courseware/xqa_interface.html/"/> + +
+ ${HTML(section_data['fragment'].body_html())} +
diff --git a/src/feedback/utils.py b/src/feedback/utils.py new file mode 100644 index 0000000..6b01fe2 --- /dev/null +++ b/src/feedback/utils.py @@ -0,0 +1,6 @@ +"""Utilities for feedback app""" + + +def _(text): + """Dummy `gettext` replacement to make string extraction tools scrape strings marked for translation""" + return text