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..f63bf67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ keywords = [ dependencies = [ "XBlock", "Django>=4.2", + "six" ] [project.urls] @@ -46,6 +47,10 @@ test = [ "pytest>=7.0", "pytest-cov", "pytest-django", + "coverage", + "edx-lint", + "edx-opaque-keys", + "mock", ] docs = [ "sphinx", @@ -54,6 +59,8 @@ docs = [ [project.entry-points."xblock.v1"] audio = "audio:AudioXBlock" +imagemodal = "imagemodal.xblocks:ImageModal" +submit-and-compare = "submit_and_compare.xblocks:SubmitAndCompareXBlock" # 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/imagemodal/README.rst b/src/imagemodal/README.rst new file mode 100644 index 0000000..28af6d9 --- /dev/null +++ b/src/imagemodal/README.rst @@ -0,0 +1,126 @@ +Image Modal XBlock +================== + +A full-screen image modal XBlock, +for use within the Open edX platform. + +|badge-ci| + +The full-screen image tool is another way of enabling participants to +see more detail in your provided images. This tool is useful for large +images with lots of details. A re-sized version of the image displays in +the page, but clicking on the image pops open a full-screen modal with +the full-size version of the image. + +|image-lms-view-normal| + + +Installation +------------ + + +System Administrator +~~~~~~~~~~~~~~~~~~~~ + +To install the XBlock on your platform, +add the following to your `requirements.txt` file: + + xblock-image-modal + +You'll also need to add this to your `INSTALLED_APPS`: + + imagemodal + + +Course Staff +~~~~~~~~~~~~ + +To install the XBlock in your course, +access your `Advanced Module List`: + + Settings -> Advanced Settings -> Advanced Module List + +|image-cms-settings-menu| + +and add the following: + + imagemodal + +|image-cms-advanced-module-list| + + +Use +--- + + +Course Staff +~~~~~~~~~~~~ + +To add a full-screen image to your course: + +- upload the image file onto your course's Files & Uploads page + + - note: you can skip this step if you've already uploaded the image + elsewhere, e.g.: S3. + +- copy the URL on that page +- go to a unit in Studio +- select "Image Modal XBlock" from the Advanced Components menu + +|image-cms-add| + +You can now edit and preview the new component. + +|image-cms-view| + +Using the Studio editor, you can edit the following fields: + +- display name +- image URL +- thumbnail URL (defaults to image URL, if not specified) +- description (useful for screen readers, longer descriptions) +- alt text (useful for screen readers, captions, tags; displays when image does not) + +|image-cms-editor-1| +|image-cms-editor-2| + + +Participants +~~~~~~~~~~~~ + +|image-lms-view-normal| + +Click on the image to zoom in full-screen. + +|image-lms-view-zoom| + +Click on the image again to zoom out. + +Click and drag to pan around. + +`View a demo of the CMS`_ + +`View a demo of the LMS`_ + + +.. |badge-ci| image:: https://github.com/openedx/xblock-image-modal/workflows/Python%20CI/badge.svg?branch=master + :target: https://github.com/openedx/xblock-image-modal/actions?query=workflow%3A%22Python+CI%22 +.. |image-cms-add| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/cms-add.jpg + :width: 100% +.. |image-cms-advanced-module-list| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/advanced-module-list.png + :width: 100% +.. |image-cms-editor-1| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-editor-1.png + :width: 100% +.. |image-cms-editor-2| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-editor-2.png + :width: 100% +.. |image-cms-settings-menu| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/settings-menu.png + :width: 100% +.. |image-cms-view| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-view.png + :width: 100% +.. |image-lms-view-normal| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/lms-view-normal.png + :width: 100% +.. |image-lms-view-zoom| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/lms-view-zoom.png + :width: 100% +.. _View a demo of the CMS: https://youtu.be/IcbGYfbav2w +.. _View a demo of the LMS: https://youtu.be/0mpjuThDoyE +.. https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/xblock-image-modal-demo-lms.mov diff --git a/src/imagemodal/__init__.py b/src/imagemodal/__init__.py new file mode 100644 index 0000000..8c398d9 --- /dev/null +++ b/src/imagemodal/__init__.py @@ -0,0 +1,5 @@ +""" +A fullscreen, zooming image modal XBlock +""" + +from .xblocks import ImageModal as ImageModal diff --git a/src/imagemodal/conf/locale/config.yaml b/src/imagemodal/conf/locale/config.yaml new file mode 100644 index 0000000..a968d94 --- /dev/null +++ b/src/imagemodal/conf/locale/config.yaml @@ -0,0 +1,4 @@ +# Configuration for i18n workflow. + +locales: + - en # English - Source Language diff --git a/src/imagemodal/conf/locale/en/LC_MESSAGES/django.po b/src/imagemodal/conf/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..38ceb7b --- /dev/null +++ b/src/imagemodal/conf/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,62 @@ +# Stanford's Image Modal XBlock. +# Copyright (C) 2019 +# This file is distributed under the same license as the package. +# Steven Burch , 2019. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-03-09 18:45-0600\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Steven Burch \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: imagemodal/imagemodal.py:35 +msgid "Display Name" +msgstr "" + +#: imagemodal/imagemodal.py:38 +msgid "This is the XBlock's display name" +msgstr "" + +#: imagemodal/imagemodal.py:42 +msgid "Image URL" +msgstr "" + +#: imagemodal/imagemodal.py:51 +msgid "This is the location of the full-screen image to be displayed." +msgstr "" + +#: imagemodal/imagemodal.py:56 +msgid "Thumbnail URL" +msgstr "" + +#: imagemodal/imagemodal.py:60 +msgid "" +"This is the (optional) location of a thumbnail image to be displayed before " +"the main image has been enlarged." +msgstr "" + +#: imagemodal/imagemodal.py:66 +msgid "Description" +msgstr "" + +#: imagemodal/imagemodal.py:69 +msgid "Description text, displayed to screen readers" +msgstr "" + +#: imagemodal/imagemodal.py:74 +msgid "Alt Text" +msgstr "" + +#: imagemodal/imagemodal.py:78 +msgid "" +"This field allows you to add alternate or descriptive text that pertains to " +"your image." +msgstr "" diff --git a/src/imagemodal/conf/locale/en/LC_MESSAGES/text.po b/src/imagemodal/conf/locale/en/LC_MESSAGES/text.po new file mode 120000 index 0000000..0082074 --- /dev/null +++ b/src/imagemodal/conf/locale/en/LC_MESSAGES/text.po @@ -0,0 +1 @@ +django.po \ No newline at end of file diff --git a/src/imagemodal/mixins/__init__.py b/src/imagemodal/mixins/__init__.py new file mode 100644 index 0000000..ccb716c --- /dev/null +++ b/src/imagemodal/mixins/__init__.py @@ -0,0 +1,3 @@ +""" +Mixin behavior to XBlocks +""" diff --git a/src/imagemodal/mixins/fragment.py b/src/imagemodal/mixins/fragment.py new file mode 100644 index 0000000..96e8eb5 --- /dev/null +++ b/src/imagemodal/mixins/fragment.py @@ -0,0 +1,89 @@ +""" +Mixin fragment/html behavior into XBlocks + +Note: We should resume test coverage for all lines in this file once +split into its own library. +""" + +from django.template.context import Context +from xblock.core import XBlock + +try: + from web_fragments.fragment import Fragment +except Exception: + from xblock.fragment import Fragment # For backward compatibility with quince and earlier. + + +class XBlockFragmentBuilderMixin: + """ + Create a default XBlock fragment builder + """ + + static_css = [ + "view.css", + ] + static_js = [ + "view.js", + ] + static_js_init = None + template = "view.html" + + def provide_context(self, context): # pragma: no cover + """ + Build a context dictionary to render the student view + + This should generally be overriden by child classes. + """ + context = context or {} + context = dict(context) + return context + + @XBlock.supports("multi_device") + def student_view(self, context=None): + """ + Build the fragment for the default student view + """ + template = self.template + context = self.provide_context(context) + static_css = self.static_css or [] + static_js = self.static_js or [] + js_init = self.static_js_init + fragment = self.build_fragment( + template=template, + context=context, + css=static_css, + js=static_js, + js_init=js_init, + ) + return fragment + + def build_fragment(self, *, template="", context=None, css=None, js=None, js_init=None): + """ + Creates a fragment for display. + """ + context = context or {} + css = css or [] + js = js or [] + rendered_template = "" + if template: # pragma: no cover + template = "templates/" + template + rendered_template = self.loader.render_django_template( + template, + context=Context(context), + i18n_service=self.runtime.service(self, "i18n"), + ) + fragment = Fragment(rendered_template) + for item in css: + if item.startswith("/"): + url = item + else: + item = "public/" + item + url = self.runtime.local_resource_url(self, item) + fragment.add_css_url(url) + for item in js: + item = "public/" + item + url = self.runtime.local_resource_url(self, item) + fragment.add_javascript_url(url) + if js_init: # pragma: no cover + fragment.initialize_js(js_init) + return fragment diff --git a/src/imagemodal/mixins/scenario.py b/src/imagemodal/mixins/scenario.py new file mode 100644 index 0000000..3b20c7b --- /dev/null +++ b/src/imagemodal/mixins/scenario.py @@ -0,0 +1,24 @@ +""" +Mixin workbench behavior into XBlocks +""" + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: + from xblockutils.resources import ResourceLoader + + +loader = ResourceLoader(__name__) + + +class XBlockWorkbenchMixin: + """ + Provide a default test workbench for the XBlock + """ + + @classmethod + def workbench_scenarios(cls): + """ + Gather scenarios to be displayed in the workbench + """ + return loader.load_scenarios_from_path("../scenarios") diff --git a/src/imagemodal/models.py b/src/imagemodal/models.py new file mode 100644 index 0000000..9f3e776 --- /dev/null +++ b/src/imagemodal/models.py @@ -0,0 +1,66 @@ +""" +Handle data access logic for the XBlock +""" + +from django.utils.translation import gettext_lazy as _ +from xblock.fields import Scope, String + + +class ImageModalModelMixin: + """ + Handle data access for Image Modal XBlock instances + """ + + editable_fields = [ + "display_name", + "image_url", + "thumbnail_url", + "description", + "alt_text", + ] + + show_in_read_only_mode = True + + display_name = String( + display_name=_("Display Name"), + default="Image Modal XBlock", + scope=Scope.settings, + help=_("This is the XBlock's display name"), + ) + + image_url = String( + display_name=_("Image URL"), + default=( + "http://upload.wikimedia.org/" + "wikipedia/commons/4/48/" + "1853_Kaei_6_Japanese_Map_of_the_World_-_" + "Geographicus_-_ChikyuBankokuHozu-nakajima-1853.jpg" + ), + scope=Scope.settings, + help=_("This is the location of the full-screen image to be displayed."), + ) + + thumbnail_url = String( + display_name=_("Thumbnail URL"), + default="", + scope=Scope.settings, + help=_( + "This is the (optional) location of a thumbnail image to be " + "displayed before the main image has been enlarged." + ), + ) + + description = String( + display_name=_("Description"), + default="", + scope=Scope.settings, + help=_("Description text, displayed to screen readers"), + multiline_editor=True, + ) + + alt_text = String( + display_name=_("Alt Text"), + default="", + scope=Scope.settings, + help=_("This field allows you to add alternate or descriptive text that pertains to your image."), + ) diff --git a/src/imagemodal/public/draggabilly.pkgd.min.js b/src/imagemodal/public/draggabilly.pkgd.min.js new file mode 100644 index 0000000..11861d9 --- /dev/null +++ b/src/imagemodal/public/draggabilly.pkgd.min.js @@ -0,0 +1,8 @@ +/*! + * Draggabilly PACKAGED v1.2.4 + * Make that shiz draggable + * http://draggabilly.desandro.com + * MIT license + */ + +!function(t){function e(){}function n(t){function n(e){e.prototype.option||(e.prototype.option=function(e){t.isPlainObject(e)&&(this.options=t.extend(!0,this.options,e))})}function o(e,n){t.fn[e]=function(o){if("string"==typeof o){for(var s=i.call(arguments,1),a=0,p=this.length;p>a;a++){var u=this[a],d=t.data(u,e);if(d)if(t.isFunction(d[o])&&"_"!==o.charAt(0)){var c=d[o].apply(d,s);if(void 0!==c)return c}else r("no such method '"+o+"' for "+e+" instance");else r("cannot call methods on "+e+" prior to initialization; attempted to call '"+o+"'")}return this}return this.each(function(){var i=t.data(this,e);i?(i.option(o),i._init()):(i=new n(this,o),t.data(this,e,i))})}}if(t){var r="undefined"==typeof console?e:function(t){console.error(t)};return t.bridget=function(t,e){n(e),o(t,e)},t.bridget}}var i=Array.prototype.slice;"function"==typeof define&&define.amd?define("jquery-bridget/jquery.bridget",["jquery"],n):n("object"==typeof exports?require("jquery"):t.jQuery)}(window),function(t){function e(t){return new RegExp("(^|\\s+)"+t+"(\\s+|$)")}function n(t,e){var n=i(t,e)?r:o;n(t,e)}var i,o,r;"classList"in document.documentElement?(i=function(t,e){return t.classList.contains(e)},o=function(t,e){t.classList.add(e)},r=function(t,e){t.classList.remove(e)}):(i=function(t,n){return e(n).test(t.className)},o=function(t,e){i(t,e)||(t.className=t.className+" "+e)},r=function(t,n){t.className=t.className.replace(e(n)," ")});var s={hasClass:i,addClass:o,removeClass:r,toggleClass:n,has:i,add:o,remove:r,toggle:n};"function"==typeof define&&define.amd?define("classie/classie",s):"object"==typeof exports?module.exports=s:t.classie=s}(window),function(t){function e(t){if(t){if("string"==typeof i[t])return t;t=t.charAt(0).toUpperCase()+t.slice(1);for(var e,o=0,r=n.length;r>o;o++)if(e=n[o]+t,"string"==typeof i[e])return e}}var n="Webkit Moz ms Ms O".split(" "),i=document.documentElement.style;"function"==typeof define&&define.amd?define("get-style-property/get-style-property",[],function(){return e}):"object"==typeof exports?module.exports=e:t.getStyleProperty=e}(window),function(t){function e(t){var e=parseFloat(t),n=-1===t.indexOf("%")&&!isNaN(e);return n&&e}function n(){}function i(){for(var t={width:0,height:0,innerWidth:0,innerHeight:0,outerWidth:0,outerHeight:0},e=0,n=s.length;n>e;e++){var i=s[e];t[i]=0}return t}function o(n){function o(){if(!h){h=!0;var i=t.getComputedStyle;if(u=function(){var t=i?function(t){return i(t,null)}:function(t){return t.currentStyle};return function(e){var n=t(e);return n||r("Style returned "+n+". Are you running this code in a hidden iframe on Firefox? See http://bit.ly/getsizebug1"),n}}(),d=n("boxSizing")){var o=document.createElement("div");o.style.width="200px",o.style.padding="1px 2px 3px 4px",o.style.borderStyle="solid",o.style.borderWidth="1px 2px 3px 4px",o.style[d]="border-box";var s=document.body||document.documentElement;s.appendChild(o);var a=u(o);c=200===e(a.width),s.removeChild(o)}}}function a(t){if(o(),"string"==typeof t&&(t=document.querySelector(t)),t&&"object"==typeof t&&t.nodeType){var n=u(t);if("none"===n.display)return i();var r={};r.width=t.offsetWidth,r.height=t.offsetHeight;for(var a=r.isBorderBox=!(!d||!n[d]||"border-box"!==n[d]),h=0,f=s.length;f>h;h++){var l=s[h],g=n[l];g=p(t,g);var v=parseFloat(g);r[l]=isNaN(v)?0:v}var y=r.paddingLeft+r.paddingRight,m=r.paddingTop+r.paddingBottom,E=r.marginLeft+r.marginRight,b=r.marginTop+r.marginBottom,P=r.borderLeftWidth+r.borderRightWidth,x=r.borderTopWidth+r.borderBottomWidth,_=a&&c,w=e(n.width);w!==!1&&(r.width=w+(_?0:y+P));var S=e(n.height);return S!==!1&&(r.height=S+(_?0:m+x)),r.innerWidth=r.width-(y+P),r.innerHeight=r.height-(m+x),r.outerWidth=r.width+E,r.outerHeight=r.height+b,r}}function p(e,n){if(t.getComputedStyle||-1===n.indexOf("%"))return n;var i=e.style,o=i.left,r=e.runtimeStyle,s=r&&r.left;return s&&(r.left=e.currentStyle.left),i.left=n,n=i.pixelLeft,i.left=o,s&&(r.left=s),n}var u,d,c,h=!1;return a}var r="undefined"==typeof console?n:function(t){console.error(t)},s=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"];"function"==typeof define&&define.amd?define("get-size/get-size",["get-style-property/get-style-property"],o):"object"==typeof exports?module.exports=o(require("desandro-get-style-property")):t.getSize=o(t.getStyleProperty)}(window),function(t){function e(e){var n=t.event;return n.target=n.target||n.srcElement||e,n}var n=document.documentElement,i=function(){};n.addEventListener?i=function(t,e,n){t.addEventListener(e,n,!1)}:n.attachEvent&&(i=function(t,n,i){t[n+i]=i.handleEvent?function(){var n=e(t);i.handleEvent.call(i,n)}:function(){var n=e(t);i.call(t,n)},t.attachEvent("on"+n,t[n+i])});var o=function(){};n.removeEventListener?o=function(t,e,n){t.removeEventListener(e,n,!1)}:n.detachEvent&&(o=function(t,e,n){t.detachEvent("on"+e,t[e+n]);try{delete t[e+n]}catch(i){t[e+n]=void 0}});var r={bind:i,unbind:o};"function"==typeof define&&define.amd?define("eventie/eventie",r):"object"==typeof exports?module.exports=r:t.eventie=r}(window),function(){function t(){}function e(t,e){for(var n=t.length;n--;)if(t[n].listener===e)return n;return-1}function n(t){return function(){return this[t].apply(this,arguments)}}var i=t.prototype,o=this,r=o.EventEmitter;i.getListeners=function(t){var e,n,i=this._getEvents();if(t instanceof RegExp){e={};for(n in i)i.hasOwnProperty(n)&&t.test(n)&&(e[n]=i[n])}else e=i[t]||(i[t]=[]);return e},i.flattenListeners=function(t){var e,n=[];for(e=0;ee;e++){var i=t[e];if(i.identifier==this.pointerIdentifier)return i}},o.prototype.onmousedown=function(t){var e=t.button;e&&0!==e&&1!==e||this._pointerDown(t,t)},o.prototype.ontouchstart=function(t){this._pointerDown(t,t.changedTouches[0])},o.prototype.onMSPointerDown=o.prototype.onpointerdown=function(t){this._pointerDown(t,t)},o.prototype._pointerDown=function(t,e){this.isPointerDown||(this.isPointerDown=!0,this.pointerIdentifier=void 0!==e.pointerId?e.pointerId:e.identifier,this.pointerDown(t,e))},o.prototype.pointerDown=function(t,e){this._bindPostStartEvents(t),this.emitEvent("pointerDown",[t,e])};var r={mousedown:["mousemove","mouseup"],touchstart:["touchmove","touchend","touchcancel"],pointerdown:["pointermove","pointerup","pointercancel"],MSPointerDown:["MSPointerMove","MSPointerUp","MSPointerCancel"]};return o.prototype._bindPostStartEvents=function(e){if(e){for(var i=r[e.type],o=e.preventDefault?t:document,s=0,a=i.length;a>s;s++){var p=i[s];n.bind(o,p,this)}this._boundPointerEvents={events:i,node:o}}},o.prototype._unbindPostStartEvents=function(){var t=this._boundPointerEvents;if(t&&t.events){for(var e=0,i=t.events.length;i>e;e++){var o=t.events[e];n.unbind(t.node,o,this)}delete this._boundPointerEvents}},o.prototype.onmousemove=function(t){this._pointerMove(t,t)},o.prototype.onMSPointerMove=o.prototype.onpointermove=function(t){t.pointerId==this.pointerIdentifier&&this._pointerMove(t,t)},o.prototype.ontouchmove=function(t){var e=this.getTouch(t.changedTouches);e&&this._pointerMove(t,e)},o.prototype._pointerMove=function(t,e){this.pointerMove(t,e)},o.prototype.pointerMove=function(t,e){this.emitEvent("pointerMove",[t,e])},o.prototype.onmouseup=function(t){this._pointerUp(t,t)},o.prototype.onMSPointerUp=o.prototype.onpointerup=function(t){t.pointerId==this.pointerIdentifier&&this._pointerUp(t,t)},o.prototype.ontouchend=function(t){var e=this.getTouch(t.changedTouches);e&&this._pointerUp(t,e)},o.prototype._pointerUp=function(t,e){this._pointerDone(),this.pointerUp(t,e)},o.prototype.pointerUp=function(t,e){this.emitEvent("pointerUp",[t,e])},o.prototype._pointerDone=function(){this.isPointerDown=!1,delete this.pointerIdentifier,this._unbindPostStartEvents(),this.pointerDone()},o.prototype.pointerDone=i,o.prototype.onMSPointerCancel=o.prototype.onpointercancel=function(t){t.pointerId==this.pointerIdentifier&&this._pointerCancel(t,t)},o.prototype.ontouchcancel=function(t){var e=this.getTouch(t.changedTouches);e&&this._pointerCancel(t,e)},o.prototype._pointerCancel=function(t,e){this._pointerDone(),this.pointerCancel(t,e)},o.prototype.pointerCancel=function(t,e){this.emitEvent("pointerCancel",[t,e])},o.getPointerPoint=function(t){return{x:void 0!==t.pageX?t.pageX:t.clientX,y:void 0!==t.pageY?t.pageY:t.clientY}},o}),function(t,e){"function"==typeof define&&define.amd?define("unidragger/unidragger",["eventie/eventie","unipointer/unipointer"],function(n,i){return e(t,n,i)}):"object"==typeof exports?module.exports=e(t,require("eventie"),require("unipointer")):t.Unidragger=e(t,t.eventie,t.Unipointer)}(window,function(t,e,n){function i(){}function o(t){t.preventDefault?t.preventDefault():t.returnValue=!1}function r(t){for(;t!=document.body;)if(t=t.parentNode,"A"==t.nodeName)return t}function s(){}function a(){return!1}s.prototype=new n,s.prototype.bindHandles=function(){this._bindHandles(!0)},s.prototype.unbindHandles=function(){this._bindHandles(!1)};var p=t.navigator;s.prototype._bindHandles=function(t){t=void 0===t?!0:!!t;var n;n=p.pointerEnabled?function(e){e.style.touchAction=t?"none":""}:p.msPointerEnabled?function(e){e.style.msTouchAction=t?"none":""}:function(){t&&d(s)};for(var i=t?"bind":"unbind",o=0,r=this.handles.length;r>o;o++){var s=this.handles[o];this._bindStartEvent(s,t),n(s),e[i](s,"click",this)}};var u="attachEvent"in document.documentElement,d=u?function(t){"IMG"==t.nodeName&&(t.ondragstart=a);for(var e=t.querySelectorAll("img"),n=0,i=e.length;i>n;n++){var o=e[n];o.ondragstart=a}}:i,c=s.allowTouchstartNodes={INPUT:!0,A:!0,BUTTON:!0,SELECT:!0};return s.prototype.pointerDown=function(t,e){this._dragPointerDown(t,e);var n=document.activeElement;n&&n.blur&&n.blur(),this._bindPostStartEvents(t),this.emitEvent("pointerDown",[t,e])},s.prototype._dragPointerDown=function(t,e){this.pointerDownPoint=n.getPointerPoint(e);var i=t.target.nodeName,s="touchstart"==t.type&&(c[i]||r(t.target));s||"SELECT"==i||o(t)},s.prototype.pointerMove=function(t,e){var n=this._dragPointerMove(t,e);this.emitEvent("pointerMove",[t,e,n]),this._dragMove(t,e,n)},s.prototype._dragPointerMove=function(t,e){var i=n.getPointerPoint(e),o={x:i.x-this.pointerDownPoint.x,y:i.y-this.pointerDownPoint.y};return!this.isDragging&&this.hasDragStarted(o)&&this._dragStart(t,e),o},s.prototype.hasDragStarted=function(t){return Math.abs(t.x)>3||Math.abs(t.y)>3},s.prototype.pointerUp=function(t,e){this.emitEvent("pointerUp",[t,e]),this._dragPointerUp(t,e)},s.prototype._dragPointerUp=function(t,e){this.isDragging?this._dragEnd(t,e):this._staticClick(t,e)},s.prototype._dragStart=function(t,e){this.isDragging=!0,this.dragStartPoint=s.getPointerPoint(e),this.isPreventingClicks=!0,this.dragStart(t,e)},s.prototype.dragStart=function(t,e){this.emitEvent("dragStart",[t,e])},s.prototype._dragMove=function(t,e,n){this.isDragging&&this.dragMove(t,e,n)},s.prototype.dragMove=function(t,e,n){this.emitEvent("dragMove",[t,e,n])},s.prototype._dragEnd=function(t,e){this.isDragging=!1;var n=this;setTimeout(function(){delete n.isPreventingClicks}),this.dragEnd(t,e)},s.prototype.dragEnd=function(t,e){this.emitEvent("dragEnd",[t,e])},s.prototype.onclick=function(t){this.isPreventingClicks&&o(t)},s.prototype._staticClick=function(t,e){"INPUT"==t.target.nodeName&&"text"==t.target.type&&t.target.focus(),this.staticClick(t,e)},s.prototype.staticClick=function(t,e){this.emitEvent("staticClick",[t,e])},s.getPointerPoint=function(t){return{x:void 0!==t.pageX?t.pageX:t.clientX,y:void 0!==t.pageY?t.pageY:t.clientY}},s.getPointerPoint=n.getPointerPoint,s}),function(t,e){"function"==typeof define&&define.amd?define(["classie/classie","get-style-property/get-style-property","get-size/get-size","unidragger/unidragger"],function(n,i,o,r){return e(t,n,i,o,r)}):"object"==typeof exports?module.exports=e(t,require("desandro-classie"),require("desandro-get-style-property"),require("get-size"),require("unidragger")):t.Draggabilly=e(t,t.classie,t.getStyleProperty,t.getSize,t.Unidragger)}(window,function(t,e,n,i,o){function r(){}function s(t,e){for(var n in e)t[n]=e[n];return t}function a(t,e){this.element="string"==typeof t?d.querySelector(t):t,P&&(this.$element=P(this.element)),this.options=s({},this.constructor.defaults),this.option(e),this._create()}function p(t,e,n){return n=n||"round",e?Math[n](t/e)*e:t}for(var u,d=t.document,c=d.defaultView,h=c&&c.getComputedStyle?function(t){return c.getComputedStyle(t,null)}:function(t){return t.currentStyle},f="object"==typeof HTMLElement?function(t){return t instanceof HTMLElement}:function(t){return t&&"object"==typeof t&&1==t.nodeType&&"string"==typeof t.nodeName},l=0,g="webkit moz ms o".split(" "),v=t.requestAnimationFrame,y=t.cancelAnimationFrame,m=0;m 0 && maskWidth > 0) { + image.parent().css({ + left: -maskLeft, + top: -maskTop, + width: maskWidth, + height: maskHeight, + }); + image.css({ + top: maskTop / 2, + left: maskLeft / 2, + }); + draggie.enable(); + } else { + draggie.enable(); + } + } + + /** + * Close the image modal + * @returns {boolean} False to stop event bubbling + */ + function closeModal() { + body.css('overflow', ''); + curtain.hide(); + body.off('.imagemodal'); + buttonZoom.off('.imagemodal'); + curtain.off('.imagemodal'); + image.off('.imagemodal'); + return false; + } + + /** + * Zoom out from the image + * @returns {undefined} nothing + */ + function zoomOut() { + buttonZoomText.text('Zoom In'); + buttonZoomIcon.removeClass('icon-zoom-out'); + buttonZoomIcon.addClass('icon-zoom-in'); + image.off('.imagemodal'); + // eslint-disable-next-line no-use-before-define + image.on('click.imagemodal_zoomout', openModal); + image.removeClass('zoomed'); + image.parent().css({ + left: 0, + top: 0, + width: '100%', + height: '100%', + }); + image.css({ + left: 0, + top: 0, + }); + if (draggie) { + draggie.disable(); + draggie = null; + } + } + + /** + * Toggle the zoom state in and out + * @returns {boolean} False to stop event bubbling + */ + function toggleZoom() { + var isZoomed = image.hasClass('zoomed'); + if (isZoomed) { + zoomOut(); + } else { + zoomIn(); + } + return false; + } + + /** + * Open the image modal div + * @returns {boolean} False to stop event bubbling + */ + function openModal() { + curtain.show(); + body.css('overflow', 'hidden'); + body.on('keyup.imagemodal', function(event) { + if (event.which === KEY_ESCAPE) { + return closeModal(); + } + if (event.which === KEY_ENTER) { + return toggleZoom(); + } + return true; + }); + buttonZoom.on('click.imagemodal', toggleZoom); + curtain.on('click.imagemodal', closeModal); + image.on('click.imagemodal', toggleZoom); + return false; + } + + closeModal(); + if ($element.attr('data-runtime-class') === 'PreviewRuntime') { + anchor.on('click.imagemodal', preventDefault); + } else { + anchor.on('click.imagemodal', openModal); + buttonFullScreen.on('click.imagemodal', openModal); + } +} diff --git a/src/imagemodal/public/view.less b/src/imagemodal/public/view.less new file mode 100644 index 0000000..a6f5f01 --- /dev/null +++ b/src/imagemodal/public/view.less @@ -0,0 +1,110 @@ +.imagemodal_block { + @dark: #2e2d29; + @light: #fbfbf9; + + P { + cursor: pointer; + } + + IMG { + max-width: 100%; + } + + A, + BUTTON { + cursor: pointer; + } + + BUTTON { + border: 2px solid @dark; + background: @light; + color: @dark; + border-radius: 5px; + cursor: pointer; + opacity: 0.9; + padding: 5px 7px 7px; + font-weight: bold; + font-size: 1em; + + I { + // Override Studio's italic style + font-style: normal; + } + } + + .close { + position: absolute; + top: 10px; + right: 10px; + } + + .zoom { + position: absolute; + bottom: 10px; + right: 10px; + } + + .fullscreen { + position: absolute; + top: 10px; + left: 10px; + } + + .wrapper { + position: relative; + } + + .count { + font-weight: bold; + } + + .curtain { + display: none; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: #000; + background-color: rgba(0, 0, 0, 0.7); + cursor: no-drop; + + /* + Override LMS's sequence-nav (z-index: 99;) + and modal-backdrop (z-index: 1000;) + */ + z-index: 1001; + + .mask { + height: 95%; + width: 95%; + margin: auto; + overflow: hidden; + position: relative; + top: 2.5%; + + .wrapper { + height: 100%; + width: 100%; + top: 0; + left: 0; + position: relative; + + IMG { + display: block; + margin: auto; + max-height: 100%; + max-width: 100%; + position: relative; + cursor: zoom-in; + } + + .zoomed { + max-height: none; + max-width: none; + cursor: move; + } + } + } + } +} diff --git a/src/imagemodal/scenarios/image-modal-many.xml b/src/imagemodal/scenarios/image-modal-many.xml new file mode 100644 index 0000000..fc9e649 --- /dev/null +++ b/src/imagemodal/scenarios/image-modal-many.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/src/imagemodal/scenarios/image-modal-single.xml b/src/imagemodal/scenarios/image-modal-single.xml new file mode 100644 index 0000000..93ab147 --- /dev/null +++ b/src/imagemodal/scenarios/image-modal-single.xml @@ -0,0 +1,7 @@ + + + diff --git a/src/imagemodal/settings.py b/src/imagemodal/settings.py new file mode 100644 index 0000000..162fc51 --- /dev/null +++ b/src/imagemodal/settings.py @@ -0,0 +1,16 @@ +""" +Stub settings for xblock +""" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + # 'NAME': 'intentionally-omitted', + }, +} +INSTALLED_APPS = ("imagemodal",) +LOCALE_PATHS = [ + "imagemodal/translations", +] +SECRET_KEY = "SECRET_KEY" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/src/imagemodal/templates/view.html b/src/imagemodal/templates/view.html new file mode 100644 index 0000000..a926876 --- /dev/null +++ b/src/imagemodal/templates/view.html @@ -0,0 +1,34 @@ +{% load i18n %} + +
+

{{display_name}}

+
+ + {{alt_text}} + + +
+
+
+
+ {{alt_text}} +
+ + +
+
+

{{description}}

+
diff --git a/src/imagemodal/tests/__init__.py b/src/imagemodal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/imagemodal/tests/test_display.py b/src/imagemodal/tests/test_display.py new file mode 100644 index 0000000..f29195e --- /dev/null +++ b/src/imagemodal/tests/test_display.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +""" +Test basic XBlock display function +""" + +import unittest +from unittest.mock import Mock + +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from xblock.field_data import DictFieldData + +from imagemodal import ImageModal + + +def make_an_xblock(**kwargs): + """ + Helper method that creates a Free-text Response XBlock + """ + course_id = SlashSeparatedCourseKey("foo", "bar", "baz") + runtime = Mock( + course_id=course_id, + service=Mock( + # Is there a cleaner mock to the `i18n` service? + return_value=Mock(_catalog={}), + ), + ) + scope_ids = Mock() + field_data = DictFieldData(kwargs) + xblock = ImageModal(runtime, field_data, scope_ids) + xblock.xmodule_runtime = runtime + return xblock + + +class TestRender(unittest.TestCase): + """ + Test the HTML rendering of the XBlock + """ + + def setUp(self): + super().setUp() + self.xblock = make_an_xblock() + + def test_render(self): + student_view = self.xblock.student_view() + html = student_view.content + self.assertIsNotNone(html) + self.assertNotEqual("", html) + self.assertIn("imagemodal_block", html) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/imagemodal/tests/test_workbench.py b/src/imagemodal/tests/test_workbench.py new file mode 100755 index 0000000..1e80aa1 --- /dev/null +++ b/src/imagemodal/tests/test_workbench.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +Test XBlock workbench integration +""" + +import unittest + +from imagemodal import ImageModal + + +class TestWorkbench(unittest.TestCase): + """ + Test XBlock workbench integration + """ + + def setUp(self): + super().setUp() + self.scenarios = ImageModal.workbench_scenarios() + + def _is_in_any_scenario(self, text): + """ + Check if the text exists in any scenario + """ + contains = any(True for scenario in self.scenarios if text in scenario[1]) + return contains + + def test_load(self): + self.assertGreater(len(self.scenarios), 0) + + def test_has_sequence(self): + """ + Make sure at least one scenario contains a sequence + """ + has_sequence = self._is_in_any_scenario("sequence_demo") + self.assertTrue(has_sequence) + + def test_has_vertical(self): + """ + Make sure at least one scenario contains a vertical + """ + has_sequence = self._is_in_any_scenario("vertical_demo") + self.assertTrue(has_sequence) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/imagemodal/translations b/src/imagemodal/translations new file mode 120000 index 0000000..618b7e2 --- /dev/null +++ b/src/imagemodal/translations @@ -0,0 +1 @@ +conf/locale \ No newline at end of file diff --git a/src/imagemodal/views.py b/src/imagemodal/views.py new file mode 100644 index 0000000..2bd2ae0 --- /dev/null +++ b/src/imagemodal/views.py @@ -0,0 +1,52 @@ +""" +Handle view logic for the XBlock +""" + +try: + from xblock.utils.resources import ResourceLoader + from xblock.utils.studio_editable import StudioEditableXBlockMixin +except ModuleNotFoundError: # For backward compatibility with releases older than Quince. + from xblockutils.resources import ResourceLoader + from xblockutils.studio_editable import StudioEditableXBlockMixin + +from .mixins.fragment import XBlockFragmentBuilderMixin + +URL_FONT_AWESOME_CSS = "//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.css" # nopep8 + + +class ImageModalViewMixin( + XBlockFragmentBuilderMixin, + StudioEditableXBlockMixin, +): + """ + Handle view logic for Image Modal XBlock instances + """ + + loader = ResourceLoader(__name__) + static_css = [ + URL_FONT_AWESOME_CSS, + "view.css", + ] + static_js = [ + "draggabilly.pkgd.min.js", + "view.js", + ] + static_js_init = "ImageModalView" + + def provide_context(self, context=None): + """ + Build a context dictionary to render the student view + """ + context = context or {} + context = dict(context) + context.update( + { + "display_name": self.display_name, + "image_url": self.image_url, + "thumbnail_url": self.thumbnail_url or self.image_url, + "description": self.description, + "xblock_id": str(self.scope_ids.usage_id), + "alt_text": self.alt_text or self.display_name, + } + ) + return context diff --git a/src/imagemodal/xblocks.py b/src/imagemodal/xblocks.py new file mode 100644 index 0000000..bae822f --- /dev/null +++ b/src/imagemodal/xblocks.py @@ -0,0 +1,21 @@ +""" +This is the core logic for the XBlock +""" + +from xblock.core import XBlock + +from .mixins.scenario import XBlockWorkbenchMixin +from .models import ImageModalModelMixin +from .views import ImageModalViewMixin + + +@XBlock.needs("i18n") +class ImageModal( + ImageModalModelMixin, + ImageModalViewMixin, + XBlockWorkbenchMixin, + XBlock, +): + """ + A fullscreen image modal XBlock. + """ diff --git a/src/submit_and_compare/README.md b/src/submit_and_compare/README.md new file mode 100644 index 0000000..4be0513 --- /dev/null +++ b/src/submit_and_compare/README.md @@ -0,0 +1,52 @@ +Submit and Compare XBlock +========================= +This XBlock provides a way to do an ungraded self assessment activity. It is useful for synthesis questions, or questions which require the student to answer in her own words. After the student submits her answer, she is able to see the instructor's answer, and compare her answer to the expert answer. + +![Completed Question](docs/img/submitted.png) + +Installation +------------ +To install the Submit and Compare XBlock within your edX python environment, simply run this command: + +```bash +$ pip install -r requirements.txt +``` + +Enabling in Studio +------------------ +Go to `Settings -> Advanced Settings` and set `Advanced Module List` to `["submit-and-compare"]`. + +![Advanced Module List](docs/img/policy.png) + +Usage +------------------ +Once the Submit and Compare XBlock is enabled in Studio, you should see it a new Component button labeled `Advanced`: + +![Component Buttons](docs/img/component.png) + +Click the `Advanced` button and you should see the Submit and Compare XBlock listed: + +![Advanced Component List](docs/img/advanced.png) + +After you've selected the Submit and Compare XBlock, a default question will be inserted into your unit: + +![Default Question](docs/img/student_view.png) + +Customization +------------- +The question and expert answer can both be customized by clicking the `Edit` button on the component: + +![Studio View](docs/img/studio_view1.png) +![Studio View](docs/img/studio_view2.png) + +The Submit and Compare XBlock uses a simple XML-based structure as shown below: +```bash + + Insert the question here. You can include html tags like

, , etc. + Insert the expert answer here. You can include html tags like

, , etc. + + Here you can include hints for the student + Here is another hint + + +``` diff --git a/src/submit_and_compare/README.rst b/src/submit_and_compare/README.rst new file mode 100644 index 0000000..1fc64b4 --- /dev/null +++ b/src/submit_and_compare/README.rst @@ -0,0 +1,126 @@ +Submit and Compare XBlock +========================= + +This XBlock provides a way to do an ungraded self assessment activity. +It is useful for synthesis questions, or questions which require the +student to answer in her own words. +After the student submits her answer, she is able to see the +instructor's answer, and compare her answer to the expert answer. + +|badge-ci| +|badge-coveralls| + +|image-lms-view-normal| + + +Installation +------------ + + +System Administrator +~~~~~~~~~~~~~~~~~~~~ + +To install the XBlock on your platform, +add the following to your `requirements.txt` file: + + xblock-submit-and-compare + +You'll also need to add this to your `INSTALLED_APPS`: + + submit_and_compare + + +Course Staff +~~~~~~~~~~~~ + +To install the XBlock in your course, +access your `Advanced Module List`: + + Settings -> Advanced Settings -> Advanced Module List + +|image-cms-settings-menu| + +and add the following: + + submit-and-compare + +|image-cms-advanced-module-list| + + +Use +--- + + +Course Staff +~~~~~~~~~~~~ + +To add a full-screen image to your course: + +- upload the image file onto your course's Files & Uploads page + + - note: you can skip this step if you've already uploaded the image + elsewhere, e.g.: S3. + +- copy the URL on that page +- go to a unit in Studio +- select "Image Modal XBlock" from the Advanced Components menu + +|image-cms-add| + +You can now edit and preview the new component. + +|image-cms-view| + +Using the Studio editor, you can edit the following fields: + +- display name +- image URL +- thumbnail URL (defaults to image URL, if not specified) +- description (useful for screen readers, longer descriptions) +- alt text (useful for screen readers, captions, tags; displays when image does not) + +|image-cms-editor-1| +|image-cms-editor-2| + + +Participants +~~~~~~~~~~~~ + +|image-lms-view-normal| + +Click on the image to zoom in full-screen. + +|image-lms-view-zoom| + +Click on the image again to zoom out. + +Click and drag to pan around. + +`View a demo of the CMS`_ + +`View a demo of the LMS`_ + + +.. |badge-coveralls| image:: https://coveralls.io/repos/github/Stanford-Online/xblock-image-modal/badge.svg?branch=master + :target: https://coveralls.io/github/Stanford-Online/xblock-image-modal?branch=master +.. |badge-ci| image:: https://github.com/openedx/xblock-submit-and-compare/workflows/Python%20CI/badge.svg?branch=master + :target: https://github.com/openedx/xblock-submit-and-compare/actions?query=workflow%3A%22Python+CI%22 +.. |image-cms-add| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/cms-add.jpg + :width: 100% +.. |image-cms-advanced-module-list| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/advanced-module-list.png + :width: 100% +.. |image-cms-editor-1| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-editor-1.png + :width: 100% +.. |image-cms-editor-2| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-editor-2.png + :width: 100% +.. |image-cms-settings-menu| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/settings-menu.png + :width: 100% +.. |image-cms-view| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/studio-view.png + :width: 100% +.. |image-lms-view-normal| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/lms-view-normal.png + :width: 100% +.. |image-lms-view-zoom| image:: https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/lms-view-zoom.png + :width: 100% +.. _View a demo of the CMS: https://youtu.be/IcbGYfbav2w +.. _View a demo of the LMS: https://youtu.be/0mpjuThDoyE +.. https://s3-us-west-1.amazonaws.com/stanford-openedx-docs/xblocks/image-modal/static/images/xblock-image-modal-demo-lms.mov diff --git a/src/submit_and_compare/__init__.py b/src/submit_and_compare/__init__.py new file mode 100644 index 0000000..ca7340b --- /dev/null +++ b/src/submit_and_compare/__init__.py @@ -0,0 +1,3 @@ +""" +This is an XBlock for submit and compare +""" diff --git a/src/submit_and_compare/conf/locale/config.yaml b/src/submit_and_compare/conf/locale/config.yaml new file mode 100644 index 0000000..a968d94 --- /dev/null +++ b/src/submit_and_compare/conf/locale/config.yaml @@ -0,0 +1,4 @@ +# Configuration for i18n workflow. + +locales: + - en # English - Source Language diff --git a/src/submit_and_compare/docs/img/advanced.png b/src/submit_and_compare/docs/img/advanced.png new file mode 100644 index 0000000..b966599 Binary files /dev/null and b/src/submit_and_compare/docs/img/advanced.png differ diff --git a/src/submit_and_compare/docs/img/component.png b/src/submit_and_compare/docs/img/component.png new file mode 100644 index 0000000..92305d4 Binary files /dev/null and b/src/submit_and_compare/docs/img/component.png differ diff --git a/src/submit_and_compare/docs/img/policy.png b/src/submit_and_compare/docs/img/policy.png new file mode 100644 index 0000000..657df52 Binary files /dev/null and b/src/submit_and_compare/docs/img/policy.png differ diff --git a/src/submit_and_compare/docs/img/student_view.png b/src/submit_and_compare/docs/img/student_view.png new file mode 100644 index 0000000..eb022e8 Binary files /dev/null and b/src/submit_and_compare/docs/img/student_view.png differ diff --git a/src/submit_and_compare/docs/img/studio_view1.png b/src/submit_and_compare/docs/img/studio_view1.png new file mode 100644 index 0000000..7a40bb2 Binary files /dev/null and b/src/submit_and_compare/docs/img/studio_view1.png differ diff --git a/src/submit_and_compare/docs/img/studio_view2.png b/src/submit_and_compare/docs/img/studio_view2.png new file mode 100644 index 0000000..5a5ea13 Binary files /dev/null and b/src/submit_and_compare/docs/img/studio_view2.png differ diff --git a/src/submit_and_compare/docs/img/submitted.png b/src/submit_and_compare/docs/img/submitted.png new file mode 100644 index 0000000..bbc82fc Binary files /dev/null and b/src/submit_and_compare/docs/img/submitted.png differ diff --git a/src/submit_and_compare/mixins/__init__.py b/src/submit_and_compare/mixins/__init__.py new file mode 100644 index 0000000..ccb716c --- /dev/null +++ b/src/submit_and_compare/mixins/__init__.py @@ -0,0 +1,3 @@ +""" +Mixin behavior to XBlocks +""" diff --git a/src/submit_and_compare/mixins/dates.py b/src/submit_and_compare/mixins/dates.py new file mode 100644 index 0000000..7e1e004 --- /dev/null +++ b/src/submit_and_compare/mixins/dates.py @@ -0,0 +1,35 @@ +""" +Extend XBlocks with datetime helpers +""" + +import datetime + + +# pylint: disable=too-few-public-methods +class EnforceDueDates: + """ + xBlock Mixin to allow xblocks to check the due date + (taking the graceperiod into account) of the + subsection in which they are placed + """ + + def is_past_due(self): + """ + Determine if component is past-due + """ + # These values are pulled from platform. + # They are defaulted to None for tests. + due = getattr(self, "due", None) + graceperiod = getattr(self, "graceperiod", None) + # Calculate the current DateTime so we can compare the due date to it. + # datetime.utcnow() returns timezone naive date object. + now = datetime.datetime.utcnow() + if due is not None: + # Remove timezone information from platform provided due date. + # Dates are stored as UTC timezone aware objects on platform. + due = due.replace(tzinfo=None) + if graceperiod is not None: + # Compare the datetime objects (both have to be timezone naive) + due = due + graceperiod + return now > due + return False diff --git a/src/submit_and_compare/mixins/events.py b/src/submit_and_compare/mixins/events.py new file mode 100644 index 0000000..137ae26 --- /dev/null +++ b/src/submit_and_compare/mixins/events.py @@ -0,0 +1,68 @@ +""" +Provide event-related mixin functionality +""" + +from xblock.core import XBlock + + +class EventableMixin: + """ + Mix in standard event logic + """ + + @XBlock.json_handler + def publish_event(self, data, *args, **kwargs): + """ + Publish events + """ + try: + event_type = data.pop("event_type") + except KeyError: + return { + "result": "error", + "message": "Missing event_type in JSON data", + } + data["user_id"] = self.scope_ids.user_id + data["component_id"] = self._get_unique_id() + self.runtime.publish(self, event_type, data) + result = { + "result": "success", + } + return result + + def _get_unique_id(self): + """ + Get a unique component identifier + """ + try: + unique_id = self.location.name + except AttributeError: + # workaround for xblock workbench + unique_id = "workbench-workaround-id" + return unique_id + + def _publish_grade(self): + """ + Publish a grade event + """ + self.runtime.publish( + self, + "grade", + { + "value": self.score, + "max_value": 1.0, + }, + ) + + def _publish_problem_check(self): + """ + Publish a problem_check event + """ + self.runtime.publish( + self, + "problem_check", + { + "grade": self.score, + "max_grade": 1.0, + }, + ) diff --git a/src/submit_and_compare/mixins/fragment.py b/src/submit_and_compare/mixins/fragment.py new file mode 100644 index 0000000..dde58f4 --- /dev/null +++ b/src/submit_and_compare/mixins/fragment.py @@ -0,0 +1,98 @@ +""" +Mixin fragment/html behavior into XBlocks + +Note: We should resume test coverage for all lines in this file once +split into its own library. +""" + +from django.template.context import Context +from web_fragments.fragment import Fragment +from xblock.core import XBlock + + +class XBlockFragmentBuilderMixin: + """ + Create a default XBlock fragment builder + """ + + static_css = [ + "view.css", + ] + static_js = [ + "view.js", + ] + static_js_init = None + template = "view.html" + + def get_i18n_service(self): + """ + Get the i18n service from the runtime + """ + return self.runtime.service(self, "i18n") + + def provide_context(self, context): # pragma: no cover + """ + Build a context dictionary to render the student view + + This should generally be overriden by child classes. + """ + context = context or {} + context = dict(context) + return context + + @XBlock.supports("multi_device") + def student_view(self, context=None): + """ + Build the fragment for the default student view + """ + template = self.template + context = self.provide_context(context) + static_css = self.static_css or [] + static_js = self.static_js or [] + js_init = self.static_js_init + fragment = self.build_fragment( + template=template, + context=context, + css=static_css, + js=static_js, + js_init=js_init, + ) + return fragment + + def build_fragment( + self, + template="", + context=None, + css=None, + js=None, + js_init=None, + ): + """ + Creates a fragment for display. + """ + context = context or {} + css = css or [] + js = js or [] + rendered_template = "" + if template: # pragma: no cover + template = "templates/" + template + rendered_template = self.loader.render_django_template( + template, + context=Context(context), + i18n_service=self.get_i18n_service(), + ) + fragment = Fragment(rendered_template) + for item in css: + if item.startswith("/"): + url = item + else: + item = "public/" + item + url = self.runtime.local_resource_url(self, item) + fragment.add_css_url(url) + for item in js: + item = "public/" + item + url = self.runtime.local_resource_url(self, item) + fragment.add_javascript_url(url) + if js_init: # pragma: no cover + fragment.initialize_js(js_init) + return fragment diff --git a/src/submit_and_compare/mixins/scenario.py b/src/submit_and_compare/mixins/scenario.py new file mode 100644 index 0000000..3b20c7b --- /dev/null +++ b/src/submit_and_compare/mixins/scenario.py @@ -0,0 +1,24 @@ +""" +Mixin workbench behavior into XBlocks +""" + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: + from xblockutils.resources import ResourceLoader + + +loader = ResourceLoader(__name__) + + +class XBlockWorkbenchMixin: + """ + Provide a default test workbench for the XBlock + """ + + @classmethod + def workbench_scenarios(cls): + """ + Gather scenarios to be displayed in the workbench + """ + return loader.load_scenarios_from_path("../scenarios") diff --git a/src/submit_and_compare/models.py b/src/submit_and_compare/models.py new file mode 100644 index 0000000..902eb23 --- /dev/null +++ b/src/submit_and_compare/models.py @@ -0,0 +1,112 @@ +""" +Handle data access logic for the XBlock +""" + +import textwrap + +from xblock.fields import Float, Integer, List, Scope, String + + +class SubmitAndCompareModelMixin: + """ + Handle data access logic for the XBlock + """ + + has_score = True + display_name = String( + display_name="Display Name", + default="Submit and Compare", + scope=Scope.settings, + help=("This name appears in the horizontal navigation at the top of the page"), + ) + student_answer = String( + default="", + scope=Scope.user_state, + help="This is the student's answer to the question", + ) + max_attempts = Integer( + default=0, + scope=Scope.settings, + ) + count_attempts = Integer( + default=0, + scope=Scope.user_state, + ) + your_answer_label = String( + default="Your Answer:", + scope=Scope.settings, + help="Label for the text area containing the student's answer", + ) + our_answer_label = String( + default="Our Answer:", + scope=Scope.settings, + help="Label for the 'expert' answer", + ) + submit_button_label = String( + default="Submit and Compare", + scope=Scope.settings, + help="Label for the submit button", + ) + hints = List( + default=[], + scope=Scope.content, + help="Hints for the question", + ) + question_string = String( + help="Default question content ", + scope=Scope.content, + multiline_editor=True, + default=textwrap.dedent(""" + + +

+ Before you begin the simulation, + think for a minute about your hypothesis. + What do you expect the outcome of the simulation + will be? What data do you need to gather in order + to prove or disprove your hypothesis? +

+ + +

+ We would expect the simulation to show that + there is no difference between the two scenarios. + Relevant data to gather would include time and + temperature. +

+
+ + + A hypothesis is a proposed explanation for a + phenomenon. In this case, the hypothesis is what + we think the simulation will show. + + + Once you've decided on your hypothesis, which data + would help you determine if that hypothesis is + correct or incorrect? + + +
+ """), + ) + score = Float( + default=0.0, + scope=Scope.user_state, + ) + weight = Integer( + display_name="Weight", + help="This assigns an integer value representing the weight of this problem", + default=0, + scope=Scope.settings, + ) + + def max_score(self): + """ + Returns the configured number of possible points for this component. + Arguments: + None + Returns: + float: The number of possible points for this component + """ + return self.weight diff --git a/src/submit_and_compare/public/edit.js b/src/submit_and_compare/public/edit.js new file mode 100644 index 0000000..2a22067 --- /dev/null +++ b/src/submit_and_compare/public/edit.js @@ -0,0 +1,36 @@ +/* Javascript for Submit and Compare XBlock. */ +function SubmitAndCompareXBlockInitEdit(runtime, element) { + + var xmlEditorTextarea = $('.block-xml-editor', element), + xmlEditor = CodeMirror.fromTextArea(xmlEditorTextarea[0], { mode: 'xml', lineWrapping: true }); + + $(element).find('.action-cancel').bind('click', function() { + runtime.notify('cancel', {}); + }); + + $(element).find('.action-save').bind('click', function() { + var data = { + 'display_name': $('#submit_and_compare_edit_display_name').val(), + 'weight': $('#submit_and_compare_edit_weight').val(), + 'max_attempts': $('#submit_and_compare_edit_max_attempts').val(), + 'your_answer_label': $('#submit_and_compare_edit_your_answer_label').val(), + 'our_answer_label': $('#submit_and_compare_edit_our_answer_label').val(), + 'submit_button_label': $('#submit_and_compare_edit_submit_button_label').val(), + 'data': xmlEditor.getValue(), + }; + + runtime.notify('save', {state: 'start'}); + + var handlerUrl = runtime.handlerUrl(element, 'studio_submit'); + $.post(handlerUrl, JSON.stringify(data)).done(function(response) { + if (response.result === 'success') { + runtime.notify('save', {state: 'end'}); + //Reload the page + //window.location.reload(false); + } else { + runtime.notify('error', {msg: response.message}) + } + }); + }); +} + diff --git a/src/submit_and_compare/public/view.css b/src/submit_and_compare/public/view.css new file mode 100644 index 0000000..ce77d0d --- /dev/null +++ b/src/submit_and_compare/public/view.css @@ -0,0 +1,65 @@ +.submit_and_compare .question_prompt { + text-align: left; +} +.submit_and_compare .student_answer { + color: #3399cc; + font-weight: 600; + padding-bottom: 20px; + padding-top: 20px; +} +.submit_and_compare .answer { + border: 3px solid #cccccc; + height: 120px; + max-width: 100%; + padding: 5px; + width: 100%; +} +.submit_and_compare .expert_answer { + padding-bottom: 20px; + text-align: left; +} +.submit_and_compare .our_answer { + color: #009900; + font-weight: 600; + text-align: left; +} +.submit_and_compare .hint { + padding-bottom: 20px; + text-align: left; +} +.submit_and_compare .reset_button { + font-weight: 600; + height: 40px; + vertical-align: middle; +} +.submit_and_compare .hint_button { + font-weight: 600; + height: 40px; + vertical-align: middle; +} +.submit_and_compare .submit_button { + font-weight: 600; + height: 40px; + vertical-align: middle; +} +.submit_and_compare .problem_progress { + color: #666; + display: inline-block; + font-size: 1em; + font-weight: 100; + padding-left: 5px; +} +.submit_and_compare .problem_header { + display: inline-block; +} +.submit_and_compare .used_attempts_feedback { + color: #666; + font-style: italic; + margin-top: 8px; +} +.submit_and_compare .nodisplay { + display: none; +} +.submit_and_compare .inline { + display: inline; +} diff --git a/src/submit_and_compare/public/view.js b/src/submit_and_compare/public/view.js new file mode 100644 index 0000000..4babad2 --- /dev/null +++ b/src/submit_and_compare/public/view.js @@ -0,0 +1,161 @@ +/* Javascript for submitcompareXBlock. */ +/* eslint-disable no-unused-vars */ +/* eslint-disable require-jsdoc */ +/** + * Initialize the student view + * @param {Object} runtime - The XBlock JS Runtime + * @param {Object} element - The containing DOM element for this instance of the XBlock + * @returns {undefined} nothing + */ +function SubmitAndCompareXBlockInitView(runtime, element) { + 'use strict'; + /* eslint-disable camelcase */ + /* eslint-enable no-unused-vars */ + + var $ = window.jQuery; + var handlerUrl = runtime.handlerUrl(element, 'student_submit'); + var hintUrl = runtime.handlerUrl(element, 'send_hints'); + var publishUrl = runtime.handlerUrl(element, 'publish_event'); + var $element = $(element); + var $xblocksContainer = $('#seq_content'); + var submit_button = $element.find('.submit_button'); + var hint_button = $element.find('hint_button'); + var reset_button = $element.find('.reset_button'); + var problem_progress = $element.find('.problem_progress'); + var used_attempts_feedback = $element.find('.used_attempts_feedback'); + var button_holder = $element.find('.button_holder'); + var answer_textarea = $element.find('.answer'); + var your_answer = $element.find('.your_answer'); + var expert_answer = $element.find('.expert_answer'); + var hint_div = $element.find('.hint'); + var hint_button_holder = $element.find('.hint_button_holder'); + var submit_button_label = $element.find('.submit_button').attr('value'); + var hint; + var hints; + var hint_counter = 0; + var xblock_id = $element.attr('data-usage-id'); + var cached_answer_id = xblock_id + '_cached_answer'; + var problem_progress_id = xblock_id + '_problem_progress'; + var used_attempts_feedback_id = xblock_id + '_used_attempts_feedback'; + if (typeof $xblocksContainer.data(cached_answer_id) !== 'undefined') { + answer_textarea.text($xblocksContainer.data(cached_answer_id)); + problem_progress.text($xblocksContainer.data(problem_progress_id)); + used_attempts_feedback.text($xblocksContainer.data(used_attempts_feedback_id)); + } + + /** + * Parse and display hints + * @param {Object} result - The result payload + * @returns {undefined} nothing + */ + function set_hints(result) { + hints = result.hints; + if (hints.length > 0) { + hint_button.css('display', 'inline'); + hint_button_holder.css('display', 'inline'); + } + } + + $.ajax({ + type: 'POST', + url: hintUrl, + data: JSON.stringify({ requested: true, }), + success: set_hints, + }); + + function publish_event(data) { + $.ajax({ + type: 'POST', + url: publishUrl, + data: JSON.stringify(data), + }); + } + + function pre_submit() { + problem_progress.text('(Loading...)'); + } + + function post_submit(result) { + $xblocksContainer.data(cached_answer_id, $('.answer', element).val()); + $xblocksContainer.data(problem_progress_id, result.problem_progress); + $xblocksContainer.data(used_attempts_feedback_id, result.used_attempts_feedback); + problem_progress.text(result.problem_progress); + button_holder.addClass(result.submit_class); + used_attempts_feedback.text(result.used_attempts_feedback); + } + + function show_answer() { + your_answer.css('display', 'block'); + expert_answer.css('display', 'block'); + submit_button.val('Resubmit'); + + } + + function reset_answer() { + your_answer.css('display', 'none'); + expert_answer.css('display', 'none'); + submit_button.val(submit_button_label); + } + + function reset_hint() { + hint_counter = 0; + hint_div.css('display', 'none'); + } + + function show_hint() { + hint = hints[hint_counter]; + hint_div.html(hint); + hint_div.css('display', 'block'); + publish_event({ + event_type: 'hint_button', + next_hint_index: hint_counter, + }); + if (hint_counter === hints.length - 1) { + hint_counter = 0; + } else { + hint_counter++; + } + } + + $('.submit_button', element).click(function () { + pre_submit(); + $.ajax({ + type: 'POST', + url: handlerUrl, + data: JSON.stringify( + { + answer: $('.answer', element).val(), + action: 'submit', + } + ), + success: post_submit, + }); + show_answer(); + }); + + reset_button.click(function () { + $('.answer', element).val(''); + $.ajax({ + type: 'POST', + url: handlerUrl, + data: JSON.stringify( + { + answer: '', + action: 'reset', + } + ), + success: post_submit, + }); + reset_answer(); + reset_hint(); + }); + + $('.hint_button', element).click(function () { + show_hint(); + }); + + if ($('.answer', element).val() !== '') { + show_answer(); + } + /* eslint-enable camelcase */ +} diff --git a/src/submit_and_compare/public/view.less b/src/submit_and_compare/public/view.less new file mode 100644 index 0000000..ed401fa --- /dev/null +++ b/src/submit_and_compare/public/view.less @@ -0,0 +1,67 @@ +.submit_and_compare { + .question_prompt { + text-align: left; + } + .student_answer { + color: #3399cc; + font-weight: 600; + padding-bottom: 20px; + padding-top: 20px; + } + .answer { + border: 3px solid #cccccc; + height: 120px; + max-width: 100%; + padding: 5px; + width: 100%; + } + .expert_answer { + padding-bottom: 20px; + text-align: left; + } + .our_answer { + color: #009900; + font-weight: 600; + text-align: left; + } + .hint { + padding-bottom: 20px; + text-align: left; + } + .reset_button { + font-weight: 600; + height: 40px; + vertical-align: middle; + } + .hint_button { + font-weight: 600; + height: 40px; + vertical-align: middle; + } + .submit_button { + font-weight: 600; + height: 40px; + vertical-align: middle; + } + .problem_progress { + color: #666; + display: inline-block; + font-size: 1em; + font-weight: 100; + padding-left: 5px; + } + .problem_header { + display: inline-block; + } + .used_attempts_feedback { + color: #666; + font-style: italic; + margin-top: 8px; + } + .nodisplay { + display: none; + } + .inline { + display: inline; + } +} diff --git a/src/submit_and_compare/scenarios/submit-and-compare-single.xml b/src/submit_and_compare/scenarios/submit-and-compare-single.xml new file mode 100644 index 0000000..34ce0fe --- /dev/null +++ b/src/submit_and_compare/scenarios/submit-and-compare-single.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/submit_and_compare/settings.py b/src/submit_and_compare/settings.py new file mode 100644 index 0000000..2bad0f7 --- /dev/null +++ b/src/submit_and_compare/settings.py @@ -0,0 +1,16 @@ +""" +Settings for submit_and_compare xblock +""" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + # 'NAME': 'intentionally-omitted', + }, +} +LOCALE_PATHS = [ + "submit_and_compare/translations", +] +SECRET_KEY = "submit_and_compare_SECRET_KEY" + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/src/submit_and_compare/templates/edit.html b/src/submit_and_compare/templates/edit.html new file mode 100644 index 0000000..4e11ba2 --- /dev/null +++ b/src/submit_and_compare/templates/edit.html @@ -0,0 +1,78 @@ +{% load i18n %} + +
+
    + +
  • +
    + + +
    + {% trans "This name appears in the horizontal navigation at the top of the page" %} +
  • + +
  • +
    + + +
    + {% trans "This assigns an integer value representing the weight of this problem" %} +
  • + +
  • +
    + + +
    + {% trans "This assigns an integer value representing the maximum number of times a student can attempt the problem" %} +
  • + +
  • +
    + + +
    + {% trans "Label for the text area containing the student's answer" %} +
  • + +
  • +
    + + +
    + {% trans "Label for the 'expert' answer" %} +
  • + +
  • +
    + + +
    + {% trans "Label for the submit button" %} +
  • + +
  • +
    + + {% trans "The XML definition for the question and expert answer" %} +
    + +
    +
    +
  • + +
+ + +
+ + diff --git a/src/submit_and_compare/templates/view.html b/src/submit_and_compare/templates/view.html new file mode 100644 index 0000000..7c31be8 --- /dev/null +++ b/src/submit_and_compare/templates/view.html @@ -0,0 +1,37 @@ +
+

{{ display_name }}

+
{{ problem_progress }}
+
{{ prompt }}
+
+
{{ your_answer_label }}
+ +
+
+
{{ our_answer_label }}
+
{{ explanation }}
+
+
{{hint}}
+
+ {% if not is_past_due %} +
+ +
+
+ +
+
+ +
+ {% endif %} +
+
{{ used_attempts_feedback }}
+
diff --git a/src/submit_and_compare/tests/__init__.py b/src/submit_and_compare/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/submit_and_compare/tests/test_all.py b/src/submit_and_compare/tests/test_all.py new file mode 100644 index 0000000..26142c5 --- /dev/null +++ b/src/submit_and_compare/tests/test_all.py @@ -0,0 +1,206 @@ +""" +Tests for xblock-submit-and-compare +""" + +import re +import unittest +from unittest import mock +from xml.sax.saxutils import escape + +from django.test.client import Client +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from xblock.field_data import DictFieldData + +from ..views import get_body +from ..xblocks import SubmitAndCompareXBlock + + +class SubmitAndCompareXblockTestCase(unittest.TestCase): + # pylint: disable=too-many-instance-attributes, too-many-public-methods + """ + A complete suite of unit tests for the Submit-and-compare XBlock + """ + + @classmethod + def make_an_xblock(cls, **kw): + """ + Helper method that creates a Free-text Response XBlock + """ + course_id = SlashSeparatedCourseKey("foo", "bar", "baz") + runtime = mock.Mock(course_id=course_id) + scope_ids = mock.Mock() + field_data = DictFieldData(kw) + xblock = SubmitAndCompareXBlock(runtime, field_data, scope_ids) + xblock.xmodule_runtime = runtime + return xblock + + def setUp(self): + self.xblock = SubmitAndCompareXblockTestCase.make_an_xblock() + self.client = Client() + + def test_student_view(self): + # pylint: disable=protected-access + """ + Checks the student view for student specific instance variables. + """ + student_view_html = self.student_view_html() + self.assertIn(self.xblock.display_name, student_view_html) + self.assertIn( + get_body(self.xblock.question_string), + student_view_html, + ) + self.assertIn(self.xblock._get_problem_progress(), student_view_html) + + def test_studio_view(self): + """ + Checks studio view for instance variables specified by the instructor. + """ + with mock.patch( + "submit_and_compare.mixins.fragment.XBlockFragmentBuilderMixin.get_i18n_service", return_value=None + ): + studio_view_html = self.studio_view_html() + self.assertIn(self.xblock.display_name, studio_view_html) + xblock_body = get_body(self.xblock.question_string) + studio_view_html = re.sub(r"\W+", " ", studio_view_html.strip()) + xblock_body = re.sub(r"\W+", " ", xblock_body.strip()) + self.assertIn( + escape(xblock_body), + studio_view_html, + ) + self.assertIn(str(self.xblock.max_attempts), studio_view_html) + + def test_initialization_variables(self): + """ + Checks that all instance variables are initialized correctly + """ + self.assertEqual("Submit and Compare", self.xblock.display_name) + self.assertIn( + "Before you begin the simulation", + self.xblock.question_string, + ) + self.assertEqual(0.0, self.xblock.score) + self.assertEqual(0, self.xblock.max_attempts) + self.assertEqual("", self.xblock.student_answer) + self.assertEqual(0, self.xblock.count_attempts) + + def student_view_html(self): + """ + Helper method that returns the html of student_view + """ + return self.xblock.student_view().content + + def studio_view_html(self): + """ + Helper method that returns the html of studio_view + """ + return self.xblock.studio_view().content + + def test_problem_progress_weight_zero(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that the the string returned by get_problem_progress + is blank when the weight of the problem is zero + """ + self.xblock.score = 1 + self.xblock.weight = 0 + self.assertEqual("", self.xblock._get_problem_progress()) + + def test_problem_progress_score_zero_weight_singular(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that the the string returned by get_problem_progress + when the weight of the problem is singular, and the score is zero + """ + self.xblock.score = 0 + self.xblock.weight = 1 + self.assertEqual( + "(1 point possible)", + self.xblock._get_problem_progress(), + ) + + def test_problem_progress_score_zero_weight_plural(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that the the string returned by get_problem_progress + when the weight of the problem is plural, and the score is zero + """ + self.xblock.score = 0 + self.xblock.weight = 3 + self.assertEqual( + "(3 points possible)", + self.xblock._get_problem_progress(), + ) + + def test_problem_progress_score_positive_weight_singular(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that the the string returned by get_problem_progress + when the weight of the problem is singular, and the score is positive + """ + self.xblock.score = 1 + self.xblock.weight = 1 + self.assertEqual( + "(1/1 point)", + self.xblock._get_problem_progress(), + ) + + def test_problem_progress_score_positive_weight_plural(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that the the string returned by get_problem_progress + when the weight of the problem is plural, and the score is positive + """ + self.xblock.score = 1 + self.xblock.weight = 3 + self.assertEqual( + "(3/3 points)", + self.xblock._get_problem_progress(), + ) + + def test_used_attempts_feedback_blank(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that get_used_attempts_feedback returns no feedback when + appropriate + """ + self.xblock.max_attempts = 0 + self.assertEqual("", self.xblock._get_used_attempts_feedback()) + + def test_used_attempts_feedback_normal(self): + # pylint: disable=invalid-name, protected-access + """ + Tests that get_used_attempts_feedback returns the expected feedback + """ + self.xblock.max_attempts = 5 + self.xblock.count_attempts = 3 + self.assertEqual( + "You have used 3 of 5 submissions", + self.xblock._get_used_attempts_feedback(), + ) + + def test_submit_class_blank(self): + # pylint: disable=protected-access + """ + Tests that get_submit_class returns a blank value when appropriate + """ + self.xblock.max_attempts = 0 + self.assertEqual("", self.xblock._get_submit_class()) + + def test_submit_class_nodisplay(self): + # pylint: disable=protected-access + """ + Tests that get_submit_class returns the appropriate class + when the number of attempts has exceeded the maximum number of + permissable attempts + """ + self.xblock.max_attempts = 5 + self.xblock.count_attempts = 6 + self.assertEqual("nodisplay", self.xblock._get_submit_class()) + + def test_max_score(self): + """ + Tests max_score function + Should return the weight + """ + self.xblock.weight = 4 + self.assertEqual(self.xblock.weight, self.xblock.max_score()) diff --git a/src/submit_and_compare/views.py b/src/submit_and_compare/views.py new file mode 100644 index 0000000..fa95a53 --- /dev/null +++ b/src/submit_and_compare/views.py @@ -0,0 +1,308 @@ +""" +Handle view logic for the XBlock +""" + +import logging + +from django.utils.translation import gettext as _ +from django.utils.translation import ngettext +from lxml import etree +from six import StringIO +from xblock.core import XBlock + +try: + from xblock.utils.resources import ResourceLoader +except ModuleNotFoundError: # For backward compatibility with releases older than Quince. + from xblockutils.resources import ResourceLoader + +from .mixins.fragment import XBlockFragmentBuilderMixin + +LOG = logging.getLogger(__name__) + + +def _convert_to_int(value_string): + """ + Convert a string to integer + + Default to 0 + """ + try: + value = int(value_string) + except ValueError: + value = 0 + return value + + +def get_body(xmlstring): + """ + Helper method + """ + # pylint: disable=no-member + tree = etree.parse(StringIO(xmlstring)) + body = tree.xpath("/submit_and_compare/body") + body_string = etree.tostring(body[0], method="text", encoding="unicode") + return body_string + + +def _get_explanation(xmlstring): + # pylint: disable=no-member + """ + Helper method + """ + tree = etree.parse(StringIO(xmlstring)) + explanation = tree.xpath("/submit_and_compare/explanation") + explanation_string = etree.tostring( + explanation[0], + method="text", + encoding="unicode", + ) + return explanation_string + + +class SubmitAndCompareViewMixin( + XBlockFragmentBuilderMixin, +): + """ + Handle view logic for Image Modal XBlock instances + """ + + loader = ResourceLoader(__name__) + static_js_init = "SubmitAndCompareXBlockInitView" + icon_class = "problem" + editable_fields = [ + "display_name", + "weight", + "max_attempts", + "your_answer_label", + "our_answer_label", + "submit_button_label", + "question_string", + ] + show_in_read_only_mode = True + + def provide_context(self, context=None): + """ + Build a context dictionary to render the student view + """ + context = context or {} + context = dict(context) + problem_progress = self._get_problem_progress() + used_attempts_feedback = self._get_used_attempts_feedback() + submit_class = self._get_submit_class() + prompt = get_body(self.question_string) + explanation = _get_explanation(self.question_string) + attributes = "" + context.update( + { + "display_name": self.display_name, + "problem_progress": problem_progress, + "used_attempts_feedback": used_attempts_feedback, + "submit_class": submit_class, + "prompt": prompt, + "student_answer": self.student_answer, + "explanation": explanation, + "your_answer_label": self.your_answer_label, + "our_answer_label": self.our_answer_label, + "submit_button_label": self.submit_button_label, + "attributes": attributes, + "is_past_due": self.is_past_due(), + } + ) + return context + + def studio_view(self, context=None): + """ + Build the fragment for the edit/studio view + + Implementation is optional. + """ + context = context or {} + context.update( + { + "display_name": self.display_name, + "weight": self.weight, + "max_attempts": self.max_attempts, + "xml_data": self.question_string, + "your_answer_label": self.your_answer_label, + "our_answer_label": self.our_answer_label, + "submit_button_label": self.submit_button_label, + } + ) + template = "edit.html" + fragment = self.build_fragment( + template=template, + context=context, + js_init="SubmitAndCompareXBlockInitEdit", + css=[ + "edit.css", + ], + js=[ + "edit.js", + ], + ) + return fragment + + @XBlock.json_handler + def studio_submit(self, data, *args, **kwargs): + """ + Save studio edits + """ + # pylint: disable=unused-argument + self.display_name = data["display_name"] + self.weight = _convert_to_int(data["weight"]) + max_attempts = _convert_to_int(data["max_attempts"]) + if max_attempts >= 0: + self.max_attempts = max_attempts # pylint: disable=consider-using-min-builtin + self.your_answer_label = data["your_answer_label"] + self.our_answer_label = data["our_answer_label"] + self.submit_button_label = data["submit_button_label"] + xml_content = data["data"] + # pylint: disable=no-member + try: + etree.parse(StringIO(xml_content)) + self.question_string = xml_content + except etree.XMLSyntaxError as error: + return { + "result": "error", + "message": error.message, + } + + return { + "result": "success", + } + + @XBlock.json_handler + def student_submit(self, data, *args, **kwargs): + """ + Save student answer + """ + # pylint: disable=unused-argument + # when max_attempts == 0, the user can make unlimited attempts + success = False + # pylint: disable=no-member + if self.max_attempts > 0 and self.count_attempts >= self.max_attempts: + # pylint: enable=no-member + LOG.error( + "User has already exceeded the maximum number of allowed attempts", + ) + elif self.is_past_due(): + LOG.debug( + "This problem is past due", + ) + else: + self.student_answer = data["answer"] + if data["action"] == "submit": + self.count_attempts += 1 # pylint: disable=no-member + if self.student_answer: + self.score = 1.0 + else: + self.score = 0.0 + self._publish_grade() + self._publish_problem_check() + success = True + result = { + "success": success, + "problem_progress": self._get_problem_progress(), + "submit_class": self._get_submit_class(), + "used_attempts_feedback": self._get_used_attempts_feedback(), + } + return result + + @XBlock.json_handler + def send_hints(self, data, *args, **kwargs): + """ + Build hints once for user + This is called once on page load and + js loop through hints on button click + """ + # pylint: disable=unused-argument + # pylint: disable=no-member + tree = etree.parse(StringIO(self.question_string)) + raw_hints = tree.xpath("/submit_and_compare/demandhint/hint") + decorated_hints = [] + total_hints = len(raw_hints) + for i, raw_hint in enumerate(raw_hints, 1): + hint = _("Hint ({number} of {total}): {hint}").format( + number=i, + total=total_hints, + hint=etree.tostring(raw_hint, encoding="unicode"), + ) + decorated_hints.append(hint) + hints = decorated_hints + return { + "result": "success", + "hints": hints, + } + + def _get_used_attempts_feedback(self): + """ + Returns the text with feedback to the user about the number of attempts + they have used if applicable + """ + result = "" + if self.max_attempts > 0: + # pylint: disable=no-member + result = ngettext( + "You have used {count_attempts} of {max_attempts} submission", + "You have used {count_attempts} of {max_attempts} submissions", + self.max_attempts, + ).format( + count_attempts=self.count_attempts, + max_attempts=self.max_attempts, + ) + # pylint: enable=no-member + return result + + def _can_submit(self): + """ + Determine if a user can submit a response + """ + if self.is_past_due(): + return False + if self.max_attempts == 0: + return True + # pylint: disable=no-member + if self.count_attempts < self.max_attempts: + return True + # pylint: enable=no-member + return False + + def _get_submit_class(self): + """ + Returns the css class for the submit button + """ + result = "" + if not self._can_submit(): + result = "nodisplay" + return result + + def _get_problem_progress(self): + """ + Returns a statement of progress for the XBlock, which depends + on the user's current score + """ + if self.weight == 0: + result = "" + elif self.score == 0.0: + result = "({})".format( + ngettext( + "{weight} point possible", + "{weight} points possible", + self.weight, + ).format( + weight=self.weight, + ) + ) + else: + scaled_score = self.score * self.weight + score_string = f"{scaled_score:g}" + result = "({})".format( + ngettext( + score_string + "/" + "{weight} point", + score_string + "/" + "{weight} points", + self.weight, + ).format( + weight=self.weight, + ) + ) + return result diff --git a/src/submit_and_compare/xblocks.py b/src/submit_and_compare/xblocks.py new file mode 100644 index 0000000..1977f34 --- /dev/null +++ b/src/submit_and_compare/xblocks.py @@ -0,0 +1,25 @@ +""" +This is the core logic for the XBlock +""" + +from xblock.core import XBlock + +from .mixins.dates import EnforceDueDates +from .mixins.events import EventableMixin +from .mixins.scenario import XBlockWorkbenchMixin +from .models import SubmitAndCompareModelMixin +from .views import SubmitAndCompareViewMixin + + +@XBlock.needs("i18n") +class SubmitAndCompareXBlock( + EnforceDueDates, + EventableMixin, + SubmitAndCompareModelMixin, + SubmitAndCompareViewMixin, + XBlockWorkbenchMixin, + XBlock, +): + """ + A Submit-And-Compare XBlock + """ diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index ba91ba3..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for xblocks-extra.""" diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index 7f0d09d..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Placeholder test to verify test infrastructure works.""" - - -def test_placeholder(): - """Placeholder test - replace with actual tests when xblocks are added.""" - assert True