Revert "Revert "refactor: move xmodule folder to root""
This commit is contained in:
committed by
GitHub
parent
46d848be41
commit
a389a9ff10
@@ -1,69 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
XBLOCKS = [
|
||||
"about = xmodule.html_module:AboutBlock",
|
||||
"book = xmodule.template_module:TranslateCustomTagBlock",
|
||||
"annotatable = xmodule.annotatable_module:AnnotatableBlock",
|
||||
"chapter = xmodule.seq_module:SectionBlock",
|
||||
"conditional = xmodule.conditional_module:ConditionalBlock",
|
||||
"course = xmodule.course_module:CourseBlock",
|
||||
"course_info = xmodule.html_module:CourseInfoBlock",
|
||||
"customtag = xmodule.template_module:CustomTagBlock",
|
||||
"custom_tag_template = xmodule.template_module:CustomTagTemplateBlock",
|
||||
"discuss = xmodule.template_module:TranslateCustomTagBlock",
|
||||
"error = xmodule.error_module:ErrorBlock",
|
||||
"hidden = xmodule.hidden_module:HiddenDescriptor",
|
||||
"html = xmodule.html_module:HtmlBlock",
|
||||
"image = xmodule.template_module:TranslateCustomTagBlock",
|
||||
"library = xmodule.library_root_xblock:LibraryRoot",
|
||||
"library_content = xmodule.library_content_module:LibraryContentBlock",
|
||||
"library_sourced = xmodule.library_sourced_block:LibrarySourcedBlock",
|
||||
"lti = xmodule.lti_module:LTIBlock",
|
||||
"nonstaff_error = xmodule.error_module:NonStaffErrorBlock",
|
||||
"poll_question = xmodule.poll_module:PollBlock",
|
||||
"problem = xmodule.capa_module:ProblemBlock",
|
||||
"randomize = xmodule.randomize_module:RandomizeBlock",
|
||||
"sequential = xmodule.seq_module:SequenceBlock",
|
||||
"slides = xmodule.template_module:TranslateCustomTagBlock",
|
||||
"split_test = xmodule.split_test_module:SplitTestBlock",
|
||||
"static_tab = xmodule.html_module:StaticTabBlock",
|
||||
"unit = xmodule.unit_block:UnitBlock",
|
||||
"vertical = xmodule.vertical_block:VerticalBlock",
|
||||
"video = xmodule.video_module:VideoBlock",
|
||||
"videoalpha = xmodule.video_module:VideoBlock",
|
||||
"videodev = xmodule.template_module:TranslateCustomTagBlock",
|
||||
"word_cloud = xmodule.word_cloud_module:WordCloudBlock",
|
||||
"wrapper = xmodule.wrapper_module:WrapperBlock",
|
||||
]
|
||||
XBLOCKS_ASIDES = [
|
||||
'tagging_aside = cms.lib.xblock.tagging:StructuredTagsAside',
|
||||
]
|
||||
|
||||
setup(
|
||||
name="XModule",
|
||||
version="0.1.2",
|
||||
packages=find_packages(exclude=["tests"]),
|
||||
install_requires=[
|
||||
'setuptools',
|
||||
'docopt',
|
||||
'capa',
|
||||
'path.py',
|
||||
'webob',
|
||||
'edx-opaque-keys>=0.4.0',
|
||||
],
|
||||
package_data={
|
||||
'xmodule': ['js/module/*'],
|
||||
},
|
||||
|
||||
# See https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins
|
||||
# for a description of entry_points
|
||||
entry_points={
|
||||
'xblock.v1': XBLOCKS,
|
||||
'xblock_asides.v1': XBLOCKS_ASIDES,
|
||||
'console_scripts': [
|
||||
'xmodule_assets = xmodule.static_content:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
<problem>
|
||||
<script type="loncapa/python">
|
||||
# from loncapa import *
|
||||
x1 = 4 # lc_random(2,4,1)
|
||||
y1 = 5 # lc_random(3,7,1)
|
||||
|
||||
x2 = 10 # lc_random(x1+1,9,1)
|
||||
y2 = 20 # lc_random(y1+1,15,1)
|
||||
|
||||
m = (y2-y1)/(x2-x1)
|
||||
b = y1 - m*x1
|
||||
answer = "%s*x+%s" % (m,b)
|
||||
answer = answer.replace('+-','-')
|
||||
|
||||
inverted_m = (x2-x1)/(y2-y1)
|
||||
inverted_b = b
|
||||
wrongans = "%s*x+%s" % (inverted_m,inverted_b)
|
||||
wrongans = wrongans.replace('+-','-')
|
||||
</script>
|
||||
|
||||
<text>
|
||||
<p>Hints can be provided to students, based on the last response given, as well as the history of responses given. Here is an example of a hint produced by a Formula Response problem.</p>
|
||||
|
||||
<p>
|
||||
What is the equation of the line which passess through ($x1,$y1) and
|
||||
($x2,$y2)?</p>
|
||||
|
||||
<p>The correct answer is <tt>$answer</tt>. A common error is to invert the equation for the slope. Enter <tt>
|
||||
$wrongans</tt> to see a hint.</p>
|
||||
|
||||
</text>
|
||||
|
||||
<formularesponse samples="x@-5:5#11" id="11" answer="$answer">
|
||||
<responseparam description="Numerical Tolerance" type="tolerance" default="0.001" name="tol" />
|
||||
<text>y = <formulaequationinput size="25" /></text>
|
||||
<hintgroup>
|
||||
<formulahint samples="x@-5:5#11" answer="$wrongans" name="inversegrad">
|
||||
</formulahint>
|
||||
<hintpart on="inversegrad">
|
||||
<text>You have inverted the slope in the question.</text>
|
||||
</hintpart>
|
||||
</hintgroup>
|
||||
</formularesponse>
|
||||
</problem>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<problem >
|
||||
<text><h2>Example: String Response Problem</h2>
|
||||
<br/>
|
||||
</text>
|
||||
|
||||
<text>Which US state has Lansing as its capital?</text>
|
||||
<stringresponse answer="Michigan" type="ci">
|
||||
<textline size="20" />
|
||||
<hintgroup>
|
||||
<stringhint answer="wisconsin" type="cs" name="wisc">
|
||||
</stringhint>
|
||||
<stringhint answer="minnesota" type="cs" name="minn">
|
||||
</stringhint>
|
||||
<hintpart on="wisc">
|
||||
<text>The state capital of Wisconsin is Madison.</text>
|
||||
</hintpart>
|
||||
<hintpart on="minn">
|
||||
<text>The state capital of Minnesota is St. Paul.</text>
|
||||
</hintpart>
|
||||
<hintpart on="default">
|
||||
<text>The state you are looking for is also known as the 'Great Lakes State'</text>
|
||||
</hintpart>
|
||||
</hintgroup>
|
||||
</stringresponse>
|
||||
</problem>
|
||||
@@ -1,26 +0,0 @@
|
||||
<problem>
|
||||
<text>
|
||||
<h2>Example: Symbolic Math Response Problem</h2>
|
||||
|
||||
<p>
|
||||
A symbolic math response problem presents one or more symbolic math
|
||||
input fields for input. Correctness of input is evaluated based on
|
||||
the symbolic properties of the expression entered. The student enters
|
||||
text, but sees a proper symbolic rendition of the entered formula, in
|
||||
real time, next to the input box.
|
||||
</p>
|
||||
|
||||
<p>This is a correct answer which may be entered below: </p>
|
||||
<p><tt>cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]</tt></p>
|
||||
|
||||
<text>Compute [mathjax] U = \exp\left( i \theta \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) [/mathjax]
|
||||
and give the resulting \(2 \times 2\) matrix. <br/>
|
||||
Your input should be typed in as a list of lists, eg <tt>[[1,2],[3,4]]</tt>. <br/>
|
||||
[mathjax]U=[/mathjax] <symbolicresponse cfn="symmath_check" answer="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]" options="matrix,imaginary" id="filenamedogi0VpEBOWedxsymmathresponse_1" state="unsubmitted">
|
||||
<textline size="80" math="1" response_id="2" answer_id="1" id="filenamedogi0VpEBOWedxsymmathresponse_2_1"/>
|
||||
</symbolicresponse>
|
||||
<br/>
|
||||
</text>
|
||||
|
||||
</text>
|
||||
</problem>
|
||||
@@ -1,19 +0,0 @@
|
||||
Status: Deprecated (DEPR-24)
|
||||
|
||||
Responsibilities
|
||||
================
|
||||
XModules render specific course run content types to users for both authoring and learning. For instance, there is an XModule for Videos, another for HTML snippets, and another for Sequences. This package provides both the implementations of these XModules as well as some supporting utilities.
|
||||
|
||||
Direction: Convert and Extract
|
||||
==============================
|
||||
XModule exists today as a complex set of compatibility shims on top of XBlock (all XModules currently inherit from XBlock). The goal is for all XModules to either be converted into pure XBlocks or be deleted altogether. Extracting them into separate repositories would be ideal, but even just converting them to pure XBlocks would significantly simplify the runtime.
|
||||
|
||||
Glossary
|
||||
========
|
||||
|
||||
More Documentation
|
||||
==================
|
||||
|
||||
`DEPR-24 <https://openedx.atlassian.net/browse/DEPR-24>`_
|
||||
|
||||
`Example conversion of Capa <https://github.com/edx/edx-platform/pull/20023/>`_
|
||||
@@ -1,226 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
import logging
|
||||
import textwrap
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, String
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from xmodule.editing_module import EditingMixin
|
||||
from xmodule.raw_module import RawMixin
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.xml_module import XmlMixin
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
@XBlock.needs('mako')
|
||||
class AnnotatableBlock(
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
):
|
||||
"""
|
||||
Annotatable XBlock.
|
||||
"""
|
||||
|
||||
data = String(
|
||||
help=_("XML data for the annotation"),
|
||||
scope=Scope.content,
|
||||
default=textwrap.dedent(HTML("""
|
||||
<annotatable>
|
||||
<instructions>
|
||||
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
|
||||
<p>Annotations are specified by an <code>{}annotation{}</code> tag which may may have the following attributes:</p>
|
||||
<ul class="instructions-template">
|
||||
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
|
||||
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
|
||||
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
|
||||
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
|
||||
</ul>
|
||||
</instructions>
|
||||
<p>Add your HTML with annotation spans here.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
|
||||
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
|
||||
</annotatable>
|
||||
""").format(Text('<'), Text('>')))
|
||||
)
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
default=_('Annotation'),
|
||||
)
|
||||
|
||||
uses_xmodule_styles_setup = True
|
||||
|
||||
preview_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/html/display.js'),
|
||||
resource_string(__name__, 'js/src/annotatable/display.js'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.js'),
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
preview_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/annotatable/display.scss'),
|
||||
],
|
||||
}
|
||||
|
||||
studio_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/raw/edit/xml.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
studio_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/codemirror/codemirror.scss'),
|
||||
],
|
||||
}
|
||||
studio_js_module_name = "XMLEditingDescriptor"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
icon_class = 'annotatable'
|
||||
resources_dir = None
|
||||
|
||||
HIGHLIGHT_COLORS = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _get_annotation_class_attr(self, index, el): # lint-amnesty, pylint: disable=unused-argument
|
||||
""" Returns a dict with the CSS class attribute to set on the annotation
|
||||
and an XML key to delete from the element.
|
||||
"""
|
||||
|
||||
attr = {}
|
||||
cls = ['annotatable-span', 'highlight']
|
||||
highlight_key = 'highlight'
|
||||
color = el.get(highlight_key)
|
||||
|
||||
if color is not None:
|
||||
if color in self.HIGHLIGHT_COLORS:
|
||||
cls.append('highlight-' + color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return {'class': attr}
|
||||
|
||||
def _get_annotation_data_attr(self, index, el): # lint-amnesty, pylint: disable=unused-argument
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
to set on the annotation element. Each data attribute has a
|
||||
corresponding 'value' and (optional) '_delete' key to specify
|
||||
an XML attribute to delete.
|
||||
"""
|
||||
|
||||
data_attrs = {}
|
||||
attrs_map = {
|
||||
'body': 'data-comment-body',
|
||||
'title': 'data-comment-title',
|
||||
'problem': 'data-problem-id'
|
||||
}
|
||||
|
||||
for xml_key in attrs_map.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
|
||||
if xml_key in el.attrib:
|
||||
value = el.get(xml_key, '')
|
||||
html_key = attrs_map[xml_key]
|
||||
data_attrs[html_key] = {'value': value, '_delete': xml_key}
|
||||
|
||||
return data_attrs
|
||||
|
||||
def _render_annotation(self, index, el):
|
||||
""" Renders an annotation element for HTML output. """
|
||||
attr = {}
|
||||
attr.update(self._get_annotation_class_attr(index, el))
|
||||
attr.update(self._get_annotation_data_attr(index, el))
|
||||
|
||||
el.tag = 'span'
|
||||
|
||||
for key in attr.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
|
||||
el.set(key, attr[key]['value'])
|
||||
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
|
||||
delete_key = attr[key]['_delete']
|
||||
del el.attrib[delete_key]
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
|
||||
xmltree = etree.fromstring(self.data)
|
||||
content = etree.tostring(xmltree, encoding='unicode')
|
||||
|
||||
xmltree = etree.fromstring(content)
|
||||
xmltree.tag = 'div'
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
index = 0
|
||||
for el in xmltree.findall('.//annotation'):
|
||||
self._render_annotation(index, el)
|
||||
index += 1
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
instructions = xmltree.find('instructions')
|
||||
if instructions is not None:
|
||||
instructions.tag = 'div'
|
||||
xmltree.remove(instructions)
|
||||
return etree.tostring(instructions, encoding='unicode')
|
||||
return None
|
||||
|
||||
def get_html(self):
|
||||
""" Renders parameters to template. """
|
||||
|
||||
xmltree = etree.fromstring(self.data)
|
||||
instructions = self._extract_instructions(xmltree)
|
||||
|
||||
context = {
|
||||
'display_name': self.display_name_with_default,
|
||||
'element_id': self.location.html_id(),
|
||||
'instructions_html': instructions,
|
||||
'content_html': self._render_content()
|
||||
}
|
||||
|
||||
return self.runtime.service(self, 'mako').render_template('annotatable.html', context)
|
||||
|
||||
def student_view(self, context): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Renders the output that a student will see.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
fragment.add_content(self.get_html())
|
||||
add_webpack_to_fragment(fragment, 'AnnotatableBlockPreview')
|
||||
shim_xmodule_js(fragment, 'Annotatable')
|
||||
|
||||
return fragment
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
fragment = Fragment(
|
||||
self.runtime.service(self, 'mako').render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
add_webpack_to_fragment(fragment, 'AnnotatableBlockStudio')
|
||||
shim_xmodule_js(fragment, self.studio_js_module_name)
|
||||
return fragment
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Annotations Tool Mixin
|
||||
This file contains global variables and functions used in the various Annotation Tools.
|
||||
"""
|
||||
|
||||
from html.parser import HTMLParser
|
||||
from os.path import basename, splitext
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def get_instructions(xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
instructions = xmltree.find('instructions')
|
||||
if instructions is not None:
|
||||
instructions.tag = 'div'
|
||||
xmltree.remove(instructions)
|
||||
return etree.tostring(instructions, encoding='unicode')
|
||||
return None
|
||||
|
||||
|
||||
def get_extension(srcurl):
|
||||
"""get the extension of a given url """
|
||||
if 'youtu' in srcurl:
|
||||
return 'video/youtube'
|
||||
else:
|
||||
disassembled = urlparse(srcurl)
|
||||
file_ext = splitext(basename(disassembled.path))[1]
|
||||
return 'video/' + file_ext.replace('.', '')
|
||||
|
||||
|
||||
class MLStripper(HTMLParser): # lint-amnesty, pylint: disable=abstract-method
|
||||
"helper function for html_to_text below"
|
||||
def __init__(self):
|
||||
HTMLParser.__init__(self)
|
||||
self.reset()
|
||||
self.fed = []
|
||||
|
||||
def handle_data(self, data):
|
||||
"""takes the data in separate chunks"""
|
||||
self.fed.append(data)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
"""appends the reference to the body"""
|
||||
self.fed.append('&%s;' % name)
|
||||
|
||||
def get_data(self):
|
||||
"""joins together the seperate chunks into one cohesive string"""
|
||||
return ''.join(self.fed)
|
||||
|
||||
|
||||
def html_to_text(html):
|
||||
"strips the html tags off of the text to return plaintext"
|
||||
htmlstripper = MLStripper()
|
||||
htmlstripper.feed(html)
|
||||
return htmlstripper.get_data()
|
||||
@@ -1,36 +0,0 @@
|
||||
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
|
||||
window.LibraryContentAuthorView = function(runtime, element) {
|
||||
'use strict';
|
||||
var $element = $(element);
|
||||
var usage_id = $element.data('usage-id');
|
||||
// The "Update Now" button is not a child of 'element', as it is in the validation message area
|
||||
// But it is still inside this xblock's wrapper element, which we can easily find:
|
||||
var $wrapper = $element.parents('*[data-locator="' + usage_id + '"]');
|
||||
|
||||
// We can't bind to the button itself because in the bok choy test environment,
|
||||
// it may not yet exist at this point in time... not sure why.
|
||||
$wrapper.on('click', '.library-update-btn', function(e) {
|
||||
e.preventDefault();
|
||||
// Update the XBlock with the latest matching content from the library:
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
element: element,
|
||||
message: gettext('Updating with latest library content')
|
||||
});
|
||||
$.post(runtime.handlerUrl(element, 'refresh_children')).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) {
|
||||
// We are on a course unit page. The notify('save') should refresh this block,
|
||||
// but that is only working on the container page view of this block.
|
||||
// Why? On the unit page, this XBlock's runtime has no reference to the
|
||||
// XBlockContainerPage - only the top-level XBlock (a vertical) runtime does.
|
||||
// But unfortunately there is no way to get a reference to our parent block's
|
||||
// JS 'runtime' object. So instead we must refresh the whole page:
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
/* JavaScript for reset option that can be done on a randomized LibraryContentBlock */
|
||||
function LibraryContentReset(runtime, element) {
|
||||
$('.problem-reset-btn', element).click((e) => {
|
||||
e.preventDefault();
|
||||
$.post({
|
||||
url: runtime.handlerUrl(element, 'reset_selected_children'),
|
||||
success(data) {
|
||||
edx.HtmlUtils.setHtml(element, edx.HtmlUtils.HTML(data));
|
||||
// Rebind the reset button for the block
|
||||
XBlock.initializeBlock(element);
|
||||
// Render the new set of problems (XBlocks)
|
||||
$(".xblock", element).each(function(i, child) {
|
||||
XBlock.initializeBlock(child);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/* globals gettext */
|
||||
|
||||
import 'whatwg-fetch';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import _ from 'underscore';
|
||||
import styles from './style.css';
|
||||
|
||||
class LibrarySourcedBlockPicker extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
libraries: [],
|
||||
xblocks: [],
|
||||
searchedLibrary: '',
|
||||
libraryLoading: false,
|
||||
xblocksLoading: false,
|
||||
selectedLibrary: undefined,
|
||||
selectedXblocks: new Set(this.props.selectedXblocks),
|
||||
};
|
||||
this.onLibrarySearchInput = this.onLibrarySearchInput.bind(this);
|
||||
this.onXBlockSearchInput = this.onXBlockSearchInput.bind(this);
|
||||
this.onLibrarySelected = this.onLibrarySelected.bind(this);
|
||||
this.onXblockSelected = this.onXblockSelected.bind(this);
|
||||
this.onDeleteClick = this.onDeleteClick.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLibraries();
|
||||
}
|
||||
|
||||
fetchLibraries(textSearch='', page=1, append=false) {
|
||||
this.setState({
|
||||
libraries: append ? this.state.libraries : [],
|
||||
libraryLoading: true,
|
||||
}, async function() {
|
||||
try {
|
||||
let res = await fetch(`/api/libraries/v2/?pagination=true&page=${page}&text_search=${textSearch}`);
|
||||
res = await res.json();
|
||||
this.setState({
|
||||
libraries: this.state.libraries.concat(res.results),
|
||||
libraryLoading: false,
|
||||
}, () => {
|
||||
if (res.next) {
|
||||
this.fetchLibraries(textSearch, page+1, true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
$('#library-sourced-block-picker').trigger('error', {
|
||||
title: 'Could not fetch library',
|
||||
message: error,
|
||||
});
|
||||
this.setState({
|
||||
libraries: [],
|
||||
libraryLoading: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchXblocks(library, textSearch='', page=1, append=false) {
|
||||
this.setState({
|
||||
xblocks: append ? this.state.xblocks : [],
|
||||
xblocksLoading: true,
|
||||
}, async function() {
|
||||
try {
|
||||
let res = await fetch(`/api/libraries/v2/${library}/blocks/?pagination=true&page=${page}&text_search=${textSearch}`);
|
||||
res = await res.json();
|
||||
this.setState({
|
||||
xblocks: this.state.xblocks.concat(res.results),
|
||||
xblocksLoading: false,
|
||||
}, () => {
|
||||
if (res.next) {
|
||||
this.fetchXblocks(library, textSearch, page+1, true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
$('#library-sourced-block-picker').trigger('error', {
|
||||
title: 'Could not fetch xblocks',
|
||||
message: error,
|
||||
});
|
||||
this.setState({
|
||||
xblocks: [],
|
||||
xblocksLoading: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onLibrarySearchInput(event) {
|
||||
event.persist()
|
||||
this.setState({
|
||||
searchedLibrary: event.target.value,
|
||||
});
|
||||
if (!this.debouncedFetchLibraries) {
|
||||
this.debouncedFetchLibraries = _.debounce(value => {
|
||||
this.fetchLibraries(value);
|
||||
}, 300);
|
||||
}
|
||||
this.debouncedFetchLibraries(event.target.value);
|
||||
}
|
||||
|
||||
onXBlockSearchInput(event) {
|
||||
event.persist()
|
||||
if (!this.debouncedFetchXblocks) {
|
||||
this.debouncedFetchXblocks = _.debounce(value => {
|
||||
this.fetchXblocks(this.state.selectedLibrary, value);
|
||||
}, 300);
|
||||
}
|
||||
this.debouncedFetchXblocks(event.target.value);
|
||||
}
|
||||
|
||||
onLibrarySelected(event) {
|
||||
this.setState({
|
||||
selectedLibrary: event.target.value,
|
||||
});
|
||||
this.fetchXblocks(event.target.value);
|
||||
}
|
||||
|
||||
onXblockSelected(event) {
|
||||
let state = new Set(this.state.selectedXblocks);
|
||||
if (event.target.checked) {
|
||||
state.add(event.target.value);
|
||||
} else {
|
||||
state.delete(event.target.value);
|
||||
}
|
||||
this.setState({
|
||||
selectedXblocks: state,
|
||||
}, this.updateList);
|
||||
}
|
||||
|
||||
onDeleteClick(event) {
|
||||
let value;
|
||||
if (event.target.tagName == 'SPAN') {
|
||||
value = event.target.parentElement.dataset.value;
|
||||
} else {
|
||||
value = event.target.dataset.value;
|
||||
}
|
||||
let state = new Set(this.state.selectedXblocks);
|
||||
state.delete(value);
|
||||
this.setState({
|
||||
selectedXblocks: state,
|
||||
}, this.updateList);
|
||||
}
|
||||
|
||||
updateList(list) {
|
||||
$('#library-sourced-block-picker').trigger('selected-xblocks', {
|
||||
sourceBlockIds: Array.from(this.state.selectedXblocks),
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section>
|
||||
<div className="container-message wrapper-message">
|
||||
<div className="message has-warnings" style={{margin: 0, color: "white"}}>
|
||||
<p className="warning">
|
||||
<span className="icon fa fa-warning" aria-hidden="true"></span>
|
||||
Hitting 'Save and Import' will import the latest versions of the selected blocks, overwriting any changes done to this block post-import.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display: "flex", flexDirection: "row", justifyContent: "center"}}>
|
||||
<div className={styles.column}>
|
||||
<input type="text" className={[styles.search]} aria-label="Search for library" placeholder="Search for library" label="Search for library" name="librarySearch" onChange={this.onLibrarySearchInput}/>
|
||||
<div className={styles.elementList} onChange={this.onLibrarySelected}>
|
||||
{
|
||||
this.state.libraries.map(lib => (
|
||||
<div key={lib.id} className={styles.element}>
|
||||
<input id={`sourced-library-${lib.id}`} type="radio" value={lib.id} name="library"/>
|
||||
<label className={styles.elementItem} htmlFor={`sourced-library-${lib.id}`}>{lib.title}</label>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{ this.state.libraryLoading && <span>{gettext('Loading...')}</span> }
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<input type="text" className={[styles.search]} aria-label="Search for XBlocks" placeholder="Search for XBlocks" name="xblockSearch" onChange={this.onXBlockSearchInput} disabled={!this.state.selectedLibrary || this.state.libraryLoading}/>
|
||||
<div className={styles.elementList} onChange={this.onXblockSelected}>
|
||||
{
|
||||
this.state.xblocks.map(block => (
|
||||
<div key={block.id} className={styles.element}>
|
||||
<input id={`sourced-block-${block.id}`} type="checkbox" value={block.id} name="block" checked={this.state.selectedXblocks.has(block.id)} readOnly/>
|
||||
<label className={styles.elementItem} htmlFor={`sourced-block-${block.id}`}>{block.display_name} ({block.id})</label>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{ this.state.xblocksLoading && <span>{gettext('Loading...')}</span> }
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.column}>
|
||||
<h4 className={styles.selectedBlocks}>{gettext('Selected blocks')}</h4>
|
||||
<ul>
|
||||
{
|
||||
Array.from(this.state.selectedXblocks).map(block => (
|
||||
<li key={block} className={styles.element} style={{display: "flex"}}>
|
||||
<label className={styles.elementItem}>
|
||||
{block}
|
||||
</label>
|
||||
<button className={[styles.remove]} data-value={block} onClick={this.onDeleteClick} aria-label="Remove block">
|
||||
<span aria-hidden="true" className="icon fa fa-times"></span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LibrarySourcedBlockPicker.propTypes = {
|
||||
selectedXblocks: PropTypes.array,
|
||||
};
|
||||
|
||||
LibrarySourcedBlockPicker.defaultProps = {
|
||||
selectedXblocks: [],
|
||||
};
|
||||
|
||||
export { LibrarySourcedBlockPicker }; // eslint-disable-line import/prefer-default-export
|
||||
@@ -1,58 +0,0 @@
|
||||
/* JavaScript for allowing editing options on LibrarySourceBlock's studio view */
|
||||
window.LibrarySourceBlockStudioView = function(runtime, element) {
|
||||
'use strict';
|
||||
var self = this;
|
||||
|
||||
$('#library-sourced-block-picker', element).on('selected-xblocks', function(e, params) {
|
||||
self.sourceBlockIds = params.sourceBlockIds;
|
||||
});
|
||||
|
||||
$('#library-sourced-block-picker', element).on('error', function(e, params) {
|
||||
runtime.notify('error', {title: gettext(params.title), message: params.message});
|
||||
});
|
||||
|
||||
$('.save-button', element).on('click', function(e) {
|
||||
e.preventDefault();
|
||||
var url = $(e.target).data('submit-url');
|
||||
var data = {
|
||||
values: {
|
||||
source_block_ids: self.sourceBlockIds
|
||||
},
|
||||
defaults: ['display_name']
|
||||
};
|
||||
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
message: gettext('Saving'),
|
||||
element: element
|
||||
});
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: JSON.stringify(data),
|
||||
global: false // Disable error handling that conflicts with studio's notify('save') and notify('cancel')
|
||||
}).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
}).fail(function(jqXHR) {
|
||||
var message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
|
||||
if (jqXHR.responseText) { // Is there a more specific error message we can show?
|
||||
try {
|
||||
message = JSON.parse(jqXHR.responseText).error;
|
||||
if (typeof message === 'object' && message.messages) {
|
||||
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
|
||||
message = $.map(message.messages, function(msg) { return msg.text; }).join(', ');
|
||||
}
|
||||
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
|
||||
}
|
||||
runtime.notify('error', {title: gettext('Unable to update settings'), message: message});
|
||||
});
|
||||
});
|
||||
|
||||
$('.cancel-button', element).on('click', function(e) {
|
||||
e.preventDefault();
|
||||
runtime.notify('cancel', {});
|
||||
});
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 10px;
|
||||
max-width: 300px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.elementList {
|
||||
margin-top: 10px;
|
||||
}
|
||||
input.search {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
.element > input[type='checkbox'],
|
||||
.element > input[type='radio'] {
|
||||
position: absolute;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
top: -9999px;
|
||||
}
|
||||
.element > .elementItem {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.element + .element > label {
|
||||
border-top: 0;
|
||||
}
|
||||
.element > input[type='checkbox']:focus + label,
|
||||
.element > input[type='radio']:focus + label,
|
||||
.element > input:hover + label {
|
||||
background: #f6f6f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.element > input:checked + label {
|
||||
background: #23419f;
|
||||
color: #fff;
|
||||
}
|
||||
.element > input[type='checkbox']:checked:focus + label,
|
||||
.element > input[type='radio']:checked:focus + label,
|
||||
.element > input:checked:hover + label {
|
||||
background: #193787;
|
||||
cursor: pointer;
|
||||
}
|
||||
.selectedBlocks {
|
||||
padding: 12px 8px 20px;
|
||||
}
|
||||
button.remove {
|
||||
background: #e00;
|
||||
color: #fff;
|
||||
border: solid rgba(0,0,0,0.25) 1px;
|
||||
}
|
||||
button.remove:focus,
|
||||
button.remove:hover {
|
||||
background: #d00;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* JavaScript for editing operations that can be done on the split test author view. */
|
||||
window.SplitTestAuthorView = function(runtime, element) {
|
||||
'use strict';
|
||||
var $element = $(element);
|
||||
var splitTestLocator = $element.closest('.studio-xblock-wrapper').data('locator');
|
||||
|
||||
runtime.listenTo('add-missing-groups', function(parentLocator) {
|
||||
if (splitTestLocator === parentLocator) {
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
element: element,
|
||||
message: gettext('Creating missing groups')
|
||||
});
|
||||
$.post(runtime.handlerUrl(element, 'add_missing_groups')).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to delete events so that the view can refresh when the last inactive group is removed.
|
||||
runtime.listenTo('deleted-child', function(parentLocator) {
|
||||
var inactiveGroups = $element.find('.is-inactive .studio-xblock-wrapper');
|
||||
if (splitTestLocator === parentLocator && inactiveGroups.length === 0) {
|
||||
runtime.refreshXBlock($element);
|
||||
}
|
||||
});
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
/* Creates a new selector for managing toggling which child to show. */
|
||||
window.ABTestSelector = function(runtime, elem) {
|
||||
'use strict';
|
||||
var _this = {};
|
||||
_this.elem = $(elem);
|
||||
_this.children = _this.elem.find('.split-test-child');
|
||||
_this.content_container = _this.elem.find('.split-test-child-container');
|
||||
|
||||
function select_child(group_id) {
|
||||
// iterate over all the children and hide all the ones that haven't been selected
|
||||
// and show the one that was selected
|
||||
_this.children.each(function() {
|
||||
// force this id to remain a string, even if it looks like something else
|
||||
var child_group_id = $(this).data('group-id').toString();
|
||||
if (child_group_id === group_id) {
|
||||
_this.content_container.html(edx.HtmlUtils.HTML($(this).text()).toString());
|
||||
XBlock.initializeBlocks(_this.content_container, $(elem).data('request-token'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var select = _this.elem.find('.split-test-select');
|
||||
var cur_group_id = select.val();
|
||||
select_child(cur_group_id);
|
||||
|
||||
// bind the change event to the dropdown
|
||||
select.change(function() {
|
||||
var group_id = $(this).val();
|
||||
select_child(group_id);
|
||||
});
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
/* Javascript for the Split Test XBlock. */
|
||||
window.SplitTestStudentView = function(runtime, element) {
|
||||
'use strict';
|
||||
$.post(runtime.handlerUrl(element, 'log_child_render'));
|
||||
return {};
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/* JavaScript for Vertical Student View. */
|
||||
|
||||
/* global Set:false */ // false means do not assign to Set
|
||||
|
||||
// The vertical marks blocks complete if they are completable by viewing. The
|
||||
// global variable SEEN_COMPLETABLES tracks blocks between separate loads of
|
||||
// the same vertical (when a learner goes from one tab to the next, and then
|
||||
// navigates back within a given sequential) to protect against duplicate calls
|
||||
// to the server.
|
||||
|
||||
import BookmarkButton from 'course_bookmarks/js/views/bookmark_button';
|
||||
import {markBlocksCompletedOnViewIfNeeded} from '../../../../../../../../lms/static/completion/js/CompletionOnViewService.js';
|
||||
|
||||
var SEEN_COMPLETABLES = new Set();
|
||||
|
||||
window.VerticalStudentView = function(runtime, element) {
|
||||
'use strict';
|
||||
var $element = $(element);
|
||||
var $bookmarkButtonElement = $element.find('.bookmark-button');
|
||||
markBlocksCompletedOnViewIfNeeded(runtime, element);
|
||||
return new BookmarkButton({
|
||||
el: $bookmarkButtonElement,
|
||||
bookmarkId: $bookmarkButtonElement.data('bookmarkId'),
|
||||
usageId: $element.data('usageId'),
|
||||
bookmarked: $element.parent('#seq_content').data('bookmarked'),
|
||||
apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl')
|
||||
});
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
extends: 'eslint-config-edx',
|
||||
root: true,
|
||||
settings: {
|
||||
'import/resolver': 'webpack',
|
||||
},
|
||||
overrides: {
|
||||
excludedFiles: 'public/js/*',
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
public
|
||||
@@ -1,434 +0,0 @@
|
||||
/*
|
||||
* Word cloud layout by Jason Davies
|
||||
* https://github.com/jasondavies/d3-cloud
|
||||
*
|
||||
*
|
||||
* Copyright (c) 2012, Jason Davies.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* * The name Jason Davies may not be used to endorse or promote products
|
||||
* derived from this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL JASON DAVIES BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
|
||||
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
(function(exports) {
|
||||
function cloud() {
|
||||
var size = [256, 256],
|
||||
text = cloudText,
|
||||
font = cloudFont,
|
||||
fontSize = cloudFontSize,
|
||||
fontStyle = cloudFontNormal,
|
||||
fontWeight = cloudFontNormal,
|
||||
rotate = cloudRotate,
|
||||
padding = cloudPadding,
|
||||
spiral = archimedeanSpiral,
|
||||
words = [],
|
||||
timeInterval = Infinity,
|
||||
event = d3.dispatch("word", "end"),
|
||||
timer = null,
|
||||
cloud = {};
|
||||
|
||||
cloud.start = function() {
|
||||
var board = zeroArray((size[0] >> 5) * size[1]),
|
||||
bounds = null,
|
||||
n = words.length,
|
||||
i = -1,
|
||||
tags = [],
|
||||
data = words.map(function(d, i) {
|
||||
d.text = text.call(this, d, i);
|
||||
d.font = font.call(this, d, i);
|
||||
d.style = fontStyle.call(this, d, i);
|
||||
d.weight = fontWeight.call(this, d, i);
|
||||
d.rotate = rotate.call(this, d, i);
|
||||
d.size = ~~fontSize.call(this, d, i);
|
||||
d.padding = cloudPadding.call(this, d, i);
|
||||
return d;
|
||||
}).sort(function(a, b) { return b.size - a.size; });
|
||||
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(step, 0);
|
||||
step();
|
||||
|
||||
return cloud;
|
||||
|
||||
function step() {
|
||||
var start = +new Date,
|
||||
d;
|
||||
while (+new Date - start < timeInterval && ++i < n && timer) {
|
||||
d = data[i];
|
||||
d.x = (size[0] * (Math.random() + .5)) >> 1;
|
||||
d.y = (size[1] * (Math.random() + .5)) >> 1;
|
||||
cloudSprite(d, data, i);
|
||||
if (place(board, d, bounds)) {
|
||||
tags.push(d);
|
||||
event.word(d);
|
||||
if (bounds) cloudBounds(bounds, d);
|
||||
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
|
||||
// Temporary hack
|
||||
d.x -= size[0] >> 1;
|
||||
d.y -= size[1] >> 1;
|
||||
}
|
||||
}
|
||||
if (i >= n) {
|
||||
cloud.stop();
|
||||
event.end(tags, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloud.stop = function() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.timeInterval = function(x) {
|
||||
if (!arguments.length) return timeInterval;
|
||||
timeInterval = x == null ? Infinity : x;
|
||||
return cloud;
|
||||
};
|
||||
|
||||
function place(board, tag, bounds) {
|
||||
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
|
||||
startX = tag.x,
|
||||
startY = tag.y,
|
||||
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
|
||||
s = spiral(size),
|
||||
dt = Math.random() < .5 ? 1 : -1,
|
||||
t = -dt,
|
||||
dxdy,
|
||||
dx,
|
||||
dy;
|
||||
|
||||
while (dxdy = s(t += dt)) {
|
||||
dx = ~~dxdy[0];
|
||||
dy = ~~dxdy[1];
|
||||
|
||||
if (Math.min(dx, dy) > maxDelta) break;
|
||||
|
||||
tag.x = startX + dx;
|
||||
tag.y = startY + dy;
|
||||
|
||||
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
|
||||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
|
||||
// TODO only check for collisions within current bounds.
|
||||
if (!bounds || !cloudCollide(tag, board, size[0])) {
|
||||
if (!bounds || collideRects(tag, bounds)) {
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
sw = size[0] >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
delete tag.sprite;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cloud.words = function(x) {
|
||||
if (!arguments.length) return words;
|
||||
words = x;
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.size = function(x) {
|
||||
if (!arguments.length) return size;
|
||||
size = [+x[0], +x[1]];
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.font = function(x) {
|
||||
if (!arguments.length) return font;
|
||||
font = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.fontStyle = function(x) {
|
||||
if (!arguments.length) return fontStyle;
|
||||
fontStyle = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.fontWeight = function(x) {
|
||||
if (!arguments.length) return fontWeight;
|
||||
fontWeight = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.rotate = function(x) {
|
||||
if (!arguments.length) return rotate;
|
||||
rotate = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.text = function(x) {
|
||||
if (!arguments.length) return text;
|
||||
text = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.spiral = function(x) {
|
||||
if (!arguments.length) return spiral;
|
||||
spiral = spirals[x + ""] || x;
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.fontSize = function(x) {
|
||||
if (!arguments.length) return fontSize;
|
||||
fontSize = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
cloud.padding = function(x) {
|
||||
if (!arguments.length) return padding;
|
||||
padding = d3.functor(x);
|
||||
return cloud;
|
||||
};
|
||||
|
||||
return d3.rebind(cloud, event, "on");
|
||||
}
|
||||
|
||||
function cloudText(d) {
|
||||
return d.text;
|
||||
}
|
||||
|
||||
function cloudFont() {
|
||||
return "serif";
|
||||
}
|
||||
|
||||
function cloudFontNormal() {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function cloudFontSize(d) {
|
||||
return Math.sqrt(d.value);
|
||||
}
|
||||
|
||||
function cloudRotate() {
|
||||
return (~~(Math.random() * 6) - 3) * 30;
|
||||
}
|
||||
|
||||
function cloudPadding() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fetches a monochrome sprite bitmap for the specified text.
|
||||
// Load in batches for speed.
|
||||
function cloudSprite(d, data, di) {
|
||||
if (d.sprite) return;
|
||||
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
|
||||
var x = 0,
|
||||
y = 0,
|
||||
maxh = 0,
|
||||
n = data.length;
|
||||
di--;
|
||||
while (++di < n) {
|
||||
d = data[di];
|
||||
c.save();
|
||||
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
|
||||
var w = c.measureText(d.text + "m").width * ratio,
|
||||
h = d.size << 1;
|
||||
if (d.rotate) {
|
||||
var sr = Math.sin(d.rotate * cloudRadians),
|
||||
cr = Math.cos(d.rotate * cloudRadians),
|
||||
wcr = w * cr,
|
||||
wsr = w * sr,
|
||||
hcr = h * cr,
|
||||
hsr = h * sr;
|
||||
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
|
||||
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
|
||||
} else {
|
||||
w = (w + 0x1f) >> 5 << 5;
|
||||
}
|
||||
if (h > maxh) maxh = h;
|
||||
if (x + w >= (cw << 5)) {
|
||||
x = 0;
|
||||
y += maxh;
|
||||
maxh = 0;
|
||||
}
|
||||
if (y + h >= ch) break;
|
||||
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
|
||||
if (d.rotate) c.rotate(d.rotate * cloudRadians);
|
||||
c.fillText(d.text, 0, 0);
|
||||
c.restore();
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.xoff = x;
|
||||
d.yoff = y;
|
||||
d.x1 = w >> 1;
|
||||
d.y1 = h >> 1;
|
||||
d.x0 = -d.x1;
|
||||
d.y0 = -d.y1;
|
||||
x += w;
|
||||
}
|
||||
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
|
||||
sprite = [];
|
||||
while (--di >= 0) {
|
||||
d = data[di];
|
||||
var w = d.width,
|
||||
w32 = w >> 5,
|
||||
h = d.y1 - d.y0,
|
||||
p = d.padding;
|
||||
// Zero the buffer
|
||||
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
|
||||
x = d.xoff;
|
||||
if (x == null) return;
|
||||
y = d.yoff;
|
||||
var seen = 0,
|
||||
seenRow = -1;
|
||||
for (var j = 0; j < h; j++) {
|
||||
for (var i = 0; i < w; i++) {
|
||||
var k = w32 * j + (i >> 5),
|
||||
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
|
||||
if (p) {
|
||||
if (j) sprite[k - w32] |= m;
|
||||
if (j < w - 1) sprite[k + w32] |= m;
|
||||
m |= (m << 1) | (m >> 1);
|
||||
}
|
||||
sprite[k] |= m;
|
||||
seen |= m;
|
||||
}
|
||||
if (seen) seenRow = j;
|
||||
else {
|
||||
d.y0++;
|
||||
h--;
|
||||
j--;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
d.y1 = d.y0 + seenRow;
|
||||
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
|
||||
}
|
||||
}
|
||||
|
||||
// Use mask-based collision detection.
|
||||
function cloudCollide(tag, board, sw) {
|
||||
sw >>= 5;
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
|
||||
& board[x + i]) return true;
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cloudBounds(bounds, d) {
|
||||
var b0 = bounds[0],
|
||||
b1 = bounds[1];
|
||||
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
|
||||
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
|
||||
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
|
||||
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
|
||||
}
|
||||
|
||||
function collideRects(a, b) {
|
||||
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
|
||||
}
|
||||
|
||||
function archimedeanSpiral(size) {
|
||||
var e = size[0] / size[1];
|
||||
return function(t) {
|
||||
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
|
||||
};
|
||||
}
|
||||
|
||||
function rectangularSpiral(size) {
|
||||
var dy = 4,
|
||||
dx = dy * size[0] / size[1],
|
||||
x = 0,
|
||||
y = 0;
|
||||
return function(t) {
|
||||
var sign = t < 0 ? -1 : 1;
|
||||
// See triangular numbers: T_n = n * (n + 1) / 2.
|
||||
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
|
||||
case 0: x += dx; break;
|
||||
case 1: y += dy; break;
|
||||
case 2: x -= dx; break;
|
||||
default: y -= dy; break;
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
// TODO reuse arrays?
|
||||
function zeroArray(n) {
|
||||
var a = [],
|
||||
i = -1;
|
||||
while (++i < n) a[i] = 0;
|
||||
return a;
|
||||
}
|
||||
|
||||
var cloudRadians = Math.PI / 180,
|
||||
cw = 1 << 11 >> 5,
|
||||
ch = 1 << 11,
|
||||
canvas,
|
||||
ratio = 1;
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
|
||||
canvas.width = (cw << 5) / ratio;
|
||||
canvas.height = ch / ratio;
|
||||
} else {
|
||||
// node-canvas support
|
||||
var Canvas = require("canvas");
|
||||
canvas = new Canvas(cw << 5, ch);
|
||||
}
|
||||
|
||||
var c = canvas.getContext("2d"),
|
||||
spirals = {
|
||||
archimedean: archimedeanSpiral,
|
||||
rectangular: rectangularSpiral
|
||||
};
|
||||
c.fillStyle = "red";
|
||||
c.textAlign = "center";
|
||||
|
||||
exports.cloud = cloud;
|
||||
})(typeof exports === "undefined" ? d3.layout || (d3.layout = {}) : exports);
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
||||
const WordCloudMain = require('xmodule/assets/word_cloud/src/js/word_cloud_main.js');
|
||||
|
||||
window.WordCloud = WordCloudMain.default;
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* @file The main module definition for Word Cloud XModule.
|
||||
*
|
||||
* Defines a constructor function which operates on a DOM element. Either
|
||||
* show the user text inputs so he can enter words, or render his selected
|
||||
* words along with the word cloud representing the top words.
|
||||
*
|
||||
* @module WordCloudMain
|
||||
*
|
||||
* @exports WordCloudMain
|
||||
*
|
||||
* @external $
|
||||
*/
|
||||
|
||||
import * as HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils';
|
||||
import d3 from 'd3.min';
|
||||
import { cloud as d3Cloud } from 'd3.layout.cloud';
|
||||
import gettext from 'gettext';
|
||||
|
||||
function generateUniqueId(wordCloudId, counter) {
|
||||
return `_wc_${wordCloudId}_${counter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @function WordCloudMain
|
||||
*
|
||||
* This function will process all the attributes from the DOM element passed, taking all of
|
||||
* the configuration attributes. It will either then attach a callback handler for the click
|
||||
* event on the button in the case when the user needs to enter words, or it will call the
|
||||
* appropriate mehtod to generate and render a word cloud from user's enetered words along with
|
||||
* all of the other words.
|
||||
*
|
||||
* @constructor
|
||||
*
|
||||
* @param {jQuery} el DOM element where the word cloud will be processed and created.
|
||||
*/
|
||||
export default class WordCloudMain {
|
||||
constructor(el) {
|
||||
this.wordCloudEl = $(el).find('.word_cloud');
|
||||
|
||||
// Get the URL to which we will post the users words.
|
||||
this.ajax_url = this.wordCloudEl.data('ajax-url');
|
||||
|
||||
// Dimensions of the box where the word cloud will be drawn.
|
||||
this.width = 635;
|
||||
this.height = 635;
|
||||
|
||||
// Hide WordCloud container before Ajax request done
|
||||
this.wordCloudEl.hide();
|
||||
|
||||
// Retriveing response from the server as an AJAX request. Attach a callback that will
|
||||
// be fired on server's response.
|
||||
$.postWithPrefix(
|
||||
`${this.ajax_url}/get_state`,
|
||||
null,
|
||||
(response) => {
|
||||
if (response.status !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.configJson = response;
|
||||
},
|
||||
)
|
||||
.done(() => {
|
||||
// Show WordCloud container after Ajax request done
|
||||
this.wordCloudEl.show();
|
||||
|
||||
if (this.configJson && this.configJson.submitted) {
|
||||
this.showWordCloud(this.configJson);
|
||||
}
|
||||
});
|
||||
|
||||
$(el).find('.save').on('click', () => {
|
||||
this.submitAnswer();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @function submitAnswer
|
||||
*
|
||||
* Callback to be executed when the user eneter his words. It will send user entries to the
|
||||
* server, and upon receiving correct response, will call the function to generate the
|
||||
* word cloud.
|
||||
*/
|
||||
submitAnswer() {
|
||||
const data = { student_words: [] };
|
||||
|
||||
// Populate the data to be sent to the server with user's words.
|
||||
this.wordCloudEl.find('input.input-cloud').each((index, value) => {
|
||||
data.student_words.push($(value).val());
|
||||
});
|
||||
|
||||
// Send the data to the server as an AJAX request. Attach a callback that will
|
||||
// be fired on server's response.
|
||||
$.postWithPrefix(
|
||||
`${this.ajax_url}/submit`, $.param(data),
|
||||
(response) => {
|
||||
if (response.status !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showWordCloud(response);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @function showWordCloud
|
||||
*
|
||||
* @param {object} response The response from the server that contains the user's entered words
|
||||
* along with all of the top words.
|
||||
*
|
||||
* This function will set up everything for d3 and launch the draw method. Among other things,
|
||||
* iw will determine maximum word size.
|
||||
*/
|
||||
showWordCloud(response) {
|
||||
const words = response.top_words;
|
||||
let maxSize = 0;
|
||||
let minSize = 10000;
|
||||
let scaleFactor = 1;
|
||||
let maxFontSize = 200;
|
||||
const minFontSize = 16;
|
||||
|
||||
this.wordCloudEl.find('.input_cloud_section').hide();
|
||||
|
||||
// Find the word with the maximum percentage. I.e. the most popular word.
|
||||
$.each(words, (index, word) => {
|
||||
if (word.size > maxSize) {
|
||||
maxSize = word.size;
|
||||
}
|
||||
if (word.size < minSize) {
|
||||
minSize = word.size;
|
||||
}
|
||||
});
|
||||
|
||||
// Find the longest word, and calculate the scale appropriately. This is
|
||||
// required so that even long words fit into the drawing area.
|
||||
//
|
||||
// This is a fix for: if the word is very long and/or big, it is discarded by
|
||||
// for unknown reason.
|
||||
$.each(words, (index, word) => {
|
||||
let tempScaleFactor = 1.0;
|
||||
const size = ((word.size / maxSize) * maxFontSize);
|
||||
|
||||
if (size * 0.7 * word.text.length > this.width) {
|
||||
tempScaleFactor = ((this.width / word.text.length) / 0.7) / size;
|
||||
}
|
||||
|
||||
if (scaleFactor > tempScaleFactor) {
|
||||
scaleFactor = tempScaleFactor;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the maximum font size based on the longest word.
|
||||
maxFontSize *= scaleFactor;
|
||||
|
||||
// Generate the word cloud.
|
||||
d3Cloud().size([this.width, this.height])
|
||||
.words(words)
|
||||
.rotate(() => Math.floor((Math.random() * 2)) * 90)
|
||||
.font('Impact')
|
||||
.fontSize((d) => {
|
||||
let size = (d.size / maxSize) * maxFontSize;
|
||||
|
||||
size = size >= minFontSize ? size : minFontSize;
|
||||
|
||||
return size;
|
||||
})
|
||||
// Draw the word cloud.
|
||||
.on('end', (wds, bounds) => this.drawWordCloud(response, wds, bounds))
|
||||
.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @function drawWordCloud
|
||||
*
|
||||
* This function will be called when d3 has finished initing the state for our word cloud,
|
||||
* and it is ready to hand off the process to the drawing routine. Basically set up everything
|
||||
* necessary for the actual drwing of the words.
|
||||
*
|
||||
* @param {object} response The response from the server that contains the user's entered words
|
||||
* along with all of the top words.
|
||||
*
|
||||
* @param {array} words An array of objects. Each object must have two properties. One property
|
||||
* is 'text' (the actual word), and the other property is 'size' which represents the number that the
|
||||
* word was enetered by the students.
|
||||
*
|
||||
* @param {array} bounds An array of two objects. First object is the top-left coordinates of the bounding
|
||||
* box where all of the words fir, second object is the bottom-right coordinates of the bounding box. Each
|
||||
* coordinate object contains two properties: 'x', and 'y'.
|
||||
*/
|
||||
drawWordCloud(response, words, bounds) {
|
||||
// Color words in different colors.
|
||||
const fill = d3.scale.category20();
|
||||
|
||||
// Will be populated by words the user enetered.
|
||||
const studentWordsKeys = [];
|
||||
|
||||
// By default we do not scale.
|
||||
let scale = 1;
|
||||
|
||||
// Caсhing of DOM element
|
||||
const cloudSectionEl = this.wordCloudEl.find('.result_cloud_section');
|
||||
|
||||
// Iterator for word cloud count for uniqueness
|
||||
let wcCount = 0;
|
||||
|
||||
// If bounding rectangle is given, scale based on the bounding box of all the words.
|
||||
if (bounds) {
|
||||
scale = 0.5 * Math.min(
|
||||
this.width / Math.abs(bounds[1].x - (this.width / 2)),
|
||||
this.width / Math.abs(bounds[0].x - (this.width / 2)),
|
||||
this.height / Math.abs(bounds[1].y - (this.height / 2)),
|
||||
this.height / Math.abs(bounds[0].y - (this.height / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
$.each(response.student_words, (word, stat) => {
|
||||
const percent = (response.display_student_percents) ? ` ${Math.round(100 * (stat / response.total_count))}%` : '';
|
||||
|
||||
studentWordsKeys.push(HtmlUtils.interpolateHtml(
|
||||
'{listStart}{startTag}{word}{endTag}{percent}{listEnd}',
|
||||
{
|
||||
listStart: HtmlUtils.HTML('<li>'),
|
||||
startTag: HtmlUtils.HTML('<strong>'),
|
||||
word,
|
||||
endTag: HtmlUtils.HTML('</strong>'),
|
||||
percent,
|
||||
listEnd: HtmlUtils.HTML('</li>'),
|
||||
},
|
||||
).toString());
|
||||
});
|
||||
|
||||
// Comma separated string of user enetered words.
|
||||
const studentWordsStr = studentWordsKeys.join('');
|
||||
|
||||
cloudSectionEl
|
||||
.addClass('active');
|
||||
|
||||
HtmlUtils.setHtml(
|
||||
cloudSectionEl.find('.your_words'),
|
||||
HtmlUtils.HTML(studentWordsStr),
|
||||
);
|
||||
|
||||
HtmlUtils.setHtml(
|
||||
cloudSectionEl.find('.your_words').end().find('.total_num_words'),
|
||||
HtmlUtils.interpolateHtml(
|
||||
gettext('{start_strong}{total}{end_strong} words submitted in total.'),
|
||||
{
|
||||
start_strong: HtmlUtils.HTML('<strong>'),
|
||||
end_strong: HtmlUtils.HTML('</strong>'),
|
||||
total: response.total_count,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
$(`${cloudSectionEl.attr('id')} .word_cloud`).empty();
|
||||
|
||||
// Actual drawing of word cloud.
|
||||
const groupEl = d3.select(`#${cloudSectionEl.attr('id')} .word_cloud`).append('svg')
|
||||
.attr('width', this.width)
|
||||
.attr('height', this.height)
|
||||
.append('g')
|
||||
.attr('transform', `translate(${0.5 * this.width},${0.5 * this.height})`)
|
||||
.selectAll('text')
|
||||
.data(words)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('data-id', () => {
|
||||
wcCount += 1;
|
||||
return wcCount;
|
||||
})
|
||||
.attr('aria-describedby', () => HtmlUtils.interpolateHtml(
|
||||
gettext('text_word_{uniqueId} title_word_{uniqueId}'),
|
||||
{
|
||||
uniqueId: generateUniqueId(cloudSectionEl.attr('id'), $(this).data('id')),
|
||||
},
|
||||
));
|
||||
|
||||
groupEl
|
||||
.append('title')
|
||||
.attr('id', () => HtmlUtils.interpolateHtml(
|
||||
gettext('title_word_{uniqueId}'),
|
||||
{
|
||||
uniqueId: generateUniqueId(cloudSectionEl.attr('id'), $(this).parent().data('id')),
|
||||
},
|
||||
))
|
||||
.text((d) => {
|
||||
let res = '';
|
||||
|
||||
$.each(response.top_words, (index, value) => {
|
||||
if (value.text === d.text) {
|
||||
res = `${value.percent}%`;
|
||||
}
|
||||
});
|
||||
|
||||
return res;
|
||||
});
|
||||
|
||||
groupEl
|
||||
.append('text')
|
||||
.attr('id', () => HtmlUtils.interpolateHtml(
|
||||
gettext('text_word_{uniqueId}'),
|
||||
{
|
||||
uniqueId: generateUniqueId(cloudSectionEl.attr('id'), $(this).parent().data('id')),
|
||||
},
|
||||
))
|
||||
.style('font-size', d => `${d.size}px`)
|
||||
.style('font-family', 'Impact')
|
||||
.style('fill', (d, i) => fill(i))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('transform', d => `translate(${d.x}, ${d.y})rotate(${d.rotate})scale(${scale})`)
|
||||
.text(d => d.text);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-env node */
|
||||
|
||||
'use strict';
|
||||
|
||||
var path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
word_cloud: 'word_cloud',
|
||||
},
|
||||
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'public/js'),
|
||||
filename: '[name].js',
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
use: 'babel-loader',
|
||||
},
|
||||
{
|
||||
test: /d3.min/,
|
||||
use: [
|
||||
'babel-loader',
|
||||
{
|
||||
loader: 'exports-loader',
|
||||
options: {
|
||||
d3: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
resolve: {
|
||||
modules: [
|
||||
path.resolve(__dirname, 'src/js'),
|
||||
path.resolve(__dirname, '../../../../../../node_modules'),
|
||||
],
|
||||
alias: {
|
||||
'edx-ui-toolkit': 'edx-ui-toolkit/src/', // @TODO: some paths in toolkit are not valid relative paths
|
||||
},
|
||||
extensions: ['.js', '.jsx', '.json'],
|
||||
},
|
||||
|
||||
externals: {
|
||||
gettext: 'gettext',
|
||||
canvas: 'canvas',
|
||||
jquery: 'jQuery',
|
||||
$: 'jQuery',
|
||||
underscore: '_',
|
||||
},
|
||||
};
|
||||
@@ -1,288 +0,0 @@
|
||||
"""
|
||||
Classes representing asset metadata.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.keys import AssetKey, CourseKey
|
||||
|
||||
|
||||
class AssetMetadata:
|
||||
"""
|
||||
Stores the metadata associated with a particular course asset. The asset metadata gets stored
|
||||
in the modulestore.
|
||||
"""
|
||||
|
||||
TOP_LEVEL_ATTRS = ['pathname', 'internal_name', 'locked', 'contenttype', 'thumbnail', 'fields']
|
||||
EDIT_INFO_ATTRS = ['curr_version', 'prev_version', 'edited_by', 'edited_by_email', 'edited_on']
|
||||
CREATE_INFO_ATTRS = ['created_by', 'created_by_email', 'created_on']
|
||||
ATTRS_ALLOWED_TO_UPDATE = TOP_LEVEL_ATTRS + EDIT_INFO_ATTRS
|
||||
ASSET_TYPE_ATTR = 'type'
|
||||
ASSET_BASENAME_ATTR = 'filename'
|
||||
XML_ONLY_ATTRS = [ASSET_TYPE_ATTR, ASSET_BASENAME_ATTR]
|
||||
XML_ATTRS = XML_ONLY_ATTRS + ATTRS_ALLOWED_TO_UPDATE + CREATE_INFO_ATTRS
|
||||
|
||||
# Type for assets uploaded by a course author in Studio.
|
||||
GENERAL_ASSET_TYPE = 'asset'
|
||||
|
||||
# Asset section XML tag for asset metadata as XML.
|
||||
ALL_ASSETS_XML_TAG = 'assets'
|
||||
|
||||
# Individual asset XML tag for asset metadata as XML.
|
||||
ASSET_XML_TAG = 'asset'
|
||||
|
||||
# Top-level directory name in exported course XML which holds asset metadata.
|
||||
EXPORTED_ASSET_DIR = 'assets'
|
||||
|
||||
# Filename of all asset metadata exported as XML.
|
||||
EXPORTED_ASSET_FILENAME = 'assets.xml'
|
||||
|
||||
def __init__(self, asset_id,
|
||||
pathname=None, internal_name=None,
|
||||
locked=None, contenttype=None,
|
||||
thumbnail=None, fields=None,
|
||||
curr_version=None, prev_version=None,
|
||||
created_by=None, created_by_email=None, created_on=None,
|
||||
edited_by=None, edited_by_email=None, edited_on=None,
|
||||
field_decorator=None,):
|
||||
"""
|
||||
Construct a AssetMetadata object.
|
||||
|
||||
Arguments:
|
||||
asset_id (AssetKey): Key identifying this particular asset.
|
||||
pathname (str): Original path to file at asset upload time.
|
||||
internal_name (str): Name, url, or handle for the storage system to access the file.
|
||||
locked (bool): If True, only course participants can access the asset.
|
||||
contenttype (str): MIME type of the asset.
|
||||
thumbnail (str): the internal_name for the thumbnail if one exists
|
||||
fields (dict): fields to save w/ the metadata
|
||||
curr_version (str): Current version of the asset.
|
||||
prev_version (str): Previous version of the asset.
|
||||
created_by (int): User ID of initial user to upload this asset.
|
||||
created_by_email (str): Email address of initial user to upload this asset.
|
||||
created_on (datetime): Datetime of intial upload of this asset.
|
||||
edited_by (int): User ID of last user to upload this asset.
|
||||
edited_by_email (str): Email address of last user to upload this asset.
|
||||
edited_on (datetime): Datetime of last upload of this asset.
|
||||
field_decorator (function): used by strip_key to convert OpaqueKeys to the app's understanding.
|
||||
Not saved.
|
||||
"""
|
||||
self.asset_id = asset_id if field_decorator is None else field_decorator(asset_id)
|
||||
self.pathname = pathname # Path w/o filename.
|
||||
self.internal_name = internal_name
|
||||
self.locked = locked
|
||||
self.contenttype = contenttype
|
||||
self.thumbnail = thumbnail
|
||||
self.curr_version = curr_version
|
||||
self.prev_version = prev_version
|
||||
now = datetime.now(pytz.utc)
|
||||
self.edited_by = edited_by
|
||||
self.edited_by_email = edited_by_email
|
||||
self.edited_on = edited_on or now
|
||||
# created_by, created_by_email, and created_on should only be set here.
|
||||
self.created_by = created_by
|
||||
self.created_by_email = created_by_email
|
||||
self.created_on = created_on or now
|
||||
self.fields = fields or {}
|
||||
|
||||
def __repr__(self):
|
||||
return """AssetMetadata{!r}""".format((
|
||||
self.asset_id,
|
||||
self.pathname, self.internal_name,
|
||||
self.locked, self.contenttype, self.fields,
|
||||
self.curr_version, self.prev_version,
|
||||
self.created_by, self.created_by_email, self.created_on,
|
||||
self.edited_by, self.edited_by_email, self.edited_on,
|
||||
))
|
||||
|
||||
def update(self, attr_dict):
|
||||
"""
|
||||
Set the attributes on the metadata. Any which are not in ATTRS_ALLOWED_TO_UPDATE get put into
|
||||
fields.
|
||||
|
||||
Arguments:
|
||||
attr_dict: Prop, val dictionary of all attributes to set.
|
||||
"""
|
||||
for attr, val in attr_dict.items():
|
||||
if attr in self.ATTRS_ALLOWED_TO_UPDATE:
|
||||
setattr(self, attr, val)
|
||||
else:
|
||||
self.fields[attr] = val
|
||||
|
||||
def to_storable(self):
|
||||
"""
|
||||
Converts metadata properties into a MongoDB-storable dict.
|
||||
"""
|
||||
return {
|
||||
'filename': self.asset_id.path,
|
||||
'asset_type': self.asset_id.asset_type,
|
||||
'pathname': self.pathname,
|
||||
'internal_name': self.internal_name,
|
||||
'locked': self.locked,
|
||||
'contenttype': self.contenttype,
|
||||
'thumbnail': self.thumbnail,
|
||||
'fields': self.fields,
|
||||
'edit_info': {
|
||||
'curr_version': self.curr_version,
|
||||
'prev_version': self.prev_version,
|
||||
'created_by': self.created_by,
|
||||
'created_by_email': self.created_by_email,
|
||||
'created_on': self.created_on,
|
||||
'edited_by': self.edited_by,
|
||||
'edited_by_email': self.edited_by_email,
|
||||
'edited_on': self.edited_on
|
||||
}
|
||||
}
|
||||
|
||||
def from_storable(self, asset_doc):
|
||||
"""
|
||||
Fill in all metadata fields from a MongoDB document.
|
||||
|
||||
The asset_id prop is initialized upon construction only.
|
||||
"""
|
||||
if asset_doc is None:
|
||||
return
|
||||
self.pathname = asset_doc['pathname']
|
||||
self.internal_name = asset_doc['internal_name']
|
||||
self.locked = asset_doc['locked']
|
||||
self.contenttype = asset_doc['contenttype']
|
||||
self.thumbnail = asset_doc['thumbnail']
|
||||
self.fields = asset_doc['fields']
|
||||
self.curr_version = asset_doc['edit_info']['curr_version']
|
||||
self.prev_version = asset_doc['edit_info']['prev_version']
|
||||
self.created_by = asset_doc['edit_info']['created_by']
|
||||
self.created_by_email = asset_doc['edit_info']['created_by_email']
|
||||
self.created_on = asset_doc['edit_info']['created_on']
|
||||
self.edited_by = asset_doc['edit_info']['edited_by']
|
||||
self.edited_by_email = asset_doc['edit_info']['edited_by_email']
|
||||
self.edited_on = asset_doc['edit_info']['edited_on']
|
||||
|
||||
def from_xml(self, node):
|
||||
"""
|
||||
Walk the etree XML node and fill in the asset metadata.
|
||||
The node should be a top-level "asset" element.
|
||||
"""
|
||||
for child in node:
|
||||
qname = etree.QName(child)
|
||||
tag = qname.localname
|
||||
if tag in self.XML_ATTRS:
|
||||
value = child.text
|
||||
if tag in self.XML_ONLY_ATTRS:
|
||||
# An AssetLocator is constructed separately from these parts.
|
||||
continue
|
||||
elif tag == 'locked':
|
||||
# Boolean.
|
||||
value = True if value == "true" else False # lint-amnesty, pylint: disable=simplifiable-if-expression
|
||||
elif value == 'None':
|
||||
# None.
|
||||
value = None
|
||||
elif tag in ('created_on', 'edited_on'):
|
||||
# ISO datetime.
|
||||
value = dateutil.parser.parse(value)
|
||||
elif tag in ('created_by', 'edited_by'):
|
||||
# Integer representing user id.
|
||||
value = int(value)
|
||||
elif tag == 'fields':
|
||||
# Dictionary.
|
||||
value = json.loads(value)
|
||||
setattr(self, tag, value)
|
||||
|
||||
def to_xml(self, node):
|
||||
"""
|
||||
Add the asset data as XML to the passed-in node.
|
||||
The node should already be created as a top-level "asset" element.
|
||||
"""
|
||||
for attr in self.XML_ATTRS:
|
||||
child = etree.SubElement(node, attr)
|
||||
# Get the value.
|
||||
if attr == self.ASSET_TYPE_ATTR:
|
||||
value = self.asset_id.asset_type
|
||||
elif attr == self.ASSET_BASENAME_ATTR:
|
||||
value = self.asset_id.path
|
||||
else:
|
||||
value = getattr(self, attr)
|
||||
|
||||
# Format the value.
|
||||
if isinstance(value, bool):
|
||||
value = "true" if value else "false"
|
||||
elif isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
elif isinstance(value, dict):
|
||||
value = json.dumps(value)
|
||||
else:
|
||||
value = str(value)
|
||||
child.text = value
|
||||
|
||||
@staticmethod
|
||||
def add_all_assets_as_xml(node, assets):
|
||||
"""
|
||||
Take a list of AssetMetadata objects. Add them all to the node.
|
||||
The node should already be created as a top-level "assets" element.
|
||||
"""
|
||||
for asset in assets:
|
||||
asset_node = etree.SubElement(node, "asset")
|
||||
asset.to_xml(asset_node)
|
||||
|
||||
|
||||
class CourseAssetsFromStorage:
|
||||
"""
|
||||
Wrapper class for asset metadata lists returned from modulestore storage.
|
||||
"""
|
||||
def __init__(self, course_id, doc_id, asset_md):
|
||||
"""
|
||||
Params:
|
||||
course_id: Course ID for which the asset metadata is stored.
|
||||
doc_id: ObjectId of MongoDB document
|
||||
asset_md: Dict with asset types as keys and lists of storable asset metadata as values.
|
||||
"""
|
||||
self.course_id = course_id
|
||||
self._doc_id = doc_id
|
||||
self.asset_md = asset_md
|
||||
|
||||
@property
|
||||
def doc_id(self):
|
||||
"""
|
||||
Returns the ID associated with the MongoDB document which stores these course assets.
|
||||
"""
|
||||
return self._doc_id
|
||||
|
||||
def setdefault(self, item, default=None):
|
||||
"""
|
||||
Provides dict-equivalent setdefault functionality.
|
||||
"""
|
||||
return self.asset_md.setdefault(item, default)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.asset_md[item]
|
||||
|
||||
def __delitem__(self, item):
|
||||
del self.asset_md[item]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.asset_md)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.asset_md[key] = value
|
||||
|
||||
def get(self, item, default=None):
|
||||
"""
|
||||
Provides dict-equivalent get functionality.
|
||||
"""
|
||||
return self.asset_md.get(item, default)
|
||||
|
||||
def iteritems(self):
|
||||
"""
|
||||
Iterates over the items of the asset dict.
|
||||
"""
|
||||
return self.asset_md.items()
|
||||
|
||||
def items(self):
|
||||
"""
|
||||
Iterates over the items of the asset dict. (Python 3 naming convention)
|
||||
"""
|
||||
return self.iteritems()
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
Asset Manager
|
||||
|
||||
Interface allowing course asset saving/retrieving.
|
||||
Handles:
|
||||
- saving asset in the BlobStore -and- saving asset metadata in course modulestore.
|
||||
- retrieving asset metadata from course modulestore -and- returning URL to asset -or- asset bytes.
|
||||
|
||||
Phase 1: Checks to see if an asset's metadata can be found in the course's modulestore.
|
||||
If not found, fails over to access the asset from the contentstore.
|
||||
At first, the asset metadata will never be found, since saving isn't implemented yet.
|
||||
Note: Hotfix (PLAT-734) No asset calls find_asset_metadata, and directly accesses from contentstore.
|
||||
|
||||
"""
|
||||
|
||||
from xmodule.contentstore.django import contentstore
|
||||
|
||||
|
||||
class AssetException(Exception):
|
||||
"""
|
||||
Base exception class for all exceptions related to assets.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class AssetMetadataNotFound(AssetException):
|
||||
"""
|
||||
Thrown when no asset metadata is present in the course modulestore for the particular asset requested.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class AssetMetadataFoundTemporary(AssetException):
|
||||
"""
|
||||
TEMPORARY: Thrown if asset metadata is actually found in the course modulestore.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class AssetManager:
|
||||
"""
|
||||
Manager for saving/loading course assets.
|
||||
"""
|
||||
@staticmethod
|
||||
def find(asset_key, throw_on_not_found=True, as_stream=False):
|
||||
"""
|
||||
Finds course asset in the deprecated contentstore.
|
||||
This method was previously searching for the course asset in the assetstore first, then in the deprecated
|
||||
contentstore. However, the asset was never found in the assetstore since an asset's metadata is
|
||||
not yet stored there.(removed calls to modulestore().find_asset_metadata(asset_key))
|
||||
The assetstore search was removed due to performance issues caused by each call unpickling the pickled and
|
||||
compressed course structure from the structure cache.
|
||||
"""
|
||||
return contentstore().find(asset_key, throw_on_not_found, as_stream)
|
||||
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
|
||||
<xs:element name="assets" type="assetListType" />
|
||||
|
||||
<xs:simpleType name="stringType">
|
||||
<xs:restriction base="xs:string"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="userIdType">
|
||||
<xs:restriction base="xs:nonNegativeInteger"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="datetimeType">
|
||||
<xs:restriction base="xs:dateTime"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:simpleType name="boolType">
|
||||
<xs:restriction base="xs:boolean"/>
|
||||
</xs:simpleType>
|
||||
|
||||
<xs:complexType name="assetListType">
|
||||
<xs:sequence>
|
||||
<xs:element name="asset" type="assetType" minOccurs="0" maxOccurs="unbounded" />
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="assetType">
|
||||
<xs:all>
|
||||
<xs:element name="type" type="stringType"/>
|
||||
<xs:element name="filename" type="stringType"/>
|
||||
<xs:element name="contenttype" type="stringType"/>
|
||||
<xs:element name="pathname" type="stringType"/>
|
||||
<xs:element name="internal_name" type="stringType"/>
|
||||
<xs:element name="locked" type="boolType"/>
|
||||
<xs:element name="thumbnail" type="stringType" minOccurs="0"/>
|
||||
<xs:element name="created_on" type="datetimeType" />
|
||||
<xs:element name="created_by" type="userIdType" />
|
||||
<xs:element name="created_by_email" type="stringType" minOccurs="0"/>
|
||||
<xs:element name="edited_on" type="datetimeType" />
|
||||
<xs:element name="edited_by" type="userIdType" />
|
||||
<xs:element name="edited_by_email" type="stringType" minOccurs="0"/>
|
||||
<xs:element name="prev_version" type="stringType"/>
|
||||
<xs:element name="curr_version" type="stringType"/>
|
||||
<xs:element name="fields" type="stringType" minOccurs="0"/>
|
||||
</xs:all>
|
||||
</xs:complexType>
|
||||
|
||||
</xs:schema>
|
||||
@@ -1,81 +0,0 @@
|
||||
"""
|
||||
Test for asset XML generation / parsing.
|
||||
"""
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from path import Path as path
|
||||
|
||||
from xmodule.assetstore import AssetMetadata
|
||||
from xmodule.modulestore.tests.test_assetstore import AssetStoreTestData
|
||||
|
||||
|
||||
class TestAssetXml(unittest.TestCase):
|
||||
"""
|
||||
Tests for storing/querying course asset metadata.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
xsd_filename = "assets.xsd"
|
||||
|
||||
self.course_id = CourseLocator('org1', 'course1', 'run1')
|
||||
|
||||
self.course_assets = []
|
||||
for asset in AssetStoreTestData.all_asset_data:
|
||||
asset_dict = dict(list(zip(AssetStoreTestData.asset_fields[1:], asset[1:])))
|
||||
asset_md = AssetMetadata(self.course_id.make_asset_key('asset', asset[0]), **asset_dict)
|
||||
self.course_assets.append(asset_md)
|
||||
|
||||
# Read in the XML schema definition and make a validator.
|
||||
xsd_path = path(__file__).realpath().parent / xsd_filename
|
||||
with open(xsd_path, 'rb') as f:
|
||||
schema_root = etree.XML(f.read())
|
||||
schema = etree.XMLSchema(schema_root)
|
||||
self.xmlparser = etree.XMLParser(schema=schema)
|
||||
|
||||
def test_export_single_asset_to_from_xml(self):
|
||||
"""
|
||||
Export a single AssetMetadata to XML and verify the structure and fields.
|
||||
"""
|
||||
asset_md = self.course_assets[0]
|
||||
root = etree.Element("assets")
|
||||
asset = etree.SubElement(root, "asset")
|
||||
asset_md.to_xml(asset)
|
||||
# If this line does *not* raise, the XML is valid.
|
||||
etree.fromstring(etree.tostring(root), self.xmlparser)
|
||||
new_asset_key = self.course_id.make_asset_key('tmp', 'tmp')
|
||||
new_asset_md = AssetMetadata(new_asset_key)
|
||||
new_asset_md.from_xml(asset)
|
||||
# Compare asset_md to new_asset_md.
|
||||
for attr in AssetMetadata.XML_ATTRS:
|
||||
if attr in AssetMetadata.XML_ONLY_ATTRS:
|
||||
continue
|
||||
orig_value = getattr(asset_md, attr)
|
||||
new_value = getattr(new_asset_md, attr)
|
||||
assert orig_value == new_value
|
||||
|
||||
def test_export_with_None_value(self):
|
||||
"""
|
||||
Export and import a single AssetMetadata to XML with a None created_by field, without causing an exception.
|
||||
"""
|
||||
asset_md = AssetMetadata(
|
||||
self.course_id.make_asset_key('asset', 'none_value'),
|
||||
created_by=None,
|
||||
)
|
||||
asset = etree.Element("asset")
|
||||
asset_md.to_xml(asset)
|
||||
asset_md.from_xml(asset)
|
||||
|
||||
def test_export_all_assets_to_xml(self):
|
||||
"""
|
||||
Export all AssetMetadatas to XML and verify the structure and fields.
|
||||
"""
|
||||
root = etree.Element("assets")
|
||||
AssetMetadata.add_all_assets_as_xml(root, self.course_assets)
|
||||
# If this line does *not* raise, the XML is valid.
|
||||
etree.fromstring(etree.tostring(root), self.xmlparser)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Simple utility functions that operate on block metadata.
|
||||
|
||||
This is a place to put simple functions that operate on block metadata. It
|
||||
allows us to share code between the XModuleMixin and CourseOverview and
|
||||
BlockStructure.
|
||||
"""
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
def url_name_for_block(block):
|
||||
"""
|
||||
Given a block, returns the block's URL name.
|
||||
|
||||
Arguments:
|
||||
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
|
||||
Block that is being accessed
|
||||
"""
|
||||
return block.location.block_id
|
||||
|
||||
|
||||
def display_name_with_default(block):
|
||||
"""
|
||||
Calculates the display name for a block.
|
||||
|
||||
Default to the display_name if it isn't None, else fall back to creating
|
||||
a name based on the URL.
|
||||
|
||||
Unlike the rest of this module's functions, this function takes an entire
|
||||
course descriptor/overview as a parameter. This is because a few test cases
|
||||
(specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
|
||||
create scenarios where course.display_name is not None but course.location
|
||||
is None, which causes calling course.url_name to fail. So, although we'd
|
||||
like to just pass course.display_name and course.url_name as arguments to
|
||||
this function, we can't do so without breaking those tests.
|
||||
|
||||
Note: This method no longer escapes as it once did, so the caller must
|
||||
ensure it is properly escaped where necessary.
|
||||
|
||||
Arguments:
|
||||
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
|
||||
Block that is being accessed
|
||||
"""
|
||||
return (
|
||||
block.display_name if block.display_name is not None
|
||||
else url_name_for_block(block).replace('_', ' ')
|
||||
)
|
||||
|
||||
|
||||
def display_name_with_default_escaped(block):
|
||||
"""
|
||||
DEPRECATED: use display_name_with_default
|
||||
|
||||
Calculates the display name for a block with some HTML escaping.
|
||||
This follows the same logic as display_name_with_default, with
|
||||
the addition of the escaping.
|
||||
|
||||
Here is an example of how to move away from this method in Mako html:
|
||||
Before:
|
||||
<span class="course-name">${course.display_name_with_default_escaped}</span>
|
||||
|
||||
After:
|
||||
<span class="course-name">${course.display_name_with_default | h}</span>
|
||||
If the context is Javascript in Mako, you'll need to follow other best practices.
|
||||
|
||||
Note: Switch to display_name_with_default, and ensure the caller
|
||||
properly escapes where necessary.
|
||||
|
||||
Note: This newly introduced method should not be used. It was only
|
||||
introduced to enable a quick search/replace and the ability to slowly
|
||||
migrate and test switching to display_name_with_default, which is no
|
||||
longer escaped.
|
||||
|
||||
Arguments:
|
||||
block (XModuleMixin|CourseOverview|BlockStructureBlockData):
|
||||
Block that is being accessed
|
||||
"""
|
||||
# This escaping is incomplete. However, rather than switching this to use
|
||||
# markupsafe.striptags() and fixing issues, better to put that energy toward
|
||||
# migrating away from this method altogether.
|
||||
return Markup(display_name_with_default(block)).striptags()
|
||||
@@ -1,2278 +0,0 @@
|
||||
"""
|
||||
Implements the Problem XBlock, which is built on top of the CAPA subsystem.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from bleach.sanitizer import Cleaner
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.functional import cached_property
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
from pytz import utc
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString
|
||||
from xblock.scorable import ScorableXBlockMixin, Score
|
||||
|
||||
from capa import responsetypes
|
||||
from capa.capa_problem import LoncapaProblem, LoncapaSystem
|
||||
from capa.inputtypes import Status
|
||||
from capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
|
||||
from capa.util import convert_files_to_filenames, get_inner_html_from_xpath
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.editing_module import EditingMixin
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.graders import ShowCorrectness
|
||||
from xmodule.raw_module import RawMixin
|
||||
from xmodule.util.sandboxing import get_python_lib_zip
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
shim_xmodule_js
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin
|
||||
from common.djangoapps.xblock_django.constants import (
|
||||
ATTR_KEY_ANONYMOUS_USER_ID,
|
||||
ATTR_KEY_USER_IS_STAFF,
|
||||
ATTR_KEY_USER_ID,
|
||||
)
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
from .fields import Date, ScoreField, Timedelta
|
||||
from .progress import Progress
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
# Generate this many different variants of problems with rerandomize=per_student
|
||||
NUM_RANDOMIZATION_BINS = 20
|
||||
# Never produce more than this many different seeds, no matter what.
|
||||
MAX_RANDOMIZATION_BINS = 1000
|
||||
|
||||
|
||||
try:
|
||||
FEATURES = getattr(settings, 'FEATURES', {})
|
||||
except ImproperlyConfigured:
|
||||
FEATURES = {}
|
||||
|
||||
|
||||
class SHOWANSWER:
|
||||
"""
|
||||
Constants for when to show answer
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ANSWERED = "answered"
|
||||
ATTEMPTED = "attempted"
|
||||
CLOSED = "closed"
|
||||
FINISHED = "finished"
|
||||
CORRECT_OR_PAST_DUE = "correct_or_past_due"
|
||||
PAST_DUE = "past_due"
|
||||
NEVER = "never"
|
||||
AFTER_SOME_NUMBER_OF_ATTEMPTS = "after_attempts"
|
||||
AFTER_ALL_ATTEMPTS = "after_all_attempts"
|
||||
AFTER_ALL_ATTEMPTS_OR_CORRECT = "after_all_attempts_or_correct"
|
||||
ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due"
|
||||
|
||||
|
||||
class RANDOMIZATION:
|
||||
"""
|
||||
Constants for problem randomization
|
||||
"""
|
||||
ALWAYS = "always"
|
||||
ONRESET = "onreset"
|
||||
NEVER = "never"
|
||||
PER_STUDENT = "per_student"
|
||||
|
||||
|
||||
class Randomization(String):
|
||||
"""
|
||||
Define a field to store how to randomize a problem.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
if value in ("", "true"):
|
||||
return RANDOMIZATION.ALWAYS
|
||||
elif value == "false":
|
||||
return RANDOMIZATION.PER_STUDENT
|
||||
return value
|
||||
|
||||
to_json = from_json
|
||||
|
||||
|
||||
@XBlock.needs('user')
|
||||
@XBlock.needs('i18n')
|
||||
@XBlock.needs('mako')
|
||||
@XBlock.needs('cache')
|
||||
@XBlock.needs('sandbox')
|
||||
@XBlock.needs('replace_urls')
|
||||
# Studio doesn't provide XQueueService, but the LMS does.
|
||||
@XBlock.wants('xqueue')
|
||||
@XBlock.wants('call_to_action')
|
||||
class ProblemBlock(
|
||||
ScorableXBlockMixin,
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
):
|
||||
"""
|
||||
An XBlock representing a "problem".
|
||||
|
||||
A problem contains zero or more respondable items, such as multiple choice,
|
||||
numeric response, true/false, etc. See common/lib/capa/capa/responsetypes.py
|
||||
for the full ensemble.
|
||||
|
||||
The rendering logic of a problem is largely encapsulated within
|
||||
LoncapaProblem, LoncapaSystem and related classes. This block serves to
|
||||
host the Loncapa system within the XBlock runtime and connect it to the
|
||||
greater LMS/CMS.
|
||||
|
||||
As historical context: the acronym LON-CAPA references the "Learning
|
||||
Online - Computer-Assisted Personalized Approach" LMS, from which this
|
||||
system is inspired.
|
||||
"""
|
||||
INDEX_CONTENT_TYPE = 'CAPA'
|
||||
|
||||
resources_dir = None
|
||||
|
||||
has_score = True
|
||||
show_in_read_only_mode = True
|
||||
template_dir_name = 'problem'
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
has_author_view = True
|
||||
|
||||
icon_class = 'problem'
|
||||
|
||||
uses_xmodule_styles_setup = True
|
||||
requires_per_student_anonymous_id = True
|
||||
|
||||
preview_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/javascript_loader.js'),
|
||||
resource_string(__name__, 'js/src/capa/display.js'),
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
resource_string(__name__, 'js/src/capa/imageinput.js'),
|
||||
resource_string(__name__, 'js/src/capa/schematic.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js')
|
||||
}
|
||||
|
||||
preview_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/capa/display.scss'),
|
||||
],
|
||||
}
|
||||
|
||||
studio_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/problem/edit.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
|
||||
studio_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/problem/edit.scss'),
|
||||
]
|
||||
}
|
||||
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
# it'd be nice to have a useful default but it screws up other things; so,
|
||||
# use display_name_with_default for those
|
||||
default=_("Blank Advanced Problem")
|
||||
)
|
||||
attempts = Integer(
|
||||
help=_("Number of attempts taken by the student on this problem"),
|
||||
default=0,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
max_attempts = Integer(
|
||||
display_name=_("Maximum Attempts"),
|
||||
help=_("Defines the number of times a student can try to answer this problem. "
|
||||
"If the value is not set, infinite attempts are allowed."),
|
||||
values={"min": 0}, scope=Scope.settings
|
||||
)
|
||||
due = Date(help=_("Date that this problem is due by"), scope=Scope.settings)
|
||||
graceperiod = Timedelta(
|
||||
help=_("Amount of time after the due date that submissions will be accepted"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
show_correctness = String(
|
||||
display_name=_("Show Results"),
|
||||
help=_("Defines when to show whether a learner's answer to the problem is correct. "
|
||||
"Configured on the subsection."),
|
||||
scope=Scope.settings,
|
||||
default=ShowCorrectness.ALWAYS,
|
||||
values=[
|
||||
{"display_name": _("Always"), "value": ShowCorrectness.ALWAYS},
|
||||
{"display_name": _("Never"), "value": ShowCorrectness.NEVER},
|
||||
{"display_name": _("Past Due"), "value": ShowCorrectness.PAST_DUE},
|
||||
],
|
||||
)
|
||||
showanswer = String(
|
||||
display_name=_("Show Answer"),
|
||||
help=_("Defines when to show the answer to the problem. "
|
||||
"A default value can be set in Advanced Settings."),
|
||||
scope=Scope.settings,
|
||||
default=SHOWANSWER.FINISHED,
|
||||
values=[
|
||||
{"display_name": _("Always"), "value": SHOWANSWER.ALWAYS},
|
||||
{"display_name": _("Answered"), "value": SHOWANSWER.ANSWERED},
|
||||
{"display_name": _("Attempted or Past Due"), "value": SHOWANSWER.ATTEMPTED},
|
||||
{"display_name": _("Closed"), "value": SHOWANSWER.CLOSED},
|
||||
{"display_name": _("Finished"), "value": SHOWANSWER.FINISHED},
|
||||
{"display_name": _("Correct or Past Due"), "value": SHOWANSWER.CORRECT_OR_PAST_DUE},
|
||||
{"display_name": _("Past Due"), "value": SHOWANSWER.PAST_DUE},
|
||||
{"display_name": _("Never"), "value": SHOWANSWER.NEVER},
|
||||
{"display_name": _("After Some Number of Attempts"), "value": SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS},
|
||||
{"display_name": _("After All Attempts"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS},
|
||||
{"display_name": _("After All Attempts or Correct"), "value": SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT},
|
||||
{"display_name": _("Attempted"), "value": SHOWANSWER.ATTEMPTED_NO_PAST_DUE},
|
||||
]
|
||||
)
|
||||
attempts_before_showanswer_button = Integer(
|
||||
display_name=_("Show Answer: Number of Attempts"),
|
||||
help=_(
|
||||
"Number of times the student must attempt to answer the question before the Show Answer button appears."
|
||||
),
|
||||
values={"min": 0},
|
||||
default=0,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
force_save_button = Boolean(
|
||||
help=_("Whether to force the save button to appear on the page"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
show_reset_button = Boolean(
|
||||
display_name=_("Show Reset Button"),
|
||||
help=_("Determines whether a 'Reset' button is shown so the user may reset their answer. "
|
||||
"A default value can be set in Advanced Settings."),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
rerandomize = Randomization(
|
||||
display_name=_("Randomization"),
|
||||
help=_(
|
||||
'Defines when to randomize the variables specified in the associated Python script. '
|
||||
'For problems that do not randomize values, specify \"Never\". '
|
||||
),
|
||||
default=RANDOMIZATION.NEVER,
|
||||
scope=Scope.settings,
|
||||
values=[
|
||||
{"display_name": _("Always"), "value": RANDOMIZATION.ALWAYS},
|
||||
{"display_name": _("On Reset"), "value": RANDOMIZATION.ONRESET},
|
||||
{"display_name": _("Never"), "value": RANDOMIZATION.NEVER},
|
||||
{"display_name": _("Per Student"), "value": RANDOMIZATION.PER_STUDENT}
|
||||
]
|
||||
)
|
||||
data = XMLString(
|
||||
help=_("XML data for the problem"),
|
||||
scope=Scope.content,
|
||||
enforce_type=FEATURES.get('ENABLE_XBLOCK_XML_VALIDATION', True),
|
||||
default="<problem></problem>"
|
||||
)
|
||||
correct_map = Dict(help=_("Dictionary with the correctness of current student answers"),
|
||||
scope=Scope.user_state, default={})
|
||||
input_state = Dict(help=_("Dictionary for maintaining the state of inputtypes"), scope=Scope.user_state)
|
||||
student_answers = Dict(help=_("Dictionary with the current student responses"), scope=Scope.user_state)
|
||||
|
||||
# enforce_type is set to False here because this field is saved as a dict in the database.
|
||||
score = ScoreField(help=_("Dictionary with the current student score"), scope=Scope.user_state, enforce_type=False)
|
||||
has_saved_answers = Boolean(help=_("Whether or not the answers have been saved since last submit"),
|
||||
scope=Scope.user_state, default=False)
|
||||
done = Boolean(help=_("Whether the student has answered the problem"), scope=Scope.user_state, default=False)
|
||||
seed = Integer(help=_("Random seed for this student"), scope=Scope.user_state)
|
||||
last_submission_time = Date(help=_("Last submission time"), scope=Scope.user_state)
|
||||
submission_wait_seconds = Integer(
|
||||
display_name=_("Timer Between Attempts"),
|
||||
help=_("Seconds a student must wait between submissions for a problem with multiple attempts."),
|
||||
scope=Scope.settings,
|
||||
default=0)
|
||||
weight = Float(
|
||||
display_name=_("Problem Weight"),
|
||||
help=_("Defines the number of points each problem is worth. "
|
||||
"If the value is not set, each response field in the problem is worth one point."),
|
||||
values={"min": 0, "step": .1},
|
||||
scope=Scope.settings
|
||||
)
|
||||
markdown = String(help=_("Markdown source of this module"), default=None, scope=Scope.settings)
|
||||
source_code = String(
|
||||
help=_("Source code for LaTeX and Word problems. This feature is not well-supported."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
use_latex_compiler = Boolean(
|
||||
help=_("Enable LaTeX templates?"),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
matlab_api_key = String(
|
||||
display_name=_("Matlab API key"),
|
||||
help=_("Enter the API key provided by MathWorks for accessing the MATLAB Hosted Service. "
|
||||
"This key is granted for exclusive use by this course for the specified duration. "
|
||||
"Please do not share the API key with other courses and notify MathWorks immediately "
|
||||
"if you believe the key is exposed or compromised. To obtain a key for your course, "
|
||||
"or to report an issue, please contact moocsupport@mathworks.com"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
def bind_for_student(self, *args, **kwargs): # lint-amnesty, pylint: disable=signature-differs
|
||||
super().bind_for_student(*args, **kwargs)
|
||||
|
||||
# Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object
|
||||
# was initialized when XModuleDescriptor._xmodule() was called next. self.lcp was constructed in CapaModule
|
||||
# init(). To keep the same behaviour, we delete self.lcp in bind_for_student().
|
||||
if 'lcp' in self.__dict__:
|
||||
del self.__dict__['lcp']
|
||||
|
||||
def student_view(self, _context, show_detailed_errors=False):
|
||||
"""
|
||||
Return the student view.
|
||||
"""
|
||||
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
|
||||
try:
|
||||
self.lcp
|
||||
except Exception as err: # lint-amnesty, pylint: disable=broad-except
|
||||
html = self.handle_fatal_lcp_error(err if show_detailed_errors else None)
|
||||
else:
|
||||
html = self.get_html()
|
||||
fragment = Fragment(html)
|
||||
add_webpack_to_fragment(fragment, 'ProblemBlockPreview')
|
||||
shim_xmodule_js(fragment, 'Problem')
|
||||
return fragment
|
||||
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Return the view seen by users who aren't logged in or who aren't
|
||||
enrolled in the course.
|
||||
"""
|
||||
if getattr(self.runtime, 'suppports_state_for_anonymous_users', False):
|
||||
# The new XBlock runtime can generally support capa problems for users who aren't logged in, so show the
|
||||
# normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies
|
||||
# and/or content groups.
|
||||
return self.student_view(context)
|
||||
else:
|
||||
# Show a message that this content requires users to login/enroll.
|
||||
return super().public_view(context)
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view.
|
||||
"""
|
||||
return self.student_view(context, show_detailed_errors=True)
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
fragment = Fragment(
|
||||
self.runtime.service(self, 'mako').render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
add_webpack_to_fragment(fragment, 'ProblemBlockStudio')
|
||||
shim_xmodule_js(fragment, 'MarkdownEditingDescriptor')
|
||||
return fragment
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
|
||||
`data` is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress' : 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
|
||||
self.lcp # lint-amnesty, pylint: disable=pointless-statement
|
||||
handlers = {
|
||||
'hint_button': self.hint_button,
|
||||
'problem_get': self.get_problem,
|
||||
'problem_check': self.submit_problem,
|
||||
'problem_reset': self.reset_problem,
|
||||
'problem_save': self.save_problem,
|
||||
'problem_show': self.get_answer,
|
||||
'score_update': self.update_score,
|
||||
'input_ajax': self.handle_input_ajax,
|
||||
'ungraded_response': self.handle_ungraded_response
|
||||
}
|
||||
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
generic_error_message = _(
|
||||
"We're sorry, there was an error with processing your request. "
|
||||
"Please try reloading your page and trying again."
|
||||
)
|
||||
|
||||
not_found_error_message = _(
|
||||
"The state of this problem has changed since you loaded this page. "
|
||||
"Please refresh your page."
|
||||
)
|
||||
|
||||
if dispatch not in handlers:
|
||||
return f'Error: {dispatch} is not a known capa action'
|
||||
|
||||
before = self.get_progress()
|
||||
before_attempts = self.attempts
|
||||
|
||||
try:
|
||||
result = handlers[dispatch](data)
|
||||
|
||||
except NotFoundError as ex:
|
||||
log.info(
|
||||
"Unable to find data when dispatching %s to %s for user %s",
|
||||
dispatch,
|
||||
self.scope_ids.usage_id,
|
||||
self.scope_ids.user_id
|
||||
)
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ProcessingError(not_found_error_message).with_traceback(traceback_obj) from ex
|
||||
|
||||
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
|
||||
log.exception(
|
||||
"Unknown error when dispatching %s to %s for user %s",
|
||||
dispatch,
|
||||
self.scope_ids.usage_id,
|
||||
self.scope_ids.user_id
|
||||
)
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ProcessingError(generic_error_message).with_traceback(traceback_obj) from ex
|
||||
|
||||
after = self.get_progress()
|
||||
after_attempts = self.attempts
|
||||
progress_changed = (after != before) or (after_attempts != before_attempts)
|
||||
curr_score, total_possible = self.get_display_progress()
|
||||
|
||||
result.update({
|
||||
'progress_changed': progress_changed,
|
||||
'current_score': curr_score,
|
||||
'total_possible': total_possible,
|
||||
'attempts_used': after_attempts,
|
||||
})
|
||||
|
||||
return json.dumps(result, cls=ComplexEncoder)
|
||||
|
||||
@property
|
||||
def display_name_with_default(self):
|
||||
"""
|
||||
Constructs the display name for a CAPA problem.
|
||||
|
||||
Default to the display_name if it isn't None or not an empty string,
|
||||
else fall back to problem category.
|
||||
"""
|
||||
if self.display_name is None or not self.display_name.strip():
|
||||
return self.location.block_type
|
||||
|
||||
return self.display_name
|
||||
|
||||
@property
|
||||
def debug(self):
|
||||
"""
|
||||
If CAPA block fails to render, we want course authors to be able to see
|
||||
the error in Studio. At the same time, in production, we don't want
|
||||
to show errors to students.
|
||||
"""
|
||||
return getattr(self.runtime, 'is_author_mode', False) or settings.DEBUG
|
||||
|
||||
@classmethod
|
||||
def filter_templates(cls, template, course):
|
||||
"""
|
||||
Filter template that contains 'latex' from templates.
|
||||
|
||||
Show them only if use_latex_compiler is set to True in
|
||||
course settings.
|
||||
"""
|
||||
return 'latex' not in template['template_id'] or course.use_latex_compiler
|
||||
|
||||
def get_context(self):
|
||||
_context = EditingMixin.get_context(self)
|
||||
_context.update({
|
||||
'markdown': self.markdown,
|
||||
'enable_markdown': self.markdown is not None,
|
||||
'enable_latex_compiler': self.use_latex_compiler,
|
||||
})
|
||||
return _context
|
||||
|
||||
# VS[compat]
|
||||
# TODO (cpennington): Delete this method once all fall 2012 course are being
|
||||
# edited in the cms
|
||||
@classmethod
|
||||
def backcompat_paths(cls, path):
|
||||
return [
|
||||
'problems/' + path[8:],
|
||||
path[8:],
|
||||
]
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
ProblemBlock.due,
|
||||
ProblemBlock.graceperiod,
|
||||
ProblemBlock.force_save_button,
|
||||
ProblemBlock.markdown,
|
||||
ProblemBlock.use_latex_compiler,
|
||||
ProblemBlock.show_correctness,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@property
|
||||
def problem_types(self):
|
||||
""" Low-level problem type introspection for content libraries filtering by problem type """
|
||||
try:
|
||||
tree = etree.XML(self.data)
|
||||
except etree.XMLSyntaxError:
|
||||
log.error(f'Error parsing problem types from xml for capa module {self.display_name}')
|
||||
return None # short-term fix to prevent errors (TNL-5057). Will be more properly addressed in TNL-4525.
|
||||
registered_tags = responsetypes.registry.registered_tags()
|
||||
return {node.tag for node in tree.iter() if node.tag in registered_tags}
|
||||
|
||||
def index_dictionary(self):
|
||||
"""
|
||||
Return dictionary prepared with module content and type for indexing.
|
||||
"""
|
||||
xblock_body = super().index_dictionary()
|
||||
|
||||
# Make optioninput's options index friendly by replacing the actual tag with the values
|
||||
capa_content = re.sub(r'<optioninput options="\(([^"]+)\)".*?>\s*|\S*<\/optioninput>', r'\1', self.data)
|
||||
|
||||
# Remove the following tags with content that can leak hints or solutions:
|
||||
# - `solution` (with optional attributes) and `solutionset`.
|
||||
# - `targetedfeedback` (with optional attributes) and `targetedfeedbackset`.
|
||||
# - `answer` (with optional attributes).
|
||||
# - `script` (with optional attributes).
|
||||
# - `style` (with optional attributes).
|
||||
# - various types of hints (with optional attributes) and `hintpart`.
|
||||
capa_content = re.sub(
|
||||
re.compile(
|
||||
r"""
|
||||
<solution.*?>.*?</solution.*?> |
|
||||
<targetedfeedback.*?>.*?</targetedfeedback.*?> |
|
||||
<answer.*?>.*?</answer> |
|
||||
<script.*?>.*?</script> |
|
||||
<style.*?>.*?</style> |
|
||||
<[a-z]*hint.*?>.*?</[a-z]*hint.*?>
|
||||
""",
|
||||
re.DOTALL |
|
||||
re.VERBOSE),
|
||||
"",
|
||||
capa_content
|
||||
)
|
||||
capa_content = re.sub(
|
||||
r"(\s| |//)+",
|
||||
" ",
|
||||
Cleaner(tags=[], strip=True).clean(capa_content)
|
||||
)
|
||||
|
||||
capa_body = {
|
||||
"capa_content": capa_content,
|
||||
"display_name": self.display_name,
|
||||
}
|
||||
if "content" in xblock_body:
|
||||
xblock_body["content"].update(capa_body)
|
||||
else:
|
||||
xblock_body["content"] = capa_body
|
||||
xblock_body["content_type"] = self.INDEX_CONTENT_TYPE
|
||||
xblock_body["problem_types"] = list(self.problem_types)
|
||||
return xblock_body
|
||||
|
||||
def has_support(self, view, functionality):
|
||||
"""
|
||||
Override the XBlock.has_support method to return appropriate
|
||||
value for the multi-device functionality.
|
||||
Returns whether the given view has support for the given functionality.
|
||||
"""
|
||||
if functionality == "multi_device":
|
||||
types = self.problem_types # Avoid calculating this property twice
|
||||
return types is not None and all(
|
||||
responsetypes.registry.get_class_for_tag(tag).multi_device_support
|
||||
for tag in types
|
||||
)
|
||||
return False
|
||||
|
||||
def max_score(self):
|
||||
"""
|
||||
Return the problem's max score if problem is instantiated successfully, else return max score of 0.
|
||||
"""
|
||||
capa_system = LoncapaSystem(
|
||||
ajax_url=None,
|
||||
anonymous_student_id=None,
|
||||
cache=None,
|
||||
can_execute_unsafe_code=None,
|
||||
get_python_lib_zip=None,
|
||||
DEBUG=None,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
render_template=None,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=None,
|
||||
STATIC_URL=None,
|
||||
xqueue=None,
|
||||
matlab_api_key=None,
|
||||
)
|
||||
try:
|
||||
lcp = LoncapaProblem(
|
||||
problem_text=self.data,
|
||||
id=self.location.html_id(),
|
||||
capa_system=capa_system,
|
||||
capa_module=self,
|
||||
state={},
|
||||
seed=1,
|
||||
minimal_init=True,
|
||||
)
|
||||
except responsetypes.LoncapaProblemError:
|
||||
log.exception(f"LcpFatalError for block {str(self.location)} while getting max score")
|
||||
maximum_score = 0
|
||||
else:
|
||||
maximum_score = lcp.get_max_score()
|
||||
return maximum_score
|
||||
|
||||
def generate_report_data(self, user_state_iterator, limit_responses=None):
|
||||
"""
|
||||
Return a list of student responses to this block in a readable way.
|
||||
|
||||
Arguments:
|
||||
user_state_iterator: iterator over UserStateClient objects.
|
||||
E.g. the result of user_state_client.iter_all_for_block(block_key)
|
||||
|
||||
limit_responses (int|None): maximum number of responses to include.
|
||||
Set to None (default) to include all.
|
||||
|
||||
Returns:
|
||||
each call returns a tuple like:
|
||||
("username", {
|
||||
"Question": "2 + 2 equals how many?",
|
||||
"Answer": "Four",
|
||||
"Answer ID": "98e6a8e915904d5389821a94e48babcf_10_1"
|
||||
})
|
||||
"""
|
||||
if self.category != 'problem':
|
||||
raise NotImplementedError()
|
||||
|
||||
if limit_responses == 0:
|
||||
# Don't even start collecting answers
|
||||
return
|
||||
capa_system = LoncapaSystem(
|
||||
ajax_url=None,
|
||||
# TODO set anonymous_student_id to the anonymous ID of the user which answered each problem
|
||||
# Anonymous ID is required for Matlab, CodeResponse, and some custom problems that include
|
||||
# '$anonymous_student_id' in their XML.
|
||||
# For the purposes of this report, we don't need to support those use cases.
|
||||
anonymous_student_id=None,
|
||||
cache=None,
|
||||
can_execute_unsafe_code=lambda: None,
|
||||
get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, self.runtime.course_id)),
|
||||
DEBUG=None,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
render_template=None,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=1,
|
||||
STATIC_URL=None,
|
||||
xqueue=None,
|
||||
matlab_api_key=None,
|
||||
)
|
||||
_ = capa_system.i18n.ugettext
|
||||
|
||||
count = 0
|
||||
for user_state in user_state_iterator:
|
||||
|
||||
if 'student_answers' not in user_state.state:
|
||||
continue
|
||||
try:
|
||||
lcp = LoncapaProblem(
|
||||
problem_text=self.data,
|
||||
id=self.location.html_id(),
|
||||
capa_system=capa_system,
|
||||
# We choose to run without a fully initialized CapaModule
|
||||
capa_module=None,
|
||||
state={
|
||||
'done': user_state.state.get('done'),
|
||||
'correct_map': user_state.state.get('correct_map'),
|
||||
'student_answers': user_state.state.get('student_answers'),
|
||||
'has_saved_answers': user_state.state.get('has_saved_answers'),
|
||||
'input_state': user_state.state.get('input_state'),
|
||||
'seed': user_state.state.get('seed'),
|
||||
},
|
||||
seed=user_state.state.get('seed'),
|
||||
# extract_tree=False allows us to work without a fully initialized CapaModule
|
||||
# We'll still be able to find particular data in the XML when we need it
|
||||
extract_tree=False,
|
||||
)
|
||||
|
||||
for answer_id, orig_answers in lcp.student_answers.items():
|
||||
# Some types of problems have data in lcp.student_answers that isn't in lcp.problem_data.
|
||||
# E.g. formulae do this to store the MathML version of the answer.
|
||||
# We exclude these rows from the report because we only need the text-only answer.
|
||||
if answer_id.endswith('_dynamath'):
|
||||
continue
|
||||
|
||||
if limit_responses and count >= limit_responses:
|
||||
# End the iterator here
|
||||
return
|
||||
|
||||
question_text = lcp.find_question_label(answer_id)
|
||||
answer_text = lcp.find_answer_text(answer_id, current_answer=orig_answers)
|
||||
correct_answer_text = lcp.find_correct_answer_text(answer_id)
|
||||
|
||||
count += 1
|
||||
report = {
|
||||
_("Answer ID"): answer_id,
|
||||
_("Question"): question_text,
|
||||
_("Answer"): answer_text,
|
||||
}
|
||||
if correct_answer_text is not None:
|
||||
report[_("Correct Answer")] = correct_answer_text
|
||||
yield (user_state.username, report)
|
||||
except LoncapaProblemError:
|
||||
# Capture a backtrace for errors from failed loncapa problems
|
||||
log.exception(
|
||||
"An error occurred generating a problem report on course %s, problem %s, and student %s",
|
||||
self.course_id, self.scope_ids.usage_id,
|
||||
self.scope_ids.user_id
|
||||
)
|
||||
# Also input error in report
|
||||
report = {
|
||||
_("Answer ID"): "Python Error",
|
||||
_("Question"): "Generating a report on the problem failed.",
|
||||
_("Answer"): "Python Error: No Answer Retrieved",
|
||||
}
|
||||
yield (user_state.username, report)
|
||||
|
||||
@property
|
||||
def close_date(self):
|
||||
"""
|
||||
Return the date submissions should be closed from.
|
||||
"""
|
||||
due_date = self.due
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
return due_date + self.graceperiod
|
||||
else:
|
||||
return due_date
|
||||
|
||||
def get_seed(self):
|
||||
"""
|
||||
Generate the seed if not set and return it.
|
||||
"""
|
||||
if self.seed is None:
|
||||
self.choose_new_seed()
|
||||
return self.seed
|
||||
|
||||
@cached_property
|
||||
def lcp(self): # lint-amnesty, pylint: disable=method-hidden, missing-function-docstring
|
||||
try:
|
||||
lcp = self.new_lcp(self.get_state_for_lcp())
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
|
||||
loc=str(self.location), err=err)
|
||||
raise LoncapaProblemError(msg).with_traceback(sys.exc_info()[2])
|
||||
|
||||
if self.score is None:
|
||||
self.set_score(self.score_from_lcp(lcp))
|
||||
|
||||
assert self.seed is not None
|
||||
return lcp
|
||||
|
||||
def choose_new_seed(self):
|
||||
"""
|
||||
Choose a new seed.
|
||||
"""
|
||||
if self.rerandomize == RANDOMIZATION.NEVER:
|
||||
self.seed = 1
|
||||
elif self.rerandomize == RANDOMIZATION.PER_STUDENT:
|
||||
user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_ID) or 0
|
||||
# see comment on randomization_bin
|
||||
self.seed = randomization_bin(user_id, str(self.location).encode('utf-8'))
|
||||
else:
|
||||
self.seed = struct.unpack('i', os.urandom(4))[0]
|
||||
|
||||
# So that sandboxed code execution can be cached, but still have an interesting
|
||||
# number of possibilities, cap the number of different random seeds.
|
||||
self.seed %= MAX_RANDOMIZATION_BINS
|
||||
|
||||
def new_lcp(self, state, text=None):
|
||||
"""
|
||||
Generate a new Loncapa Problem
|
||||
"""
|
||||
if text is None:
|
||||
text = self.data
|
||||
|
||||
user_service = self.runtime.service(self, 'user')
|
||||
anonymous_student_id = user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
|
||||
seed = user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID) or 0
|
||||
|
||||
sandbox_service = self.runtime.service(self, 'sandbox')
|
||||
cache_service = self.runtime.service(self, 'cache')
|
||||
|
||||
capa_system = LoncapaSystem(
|
||||
ajax_url=self.ajax_url,
|
||||
anonymous_student_id=anonymous_student_id,
|
||||
cache=cache_service,
|
||||
can_execute_unsafe_code=sandbox_service.can_execute_unsafe_code,
|
||||
get_python_lib_zip=sandbox_service.get_python_lib_zip,
|
||||
DEBUG=self.debug,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
render_template=self.runtime.service(self, 'mako').render_template,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=seed, # Why do we do this if we have self.seed?
|
||||
STATIC_URL=self.runtime.STATIC_URL,
|
||||
xqueue=self.runtime.service(self, 'xqueue'),
|
||||
matlab_api_key=self.matlab_api_key
|
||||
)
|
||||
|
||||
return LoncapaProblem(
|
||||
problem_text=text,
|
||||
id=self.location.html_id(),
|
||||
state=state,
|
||||
seed=self.get_seed(),
|
||||
capa_system=capa_system,
|
||||
capa_module=self, # njp
|
||||
)
|
||||
|
||||
def get_state_for_lcp(self):
|
||||
"""
|
||||
Give a dictionary holding the state of the module
|
||||
"""
|
||||
return {
|
||||
'done': self.done,
|
||||
'correct_map': self.correct_map,
|
||||
'student_answers': self.student_answers,
|
||||
'has_saved_answers': self.has_saved_answers,
|
||||
'input_state': self.input_state,
|
||||
'seed': self.get_seed(),
|
||||
}
|
||||
|
||||
def set_state_from_lcp(self):
|
||||
"""
|
||||
Set the module's state from the settings in `self.lcp`
|
||||
"""
|
||||
lcp_state = self.lcp.get_state()
|
||||
self.done = lcp_state['done']
|
||||
self.correct_map = lcp_state['correct_map']
|
||||
self.input_state = lcp_state['input_state']
|
||||
self.student_answers = lcp_state['student_answers']
|
||||
self.has_saved_answers = lcp_state['has_saved_answers']
|
||||
|
||||
def set_last_submission_time(self):
|
||||
"""
|
||||
Set the module's last submission time (when the problem was submitted)
|
||||
"""
|
||||
self.last_submission_time = datetime.datetime.now(utc)
|
||||
|
||||
def get_progress(self):
|
||||
"""
|
||||
For now, just return weighted earned / weighted possible
|
||||
"""
|
||||
if self.score:
|
||||
raw_earned = self.score.raw_earned
|
||||
raw_possible = self.score.raw_possible
|
||||
else:
|
||||
raw_earned = raw_possible = 0
|
||||
|
||||
if raw_possible > 0:
|
||||
if self.weight is not None:
|
||||
# Progress objects expect total > 0
|
||||
if self.weight == 0:
|
||||
return None
|
||||
|
||||
# scale score and total by weight/total:
|
||||
weighted_earned = raw_earned * self.weight / raw_possible
|
||||
weighted_possible = self.weight
|
||||
else:
|
||||
weighted_earned = raw_earned
|
||||
weighted_possible = raw_possible
|
||||
try:
|
||||
return Progress(weighted_earned, weighted_possible)
|
||||
except (TypeError, ValueError):
|
||||
log.exception("Got bad progress")
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_display_progress(self):
|
||||
"""
|
||||
Return (score, total) to be displayed to the learner.
|
||||
"""
|
||||
progress = self.get_progress()
|
||||
score, total = (progress.frac() if progress else (0, 0))
|
||||
|
||||
# Withhold the score if hiding correctness
|
||||
if not self.correctness_available():
|
||||
score = None
|
||||
|
||||
return score, total
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Return some html with data about the module
|
||||
"""
|
||||
curr_score, total_possible = self.get_display_progress()
|
||||
|
||||
return self.runtime.service(self, 'mako').render_template('problem_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': str(self.location),
|
||||
'ajax_url': self.ajax_url,
|
||||
'current_score': curr_score,
|
||||
'total_possible': total_possible,
|
||||
'attempts_used': self.attempts,
|
||||
'content': self.get_problem_html(encapsulate=False),
|
||||
'graded': self.graded, # pylint: disable=no-member
|
||||
})
|
||||
|
||||
def handle_fatal_lcp_error(self, error): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
log.exception(f"LcpFatalError Encountered for {str(self.location)}")
|
||||
if error:
|
||||
return(
|
||||
HTML('<p>Error formatting HTML for problem:</p><p><pre style="color:red">{msg}</pre></p>').format(
|
||||
msg=str(error))
|
||||
)
|
||||
else:
|
||||
return HTML(
|
||||
'<p>Could not format HTML for problem. '
|
||||
'Contact course staff in the discussion forum for assistance.</p>'
|
||||
)
|
||||
|
||||
def submit_button_name(self):
|
||||
"""
|
||||
Determine the name for the "submit" button.
|
||||
"""
|
||||
# The logic flow is a little odd so that _('xxx') strings can be found for
|
||||
# translation while also running _() just once for each string.
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
submit = _('Submit')
|
||||
|
||||
return submit
|
||||
|
||||
def submit_button_submitting_name(self):
|
||||
"""
|
||||
Return the "Submitting" text for the "submit" button.
|
||||
|
||||
After the user presses the "submit" button, the button will briefly
|
||||
display the value returned by this function until a response is
|
||||
received by the server.
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
return _('Submitting')
|
||||
|
||||
def should_enable_submit_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to enable the "Submit" button.
|
||||
"""
|
||||
submitted_without_reset = (self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS)
|
||||
|
||||
# If the problem is closed (past due / too many attempts)
|
||||
# then we disable the "submit" button
|
||||
# Also, disable the "submit" button if we're waiting
|
||||
# for the user to reset a randomized problem
|
||||
if self.closed() or submitted_without_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def should_show_reset_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Reset" button.
|
||||
"""
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the reset button.
|
||||
if self.closed() and not is_survey_question:
|
||||
return False
|
||||
|
||||
# Button only shows up for randomized problems if the question has been submitted
|
||||
if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted():
|
||||
return True
|
||||
else:
|
||||
# Do NOT show the button if the problem is correct
|
||||
if self.is_correct():
|
||||
return False
|
||||
else:
|
||||
return self.show_reset_button
|
||||
|
||||
def should_show_save_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Save" button.
|
||||
"""
|
||||
|
||||
# If the user has forced the save button to display,
|
||||
# then show it as long as the problem is not closed
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button:
|
||||
return not self.closed()
|
||||
else:
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
|
||||
|
||||
# If the student has unlimited attempts, and their answers
|
||||
# are not randomized, then we do not need a save button
|
||||
# because they can use the "Check" button without consequences.
|
||||
#
|
||||
# The consequences we want to avoid are:
|
||||
# * Using up an attempt (if max_attempts is set)
|
||||
# * Changing the current problem, and no longer being
|
||||
# able to view it (if rerandomize is "always")
|
||||
#
|
||||
# In those cases. the if statement below is false,
|
||||
# and the save button can still be displayed.
|
||||
#
|
||||
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
|
||||
return False
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the save button
|
||||
# If we're waiting for the user to reset a randomized problem
|
||||
# then do NOT show the save button
|
||||
elif (self.closed() and not is_survey_question) or needs_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def handle_problem_html_error(self, err):
|
||||
"""
|
||||
Create a dummy problem to represent any errors.
|
||||
|
||||
Change our problem to a dummy problem containing a warning message to
|
||||
display to users. Returns the HTML to show to users
|
||||
|
||||
`err` is the Exception encountered while rendering the problem HTML.
|
||||
"""
|
||||
problem_display_name = self.display_name_with_default
|
||||
problem_location = str(self.location)
|
||||
log.exception(
|
||||
"ProblemGetHtmlError: %r, %r, %s",
|
||||
problem_display_name,
|
||||
problem_location,
|
||||
str(err)
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
msg = HTML(
|
||||
'[courseware.capa.capa_module] '
|
||||
'Failed to generate HTML for problem {url}'
|
||||
).format(
|
||||
url=str(self.location)
|
||||
)
|
||||
msg += HTML('<p>Error:</p><p><pre>{msg}</pre></p>').format(msg=str(err))
|
||||
msg += HTML('<p><pre>{tb}</pre></p>').format(tb=traceback.format_exc())
|
||||
html = msg
|
||||
|
||||
else:
|
||||
# We're in non-debug mode, and possibly even in production. We want
|
||||
# to avoid bricking of problem as much as possible
|
||||
|
||||
# Presumably, student submission has corrupted LoncapaProblem HTML.
|
||||
# First, pull down all student answers
|
||||
|
||||
student_answers = self.lcp.student_answers
|
||||
answer_ids = list(student_answers.keys())
|
||||
|
||||
# Some inputtypes, such as dynamath, have additional "hidden" state that
|
||||
# is not exposed to the student. Keep those hidden
|
||||
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
|
||||
hidden_state_keywords = ['dynamath']
|
||||
for answer_id in answer_ids:
|
||||
for hidden_state_keyword in hidden_state_keywords:
|
||||
if answer_id.find(hidden_state_keyword) >= 0:
|
||||
student_answers.pop(answer_id)
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = self.new_lcp(None)
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
# Prepend a scary warning to the student
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
warning_msg = Text(_("Warning: The problem has been reset to its initial state!"))
|
||||
warning = HTML('<div class="capa_reset"> <h2>{}</h2>').format(warning_msg)
|
||||
|
||||
# Translators: Following this message, there will be a bulleted list of items.
|
||||
warning_msg = _("The problem's state was corrupted by an invalid submission. The submission consisted of:")
|
||||
warning += HTML('{}<ul>').format(warning_msg)
|
||||
|
||||
for student_answer in student_answers.values():
|
||||
if student_answer != '':
|
||||
warning += HTML('<li>{}</li>').format(student_answer)
|
||||
|
||||
warning_msg = _('If this error persists, please contact the course staff.')
|
||||
warning += HTML('</ul>{}</div>').format(warning_msg)
|
||||
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception as error:
|
||||
# Couldn't do it. Give up.
|
||||
log.exception(
|
||||
"ProblemGetHtmlError: Unable to generate html from LoncapaProblem: %r, %r, %s",
|
||||
problem_display_name,
|
||||
problem_location,
|
||||
str(error)
|
||||
)
|
||||
raise
|
||||
|
||||
return html
|
||||
|
||||
def _should_enable_demand_hint(self, demand_hints, hint_index=None):
|
||||
"""
|
||||
Should the demand hint option be enabled?
|
||||
|
||||
Arguments:
|
||||
hint_index (int): The current hint index, or None (default value) if no hint is currently being shown.
|
||||
demand_hints (list): List of hints.
|
||||
Returns:
|
||||
bool: True is the demand hint is possible.
|
||||
bool: True is demand hint should be enabled.
|
||||
"""
|
||||
# hint_index is the index of the last hint that will be displayed in this rendering,
|
||||
# so add 1 to check if others exist.
|
||||
if hint_index is None:
|
||||
should_enable = len(demand_hints) > 0
|
||||
else:
|
||||
should_enable = len(demand_hints) > 0 and hint_index + 1 < len(demand_hints)
|
||||
return len(demand_hints) > 0, should_enable
|
||||
|
||||
def get_demand_hint(self, hint_index):
|
||||
"""
|
||||
Return html for the problem, including demand hints.
|
||||
|
||||
hint_index (int): (None is the default) if not None, this is the index of the next demand
|
||||
hint to show.
|
||||
"""
|
||||
demand_hints = self.lcp.tree.xpath("//problem/demandhint/hint")
|
||||
hint_index = hint_index % len(demand_hints)
|
||||
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
counter = 0
|
||||
total_text = ''
|
||||
while counter <= hint_index:
|
||||
# Translators: {previous_hints} is the HTML of hints that have already been generated, {hint_number_prefix}
|
||||
# is a header for this hint, and {hint_text} is the text of the hint itself.
|
||||
# This string is being passed to translation only for possible reordering of the placeholders.
|
||||
total_text = HTML(_('{previous_hints}{list_start_tag}{strong_text}{hint_text}</li>')).format(
|
||||
previous_hints=HTML(total_text),
|
||||
list_start_tag=HTML('<li class="hint-index-{counter}" tabindex="-1">').format(counter=counter),
|
||||
strong_text=HTML('<strong>{hint_number_prefix}</strong>').format(
|
||||
# Translators: e.g. "Hint 1 of 3: " meaning we are showing the first of three hints.
|
||||
# This text is shown in bold before the accompanying hint text.
|
||||
hint_number_prefix=Text(_("Hint ({hint_num} of {hints_count}): ")).format(
|
||||
hint_num=counter + 1, hints_count=len(demand_hints)
|
||||
)
|
||||
),
|
||||
# Course-authored HTML demand hints are supported.
|
||||
hint_text=HTML(self.runtime.service(self, "replace_urls").replace_urls(
|
||||
get_inner_html_from_xpath(demand_hints[counter])
|
||||
))
|
||||
)
|
||||
counter += 1
|
||||
|
||||
total_text = HTML('<ol>{hints}</ol>').format(hints=total_text)
|
||||
|
||||
# Log this demand-hint request. Note that this only logs the last hint requested (although now
|
||||
# all previously shown hints are still displayed).
|
||||
event_info = {}
|
||||
event_info['module_id'] = str(self.location)
|
||||
event_info['hint_index'] = hint_index
|
||||
event_info['hint_len'] = len(demand_hints)
|
||||
event_info['hint_text'] = get_inner_html_from_xpath(demand_hints[hint_index])
|
||||
self.runtime.publish(self, 'edx.problem.hint.demandhint_displayed', event_info)
|
||||
|
||||
_, should_enable_next_hint = self._should_enable_demand_hint(demand_hints=demand_hints, hint_index=hint_index)
|
||||
|
||||
# We report the index of this hint, the client works out what index to use to get the next hint
|
||||
return {
|
||||
'success': True,
|
||||
'hint_index': hint_index,
|
||||
'should_enable_next_hint': should_enable_next_hint,
|
||||
'msg': total_text,
|
||||
}
|
||||
|
||||
def get_problem_html(self, encapsulate=True, submit_notification=False):
|
||||
"""
|
||||
Return html for the problem.
|
||||
|
||||
Adds submit, reset, save, and hint buttons as necessary based on the problem config
|
||||
and state.
|
||||
|
||||
encapsulate (bool): if True (the default) embed the html in a problem <div>
|
||||
submit_notification (bool): True if the submit notification should be added
|
||||
"""
|
||||
try:
|
||||
html = self.lcp.get_html()
|
||||
|
||||
# If we cannot construct the problem HTML,
|
||||
# then generate an error message instead.
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
html = self.handle_problem_html_error(err)
|
||||
|
||||
html = self.remove_tags_from_html(html)
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
# Enable/Disable Submit button if should_enable_submit_button returns True/False.
|
||||
submit_button = self.submit_button_name()
|
||||
submit_button_submitting = self.submit_button_submitting_name()
|
||||
should_enable_submit_button = self.should_enable_submit_button()
|
||||
submit_disabled_ctas = None
|
||||
if not should_enable_submit_button:
|
||||
cta_service = self.runtime.service(self, "call_to_action")
|
||||
if cta_service:
|
||||
submit_disabled_ctas = cta_service.get_ctas(self, 'capa_submit_disabled')
|
||||
|
||||
content = {
|
||||
'name': self.display_name_with_default,
|
||||
'html': smart_str(html),
|
||||
'weight': self.weight,
|
||||
}
|
||||
|
||||
# If demand hints are available, emit hint button and div.
|
||||
demand_hints = self.lcp.tree.xpath("//problem/demandhint/hint")
|
||||
demand_hint_possible, should_enable_next_hint = self._should_enable_demand_hint(demand_hints=demand_hints)
|
||||
|
||||
answer_notification_type, answer_notification_message = self._get_answer_notification(
|
||||
render_notifications=submit_notification)
|
||||
|
||||
save_message = None
|
||||
if self.has_saved_answers:
|
||||
save_message = _(
|
||||
"Your answers were previously saved. Click '{button_name}' to grade them."
|
||||
).format(button_name=self.submit_button_name())
|
||||
|
||||
context = {
|
||||
'problem': content,
|
||||
'id': str(self.location),
|
||||
'short_id': self.location.html_id(),
|
||||
'submit_button': submit_button,
|
||||
'submit_button_submitting': submit_button_submitting,
|
||||
'should_enable_submit_button': should_enable_submit_button,
|
||||
'reset_button': self.should_show_reset_button(),
|
||||
'save_button': self.should_show_save_button(),
|
||||
'answer_available': self.answer_available(),
|
||||
'attempts_used': self.attempts,
|
||||
'attempts_allowed': self.max_attempts,
|
||||
'demand_hint_possible': demand_hint_possible,
|
||||
'should_enable_next_hint': should_enable_next_hint,
|
||||
'answer_notification_type': answer_notification_type,
|
||||
'answer_notification_message': answer_notification_message,
|
||||
'has_saved_answers': self.has_saved_answers,
|
||||
'save_message': save_message,
|
||||
'submit_disabled_cta': submit_disabled_ctas[0] if submit_disabled_ctas else None,
|
||||
}
|
||||
|
||||
html = self.runtime.service(self, 'mako').render_template('problem.html', context)
|
||||
|
||||
if encapsulate:
|
||||
html = HTML('<div id="problem_{id}" class="problem" data-url="{ajax_url}">{html}</div>').format(
|
||||
id=self.location.html_id(), ajax_url=self.ajax_url, html=HTML(html)
|
||||
)
|
||||
|
||||
# Now do all the substitutions which the LMS module_render normally does, but
|
||||
# we need to do here explicitly since we can get called for our HTML via AJAX
|
||||
html = self.runtime.service(self, "replace_urls").replace_urls(html)
|
||||
|
||||
return html
|
||||
|
||||
def _get_answer_notification(self, render_notifications):
|
||||
"""
|
||||
Generate the answer notification type and message from the current problem status.
|
||||
|
||||
Arguments:
|
||||
render_notifications (bool): If false the method will return an None for type and message
|
||||
"""
|
||||
answer_notification_message = None
|
||||
answer_notification_type = None
|
||||
|
||||
if render_notifications:
|
||||
progress = self.get_progress()
|
||||
id_list = list(self.lcp.correct_map.keys())
|
||||
|
||||
# Show only a generic message if hiding correctness
|
||||
if not self.correctness_available():
|
||||
answer_notification_type = 'submitted'
|
||||
elif len(id_list) == 1:
|
||||
# Only one answer available
|
||||
answer_notification_type = self.lcp.correct_map.get_correctness(id_list[0])
|
||||
elif len(id_list) > 1:
|
||||
# Check the multiple answers that are available
|
||||
answer_notification_type = self.lcp.correct_map.get_correctness(id_list[0])
|
||||
for answer_id in id_list[1:]:
|
||||
if self.lcp.correct_map.get_correctness(answer_id) != answer_notification_type:
|
||||
# There is at least 1 of the following combinations of correctness states
|
||||
# Correct and incorrect, Correct and partially correct, or Incorrect and partially correct
|
||||
# which all should have a message type of Partially Correct
|
||||
answer_notification_type = 'partially-correct'
|
||||
break
|
||||
|
||||
# Build the notification message based on the notification type and translate it.
|
||||
ungettext = self.runtime.service(self, "i18n").ungettext
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
if answer_notification_type == 'incorrect':
|
||||
if progress is not None:
|
||||
answer_notification_message = ungettext(
|
||||
"Incorrect ({progress} point)",
|
||||
"Incorrect ({progress} points)",
|
||||
progress.frac()[1]
|
||||
).format(progress=str(progress))
|
||||
else:
|
||||
answer_notification_message = _('Incorrect')
|
||||
elif answer_notification_type == 'correct':
|
||||
if progress is not None:
|
||||
answer_notification_message = ungettext(
|
||||
"Correct ({progress} point)",
|
||||
"Correct ({progress} points)",
|
||||
progress.frac()[1]
|
||||
).format(progress=str(progress))
|
||||
else:
|
||||
answer_notification_message = _('Correct')
|
||||
elif answer_notification_type == 'partially-correct':
|
||||
if progress is not None:
|
||||
answer_notification_message = ungettext(
|
||||
"Partially correct ({progress} point)",
|
||||
"Partially correct ({progress} points)",
|
||||
progress.frac()[1]
|
||||
).format(progress=str(progress))
|
||||
else:
|
||||
answer_notification_message = _('Partially Correct')
|
||||
elif answer_notification_type == 'submitted':
|
||||
answer_notification_message = _("Answer submitted.")
|
||||
|
||||
return answer_notification_type, answer_notification_message
|
||||
|
||||
def remove_tags_from_html(self, html):
|
||||
"""
|
||||
The capa xml includes many tags such as <additional_answer> or <demandhint> which are not
|
||||
meant to be part of the client html. We strip them all and return the resulting html.
|
||||
"""
|
||||
tags = ['demandhint', 'choicehint', 'optionhint', 'stringhint', 'numerichint', 'optionhint',
|
||||
'correcthint', 'regexphint', 'additional_answer', 'stringequalhint', 'compoundhint',
|
||||
'stringequalhint']
|
||||
for tag in tags:
|
||||
html = re.sub(fr'<{tag}.*?>.*?</{tag}>', '', html, flags=re.DOTALL) # xss-lint: disable=python-interpolate-html # lint-amnesty, pylint: disable=line-too-long
|
||||
# Some of these tags span multiple lines
|
||||
# Note: could probably speed this up by calling sub() once with a big regex
|
||||
# vs. simply calling sub() many times as we have here.
|
||||
return html
|
||||
|
||||
def hint_button(self, data):
|
||||
"""
|
||||
Hint button handler, returns new html using hint_index from the client.
|
||||
"""
|
||||
hint_index = int(data['hint_index'])
|
||||
return self.get_demand_hint(hint_index)
|
||||
|
||||
def used_all_attempts(self):
|
||||
""" All attempts have been used """
|
||||
return self.max_attempts is not None and self.attempts >= self.max_attempts
|
||||
|
||||
def is_past_due(self):
|
||||
"""
|
||||
Is it now past this problem's due date, including grace period?
|
||||
"""
|
||||
return (self.close_date is not None and
|
||||
datetime.datetime.now(utc) > self.close_date)
|
||||
|
||||
def closed(self):
|
||||
"""
|
||||
Is the student still allowed to submit answers?
|
||||
"""
|
||||
if self.used_all_attempts():
|
||||
return True
|
||||
if self.is_past_due():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_submitted(self):
|
||||
"""
|
||||
Used to decide to show or hide RESET or CHECK buttons.
|
||||
|
||||
Means that student submitted problem and nothing more.
|
||||
Problem can be completely wrong.
|
||||
Pressing RESET button makes this function to return False.
|
||||
"""
|
||||
# used by conditional module
|
||||
return self.lcp.done
|
||||
|
||||
def is_attempted(self):
|
||||
"""
|
||||
Has the problem been attempted?
|
||||
|
||||
used by conditional module
|
||||
"""
|
||||
return self.attempts > 0
|
||||
|
||||
def is_correct(self):
|
||||
"""
|
||||
True iff full points
|
||||
"""
|
||||
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
|
||||
self.lcp # pylint: disable=pointless-statement
|
||||
return self.score.raw_earned == self.score.raw_possible
|
||||
|
||||
def answer_available(self):
|
||||
"""
|
||||
Is the user allowed to see an answer?
|
||||
"""
|
||||
user_is_staff = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
|
||||
if not self.correctness_available():
|
||||
# If correctness is being withheld, then don't show answers either.
|
||||
return False
|
||||
elif self.showanswer == '':
|
||||
return False
|
||||
elif self.showanswer == SHOWANSWER.NEVER:
|
||||
return False
|
||||
elif user_is_staff:
|
||||
# This is after the 'never' check because admins can see the answer
|
||||
# unless the problem explicitly prevents it
|
||||
return True
|
||||
elif self.showanswer == SHOWANSWER.ATTEMPTED:
|
||||
return self.is_attempted() or self.is_past_due()
|
||||
elif self.showanswer == SHOWANSWER.ANSWERED:
|
||||
# NOTE: this is slightly different from 'attempted' -- resetting the problems
|
||||
# makes lcp.done False, but leaves attempts unchanged.
|
||||
return self.is_correct()
|
||||
elif self.showanswer == SHOWANSWER.CLOSED:
|
||||
return self.closed()
|
||||
elif self.showanswer == SHOWANSWER.FINISHED:
|
||||
return self.closed() or self.is_correct()
|
||||
|
||||
elif self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
|
||||
return self.is_correct() or self.is_past_due()
|
||||
elif self.showanswer == SHOWANSWER.PAST_DUE:
|
||||
return self.is_past_due()
|
||||
elif self.showanswer == SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS:
|
||||
required_attempts = self.attempts_before_showanswer_button
|
||||
if self.max_attempts and required_attempts >= self.max_attempts:
|
||||
required_attempts = self.max_attempts
|
||||
return self.attempts >= required_attempts
|
||||
elif self.showanswer == SHOWANSWER.ALWAYS:
|
||||
return True
|
||||
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS:
|
||||
return self.used_all_attempts()
|
||||
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT:
|
||||
return self.used_all_attempts() or self.is_correct()
|
||||
elif self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
|
||||
return self.is_attempted()
|
||||
return False
|
||||
|
||||
def correctness_available(self):
|
||||
"""
|
||||
Is the user allowed to see whether she's answered correctly?
|
||||
|
||||
Limits access to the correct/incorrect flags, messages, and problem score.
|
||||
"""
|
||||
user_is_staff = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
|
||||
return ShowCorrectness.correctness_available(
|
||||
show_correctness=self.show_correctness,
|
||||
due_date=self.close_date,
|
||||
has_staff_access=user_is_staff,
|
||||
)
|
||||
|
||||
def update_score(self, data):
|
||||
"""
|
||||
Delivers grading response (e.g. from asynchronous code checking) to
|
||||
the capa problem, so its score can be updated
|
||||
|
||||
'data' must have a key 'response' which is a string that contains the
|
||||
grader's response
|
||||
|
||||
No ajax return is needed. Return empty dict.
|
||||
"""
|
||||
queuekey = data['queuekey']
|
||||
score_msg = data['xqueue_body']
|
||||
self.lcp.update_score(score_msg, queuekey)
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
self.publish_grade(grader_response=True)
|
||||
|
||||
return {} # No AJAX return is needed
|
||||
|
||||
def handle_ungraded_response(self, data):
|
||||
"""
|
||||
Delivers a response from the XQueue to the capa problem
|
||||
|
||||
The score of the problem will not be updated
|
||||
|
||||
Args:
|
||||
- data (dict) must contain keys:
|
||||
queuekey - a key specific to this response
|
||||
xqueue_body - the body of the response
|
||||
Returns:
|
||||
empty dictionary
|
||||
|
||||
No ajax return is needed, so an empty dict is returned
|
||||
"""
|
||||
queuekey = data['queuekey']
|
||||
score_msg = data['xqueue_body']
|
||||
|
||||
# pass along the xqueue message to the problem
|
||||
self.lcp.ungraded_response(score_msg, queuekey)
|
||||
self.set_state_from_lcp()
|
||||
return {}
|
||||
|
||||
def handle_input_ajax(self, data):
|
||||
"""
|
||||
Handle ajax calls meant for a particular input in the problem
|
||||
|
||||
Args:
|
||||
- data (dict) - data that should be passed to the input
|
||||
Returns:
|
||||
- dict containing the response from the input
|
||||
"""
|
||||
response = self.lcp.handle_input_ajax(data)
|
||||
|
||||
# save any state changes that may occur
|
||||
self.set_state_from_lcp()
|
||||
return response
|
||||
|
||||
def get_answer(self, _data):
|
||||
"""
|
||||
For the "show answer" button.
|
||||
|
||||
Returns the answers and rendered "correct status span" HTML:
|
||||
{'answers' : answers, 'correct_status_html': correct_status_span_html}.
|
||||
The "correct status span" HTML is injected beside the correct answers
|
||||
for radio button and checkmark problems, so that there is a visual
|
||||
indication of the correct answers that is not solely based on color
|
||||
(and also screen reader text).
|
||||
"""
|
||||
event_info = {}
|
||||
event_info['problem_id'] = str(self.location)
|
||||
self.track_function_unmask('showanswer', event_info)
|
||||
if not self.answer_available(): # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise NotFoundError('Answer is not available')
|
||||
else:
|
||||
answers = self.lcp.get_question_answers()
|
||||
self.set_state_from_lcp()
|
||||
|
||||
# answers (eg <solution>) may have embedded images
|
||||
# but be careful, some problems are using non-string answer dicts
|
||||
new_answers = {}
|
||||
for answer_id in answers:
|
||||
try:
|
||||
answer_content = self.runtime.service(self, "replace_urls").replace_urls(answers[answer_id])
|
||||
new_answer = {answer_id: answer_content}
|
||||
except TypeError:
|
||||
log.debug('Unable to perform URL substitution on answers[%s]: %s',
|
||||
answer_id, answers[answer_id])
|
||||
new_answer = {answer_id: answers[answer_id]}
|
||||
new_answers.update(new_answer)
|
||||
|
||||
return {
|
||||
'answers': new_answers,
|
||||
'correct_status_html': self.runtime.service(self, 'mako').render_template(
|
||||
'status_span.html',
|
||||
{'status': Status('correct', self.runtime.service(self, "i18n").ugettext)}
|
||||
)
|
||||
}
|
||||
|
||||
# Figure out if we should move these to capa_problem?
|
||||
def get_problem(self, _data):
|
||||
"""
|
||||
Return results of get_problem_html, as a simple dict for json-ing.
|
||||
{ 'html': <the-html> }
|
||||
|
||||
Used if we want to reconfirm we have the right thing e.g. after
|
||||
several AJAX calls.
|
||||
"""
|
||||
return {'html': self.get_problem_html(encapsulate=False, submit_notification=True)}
|
||||
|
||||
@staticmethod
|
||||
def make_dict_of_responses(data):
|
||||
"""
|
||||
Make dictionary of student responses (aka "answers")
|
||||
|
||||
`data` is POST dictionary (webob.multidict.MultiDict).
|
||||
|
||||
The `data` dict has keys of the form 'x_y', which are mapped
|
||||
to key 'y' in the returned dict. For example,
|
||||
'input_1_2_3' would be mapped to '1_2_3' in the returned dict.
|
||||
|
||||
Some inputs always expect a list in the returned dict
|
||||
(e.g. checkbox inputs). The convention is that
|
||||
keys in the `data` dict that end with '[]' will always
|
||||
have list values in the returned dict.
|
||||
For example, if the `data` dict contains {'input_1[]': 'test' }
|
||||
then the output dict would contain {'1': ['test'] }
|
||||
(the value is a list).
|
||||
|
||||
Some other inputs such as ChoiceTextInput expect a dict of values in the returned
|
||||
dict If the key ends with '{}' then we will assume that the value is a json
|
||||
encoded dict and deserialize it.
|
||||
For example, if the `data` dict contains {'input_1{}': '{"1_2_1": 1}'}
|
||||
then the output dict would contain {'1': {"1_2_1": 1} }
|
||||
(the value is a dictionary)
|
||||
|
||||
Raises an exception if:
|
||||
|
||||
-A key in the `data` dictionary does not contain at least one underscore
|
||||
(e.g. "input" is invalid, but "input_1" is valid)
|
||||
|
||||
-Two keys end up with the same name in the returned dict.
|
||||
(e.g. 'input_1' and 'input_1[]', which both get mapped to 'input_1'
|
||||
in the returned dict)
|
||||
"""
|
||||
answers = {}
|
||||
|
||||
# webob.multidict.MultiDict is a view of a list of tuples,
|
||||
# so it will return a multi-value key once for each value.
|
||||
# We only want to consider each key a single time, so we use set(data.keys())
|
||||
for key in set(data.keys()):
|
||||
# e.g. input_resistor_1 ==> resistor_1
|
||||
_, _, name = key.partition('_')
|
||||
|
||||
# If key has no underscores, then partition
|
||||
# will return (key, '', '')
|
||||
# We detect this and raise an error
|
||||
if not name: # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise ValueError(f"{key} must contain at least one underscore")
|
||||
|
||||
else:
|
||||
# This allows for answers which require more than one value for
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
# if the name ends with '{}' (Which looks like a dict),
|
||||
# then the answer will be a dict
|
||||
is_list_key = name.endswith('[]')
|
||||
is_dict_key = name.endswith('{}')
|
||||
name = name[:-2] if is_list_key or is_dict_key else name
|
||||
|
||||
if is_list_key:
|
||||
val = data.getall(key)
|
||||
elif is_dict_key:
|
||||
try:
|
||||
val = json.loads(data[key])
|
||||
# If the submission wasn't deserializable, raise an error.
|
||||
except(KeyError, ValueError):
|
||||
raise ValueError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
f"Invalid submission: {data[key]} for {key}"
|
||||
)
|
||||
else:
|
||||
val = data[key]
|
||||
|
||||
# If the name already exists, then we don't want
|
||||
# to override it. Raise an error instead
|
||||
if name in answers: # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise ValueError(f"Key {name} already exists in answers dict")
|
||||
else:
|
||||
answers[name] = val
|
||||
|
||||
return answers
|
||||
|
||||
def publish_grade(self, score=None, only_if_higher=None, **kwargs):
|
||||
"""
|
||||
Publishes the student's current grade to the system as an event
|
||||
"""
|
||||
if not score:
|
||||
score = self.score
|
||||
event = {
|
||||
'value': score.raw_earned,
|
||||
'max_value': score.raw_possible,
|
||||
'only_if_higher': only_if_higher,
|
||||
}
|
||||
if kwargs.get('grader_response'):
|
||||
event['grader_response'] = kwargs['grader_response']
|
||||
|
||||
self.runtime.publish(self, 'grade', event)
|
||||
|
||||
return {'grade': self.score.raw_earned, 'max_grade': self.score.raw_possible}
|
||||
|
||||
# pylint: disable=too-many-statements
|
||||
def submit_problem(self, data, override_time=False):
|
||||
"""
|
||||
Checks whether answers to a problem are correct
|
||||
|
||||
Returns a map of correct/incorrect answers:
|
||||
{'success' : 'correct' | 'incorrect' | AJAX alert msg string,
|
||||
'contents' : html}
|
||||
"""
|
||||
event_info = {}
|
||||
event_info['state'] = self.lcp.get_state()
|
||||
event_info['problem_id'] = str(self.location)
|
||||
|
||||
self.lcp.has_saved_answers = False
|
||||
answers = self.make_dict_of_responses(data)
|
||||
answers_without_files = convert_files_to_filenames(answers)
|
||||
event_info['answers'] = answers_without_files
|
||||
|
||||
metric_name = 'capa.check_problem.{}'.format # lint-amnesty, pylint: disable=unused-variable
|
||||
# Can override current time
|
||||
current_time = datetime.datetime.now(utc)
|
||||
if override_time is not False:
|
||||
current_time = override_time
|
||||
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
# Too late. Cannot submit
|
||||
if self.closed():
|
||||
log.error(
|
||||
'ProblemClosedError: Problem %s, close date: %s, due:%s, is_past_due: %s, attempts: %s/%s,',
|
||||
str(self.location),
|
||||
self.close_date,
|
||||
self.due,
|
||||
self.is_past_due(),
|
||||
self.attempts,
|
||||
self.max_attempts,
|
||||
)
|
||||
event_info['failure'] = 'closed'
|
||||
self.track_function_unmask('problem_check_fail', event_info)
|
||||
raise NotFoundError(_("Problem is closed."))
|
||||
|
||||
# Problem submitted. Student should reset before checking again
|
||||
if self.done and self.rerandomize == RANDOMIZATION.ALWAYS:
|
||||
event_info['failure'] = 'unreset'
|
||||
self.track_function_unmask('problem_check_fail', event_info)
|
||||
raise NotFoundError(_("Problem must be reset before it can be submitted again."))
|
||||
|
||||
# Problem queued. Students must wait a specified waittime before they are allowed to submit
|
||||
# IDEA: consider stealing code from below: pretty-print of seconds, cueing of time remaining
|
||||
if self.lcp.is_queued():
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
|
||||
xqueue_service = self.runtime.service(self, 'xqueue')
|
||||
waittime_between_requests = xqueue_service.waittime if xqueue_service else 0
|
||||
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
msg = _("You must wait at least {wait} seconds between submissions.").format(
|
||||
wait=waittime_between_requests)
|
||||
return {'success': msg, 'html': ''}
|
||||
|
||||
# Wait time between resets: check if is too soon for submission.
|
||||
if self.last_submission_time is not None and self.submission_wait_seconds not in [0, None]:
|
||||
seconds_since_submission = (current_time - self.last_submission_time).total_seconds()
|
||||
if seconds_since_submission < self.submission_wait_seconds:
|
||||
remaining_secs = int(self.submission_wait_seconds - seconds_since_submission)
|
||||
msg = _('You must wait at least {wait_secs} between submissions. {remaining_secs} remaining.').format(
|
||||
wait_secs=self.pretty_print_seconds(self.submission_wait_seconds),
|
||||
remaining_secs=self.pretty_print_seconds(remaining_secs))
|
||||
return {
|
||||
'success': msg,
|
||||
'html': ''
|
||||
}
|
||||
|
||||
try:
|
||||
# expose the attempt number to a potential python custom grader
|
||||
# self.lcp.context['attempt'] refers to the attempt number (1-based)
|
||||
self.lcp.context['attempt'] = self.attempts + 1
|
||||
correct_map = self.lcp.grade_answers(answers)
|
||||
# self.attempts refers to the number of attempts that did not
|
||||
# raise an error (0-based)
|
||||
self.attempts = self.attempts + 1
|
||||
self.lcp.done = True
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
self.set_last_submission_time()
|
||||
|
||||
except (StudentInputError, ResponseError, LoncapaProblemError) as inst:
|
||||
if self.debug:
|
||||
log.warning(
|
||||
"StudentInputError in capa_module:problem_check",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Save the user's state before failing
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
# If the user is a staff member, include
|
||||
# the full exception, including traceback,
|
||||
# in the response
|
||||
user_is_staff = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
|
||||
if user_is_staff:
|
||||
msg = f"Staff debug info: {traceback.format_exc()}"
|
||||
|
||||
# Otherwise, display just an error message,
|
||||
# without a stack trace
|
||||
else:
|
||||
full_error = inst.args[0]
|
||||
try:
|
||||
# only return the error value of the exception
|
||||
msg = full_error.split("\\n")[-2].split(": ", 1)[1]
|
||||
except IndexError:
|
||||
msg = full_error
|
||||
|
||||
return {'success': msg}
|
||||
|
||||
except Exception as err:
|
||||
# Save the user's state before failing
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
if self.debug:
|
||||
msg = f"Error checking problem: {str(err)}"
|
||||
msg += f'\nTraceback:\n{traceback.format_exc()}'
|
||||
return {'success': msg}
|
||||
raise
|
||||
published_grade = self.publish_grade()
|
||||
|
||||
# success = correct if ALL questions in this problem are correct
|
||||
success = 'correct'
|
||||
for answer_id in correct_map:
|
||||
if not correct_map.is_correct(answer_id):
|
||||
success = 'incorrect'
|
||||
|
||||
# NOTE: We are logging both full grading and queued-grading submissions. In the latter,
|
||||
# 'success' will always be incorrect
|
||||
event_info['grade'] = published_grade['grade']
|
||||
event_info['max_grade'] = published_grade['max_grade']
|
||||
event_info['correct_map'] = correct_map.get_dict()
|
||||
event_info['success'] = success
|
||||
event_info['attempts'] = self.attempts
|
||||
event_info['submission'] = self.get_submission_metadata_safe(answers_without_files, correct_map)
|
||||
self.track_function_unmask('problem_check', event_info)
|
||||
|
||||
# render problem into HTML
|
||||
html = self.get_problem_html(encapsulate=False, submit_notification=True)
|
||||
|
||||
# Withhold success indicator if hiding correctness
|
||||
if not self.correctness_available():
|
||||
success = 'submitted'
|
||||
|
||||
return {
|
||||
'success': success,
|
||||
'contents': html
|
||||
}
|
||||
# pylint: enable=too-many-statements
|
||||
|
||||
def track_function_unmask(self, title, event_info):
|
||||
"""
|
||||
All calls to runtime.track_function route through here so that the
|
||||
choice names can be unmasked.
|
||||
"""
|
||||
# Do the unmask translates on a copy of event_info,
|
||||
# avoiding problems where an event_info is unmasked twice.
|
||||
event_unmasked = copy.deepcopy(event_info)
|
||||
self.unmask_event(event_unmasked)
|
||||
self.runtime.publish(self, title, event_unmasked)
|
||||
|
||||
def unmask_event(self, event_info):
|
||||
"""
|
||||
Translates in-place the event_info to account for masking
|
||||
and adds information about permutation options in force.
|
||||
"""
|
||||
# answers is like: {u'i4x-Stanford-CS99-problem-dada976e76f34c24bc8415039dee1300_2_1': u'mask_0'}
|
||||
# Each response values has an answer_id which matches the key in answers.
|
||||
for response in self.lcp.responders.values():
|
||||
# Un-mask choice names in event_info for masked responses.
|
||||
if response.has_mask():
|
||||
# We don't assume much about the structure of event_info,
|
||||
# but check for the existence of the things we need to un-mask.
|
||||
|
||||
# Look for answers/id
|
||||
answer = event_info.get('answers', {}).get(response.answer_id)
|
||||
if answer is not None:
|
||||
event_info['answers'][response.answer_id] = response.unmask_name(answer)
|
||||
|
||||
# Look for state/student_answers/id
|
||||
answer = event_info.get('state', {}).get('student_answers', {}).get(response.answer_id)
|
||||
if answer is not None:
|
||||
event_info['state']['student_answers'][response.answer_id] = response.unmask_name(answer)
|
||||
|
||||
# Look for old_state/student_answers/id -- parallel to the above case, happens on reset
|
||||
answer = event_info.get('old_state', {}).get('student_answers', {}).get(response.answer_id)
|
||||
if answer is not None:
|
||||
event_info['old_state']['student_answers'][response.answer_id] = response.unmask_name(answer)
|
||||
|
||||
# Add 'permutation' to event_info for permuted responses.
|
||||
permutation_option = None
|
||||
if response.has_shuffle():
|
||||
permutation_option = 'shuffle'
|
||||
elif response.has_answerpool():
|
||||
permutation_option = 'answerpool'
|
||||
|
||||
if permutation_option is not None:
|
||||
# Add permutation record tuple: (one of:'shuffle'/'answerpool', [as-displayed list])
|
||||
if 'permutation' not in event_info:
|
||||
event_info['permutation'] = {}
|
||||
event_info['permutation'][response.answer_id] = (permutation_option, response.unmask_order())
|
||||
|
||||
def pretty_print_seconds(self, num_seconds):
|
||||
"""
|
||||
Returns time duration nicely formated, e.g. "3 minutes 4 seconds"
|
||||
"""
|
||||
# Here _ is the N variant ungettext that does pluralization with a 3-arg call
|
||||
ungettext = self.runtime.service(self, "i18n").ungettext
|
||||
hours = num_seconds // 3600
|
||||
sub_hour = num_seconds % 3600
|
||||
minutes = sub_hour // 60
|
||||
seconds = sub_hour % 60
|
||||
display = ""
|
||||
if hours > 0:
|
||||
display += ungettext("{num_hour} hour", "{num_hour} hours", hours).format(num_hour=hours)
|
||||
if minutes > 0:
|
||||
if display != "":
|
||||
display += " "
|
||||
# translators: "minute" refers to a minute of time
|
||||
display += ungettext("{num_minute} minute", "{num_minute} minutes", minutes).format(num_minute=minutes)
|
||||
# Taking care to make "0 seconds" instead of "" for 0 time
|
||||
if seconds > 0 or (hours == 0 and minutes == 0):
|
||||
if display != "":
|
||||
display += " "
|
||||
# translators: "second" refers to a second of time
|
||||
display += ungettext("{num_second} second", "{num_second} seconds", seconds).format(num_second=seconds)
|
||||
return display
|
||||
|
||||
def get_submission_metadata_safe(self, answers, correct_map):
|
||||
"""
|
||||
Ensures that no exceptions are thrown while generating input metadata summaries. Returns the
|
||||
summary if it is successfully created, otherwise an empty dictionary.
|
||||
"""
|
||||
try:
|
||||
return self.get_submission_metadata(answers, correct_map)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# NOTE: The above process requires deep inspection of capa structures that may break for some
|
||||
# uncommon problem types. Ensure that it does not prevent answer submission in those
|
||||
# cases. Any occurrences of errors in this block should be investigated and resolved.
|
||||
log.exception('Unable to gather submission metadata, it will not be included in the event.')
|
||||
|
||||
return {}
|
||||
|
||||
def get_submission_metadata(self, answers, correct_map):
|
||||
"""
|
||||
Return a map of inputs to their corresponding summarized metadata.
|
||||
|
||||
Returns:
|
||||
A map whose keys are a unique identifier for the input (in this case a capa input_id) and
|
||||
whose values are:
|
||||
|
||||
question (str): Is the prompt that was presented to the student. It corresponds to the
|
||||
label of the input.
|
||||
answer (mixed): Is the answer the student provided. This may be a rich structure,
|
||||
however it must be json serializable.
|
||||
response_type (str): The XML tag of the capa response type.
|
||||
input_type (str): The XML tag of the capa input type.
|
||||
correct (bool): Whether or not the provided answer is correct. Will be an empty
|
||||
string if correctness could not be determined.
|
||||
variant (str): In some cases the same question can have several different variants.
|
||||
This string should uniquely identify the variant of the question that was answered.
|
||||
In the capa context this corresponds to the `seed`.
|
||||
|
||||
This function attempts to be very conservative and make very few assumptions about the structure
|
||||
of the problem. If problem related metadata cannot be located it should be replaced with empty
|
||||
strings ''.
|
||||
"""
|
||||
input_metadata = {}
|
||||
for input_id, internal_answer in answers.items():
|
||||
answer_input = self.lcp.inputs.get(input_id)
|
||||
|
||||
if answer_input is None:
|
||||
log.warning('Input id %s is not mapped to an input type.', input_id)
|
||||
|
||||
answer_response = None
|
||||
for responder in self.lcp.responders.values():
|
||||
if input_id in responder.answer_ids:
|
||||
answer_response = responder
|
||||
|
||||
if answer_response is None:
|
||||
log.warning('Answer responder could not be found for input_id %s.', input_id)
|
||||
|
||||
user_visible_answer = internal_answer
|
||||
if hasattr(answer_input, 'get_user_visible_answer'):
|
||||
user_visible_answer = answer_input.get_user_visible_answer(internal_answer)
|
||||
|
||||
# If this problem has rerandomize enabled, then it will generate N variants of the
|
||||
# question, one per unique seed value. In this case we would like to know which
|
||||
# variant was selected. Ideally it would be nice to have the exact question that
|
||||
# was presented to the user, with values interpolated etc, but that can be done
|
||||
# later if necessary.
|
||||
variant = ''
|
||||
if self.rerandomize != RANDOMIZATION.NEVER:
|
||||
variant = self.get_seed()
|
||||
|
||||
is_correct = correct_map.is_correct(input_id)
|
||||
if is_correct is None:
|
||||
is_correct = ''
|
||||
|
||||
response_data = getattr(answer_input, 'response_data', {})
|
||||
input_metadata[input_id] = {
|
||||
'question': response_data.get('label', ''),
|
||||
'answer': user_visible_answer,
|
||||
'response_type': getattr(getattr(answer_response, 'xml', None), 'tag', ''),
|
||||
'input_type': getattr(answer_input, 'tag', ''),
|
||||
'correct': is_correct,
|
||||
'variant': variant,
|
||||
'group_label': response_data.get('group_label', ''),
|
||||
}
|
||||
|
||||
return input_metadata
|
||||
|
||||
def save_problem(self, data):
|
||||
"""
|
||||
Save the passed in answers.
|
||||
Returns a dict { 'success' : bool, 'msg' : message }
|
||||
The message is informative on success, and an error message on failure.
|
||||
"""
|
||||
event_info = {}
|
||||
event_info['state'] = self.lcp.get_state()
|
||||
event_info['problem_id'] = str(self.location)
|
||||
|
||||
answers = self.make_dict_of_responses(data)
|
||||
event_info['answers'] = answers
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
# Too late. Cannot submit
|
||||
if self.closed() and not self.max_attempts == 0:
|
||||
event_info['failure'] = 'closed'
|
||||
self.track_function_unmask('save_problem_fail', event_info)
|
||||
return {
|
||||
'success': False,
|
||||
# pylint: disable=line-too-long
|
||||
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
|
||||
'msg': _("Problem is closed."),
|
||||
# pylint: enable=line-too-long
|
||||
}
|
||||
|
||||
# Problem submitted. Student should reset before saving
|
||||
# again.
|
||||
if self.done and self.rerandomize == RANDOMIZATION.ALWAYS:
|
||||
event_info['failure'] = 'done'
|
||||
self.track_function_unmask('save_problem_fail', event_info)
|
||||
return {
|
||||
'success': False,
|
||||
'msg': _("Problem needs to be reset prior to save.")
|
||||
}
|
||||
|
||||
self.lcp.student_answers = answers
|
||||
self.lcp.has_saved_answers = True
|
||||
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
self.track_function_unmask('save_problem_success', event_info)
|
||||
msg = _("Your answers have been saved.")
|
||||
if not self.max_attempts == 0:
|
||||
msg = _(
|
||||
"Your answers have been saved but not graded. Click '{button_name}' to grade them."
|
||||
).format(button_name=self.submit_button_name())
|
||||
return {
|
||||
'success': True,
|
||||
'msg': msg,
|
||||
'html': self.get_problem_html(encapsulate=False)
|
||||
}
|
||||
|
||||
def reset_problem(self, _data):
|
||||
"""
|
||||
Changes problem state to unfinished -- removes student answers,
|
||||
Causes problem to rerender itself if randomization is enabled.
|
||||
|
||||
Returns a dictionary of the form:
|
||||
{'success': True/False,
|
||||
'html': Problem HTML string }
|
||||
|
||||
If an error occurs, the dictionary will also have an
|
||||
`error` key containing an error message.
|
||||
"""
|
||||
event_info = {}
|
||||
event_info['old_state'] = self.lcp.get_state()
|
||||
event_info['problem_id'] = str(self.location)
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
if self.closed():
|
||||
event_info['failure'] = 'closed'
|
||||
self.track_function_unmask('reset_problem_fail', event_info)
|
||||
return {
|
||||
'success': False,
|
||||
# pylint: disable=line-too-long
|
||||
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
|
||||
'msg': _("You cannot select Reset for a problem that is closed."),
|
||||
# pylint: enable=line-too-long
|
||||
}
|
||||
|
||||
if not self.is_submitted():
|
||||
event_info['failure'] = 'not_done'
|
||||
self.track_function_unmask('reset_problem_fail', event_info)
|
||||
return {
|
||||
'success': False,
|
||||
'msg': _("You must submit an answer before you can select Reset."),
|
||||
}
|
||||
|
||||
if self.is_submitted() and self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET]:
|
||||
# Reset random number generator seed.
|
||||
self.choose_new_seed()
|
||||
|
||||
# Generate a new problem with either the previous seed or a new seed
|
||||
self.lcp = self.new_lcp(None)
|
||||
|
||||
# Pull in the new problem seed
|
||||
self.set_state_from_lcp()
|
||||
self.set_score(self.score_from_lcp(self.lcp))
|
||||
|
||||
# Grade may have changed, so publish new value
|
||||
self.publish_grade()
|
||||
|
||||
event_info['new_state'] = self.lcp.get_state()
|
||||
self.track_function_unmask('reset_problem', event_info)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'html': self.get_problem_html(encapsulate=False),
|
||||
}
|
||||
|
||||
# ScorableXBlockMixin methods
|
||||
|
||||
def rescore(self, only_if_higher=False):
|
||||
"""
|
||||
Checks whether the existing answers to a problem are correct.
|
||||
|
||||
This is called when the correct answer to a problem has been changed,
|
||||
and the grade should be re-evaluated.
|
||||
|
||||
If only_if_higher is True, the answer and grade are updated
|
||||
only if the resulting score is higher than before.
|
||||
|
||||
Returns a dict with one key:
|
||||
{'success' : 'correct' | 'incorrect' | AJAX alert msg string }
|
||||
|
||||
Raises NotFoundError if called on a problem that has not yet been
|
||||
answered, or NotImplementedError if it's a problem that cannot be rescored.
|
||||
|
||||
Returns the error messages for exceptions occurring while performing
|
||||
the rescoring, rather than throwing them.
|
||||
"""
|
||||
event_info = {'state': self.lcp.get_state(), 'problem_id': str(self.location)}
|
||||
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
if not self.lcp.supports_rescoring():
|
||||
event_info['failure'] = 'unsupported'
|
||||
self.track_function_unmask('problem_rescore_fail', event_info)
|
||||
# pylint: disable=line-too-long
|
||||
# Translators: 'rescoring' refers to the act of re-submitting a student's solution so it can get a new score.
|
||||
raise NotImplementedError(_("Problem's definition does not support rescoring."))
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
if not self.done:
|
||||
event_info['failure'] = 'unanswered'
|
||||
self.track_function_unmask('problem_rescore_fail', event_info)
|
||||
raise NotFoundError(_("Problem must be answered before it can be graded again."))
|
||||
|
||||
# get old score, for comparison:
|
||||
orig_score = self.get_score()
|
||||
event_info['orig_score'] = orig_score.raw_earned
|
||||
event_info['orig_total'] = orig_score.raw_possible
|
||||
try:
|
||||
self.update_correctness()
|
||||
calculated_score = self.calculate_score()
|
||||
except (StudentInputError, ResponseError, LoncapaProblemError) as inst: # lint-amnesty, pylint: disable=unused-variable
|
||||
log.warning("Input error in capa_module:problem_rescore", exc_info=True)
|
||||
event_info['failure'] = 'input_error'
|
||||
self.track_function_unmask('problem_rescore_fail', event_info)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
event_info['failure'] = 'unexpected'
|
||||
self.track_function_unmask('problem_rescore_fail', event_info)
|
||||
raise
|
||||
|
||||
# rescoring should have no effect on attempts, so don't
|
||||
# need to increment here, or mark done. Just save.
|
||||
self.set_state_from_lcp()
|
||||
self.publish_grade(score=calculated_score, only_if_higher=only_if_higher)
|
||||
|
||||
event_info['new_score'] = calculated_score.raw_earned
|
||||
event_info['new_total'] = calculated_score.raw_possible
|
||||
|
||||
# success = correct if ALL questions in this problem are correct
|
||||
success = 'correct'
|
||||
for answer_id in self.lcp.correct_map:
|
||||
if not self.lcp.correct_map.is_correct(answer_id):
|
||||
success = 'incorrect'
|
||||
|
||||
# NOTE: We are logging both full grading and queued-grading submissions. In the latter,
|
||||
# 'success' will always be incorrect
|
||||
event_info['correct_map'] = self.lcp.correct_map.get_dict()
|
||||
event_info['success'] = success
|
||||
event_info['attempts'] = self.attempts
|
||||
self.track_function_unmask('problem_rescore', event_info)
|
||||
|
||||
def has_submitted_answer(self):
|
||||
return self.done
|
||||
|
||||
def set_score(self, score):
|
||||
"""
|
||||
Sets the internal score for the problem. This is not derived directly
|
||||
from the internal LCP in keeping with the ScorableXBlock spec.
|
||||
"""
|
||||
self.score = score
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Returns the score currently set on the block.
|
||||
"""
|
||||
return self.score
|
||||
|
||||
def update_correctness(self):
|
||||
"""
|
||||
Updates correct map of the LCP.
|
||||
Operates by creating a new correctness map based on the current
|
||||
state of the LCP, and updating the old correctness map of the LCP.
|
||||
"""
|
||||
# Make sure that the attempt number is always at least 1 for grading purposes,
|
||||
# even if the number of attempts have been reset and this problem is regraded.
|
||||
self.lcp.context['attempt'] = max(self.attempts, 1)
|
||||
new_correct_map = self.lcp.get_grade_from_current_answers(None)
|
||||
self.lcp.correct_map.update(new_correct_map)
|
||||
|
||||
def calculate_score(self):
|
||||
"""
|
||||
Returns the score calculated from the current problem state.
|
||||
"""
|
||||
new_score = self.lcp.calculate_score()
|
||||
return Score(raw_earned=new_score['score'], raw_possible=new_score['total'])
|
||||
|
||||
def score_from_lcp(self, lcp):
|
||||
"""
|
||||
Returns the score associated with the correctness map
|
||||
currently stored by the LCP.
|
||||
"""
|
||||
lcp_score = lcp.calculate_score()
|
||||
return Score(raw_earned=lcp_score['score'], raw_possible=lcp_score['total'])
|
||||
|
||||
|
||||
class ComplexEncoder(json.JSONEncoder):
|
||||
"""
|
||||
Extend the JSON encoder to correctly handle complex numbers
|
||||
"""
|
||||
def default(self, obj): # lint-amnesty, pylint: disable=arguments-differ, method-hidden
|
||||
"""
|
||||
Print a nicely formatted complex number, or default to the JSON encoder
|
||||
"""
|
||||
if isinstance(obj, complex):
|
||||
return f"{obj.real:.7g}{obj.imag:+.7g}*j"
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def randomization_bin(seed, problem_id):
|
||||
"""
|
||||
Pick a randomization bin for the problem given the user's seed and a problem id.
|
||||
|
||||
We do this because we only want e.g. 20 randomizations of a problem to make analytics
|
||||
interesting. To avoid having sets of students that always get the same problems,
|
||||
we'll combine the system's per-student seed with the problem id in picking the bin.
|
||||
"""
|
||||
r_hash = hashlib.sha1()
|
||||
r_hash.update(str(seed).encode())
|
||||
r_hash.update(str(problem_id).encode())
|
||||
# get the first few digits of the hash, convert to an int, then mod.
|
||||
return int(r_hash.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS
|
||||
@@ -1,412 +0,0 @@
|
||||
"""
|
||||
ConditionalBlock is an XBlock which you can use for disabling some XBlocks by conditions.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from lazy import lazy
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
from pkg_resources import resource_string
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import ReferenceList, Scope, String
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from xmodule.mako_module import MakoTemplateBlockBase
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.seq_module import SequenceMixin
|
||||
from xmodule.studio_editable import StudioEditableBlock
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
from xmodule.xml_module import XmlMixin
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger('edx.' + __name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
@XBlock.needs('mako')
|
||||
class ConditionalBlock(
|
||||
SequenceMixin,
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
StudioEditableBlock,
|
||||
):
|
||||
"""
|
||||
Blocks child blocks from showing unless certain conditions are met.
|
||||
|
||||
Example:
|
||||
|
||||
<conditional sources="i4x://.../problem_1; i4x://.../problem_2" completed="True">
|
||||
<show sources="i4x://.../test_6; i4x://.../Avi_resources"/>
|
||||
<video url_name="secret_video" />
|
||||
</conditional>
|
||||
|
||||
<conditional> tag attributes:
|
||||
sources - location id of required modules, separated by ';'
|
||||
|
||||
submitted - map to `is_submitted` module method.
|
||||
(pressing RESET button makes this function to return False.)
|
||||
|
||||
attempted - map to `is_attempted` module method
|
||||
correct - map to `is_correct` module method
|
||||
poll_answer - map to `poll_answer` module attribute
|
||||
voted - map to `voted` module attribute
|
||||
|
||||
<show> tag attributes:
|
||||
sources - location id of required modules, separated by ';'
|
||||
|
||||
You can add you own rules for <conditional> tag, like
|
||||
"completed", "attempted" etc. To do that yo must extend
|
||||
`ConditionalBlock.conditions_map` variable and add pair:
|
||||
my_attr: my_property/my_method
|
||||
|
||||
After that you can use it:
|
||||
<conditional my_attr="some value" ...>
|
||||
...
|
||||
</conditional>
|
||||
|
||||
And my_property/my_method will be called for required modules.
|
||||
|
||||
"""
|
||||
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
default=_('Conditional')
|
||||
)
|
||||
|
||||
show_tag_list = ReferenceList(
|
||||
help=_("List of urls of children that are references to external modules"),
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
sources_list = ReferenceList(
|
||||
display_name=_("Source Components"),
|
||||
help=_("The component location IDs of all source components that are used to determine whether a learner is "
|
||||
"shown the content of this conditional module. Copy the component location ID of a component from its "
|
||||
"Settings dialog in Studio."),
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
conditional_attr = String(
|
||||
display_name=_("Conditional Attribute"),
|
||||
help=_("The attribute of the source components that determines whether a learner is shown the content of this "
|
||||
"conditional module."),
|
||||
scope=Scope.content,
|
||||
default='correct',
|
||||
values=lambda: [{'display_name': xml_attr, 'value': xml_attr}
|
||||
for xml_attr in ConditionalBlock.conditions_map]
|
||||
)
|
||||
|
||||
conditional_value = String(
|
||||
display_name=_("Conditional Value"),
|
||||
help=_("The value that the conditional attribute of the source components must match before a learner is shown "
|
||||
"the content of this conditional module."),
|
||||
scope=Scope.content,
|
||||
default='True'
|
||||
)
|
||||
|
||||
conditional_message = String(
|
||||
display_name=_("Blocked Content Message"),
|
||||
help=_("The message that is shown to learners when not all conditions are met to show the content of this "
|
||||
"conditional module. Include {link} in the text of your message to give learners a direct link to "
|
||||
"required units. For example, 'You must complete {link} before you can access this unit'."),
|
||||
scope=Scope.content,
|
||||
default=_('You must complete {link} before you can access this unit.')
|
||||
)
|
||||
|
||||
has_children = True
|
||||
|
||||
_tag_name = 'conditional'
|
||||
|
||||
resources_dir = None
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
has_score = False
|
||||
|
||||
show_in_read_only_mode = True
|
||||
|
||||
preview_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/conditional/display.js'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.js'),
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
preview_view_css = {
|
||||
'scss': [],
|
||||
}
|
||||
|
||||
mako_template = 'widgets/metadata-edit.html'
|
||||
studio_js_module_name = 'SequenceDescriptor'
|
||||
studio_view_js = {
|
||||
'js': [resource_string(__name__, 'js/src/sequence/edit.js')],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
studio_view_css = {
|
||||
'scss': [],
|
||||
}
|
||||
|
||||
# Map
|
||||
# key: <tag attribute in xml>
|
||||
# value: <name of module attribute>
|
||||
conditions_map = {
|
||||
'poll_answer': 'poll_answer', # poll_question attr
|
||||
|
||||
# problem was submitted (it can be wrong)
|
||||
# if student will press reset button after that,
|
||||
# state will be reverted
|
||||
'submitted': 'is_submitted', # capa_problem attr
|
||||
|
||||
# if student attempted problem
|
||||
'attempted': 'is_attempted', # capa_problem attr
|
||||
|
||||
# if problem is full points
|
||||
'correct': 'is_correct',
|
||||
|
||||
'voted': 'voted' # poll_question attr
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Create an instance of the Conditional XBlock.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
# Convert sources xml_attribute to a ReferenceList field type so Location/Locator
|
||||
# substitution can be done.
|
||||
if not self.sources_list:
|
||||
if 'sources' in self.xml_attributes and isinstance(self.xml_attributes['sources'], str):
|
||||
self.sources_list = [
|
||||
# TODO: it is not clear why we are replacing the run here (which actually is a no-op
|
||||
# for old-style course locators. However, this is the implementation of
|
||||
# CourseLocator.make_usage_key_from_deprecated_string, which was previously
|
||||
# being called in this location.
|
||||
BlockUsageLocator.from_string(item).replace(run=self.location.course_key.run)
|
||||
for item in ConditionalBlock.parse_sources(self.xml_attributes)
|
||||
]
|
||||
|
||||
def is_condition_satisfied(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
attr_name = self.conditions_map[self.conditional_attr]
|
||||
|
||||
if self.conditional_value and self.get_required_blocks:
|
||||
for module in self.get_required_blocks:
|
||||
if not hasattr(module, attr_name):
|
||||
# We don't throw an exception here because it is possible for
|
||||
# the descriptor of a required module to have a property but
|
||||
# for the resulting module to be a (flavor of) ErrorBlock.
|
||||
# So just log and return false.
|
||||
if module is not None:
|
||||
# We do not want to log when module is None, and it is when requester
|
||||
# does not have access to the requested required module.
|
||||
log.warning('Error in conditional module: \
|
||||
required module {module} has no {module_attr}'.format(module=module, module_attr=attr_name))
|
||||
return False
|
||||
|
||||
attr = getattr(module, attr_name)
|
||||
if callable(attr):
|
||||
attr = attr()
|
||||
|
||||
if self.conditional_value != str(attr):
|
||||
break
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Renders the student view.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
fragment.add_content(self.get_html())
|
||||
add_webpack_to_fragment(fragment, 'ConditionalBlockPreview')
|
||||
shim_xmodule_js(fragment, 'Conditional')
|
||||
return fragment
|
||||
|
||||
def get_html(self):
|
||||
required_html_ids = [descriptor.location.html_id() for descriptor in self.get_required_blocks]
|
||||
return self.runtime.service(self, 'mako').render_template('conditional_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'ajax_url': self.ajax_url,
|
||||
'depends': ';'.join(required_html_ids)
|
||||
})
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview by rendering each child so that they can all be seen and edited.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.location == self.location
|
||||
if is_root:
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
self.render_children(context, fragment, can_reorder=True, can_add=True)
|
||||
# else: When shown on a unit page, don't show any sort of preview -
|
||||
# just the status of this block in the validation area.
|
||||
|
||||
return fragment
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
fragment = Fragment(
|
||||
self.runtime.service(self, 'mako').render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
add_webpack_to_fragment(fragment, 'ConditionalBlockStudio')
|
||||
shim_xmodule_js(fragment, self.studio_js_module_name)
|
||||
return fragment
|
||||
|
||||
def handle_ajax(self, _dispatch, _data):
|
||||
"""This is called by courseware.moduleodule_render, to handle
|
||||
an AJAX call.
|
||||
"""
|
||||
if not self.is_condition_satisfied():
|
||||
context = {'module': self,
|
||||
'message': self.conditional_message}
|
||||
html = self.runtime.service(self, 'mako').render_template('conditional_module.html', context)
|
||||
return json.dumps({'fragments': [{'content': html}], 'message': bool(self.conditional_message)})
|
||||
|
||||
fragments = [child.render(STUDENT_VIEW).to_dict() for child in self.get_display_items()]
|
||||
|
||||
return json.dumps({'fragments': fragments})
|
||||
|
||||
def get_icon_class(self):
|
||||
new_class = 'other'
|
||||
# HACK: This shouldn't be hard-coded to two types
|
||||
# OBSOLETE: This obsoletes 'type'
|
||||
class_priority = ['video', 'problem']
|
||||
|
||||
child_classes = [
|
||||
child_descriptor.get_icon_class() for child_descriptor in self.get_children()
|
||||
]
|
||||
for c in class_priority:
|
||||
if c in child_classes:
|
||||
new_class = c
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
def parse_sources(xml_element):
|
||||
""" Parse xml_element 'sources' attr and return a list of location strings. """
|
||||
sources = xml_element.get('sources')
|
||||
if sources:
|
||||
return [location.strip() for location in sources.split(';')]
|
||||
|
||||
@lazy
|
||||
def get_required_blocks(self):
|
||||
"""
|
||||
Returns a list of bound XBlocks instances upon which XBlock depends.
|
||||
"""
|
||||
return [self.system.get_module(descriptor) for descriptor in self.get_required_module_descriptors()]
|
||||
|
||||
def get_required_module_descriptors(self):
|
||||
"""
|
||||
Returns a list of unbound XBlocks instances upon which this XBlock depends.
|
||||
"""
|
||||
descriptors = []
|
||||
for location in self.sources_list:
|
||||
try:
|
||||
descriptor = self.system.load_item(location)
|
||||
descriptors.append(descriptor)
|
||||
except ItemNotFoundError:
|
||||
msg = "Invalid module by location."
|
||||
log.exception(msg)
|
||||
self.system.error_tracker(msg)
|
||||
|
||||
return descriptors
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
children = []
|
||||
show_tag_list = []
|
||||
definition = {}
|
||||
for conditional_attr in cls.conditions_map:
|
||||
conditional_value = xml_object.get(conditional_attr)
|
||||
if conditional_value is not None:
|
||||
definition.update({
|
||||
'conditional_attr': conditional_attr,
|
||||
'conditional_value': str(conditional_value),
|
||||
})
|
||||
for child in xml_object:
|
||||
if child.tag == 'show':
|
||||
locations = cls.parse_sources(child)
|
||||
for location in locations:
|
||||
children.append(location)
|
||||
show_tag_list.append(location)
|
||||
else:
|
||||
try:
|
||||
descriptor = system.process_xml(etree.tostring(child, encoding='unicode'))
|
||||
children.append(descriptor.scope_ids.usage_id)
|
||||
except: # lint-amnesty, pylint: disable=bare-except
|
||||
msg = "Unable to load child when parsing Conditional."
|
||||
log.exception(msg)
|
||||
system.error_tracker(msg)
|
||||
definition.update({
|
||||
'show_tag_list': show_tag_list,
|
||||
'conditional_message': xml_object.get('message', '')
|
||||
})
|
||||
return definition, children
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element(self._tag_name)
|
||||
for child in self.get_children():
|
||||
if child.location not in self.show_tag_list:
|
||||
self.runtime.add_block_as_child_node(child, xml_object)
|
||||
|
||||
if self.show_tag_list:
|
||||
show_str = HTML('<show sources="{sources}" />').format(
|
||||
sources=Text(';'.join(str(location) for location in self.show_tag_list)))
|
||||
xml_object.append(etree.fromstring(show_str))
|
||||
|
||||
# Overwrite the original sources attribute with the value from sources_list, as
|
||||
# Locations may have been changed to Locators.
|
||||
stringified_sources_list = [str(loc) for loc in self.sources_list]
|
||||
self.xml_attributes['sources'] = ';'.join(stringified_sources_list)
|
||||
self.xml_attributes[self.conditional_attr] = self.conditional_value
|
||||
self.xml_attributes['message'] = self.conditional_message
|
||||
return xml_object
|
||||
|
||||
def validate(self):
|
||||
validation = super().validate()
|
||||
if not self.sources_list:
|
||||
conditional_validation = StudioValidation(self.location)
|
||||
conditional_validation.add(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.NOT_CONFIGURED,
|
||||
_("This component has no source components configured yet."),
|
||||
action_class='edit-button',
|
||||
action_label=_("Configure list of sources")
|
||||
)
|
||||
)
|
||||
validation = StudioValidation.copy(validation)
|
||||
validation.summary = conditional_validation.messages[0]
|
||||
return validation
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
ConditionalBlock.due,
|
||||
ConditionalBlock.show_tag_list,
|
||||
])
|
||||
return non_editable_fields
|
||||
@@ -1,471 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from urllib.parse import parse_qsl, quote_plus, urlencode, urlparse, urlunparse
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import AssetKey, CourseKey
|
||||
from opaque_keys.edx.locator import AssetLocator, LibraryLocatorV2
|
||||
from PIL import Image
|
||||
|
||||
from xmodule.assetstore.assetmgr import AssetManager
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
STATIC_CONTENT_VERSION = 1
|
||||
XASSET_LOCATION_TAG = 'c4x'
|
||||
XASSET_SRCREF_PREFIX = 'xasset:'
|
||||
XASSET_THUMBNAIL_TAIL_NAME = '.jpg'
|
||||
STREAM_DATA_CHUNK_SIZE = 1024
|
||||
VERSIONED_ASSETS_PREFIX = '/assets/courseware'
|
||||
VERSIONED_ASSETS_PATTERN = r'/assets/courseware/(v[\d]/)?([a-f0-9]{32})'
|
||||
|
||||
|
||||
class StaticContent: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None,
|
||||
length=None, locked=False, content_digest=None):
|
||||
self.location = loc
|
||||
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed # lint-amnesty, pylint: disable=line-too-long
|
||||
self.content_type = content_type
|
||||
self._data = data
|
||||
self.length = length
|
||||
self.last_modified_at = last_modified_at
|
||||
self.thumbnail_location = thumbnail_location
|
||||
# optional information about where this file was imported from. This is needed to support import/export
|
||||
# cycles
|
||||
self.import_path = import_path
|
||||
self.locked = locked
|
||||
self.content_digest = content_digest
|
||||
|
||||
@property
|
||||
def is_thumbnail(self):
|
||||
return self.location.category == 'thumbnail'
|
||||
|
||||
@staticmethod
|
||||
def generate_thumbnail_name(original_name, dimensions=None, extension=None):
|
||||
"""
|
||||
- original_name: Name of the asset (typically its location.name)
|
||||
- dimensions: `None` or a tuple of (width, height) in pixels
|
||||
- extension: `None` or desired filename extension of the thumbnail
|
||||
"""
|
||||
if extension is None:
|
||||
extension = XASSET_THUMBNAIL_TAIL_NAME
|
||||
|
||||
name_root, ext = os.path.splitext(original_name)
|
||||
if not ext == extension:
|
||||
name_root = name_root + ext.replace('.', '-')
|
||||
|
||||
if dimensions:
|
||||
width, height = dimensions
|
||||
name_root += f"-{width}x{height}"
|
||||
|
||||
return "{name_root}{extension}".format(
|
||||
name_root=name_root,
|
||||
extension=extension,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def compute_location(course_key, path, revision=None, is_thumbnail=False): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Constructs a location object for static content.
|
||||
|
||||
- course_key: the course that this asset belongs to
|
||||
- path: is the name of the static asset
|
||||
- revision: is the object's revision information
|
||||
- is_thumbnail: is whether or not we want the thumbnail version of this
|
||||
asset
|
||||
"""
|
||||
path = path.replace('/', '_')
|
||||
return course_key.make_asset_key(
|
||||
'asset' if not is_thumbnail else 'thumbnail',
|
||||
AssetLocator.clean_keeping_underscores(path)
|
||||
).for_branch(None)
|
||||
|
||||
def get_id(self):
|
||||
return self.location
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
ASSET_URL_RE = re.compile(r"""
|
||||
/?c4x/
|
||||
(?P<org>[^/]+)/
|
||||
(?P<course>[^/]+)/
|
||||
(?P<category>[^/]+)/
|
||||
(?P<name>[^/]+)
|
||||
""", re.VERBOSE | re.IGNORECASE)
|
||||
|
||||
@staticmethod
|
||||
def is_c4x_path(path_string):
|
||||
"""
|
||||
Returns a boolean if a path is believed to be a c4x link based on the leading element
|
||||
"""
|
||||
return StaticContent.ASSET_URL_RE.match(path_string) is not None
|
||||
|
||||
@staticmethod
|
||||
def get_static_path_from_location(location):
|
||||
"""
|
||||
This utility static method will take a location identifier and create a 'durable' /static/.. URL representation of it. # lint-amnesty, pylint: disable=line-too-long
|
||||
This link is 'durable' as it can maintain integrity across cloning of courseware across course-ids, e.g. reruns of # lint-amnesty, pylint: disable=line-too-long
|
||||
courses.
|
||||
In the LMS/CMS, we have runtime link-rewriting, so at render time, this /static/... format will get translated into # lint-amnesty, pylint: disable=line-too-long
|
||||
the actual /c4x/... path which the client needs to reference static content
|
||||
"""
|
||||
if location is not None:
|
||||
return f"/static/{location.block_id}"
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_base_url_path_for_course_assets(course_key): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
if (course_key is None) or isinstance(course_key, LibraryLocatorV2):
|
||||
return None
|
||||
|
||||
assert isinstance(course_key, CourseKey)
|
||||
placeholder_id = uuid.uuid4().hex
|
||||
# create a dummy asset location with a fake but unique name. strip off the name, and return it
|
||||
url_path = StaticContent.serialize_asset_key_with_slash(
|
||||
course_key.make_asset_key('asset', placeholder_id).for_branch(None)
|
||||
)
|
||||
return url_path.replace(placeholder_id, '')
|
||||
|
||||
@staticmethod
|
||||
def get_location_from_path(path):
|
||||
"""
|
||||
Generate an AssetKey for the given path (old c4x/org/course/asset/name syntax)
|
||||
"""
|
||||
try:
|
||||
return AssetKey.from_string(path)
|
||||
except InvalidKeyError:
|
||||
# TODO - re-address this once LMS-11198 is tackled.
|
||||
if path.startswith('/'):
|
||||
# try stripping off the leading slash and try again
|
||||
return AssetKey.from_string(path[1:])
|
||||
|
||||
@staticmethod
|
||||
def is_versioned_asset_path(path):
|
||||
"""Determines whether the given asset path is versioned."""
|
||||
return path.startswith(VERSIONED_ASSETS_PREFIX)
|
||||
|
||||
@staticmethod
|
||||
def parse_versioned_asset_path(path):
|
||||
"""
|
||||
Examines an asset path and breaks it apart if it is versioned,
|
||||
returning both the asset digest and the unversioned asset path,
|
||||
which will normally be an AssetKey.
|
||||
"""
|
||||
asset_digest = None
|
||||
asset_path = path
|
||||
if StaticContent.is_versioned_asset_path(asset_path):
|
||||
result = re.match(VERSIONED_ASSETS_PATTERN, asset_path)
|
||||
if result is not None:
|
||||
asset_digest = result.groups()[1]
|
||||
asset_path = re.sub(VERSIONED_ASSETS_PATTERN, '', asset_path)
|
||||
|
||||
return (asset_digest, asset_path)
|
||||
|
||||
@staticmethod
|
||||
def add_version_to_asset_path(path, version):
|
||||
"""
|
||||
Adds a prefix to an asset path indicating the asset's version.
|
||||
"""
|
||||
|
||||
# Don't version an already-versioned path.
|
||||
if StaticContent.is_versioned_asset_path(path):
|
||||
return path
|
||||
|
||||
structure_version = f'v{STATIC_CONTENT_VERSION}'
|
||||
|
||||
return f'{VERSIONED_ASSETS_PREFIX}/{structure_version}/{version}{path}'
|
||||
|
||||
@staticmethod
|
||||
def get_asset_key_from_path(course_key, path):
|
||||
"""
|
||||
Parses a path, extracting an asset key or creating one.
|
||||
|
||||
Args:
|
||||
course_key: key to the course which owns this asset
|
||||
path: the path to said content
|
||||
|
||||
Returns:
|
||||
AssetKey: the asset key that represents the path
|
||||
"""
|
||||
|
||||
# Clean up the path, removing any static prefix and any leading slash.
|
||||
if path.startswith('/static/'):
|
||||
path = path[len('/static/'):]
|
||||
|
||||
# Old-style asset keys start with `/`, so don't try and strip it
|
||||
# in that case.
|
||||
if not path.startswith('/c4x'):
|
||||
path = path.lstrip('/')
|
||||
|
||||
try:
|
||||
return AssetKey.from_string(path)
|
||||
except InvalidKeyError:
|
||||
# If we couldn't parse the path, just let compute_location figure it out.
|
||||
# It's most likely a path like /image.png or something.
|
||||
return StaticContent.compute_location(course_key, path)
|
||||
|
||||
@staticmethod
|
||||
def is_excluded_asset_type(path, excluded_exts):
|
||||
"""
|
||||
Check if this is an allowed file extension to serve.
|
||||
|
||||
Some files aren't served through the CDN in order to avoid same-origin policy/CORS-related issues.
|
||||
"""
|
||||
return any(path.lower().endswith(excluded_ext.lower()) for excluded_ext in excluded_exts)
|
||||
|
||||
@staticmethod
|
||||
def get_canonicalized_asset_path(course_key, path, base_url, excluded_exts, encode=True):
|
||||
"""
|
||||
Returns a fully-qualified path to a piece of static content.
|
||||
|
||||
If a static asset CDN is configured, this path will include it.
|
||||
Otherwise, the path will simply be relative.
|
||||
|
||||
Args:
|
||||
course_key: key to the course which owns this asset
|
||||
path: the path to said content
|
||||
|
||||
Returns:
|
||||
string: fully-qualified path to asset
|
||||
"""
|
||||
|
||||
# Break down the input path.
|
||||
_, _, relative_path, params, query_string, _ = urlparse(path)
|
||||
|
||||
# Convert our path to an asset key if it isn't one already.
|
||||
asset_key = StaticContent.get_asset_key_from_path(course_key, relative_path)
|
||||
|
||||
# Check the status of the asset to see if this can be served via CDN aka publicly.
|
||||
serve_from_cdn = False
|
||||
content_digest = None
|
||||
try:
|
||||
content = AssetManager.find(asset_key, as_stream=True)
|
||||
serve_from_cdn = not getattr(content, "locked", True)
|
||||
content_digest = getattr(content, "content_digest", None)
|
||||
except (ItemNotFoundError, NotFoundError):
|
||||
# If we can't find the item, just treat it as if it's locked.
|
||||
serve_from_cdn = False
|
||||
|
||||
# Do a generic check to see if anything about this asset disqualifies it from being CDN'd.
|
||||
is_excluded = False
|
||||
if StaticContent.is_excluded_asset_type(relative_path, excluded_exts):
|
||||
serve_from_cdn = False
|
||||
is_excluded = True
|
||||
|
||||
# Update any query parameter values that have asset paths in them. This is for assets that
|
||||
# require their own after-the-fact values, like a Flash file that needs the path of a config
|
||||
# file passed to it e.g. /static/visualization.swf?configFile=/static/visualization.xml
|
||||
query_params = parse_qsl(query_string)
|
||||
updated_query_params = []
|
||||
for query_name, query_val in query_params:
|
||||
if query_val.startswith("/static/"):
|
||||
new_val = StaticContent.get_canonicalized_asset_path(
|
||||
course_key, query_val, base_url, excluded_exts, encode=False)
|
||||
updated_query_params.append((query_name, new_val.encode('utf-8')))
|
||||
else:
|
||||
# Make sure we're encoding Unicode strings down to their byte string
|
||||
# representation so that `urlencode` can handle it.
|
||||
updated_query_params.append((query_name, query_val.encode('utf-8')))
|
||||
|
||||
serialized_asset_key = StaticContent.serialize_asset_key_with_slash(asset_key)
|
||||
base_url = base_url if serve_from_cdn else ''
|
||||
asset_path = serialized_asset_key
|
||||
|
||||
# If the content has a digest (i.e. md5sum) value specified, create a versioned path to the asset using it.
|
||||
if not is_excluded and content_digest:
|
||||
asset_path = StaticContent.add_version_to_asset_path(serialized_asset_key, content_digest)
|
||||
|
||||
# Only encode this if told to. Important so that we don't double encode
|
||||
# when working with paths that are in query parameters.
|
||||
if encode:
|
||||
asset_path = asset_path.encode('utf-8')
|
||||
asset_path = quote_plus(asset_path, '/:+@')
|
||||
|
||||
return urlunparse(('', base_url, asset_path, params, urlencode(updated_query_params), ''))
|
||||
|
||||
def stream_data(self):
|
||||
yield self._data
|
||||
|
||||
@staticmethod
|
||||
def serialize_asset_key_with_slash(asset_key):
|
||||
"""
|
||||
Legacy code expects the serialized asset key to start w/ a slash; so, do that in one place
|
||||
:param asset_key:
|
||||
"""
|
||||
url = str(asset_key)
|
||||
if not url.startswith('/'):
|
||||
url = '/' + url # TODO - re-address this once LMS-11198 is tackled.
|
||||
return url
|
||||
|
||||
|
||||
class StaticContentStream(StaticContent): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None, # lint-amnesty, pylint: disable=line-too-long
|
||||
length=None, locked=False, content_digest=None):
|
||||
super().__init__(loc, name, content_type, None, last_modified_at=last_modified_at,
|
||||
thumbnail_location=thumbnail_location, import_path=import_path,
|
||||
length=length, locked=locked, content_digest=content_digest)
|
||||
self._stream = stream
|
||||
|
||||
def stream_data(self):
|
||||
while True:
|
||||
chunk = self._stream.read(STREAM_DATA_CHUNK_SIZE)
|
||||
if len(chunk) == 0:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
def stream_data_in_range(self, first_byte, last_byte):
|
||||
"""
|
||||
Stream the data between first_byte and last_byte (included)
|
||||
"""
|
||||
self._stream.seek(first_byte)
|
||||
position = first_byte
|
||||
while True:
|
||||
if last_byte < position + STREAM_DATA_CHUNK_SIZE - 1:
|
||||
chunk = self._stream.read(last_byte - position + 1)
|
||||
yield chunk
|
||||
break
|
||||
chunk = self._stream.read(STREAM_DATA_CHUNK_SIZE)
|
||||
position += STREAM_DATA_CHUNK_SIZE
|
||||
yield chunk
|
||||
|
||||
def close(self):
|
||||
self._stream.close()
|
||||
|
||||
def copy_to_in_mem(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
self._stream.seek(0)
|
||||
content = StaticContent(self.location, self.name, self.content_type, self._stream.read(),
|
||||
last_modified_at=self.last_modified_at, thumbnail_location=self.thumbnail_location,
|
||||
import_path=self.import_path, length=self.length, locked=self.locked,
|
||||
content_digest=self.content_digest)
|
||||
return content
|
||||
|
||||
|
||||
class ContentStore:
|
||||
'''
|
||||
Abstraction for all ContentStore providers (e.g. MongoDB)
|
||||
'''
|
||||
def save(self, content):
|
||||
raise NotImplementedError
|
||||
|
||||
def find(self, filename):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
|
||||
'''
|
||||
Returns a list of static assets for a course, followed by the total number of assets.
|
||||
By default all assets are returned, but start and maxresults can be provided to limit the query.
|
||||
|
||||
The return format is a list of asset data dictionaries.
|
||||
The asset data dictionaries have the following keys:
|
||||
asset_key (:class:`opaque_keys.edx.AssetKey`): The key of the asset
|
||||
displayname: The human-readable name of the asset
|
||||
uploadDate (datetime.datetime): The date and time that the file was uploadDate
|
||||
contentType: The mimetype string of the asset
|
||||
md5: An md5 hash of the asset content
|
||||
'''
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_all_course_assets(self, course_key):
|
||||
"""
|
||||
Delete all of the assets which use this course_key as an identifier
|
||||
:param course_key:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def copy_all_course_assets(self, source_course_key, dest_course_key):
|
||||
"""
|
||||
Copy all the course assets from source_course_key to dest_course_key
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_thumbnail(self, content, tempfile_path=None, dimensions=None):
|
||||
"""Create a thumbnail for a given image.
|
||||
|
||||
Returns a tuple of (StaticContent, AssetKey)
|
||||
|
||||
`content` is the StaticContent representing the image you want to make a
|
||||
thumbnail out of.
|
||||
|
||||
`tempfile_path` is a string path to the location of a file to read from
|
||||
in order to grab the image data, instead of relying on `content.data`
|
||||
|
||||
`dimensions` is an optional param that represents (width, height) in
|
||||
pixels. It defaults to None.
|
||||
"""
|
||||
thumbnail_content = None
|
||||
is_svg = content.content_type == 'image/svg+xml'
|
||||
# use a naming convention to associate originals with the thumbnail
|
||||
thumbnail_name = StaticContent.generate_thumbnail_name(
|
||||
content.location.block_id, dimensions=dimensions, extension='.svg' if is_svg else None
|
||||
)
|
||||
thumbnail_file_location = StaticContent.compute_location(
|
||||
content.location.course_key, thumbnail_name, is_thumbnail=True
|
||||
)
|
||||
|
||||
# if we're uploading an image, then let's generate a thumbnail so that we can
|
||||
# serve it up when needed without having to rescale on the fly
|
||||
try:
|
||||
if is_svg:
|
||||
# for svg simply store the provided svg file, since vector graphics should be good enough
|
||||
# for downscaling client-side
|
||||
if tempfile_path is None:
|
||||
thumbnail_file = BytesIO(content.data)
|
||||
else:
|
||||
with open(tempfile_path) as f:
|
||||
thumbnail_file = BytesIO(f.read())
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
'image/svg+xml', thumbnail_file)
|
||||
self.save(thumbnail_content)
|
||||
elif content.content_type is not None and content.content_type.split('/')[0] == 'image':
|
||||
# use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/)
|
||||
# My understanding is that PIL will maintain aspect ratios while restricting
|
||||
# the max-height/width to be whatever you pass in as 'size'
|
||||
# @todo: move the thumbnail size to a configuration setting?!?
|
||||
if tempfile_path is None:
|
||||
source = BytesIO(content.data)
|
||||
else:
|
||||
source = tempfile_path
|
||||
|
||||
# We use the context manager here to avoid leaking the inner file descriptor
|
||||
# of the Image object -- this way it gets closed after we're done with using it.
|
||||
thumbnail_file = BytesIO()
|
||||
with Image.open(source) as image:
|
||||
# I've seen some exceptions from the PIL library when trying to save palletted
|
||||
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
|
||||
thumbnail_image = image.convert('RGB')
|
||||
|
||||
if not dimensions:
|
||||
dimensions = (128, 128)
|
||||
|
||||
thumbnail_image.thumbnail(dimensions, Image.ANTIALIAS)
|
||||
thumbnail_image.save(thumbnail_file, 'JPEG')
|
||||
thumbnail_file.seek(0)
|
||||
|
||||
# store this thumbnail as any other piece of content
|
||||
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
|
||||
'image/jpeg', thumbnail_file)
|
||||
|
||||
self.save(thumbnail_content)
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# log and continue as thumbnails are generally considered as optional
|
||||
logging.exception(
|
||||
"Failed to generate thumbnail for {}. Exception: {}".format(content.location, str(exc))
|
||||
)
|
||||
|
||||
return thumbnail_content, thumbnail_file_location
|
||||
|
||||
def ensure_indexes(self):
|
||||
"""
|
||||
Ensure that all appropriate indexes are created that are needed by this modulestore, or raise
|
||||
an exception if unable to.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
@@ -1,31 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
_CONTENTSTORE = {}
|
||||
|
||||
|
||||
def load_function(path):
|
||||
"""
|
||||
Load a function by name.
|
||||
|
||||
path is a string of the form "path.to.module.function"
|
||||
returns the imported python object `function` from `path.to.module`
|
||||
"""
|
||||
module_path, _, name = path.rpartition('.')
|
||||
return getattr(import_module(module_path), name)
|
||||
|
||||
|
||||
def contentstore(name='default'): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
if name not in _CONTENTSTORE:
|
||||
class_ = load_function(settings.CONTENTSTORE['ENGINE'])
|
||||
options = {}
|
||||
options.update(settings.CONTENTSTORE['DOC_STORE_CONFIG'])
|
||||
if 'ADDITIONAL_OPTIONS' in settings.CONTENTSTORE:
|
||||
if name in settings.CONTENTSTORE['ADDITIONAL_OPTIONS']:
|
||||
options.update(settings.CONTENTSTORE['ADDITIONAL_OPTIONS'][name])
|
||||
_CONTENTSTORE[name] = class_(**options)
|
||||
|
||||
return _CONTENTSTORE[name]
|
||||
@@ -1,625 +0,0 @@
|
||||
"""
|
||||
MongoDB/GridFS-level code for the contentstore.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import gridfs
|
||||
import pymongo
|
||||
from bson.son import SON
|
||||
from fs.osfs import OSFS
|
||||
from gridfs.errors import NoFile, FileExists
|
||||
from mongodb_proxy import autoretry_read
|
||||
from opaque_keys.edx.keys import AssetKey
|
||||
|
||||
from xmodule.contentstore.content import XASSET_LOCATION_TAG
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import ASSET_IGNORE_REGEX
|
||||
from xmodule.mongo_utils import connect_to_mongodb, create_collection_index
|
||||
from xmodule.util.misc import escape_invalid_characters, get_library_or_course_attribute
|
||||
|
||||
from .content import ContentStore, StaticContent, StaticContentStream
|
||||
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
"""
|
||||
MongoDB-backed ContentStore.
|
||||
"""
|
||||
# lint-amnesty, pylint: disable=unused-argument
|
||||
def __init__(
|
||||
self, host, db,
|
||||
port=27017, tz_aware=True, user=None, password=None, bucket='fs', collection=None, **kwargs
|
||||
):
|
||||
"""
|
||||
Establish the connection with the mongo backend and connect to the collections
|
||||
|
||||
:param collection: ignores but provided for consistency w/ other doc_store_config patterns
|
||||
"""
|
||||
# GridFS will throw an exception if the Database is wrapped in a MongoProxy. So don't wrap it.
|
||||
# The appropriate methods below are marked as autoretry_read - those methods will handle
|
||||
# the AutoReconnect errors.
|
||||
proxy = False
|
||||
mongo_db = connect_to_mongodb(
|
||||
db, host,
|
||||
port=port, tz_aware=tz_aware, user=user, password=password, proxy=proxy, **kwargs
|
||||
)
|
||||
|
||||
self.fs = gridfs.GridFS(mongo_db, bucket) # pylint: disable=invalid-name
|
||||
|
||||
self.fs_files = mongo_db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
self.chunks = mongo_db[bucket + ".chunks"]
|
||||
|
||||
def close_connections(self):
|
||||
"""
|
||||
Closes any open connections to the underlying databases
|
||||
"""
|
||||
self.fs_files.database.client.close()
|
||||
|
||||
def _drop_database(self, database=True, collections=True, connections=True):
|
||||
"""
|
||||
A destructive operation to drop the underlying database and close all connections.
|
||||
Intended to be used by test code for cleanup.
|
||||
|
||||
If database is True, then this should drop the entire database.
|
||||
Otherwise, if collections is True, then this should drop all of the collections used
|
||||
by this modulestore.
|
||||
Otherwise, the modulestore should remove all data from the collections.
|
||||
|
||||
If connections is True, then close the connection to the database as well.
|
||||
"""
|
||||
connection = self.fs_files.database.client
|
||||
|
||||
if database:
|
||||
connection.drop_database(self.fs_files.database.name)
|
||||
elif collections:
|
||||
self.fs_files.drop()
|
||||
self.chunks.drop()
|
||||
else:
|
||||
self.fs_files.remove({})
|
||||
self.chunks.remove({})
|
||||
|
||||
if connections:
|
||||
self.close_connections()
|
||||
|
||||
def save(self, content):
|
||||
content_id, content_son = self.asset_db_key(content.location)
|
||||
|
||||
# The way to version files in gridFS is to not use the file id as the _id but just as the filename.
|
||||
# Then you can upload as many versions as you like and access by date or version. Because we use
|
||||
# the location as the _id, we must delete before adding (there's no replace method in gridFS)
|
||||
self.delete(content_id) # delete is a noop if the entry doesn't exist; so, don't waste time checking
|
||||
|
||||
thumbnail_location = content.thumbnail_location.to_deprecated_list_repr() if content.thumbnail_location else None # lint-amnesty, pylint: disable=line-too-long
|
||||
with self.fs.new_file(_id=content_id, filename=str(content.location), content_type=content.content_type, # lint-amnesty, pylint: disable=line-too-long
|
||||
displayname=content.name, content_son=content_son,
|
||||
thumbnail_location=thumbnail_location,
|
||||
import_path=content.import_path,
|
||||
# getattr b/c caching may mean some pickled instances don't have attr
|
||||
locked=getattr(content, 'locked', False)) as fp:
|
||||
|
||||
# It seems that this code thought that only some specific object would have the `__iter__` attribute
|
||||
# but many more objects have this in python3 and shouldn't be using the chunking logic. For string and
|
||||
# byte streams we write them directly to gridfs and convert them to byetarrys if necessary.
|
||||
if hasattr(content.data, '__iter__') and not isinstance(content.data, (bytes, (str,))):
|
||||
for chunk in content.data:
|
||||
fp.write(chunk)
|
||||
else:
|
||||
# Ideally we could just ensure that we don't get strings in here and only byte streams
|
||||
# but being confident of that wolud be a lot more work than we have time for so we just
|
||||
# handle both cases here.
|
||||
if isinstance(content.data, str):
|
||||
fp.write(content.data.encode('utf-8'))
|
||||
else:
|
||||
fp.write(content.data)
|
||||
|
||||
return content
|
||||
|
||||
def delete(self, location_or_id):
|
||||
"""
|
||||
Delete an asset.
|
||||
"""
|
||||
if isinstance(location_or_id, AssetKey):
|
||||
location_or_id, _ = self.asset_db_key(location_or_id)
|
||||
# Deletes of non-existent files are considered successful
|
||||
self.fs.delete(location_or_id)
|
||||
|
||||
@autoretry_read()
|
||||
def find(self, location, throw_on_not_found=True, as_stream=False): # lint-amnesty, pylint: disable=arguments-differ
|
||||
content_id, __ = self.asset_db_key(location)
|
||||
|
||||
try:
|
||||
if as_stream:
|
||||
fp = self.fs.get(content_id)
|
||||
# Need to replace dict IDs with SON for chunk lookup to work under Python 3
|
||||
# because field order can be different and mongo cares about the order
|
||||
if isinstance(fp._id, dict): # lint-amnesty, pylint: disable=protected-access
|
||||
fp._file['_id'] = content_id # lint-amnesty, pylint: disable=protected-access
|
||||
thumbnail_location = getattr(fp, 'thumbnail_location', None)
|
||||
if thumbnail_location:
|
||||
thumbnail_location = location.course_key.make_asset_key(
|
||||
'thumbnail',
|
||||
thumbnail_location[4]
|
||||
)
|
||||
return StaticContentStream(
|
||||
location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
|
||||
thumbnail_location=thumbnail_location,
|
||||
import_path=getattr(fp, 'import_path', None),
|
||||
length=fp.length, locked=getattr(fp, 'locked', False),
|
||||
content_digest=getattr(fp, 'md5', None),
|
||||
)
|
||||
else:
|
||||
with self.fs.get(content_id) as fp:
|
||||
# Need to replace dict IDs with SON for chunk lookup to work under Python 3
|
||||
# because field order can be different and mongo cares about the order
|
||||
if isinstance(fp._id, dict): # lint-amnesty, pylint: disable=protected-access
|
||||
fp._file['_id'] = content_id # lint-amnesty, pylint: disable=protected-access
|
||||
thumbnail_location = getattr(fp, 'thumbnail_location', None)
|
||||
if thumbnail_location:
|
||||
thumbnail_location = location.course_key.make_asset_key(
|
||||
'thumbnail',
|
||||
thumbnail_location[4]
|
||||
)
|
||||
return StaticContent(
|
||||
location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
|
||||
thumbnail_location=thumbnail_location,
|
||||
import_path=getattr(fp, 'import_path', None),
|
||||
length=fp.length, locked=getattr(fp, 'locked', False),
|
||||
content_digest=getattr(fp, 'md5', None),
|
||||
)
|
||||
except NoFile:
|
||||
if throw_on_not_found: # lint-amnesty, pylint: disable=no-else-raise
|
||||
raise NotFoundError(content_id) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
else:
|
||||
return None
|
||||
|
||||
def export(self, location, output_directory): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
content = self.find(location)
|
||||
|
||||
filename = content.name
|
||||
if content.import_path is not None:
|
||||
output_directory = output_directory + '/' + os.path.dirname(content.import_path)
|
||||
|
||||
if not os.path.exists(output_directory):
|
||||
os.makedirs(output_directory)
|
||||
|
||||
# Escape invalid char from filename.
|
||||
export_name = escape_invalid_characters(name=filename, invalid_char_list=['/', '\\'])
|
||||
|
||||
disk_fs = OSFS(output_directory)
|
||||
|
||||
with disk_fs.open(export_name, 'wb') as asset_file:
|
||||
asset_file.write(content.data)
|
||||
|
||||
def export_all_for_course(self, course_key, output_directory, assets_policy_file):
|
||||
"""
|
||||
Export all of this course's assets to the output_directory. Export all of the assets'
|
||||
attributes to the policy file.
|
||||
|
||||
Args:
|
||||
course_key (CourseKey): the :class:`CourseKey` identifying the course
|
||||
output_directory: the directory under which to put all the asset files
|
||||
assets_policy_file: the filename for the policy file which should be in the same
|
||||
directory as the other policy files.
|
||||
"""
|
||||
policy = {}
|
||||
assets, __ = self.get_all_content_for_course(course_key)
|
||||
|
||||
for asset in assets:
|
||||
# TODO: On 6/19/14, I had to put a try/except around this
|
||||
# to export a course. The course failed on JSON files in
|
||||
# the /static/ directory placed in it with an import.
|
||||
#
|
||||
# If this hasn't been looked at in a while, remove this comment.
|
||||
#
|
||||
# When debugging course exports, this might be a good place
|
||||
# to look. -- pmitros
|
||||
self.export(asset['asset_key'], output_directory)
|
||||
for attr, value in asset.items():
|
||||
if attr not in ['_id', 'md5', 'uploadDate', 'length', 'chunkSize', 'asset_key']:
|
||||
policy.setdefault(asset['asset_key'].block_id, {})[attr] = value
|
||||
|
||||
with open(assets_policy_file, 'w') as f:
|
||||
json.dump(policy, f, sort_keys=True, indent=4)
|
||||
|
||||
def get_all_content_thumbnails_for_course(self, course_key):
|
||||
return self._get_all_content_for_course(course_key, get_thumbnails=True)[0]
|
||||
|
||||
def get_all_content_for_course(self, course_key, start=0, maxresults=-1, sort=None, filter_params=None):
|
||||
return self._get_all_content_for_course(
|
||||
course_key, start=start, maxresults=maxresults, get_thumbnails=False, sort=sort, filter_params=filter_params
|
||||
)
|
||||
|
||||
def remove_redundant_content_for_courses(self):
|
||||
"""
|
||||
Finds and removes all redundant files (Mac OS metadata files with filename ".DS_Store"
|
||||
or filename starts with "._") for all courses
|
||||
"""
|
||||
assets_to_delete = 0
|
||||
for prefix in ['_id', 'content_son']:
|
||||
query = SON([
|
||||
(f'{prefix}.tag', XASSET_LOCATION_TAG),
|
||||
(f'{prefix}.category', 'asset'),
|
||||
(f'{prefix}.name', {'$regex': ASSET_IGNORE_REGEX}),
|
||||
])
|
||||
items = self.fs_files.find(query)
|
||||
for asset in items:
|
||||
self.fs.delete(asset[prefix])
|
||||
assets_to_delete += 1
|
||||
|
||||
self.fs_files.remove(query)
|
||||
return assets_to_delete
|
||||
|
||||
@autoretry_read()
|
||||
def _get_all_content_for_course(self,
|
||||
course_key,
|
||||
get_thumbnails=False,
|
||||
start=0,
|
||||
maxresults=-1,
|
||||
sort=None,
|
||||
filter_params=None):
|
||||
'''
|
||||
Returns a list of all static assets for a course. The return format is a list of asset data dictionary elements.
|
||||
|
||||
The asset data dictionaries have the following keys:
|
||||
asset_key (:class:`opaque_keys.edx.AssetKey`): The key of the asset
|
||||
displayname: The human-readable name of the asset
|
||||
uploadDate (datetime.datetime): The date and time that the file was uploadDate
|
||||
contentType: The mimetype string of the asset
|
||||
md5: An md5 hash of the asset content
|
||||
'''
|
||||
# TODO: Using an aggregate() instead of a find() here is a hack to get around the fact that Mongo 3.2 does not
|
||||
# support sorting case-insensitively.
|
||||
# If a sort on displayname is requested, the aggregation pipeline creates a new field:
|
||||
# `insensitive_displayname`, a lowercase version of `displayname` that is sorted on instead.
|
||||
# Mongo 3.4 does not require this hack. When upgraded, change this aggregation back to a find and specifiy
|
||||
# a collation based on user's language locale instead.
|
||||
# See: https://openedx.atlassian.net/browse/EDUCATOR-2221
|
||||
pipeline_stages = []
|
||||
query = query_for_course(course_key, 'asset' if not get_thumbnails else 'thumbnail')
|
||||
if filter_params:
|
||||
query.update(filter_params)
|
||||
pipeline_stages.append({'$match': query})
|
||||
|
||||
if sort:
|
||||
sort = dict(sort)
|
||||
if 'displayname' in sort:
|
||||
pipeline_stages.append({
|
||||
'$project': {
|
||||
'contentType': 1,
|
||||
'locked': 1,
|
||||
'chunkSize': 1,
|
||||
'content_son': 1,
|
||||
'displayname': 1,
|
||||
'filename': 1,
|
||||
'length': 1,
|
||||
'import_path': 1,
|
||||
'uploadDate': 1,
|
||||
'thumbnail_location': 1,
|
||||
'md5': 1,
|
||||
'insensitive_displayname': {
|
||||
'$toLower': '$displayname'
|
||||
}
|
||||
}
|
||||
})
|
||||
sort = {'insensitive_displayname': sort['displayname']}
|
||||
pipeline_stages.append({'$sort': sort})
|
||||
|
||||
# This is another hack to get the total query result count, but only the Nth page of actual documents
|
||||
# See: https://stackoverflow.com/a/39784851/6620612
|
||||
pipeline_stages.append({'$group': {'_id': None, 'count': {'$sum': 1}, 'results': {'$push': '$$ROOT'}}})
|
||||
if maxresults > 0:
|
||||
pipeline_stages.append({
|
||||
'$project': {
|
||||
'count': 1,
|
||||
'results': {
|
||||
'$slice': ['$results', start, maxresults]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cursor = self.fs_files.aggregate(pipeline_stages)
|
||||
# Set values if result of query is empty
|
||||
count = 0
|
||||
assets = []
|
||||
try:
|
||||
result = cursor.next()
|
||||
if result:
|
||||
count = result['count']
|
||||
assets = list(result['results'])
|
||||
except StopIteration:
|
||||
# Skip if no assets were returned
|
||||
pass
|
||||
|
||||
# We're constructing the asset key immediately after retrieval from the database so that
|
||||
# callers are insulated from knowing how our identifiers are stored.
|
||||
for asset in assets:
|
||||
asset_id = asset.get('content_son', asset['_id'])
|
||||
asset['asset_key'] = course_key.make_asset_key(asset_id['category'], asset_id['name'])
|
||||
return assets, count
|
||||
|
||||
def set_attr(self, asset_key, attr, value=True):
|
||||
"""
|
||||
Add/set the given attr on the asset at the given location. Does not allow overwriting gridFS built in
|
||||
attrs such as _id, md5, uploadDate, length. Value can be any type which pymongo accepts.
|
||||
|
||||
Returns nothing
|
||||
|
||||
Raises NotFoundError if no such item exists
|
||||
Raises AttributeError is attr is one of the build in attrs.
|
||||
|
||||
:param asset_key: an AssetKey
|
||||
:param attr: which attribute to set
|
||||
:param value: the value to set it to (any type pymongo accepts such as datetime, number, string)
|
||||
"""
|
||||
self.set_attrs(asset_key, {attr: value})
|
||||
|
||||
def get_attr(self, location, attr, default=None):
|
||||
"""
|
||||
Get the value of attr set on location. If attr is unset, it returns default. Unlike set, this accessor
|
||||
does allow getting the value of reserved keywords.
|
||||
:param location: a c4x asset location
|
||||
"""
|
||||
return self.get_attrs(location).get(attr, default)
|
||||
|
||||
def set_attrs(self, location, attr_dict):
|
||||
"""
|
||||
Like set_attr but sets multiple key value pairs.
|
||||
|
||||
Returns nothing.
|
||||
|
||||
Raises NotFoundError if no such item exists
|
||||
Raises AttributeError is attr_dict has any attrs which are one of the build in attrs.
|
||||
|
||||
:param location: a c4x asset location
|
||||
"""
|
||||
for attr in attr_dict.keys():
|
||||
if attr in ['_id', 'md5', 'uploadDate', 'length']:
|
||||
raise AttributeError(f"{attr} is a protected attribute.")
|
||||
asset_db_key, __ = self.asset_db_key(location)
|
||||
# catch upsert error and raise NotFoundError if asset doesn't exist
|
||||
result = self.fs_files.update_one({'_id': asset_db_key}, {"$set": attr_dict}, upsert=False)
|
||||
if result.matched_count == 0:
|
||||
raise NotFoundError(asset_db_key)
|
||||
|
||||
@autoretry_read()
|
||||
def get_attrs(self, location):
|
||||
"""
|
||||
Gets all of the attributes associated with the given asset. Note, returns even built in attrs
|
||||
such as md5 which you cannot resubmit in an update; so, don't call set_attrs with the result of this
|
||||
but only with the set of attrs you want to explicitly update.
|
||||
|
||||
The attrs will be a superset of _id, contentType, chunkSize, filename, uploadDate, & md5
|
||||
|
||||
:param location: a c4x asset location
|
||||
"""
|
||||
asset_db_key, __ = self.asset_db_key(location)
|
||||
item = self.fs_files.find_one({'_id': asset_db_key})
|
||||
if item is None:
|
||||
raise NotFoundError(asset_db_key)
|
||||
return item
|
||||
|
||||
def copy_all_course_assets(self, source_course_key, dest_course_key):
|
||||
"""
|
||||
See :meth:`.ContentStore.copy_all_course_assets`
|
||||
|
||||
This implementation fairly expensively copies all of the data
|
||||
"""
|
||||
source_query = query_for_course(source_course_key)
|
||||
# it'd be great to figure out how to do all of this on the db server and not pull the bits over
|
||||
for asset in self.fs_files.find(source_query):
|
||||
asset_key = self.make_id_son(asset)
|
||||
# don't convert from string until fs access
|
||||
source_content = self.fs.get(asset_key)
|
||||
if isinstance(asset_key, str):
|
||||
asset_key = AssetKey.from_string(asset_key)
|
||||
__, asset_key = self.asset_db_key(asset_key)
|
||||
# Need to replace dict IDs with SON for chunk lookup to work under Python 3
|
||||
# because field order can be different and mongo cares about the order
|
||||
if isinstance(source_content._id, dict): # lint-amnesty, pylint: disable=protected-access
|
||||
source_content._file['_id'] = asset_key.copy() # lint-amnesty, pylint: disable=protected-access
|
||||
asset_key['org'] = dest_course_key.org
|
||||
asset_key['course'] = dest_course_key.course
|
||||
if getattr(dest_course_key, 'deprecated', False): # remove the run if exists
|
||||
if 'run' in asset_key:
|
||||
del asset_key['run']
|
||||
asset_id = asset_key
|
||||
else: # add the run, since it's the last field, we're golden
|
||||
asset_key['run'] = dest_course_key.run
|
||||
asset_id = str(
|
||||
dest_course_key.make_asset_key(asset_key['category'], asset_key['name']).for_branch(None)
|
||||
)
|
||||
try:
|
||||
self.create_asset(source_content, asset_id, asset, asset_key)
|
||||
except FileExists:
|
||||
self.fs.delete(file_id=asset_id)
|
||||
self.create_asset(source_content, asset_id, asset, asset_key)
|
||||
|
||||
def create_asset(self, source_content, asset_id, asset, asset_key):
|
||||
"""
|
||||
Creates a new asset
|
||||
:param source_content:
|
||||
:param asset_id:
|
||||
:param asset:
|
||||
:param asset_key:
|
||||
:return:
|
||||
"""
|
||||
self.fs.put(
|
||||
source_content.read(),
|
||||
_id=asset_id, filename=asset['filename'], content_type=asset['contentType'],
|
||||
displayname=asset['displayname'], content_son=asset_key,
|
||||
# thumbnail is not technically correct but will be functionally correct as the code
|
||||
# only looks at the name which is not course relative.
|
||||
thumbnail_location=asset['thumbnail_location'],
|
||||
import_path=asset['import_path'],
|
||||
# getattr b/c caching may mean some pickled instances don't have attr
|
||||
locked=asset.get('locked', False)
|
||||
)
|
||||
|
||||
def delete_all_course_assets(self, course_key):
|
||||
"""
|
||||
Delete all assets identified via this course_key. Dangerous operation which may remove assets
|
||||
referenced by other runs or other courses.
|
||||
:param course_key:
|
||||
"""
|
||||
course_query = query_for_course(course_key)
|
||||
matching_assets = self.fs_files.find(course_query)
|
||||
for asset in matching_assets:
|
||||
asset_key = self.make_id_son(asset)
|
||||
self.fs.delete(asset_key)
|
||||
|
||||
# codifying the original order which pymongo used for the dicts coming out of location_to_dict
|
||||
# stability of order is more important than sanity of order as any changes to order make things
|
||||
# unfindable
|
||||
ordered_key_fields = ['category', 'name', 'course', 'tag', 'org', 'revision']
|
||||
property_names = {
|
||||
'category': 'block_type',
|
||||
'name': 'block_id',
|
||||
'course': 'course',
|
||||
'tag': 'DEPRECATED_TAG',
|
||||
'org': 'org',
|
||||
'revision': 'branch',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def asset_db_key(cls, location):
|
||||
"""
|
||||
Returns the database _id and son structured lookup to find the given asset location.
|
||||
"""
|
||||
dbkey = SON((field_name,
|
||||
getattr(location, cls.property_names[field_name])) for field_name in cls.ordered_key_fields)
|
||||
if getattr(location, 'deprecated', False):
|
||||
content_id = dbkey
|
||||
else:
|
||||
# NOTE, there's no need to state that run doesn't exist in the negative case b/c access via
|
||||
# SON requires equivalence (same keys and values in exact same order)
|
||||
dbkey['run'] = location.run
|
||||
content_id = str(location.for_branch(None))
|
||||
return content_id, dbkey
|
||||
|
||||
def make_id_son(self, fs_entry):
|
||||
"""
|
||||
Change the _id field in fs_entry into the properly ordered SON or string
|
||||
Args:
|
||||
fs_entry: the element returned by self.fs_files.find
|
||||
"""
|
||||
_id_field = fs_entry.get('_id', fs_entry)
|
||||
if isinstance(_id_field, str):
|
||||
return _id_field
|
||||
dbkey = SON((field_name, _id_field.get(field_name)) for field_name in self.ordered_key_fields)
|
||||
if 'run' in _id_field:
|
||||
# NOTE, there's no need to state that run doesn't exist in the negative case b/c access via
|
||||
# SON requires equivalence (same keys and values in exact same order)
|
||||
dbkey['run'] = _id_field['run']
|
||||
fs_entry['_id'] = dbkey
|
||||
return dbkey
|
||||
|
||||
def ensure_indexes(self):
|
||||
# Index needed thru 'category' by `_get_all_content_for_course` and others. That query also takes a sort
|
||||
# which can be `uploadDate`, `displayname`,
|
||||
# TODO: uncomment this line once this index in prod is cleaned up. See OPS-2863 for tracking clean up.
|
||||
# create_collection_index(
|
||||
# self.fs_files,
|
||||
# [
|
||||
# ('_id.tag', pymongo.ASCENDING),
|
||||
# ('_id.org', pymongo.ASCENDING),
|
||||
# ('_id.course', pymongo.ASCENDING),
|
||||
# ('_id.category', pymongo.ASCENDING)
|
||||
# ],
|
||||
# sparse=True,
|
||||
# background=True
|
||||
# )
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('content_son.org', pymongo.ASCENDING),
|
||||
('content_son.course', pymongo.ASCENDING),
|
||||
('uploadDate', pymongo.DESCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('_id.org', pymongo.ASCENDING),
|
||||
('_id.course', pymongo.ASCENDING),
|
||||
('_id.name', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('content_son.org', pymongo.ASCENDING),
|
||||
('content_son.course', pymongo.ASCENDING),
|
||||
('content_son.name', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('_id.org', pymongo.ASCENDING),
|
||||
('_id.course', pymongo.ASCENDING),
|
||||
('uploadDate', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('_id.org', pymongo.ASCENDING),
|
||||
('_id.course', pymongo.ASCENDING),
|
||||
('displayname', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('content_son.org', pymongo.ASCENDING),
|
||||
('content_son.course', pymongo.ASCENDING),
|
||||
('uploadDate', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
create_collection_index(
|
||||
self.fs_files,
|
||||
[
|
||||
('content_son.org', pymongo.ASCENDING),
|
||||
('content_son.course', pymongo.ASCENDING),
|
||||
('displayname', pymongo.ASCENDING)
|
||||
],
|
||||
sparse=True,
|
||||
background=True
|
||||
)
|
||||
|
||||
|
||||
def query_for_course(course_key, category=None):
|
||||
"""
|
||||
Construct a SON object that will query for all assets possibly limited to the given type
|
||||
(thumbnail v assets) in the course using the index in mongo_indexes.md
|
||||
"""
|
||||
if getattr(course_key, 'deprecated', False):
|
||||
prefix = '_id'
|
||||
else:
|
||||
prefix = 'content_son'
|
||||
dbkey = SON([
|
||||
(f'{prefix}.tag', XASSET_LOCATION_TAG),
|
||||
(f'{prefix}.org', course_key.org),
|
||||
(f'{prefix}.course', get_library_or_course_attribute(course_key)),
|
||||
])
|
||||
if category:
|
||||
dbkey[f'{prefix}.category'] = category
|
||||
if getattr(course_key, 'deprecated', False):
|
||||
dbkey[f'{prefix}.run'] = {'$exists': False}
|
||||
else:
|
||||
dbkey[f'{prefix}.run'] = course_key.run
|
||||
return dbkey
|
||||
@@ -1,47 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
from .django import contentstore
|
||||
|
||||
|
||||
def empty_asset_trashcan(course_locs):
|
||||
'''
|
||||
This method will hard delete all assets (optionally within a course_id) from the trashcan
|
||||
'''
|
||||
store = contentstore('trashcan')
|
||||
|
||||
for course_loc in course_locs:
|
||||
# first delete all of the thumbnails
|
||||
thumbs = store.get_all_content_thumbnails_for_course(course_loc)
|
||||
for thumb in thumbs:
|
||||
print(f"Deleting {thumb}...")
|
||||
store.delete(thumb['asset_key'])
|
||||
|
||||
# then delete all of the assets
|
||||
assets, __ = store.get_all_content_for_course(course_loc)
|
||||
for asset in assets:
|
||||
print(f"Deleting {asset}...")
|
||||
store.delete(asset['asset_key'])
|
||||
|
||||
|
||||
def restore_asset_from_trashcan(location):
|
||||
'''
|
||||
This method will restore an asset which got soft deleted and put back in the original course
|
||||
'''
|
||||
trash = contentstore('trashcan')
|
||||
store = contentstore()
|
||||
|
||||
loc = StaticContent.get_location_from_path(location)
|
||||
content = trash.find(loc)
|
||||
|
||||
# ok, save the content into the courseware
|
||||
store.save(content)
|
||||
|
||||
# see if there is a thumbnail as well, if so move that as well
|
||||
if content.thumbnail_location is not None:
|
||||
try:
|
||||
thumbnail_content = trash.find(content.thumbnail_location)
|
||||
store.save(thumbnail_content)
|
||||
except Exception: # lint-amnesty, pylint: disable=broad-except
|
||||
pass # OK if this is left dangling
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
Simple utility functions that operate on course metadata.
|
||||
|
||||
This is a place to put simple functions that operate on course metadata. It
|
||||
allows us to share code between the CourseBlock and CourseOverview
|
||||
classes, which both need these type of functions.
|
||||
"""
|
||||
|
||||
|
||||
from base64 import b32encode
|
||||
from datetime import datetime, timedelta
|
||||
from math import exp
|
||||
|
||||
import dateutil.parser
|
||||
from pytz import utc
|
||||
|
||||
DEFAULT_START_DATE = datetime(2030, 1, 1, tzinfo=utc)
|
||||
|
||||
"""
|
||||
Default grading policy for a course run.
|
||||
"""
|
||||
DEFAULT_GRADING_POLICY = {
|
||||
"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"short_label": "HW",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15,
|
||||
},
|
||||
{
|
||||
"type": "Lab",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15,
|
||||
},
|
||||
{
|
||||
"type": "Midterm Exam",
|
||||
"short_label": "Midterm",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.3,
|
||||
},
|
||||
{
|
||||
"type": "Final Exam",
|
||||
"short_label": "Final",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.4,
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def clean_course_key(course_key, padding_char):
|
||||
"""
|
||||
Encode a course's key into a unique, deterministic base32-encoded ID for
|
||||
the course.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseKey): A course key.
|
||||
padding_char (str): Character used for padding at end of the encoded
|
||||
string. The standard value for this is '='.
|
||||
"""
|
||||
encoded = b32encode(str(course_key).encode('utf8')).decode('utf8')
|
||||
return "course_{}".format(
|
||||
encoded.replace('=', padding_char)
|
||||
)
|
||||
|
||||
|
||||
def number_for_course_location(location):
|
||||
"""
|
||||
Given a course's block usage locator, returns the course's number.
|
||||
|
||||
This is a "number" in the sense of the "course numbers" that you see at
|
||||
lots of universities. For example, given a course
|
||||
"Intro to Computer Science" with the course key "edX/CS-101/2014", the
|
||||
course number would be "CS-101"
|
||||
|
||||
Arguments:
|
||||
location (BlockUsageLocator): The usage locator of the course in
|
||||
question.
|
||||
"""
|
||||
return location.course
|
||||
|
||||
|
||||
def has_course_started(start_date):
|
||||
"""
|
||||
Given a course's start datetime, returns whether the current time's past it.
|
||||
|
||||
Arguments:
|
||||
start_date (datetime): The start datetime of the course in question.
|
||||
"""
|
||||
# TODO: This will throw if start_date is None... consider changing this behavior?
|
||||
return datetime.now(utc) > start_date
|
||||
|
||||
|
||||
def has_course_ended(end_date):
|
||||
"""
|
||||
Given a course's end datetime, returns whether
|
||||
(a) it is not None, and
|
||||
(b) the current time is past it.
|
||||
|
||||
Arguments:
|
||||
end_date (datetime): The end datetime of the course in question.
|
||||
"""
|
||||
return datetime.now(utc) > end_date if end_date is not None else False
|
||||
|
||||
|
||||
def course_starts_within(start_date, look_ahead_days):
|
||||
"""
|
||||
Given a course's start datetime and look ahead days, returns True if
|
||||
course's start date falls within look ahead days otherwise False
|
||||
|
||||
Arguments:
|
||||
start_date (datetime): The start datetime of the course in question.
|
||||
look_ahead_days (int): number of days to see in future for course start date.
|
||||
"""
|
||||
return datetime.now(utc) + timedelta(days=look_ahead_days) > start_date
|
||||
|
||||
|
||||
def course_start_date_is_default(start, advertised_start):
|
||||
"""
|
||||
Returns whether a course's start date hasn't yet been set.
|
||||
|
||||
Arguments:
|
||||
start (datetime): The start datetime of the course in question.
|
||||
advertised_start (str): The advertised start date of the course
|
||||
in question.
|
||||
"""
|
||||
return advertised_start is None and start == DEFAULT_START_DATE
|
||||
|
||||
|
||||
def sorting_score(start, advertised_start, announcement):
|
||||
"""
|
||||
Returns a tuple that can be used to sort the courses according
|
||||
to how "new" they are. The "newness" score is computed using a
|
||||
heuristic that takes into account the announcement and
|
||||
(advertised) start dates of the course if available.
|
||||
|
||||
The lower the number the "newer" the course.
|
||||
"""
|
||||
# Make courses that have an announcement date have a lower
|
||||
# score than courses than don't, older courses should have a
|
||||
# higher score.
|
||||
announcement, start, now = sorting_dates(start, advertised_start, announcement)
|
||||
scale = 300.0 # about a year
|
||||
if announcement:
|
||||
days = (now - announcement).days
|
||||
score = -exp(-days / scale)
|
||||
else:
|
||||
days = (now - start).days
|
||||
score = exp(days / scale)
|
||||
return score
|
||||
|
||||
|
||||
def sorting_dates(start, advertised_start, announcement):
|
||||
"""
|
||||
Utility function to get datetime objects for dates used to
|
||||
compute the is_new flag and the sorting_score.
|
||||
"""
|
||||
try:
|
||||
start = dateutil.parser.parse(advertised_start)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=utc)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
start = start # lint-amnesty, pylint: disable=self-assigning-variable
|
||||
|
||||
now = datetime.now(utc)
|
||||
|
||||
return announcement, start, now
|
||||
@@ -1,1623 +0,0 @@
|
||||
"""
|
||||
Django module container for classes and operations related to the "Course Module" content type
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
import dateutil.parser
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.validators import validate_email
|
||||
from lazy import lazy
|
||||
from lxml import etree
|
||||
from path import Path as path
|
||||
from pytz import utc
|
||||
from xblock.fields import Boolean, Dict, Float, Integer, List, Scope, String
|
||||
from openedx.core.djangoapps.video_pipeline.models import VideoUploadsEnabledByDefault
|
||||
from openedx.core.lib.license import LicenseMixin
|
||||
from openedx.core.lib.teams_config import TeamsConfig # lint-amnesty, pylint: disable=unused-import
|
||||
from xmodule import course_metadata_utils
|
||||
from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY, DEFAULT_START_DATE
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.seq_module import SequenceBlock
|
||||
from xmodule.tabs import CourseTabList, InvalidTabsException
|
||||
|
||||
from .fields import Date
|
||||
from .modulestore.exceptions import InvalidProctoringProvider
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
CATALOG_VISIBILITY_CATALOG_AND_ABOUT = "both"
|
||||
CATALOG_VISIBILITY_ABOUT = "about"
|
||||
CATALOG_VISIBILITY_NONE = "none"
|
||||
|
||||
DEFAULT_COURSE_VISIBILITY_IN_CATALOG = getattr(
|
||||
settings,
|
||||
'DEFAULT_COURSE_VISIBILITY_IN_CATALOG',
|
||||
'both'
|
||||
)
|
||||
|
||||
DEFAULT_MOBILE_AVAILABLE = getattr(settings, 'DEFAULT_MOBILE_AVAILABLE', False)
|
||||
# Note: updating assets does not have settings defined, so using `getattr`.
|
||||
EXAM_SETTINGS_HTML_VIEW_ENABLED = getattr(settings, 'FEATURES', {}).get('ENABLE_EXAM_SETTINGS_HTML_VIEW', False)
|
||||
SPECIAL_EXAMS_ENABLED = getattr(settings, 'FEATURES', {}).get('ENABLE_SPECIAL_EXAMS', False)
|
||||
|
||||
COURSE_VISIBILITY_PRIVATE = 'private'
|
||||
COURSE_VISIBILITY_PUBLIC_OUTLINE = 'public_outline'
|
||||
COURSE_VISIBILITY_PUBLIC = 'public'
|
||||
|
||||
|
||||
class StringOrDate(Date): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
def from_json(self, value): # lint-amnesty, pylint: disable=arguments-differ
|
||||
"""
|
||||
Parse an optional metadata key containing a time or a string:
|
||||
if present, assume it's a string if it doesn't parse.
|
||||
"""
|
||||
try:
|
||||
result = super().from_json(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if result is None:
|
||||
return value
|
||||
else:
|
||||
return result
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
Convert a time struct or string to a string.
|
||||
"""
|
||||
try:
|
||||
result = super().to_json(value)
|
||||
except: # lint-amnesty, pylint: disable=bare-except
|
||||
return value
|
||||
if result is None:
|
||||
return value
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
class EmailString(String):
|
||||
"""
|
||||
Parse String with email validation
|
||||
"""
|
||||
def from_json(self, value):
|
||||
if value:
|
||||
validate_email(value)
|
||||
return value
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
|
||||
remove_comments=True, remove_blank_text=True)
|
||||
|
||||
_cached_toc = {}
|
||||
|
||||
|
||||
class Textbook: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
def __init__(self, title, book_url):
|
||||
self.title = title
|
||||
self.book_url = book_url
|
||||
|
||||
@lazy
|
||||
def start_page(self):
|
||||
return int(self.table_of_contents[0].attrib['page'])
|
||||
|
||||
@lazy
|
||||
def end_page(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
# The last page should be the last element in the table of contents,
|
||||
# but it may be nested. So recurse all the way down the last element
|
||||
last_el = self.table_of_contents[-1]
|
||||
while last_el.getchildren():
|
||||
last_el = last_el[-1]
|
||||
|
||||
return int(last_el.attrib['page'])
|
||||
|
||||
@lazy
|
||||
def table_of_contents(self):
|
||||
"""
|
||||
Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
|
||||
|
||||
Returns XML tree representation of the table of contents
|
||||
"""
|
||||
toc_url = self.book_url + 'toc.xml'
|
||||
|
||||
# cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
|
||||
# course modules have a very short lifespan and are constantly being created and torn down.
|
||||
# Since this module in the __init__() method does a synchronous call to AWS to get the TOC
|
||||
# this is causing a big performance problem. So let's be a bit smarter about this and cache
|
||||
# each fetch and store in-mem for 10 minutes.
|
||||
# NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
|
||||
# rewrite to use the traditional Django in-memory cache.
|
||||
try:
|
||||
# see if we already fetched this
|
||||
if toc_url in _cached_toc:
|
||||
(table_of_contents, timestamp) = _cached_toc[toc_url]
|
||||
age = datetime.now(utc) - timestamp
|
||||
# expire every 10 minutes
|
||||
if age.seconds < 600:
|
||||
return table_of_contents
|
||||
except Exception as err: # lint-amnesty, pylint: disable=broad-except, unused-variable
|
||||
pass
|
||||
|
||||
# Get the table of contents from S3
|
||||
log.info("Retrieving textbook table of contents from %s", toc_url)
|
||||
try:
|
||||
r = requests.get(toc_url)
|
||||
except Exception as err:
|
||||
msg = f'Error {err}: Unable to retrieve textbook table of contents at {toc_url}'
|
||||
log.error(msg)
|
||||
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
# TOC is XML. Parse it
|
||||
try:
|
||||
table_of_contents = etree.fromstring(r.text)
|
||||
except Exception as err:
|
||||
msg = f'Error {err}: Unable to parse XML for textbook table of contents at {toc_url}'
|
||||
log.error(msg)
|
||||
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
return table_of_contents
|
||||
|
||||
def __eq__(self, other):
|
||||
return (self.title == other.title and
|
||||
self.book_url == other.book_url)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
|
||||
class TextbookList(List): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
def from_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
||||
textbooks = []
|
||||
for title, book_url in values:
|
||||
try:
|
||||
textbooks.append(Textbook(title, book_url))
|
||||
except: # lint-amnesty, pylint: disable=bare-except
|
||||
# If we can't get to S3 (e.g. on a train with no internet), don't break
|
||||
# the rest of the courseware.
|
||||
log.exception(f"Couldn't load textbook ({title}, {book_url})")
|
||||
continue
|
||||
|
||||
return textbooks
|
||||
|
||||
def to_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
||||
json_data = []
|
||||
for val in values:
|
||||
if isinstance(val, Textbook):
|
||||
json_data.append((val.title, val.book_url))
|
||||
elif isinstance(val, tuple):
|
||||
json_data.append(val)
|
||||
else:
|
||||
continue
|
||||
return json_data
|
||||
|
||||
|
||||
class ProctoringProvider(String):
|
||||
"""
|
||||
ProctoringProvider field, which includes validation of the provider
|
||||
and default that pulls from edx platform settings.
|
||||
"""
|
||||
def from_json(self, value):
|
||||
"""
|
||||
Return ProctoringProvider as full featured Python type. Perform validation on the provider
|
||||
and include any inherited values from the platform default.
|
||||
"""
|
||||
value = super().from_json(value)
|
||||
if settings.FEATURES.get('ENABLE_PROCTORED_EXAMS'):
|
||||
# Only validate the provider value if ProctoredExams are enabled on the environment
|
||||
# Otherwise, the passed in provider does not matter. We should always return default
|
||||
self._validate_proctoring_provider(value)
|
||||
value = self._get_proctoring_value(value)
|
||||
return value
|
||||
else:
|
||||
return self.default
|
||||
|
||||
def _get_proctoring_value(self, value):
|
||||
"""
|
||||
Return a proctoring value that includes any inherited attributes from the platform defaults
|
||||
for the provider.
|
||||
"""
|
||||
# if provider is missing from the value, return the default
|
||||
if value is None:
|
||||
return self.default
|
||||
|
||||
return value
|
||||
|
||||
def _validate_proctoring_provider(self, value):
|
||||
"""
|
||||
Validate the value for the proctoring provider. If the proctoring provider value is
|
||||
specified, and it is not one of the providers configured at the platform level, return
|
||||
a list of error messages to the caller.
|
||||
"""
|
||||
available_providers = get_available_providers()
|
||||
|
||||
if value is not None and value not in available_providers:
|
||||
raise InvalidProctoringProvider(value, available_providers)
|
||||
|
||||
@property
|
||||
def default(self):
|
||||
"""
|
||||
Return default value for ProctoringProvider.
|
||||
"""
|
||||
default = super().default
|
||||
|
||||
proctoring_backend_settings = getattr(settings, 'PROCTORING_BACKENDS', None)
|
||||
|
||||
if proctoring_backend_settings:
|
||||
return proctoring_backend_settings.get('DEFAULT', None)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def get_available_providers(): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
proctoring_backend_settings = getattr(
|
||||
settings,
|
||||
'PROCTORING_BACKENDS',
|
||||
{}
|
||||
)
|
||||
|
||||
available_providers = [provider for provider in proctoring_backend_settings if provider != 'DEFAULT']
|
||||
available_providers.sort()
|
||||
return available_providers
|
||||
|
||||
|
||||
class TeamsConfigField(Dict):
|
||||
"""
|
||||
XBlock field for teams configuration, including definitions for teamsets.
|
||||
|
||||
Serializes to JSON dictionary.
|
||||
"""
|
||||
_default = TeamsConfig({})
|
||||
|
||||
def from_json(self, value):
|
||||
"""
|
||||
Return a TeamsConfig instance from a dict.
|
||||
"""
|
||||
return TeamsConfig(value)
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
Convert a TeamsConfig instance back to a dict.
|
||||
|
||||
If we have the data that was used to build the TeamsConfig instance,
|
||||
return that instead of `value.cleaned_data`, thus preserving the
|
||||
data in the form that the user entered it.
|
||||
"""
|
||||
if value.source_data is not None:
|
||||
return value.source_data
|
||||
return value.cleaned_data
|
||||
|
||||
|
||||
class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
lti_passports = List(
|
||||
display_name=_("LTI Passports"),
|
||||
help=_('Enter the passports for course LTI tools in the following format: "id:client_key:client_secret".'),
|
||||
scope=Scope.settings
|
||||
)
|
||||
textbooks = TextbookList(
|
||||
help=_("List of Textbook objects with (title, url) for textbooks used in this course"),
|
||||
default=[],
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
wiki_slug = String(help=_("Slug that points to the wiki for this course"), scope=Scope.content)
|
||||
enrollment_start = Date(help=_("Date that enrollment for this class is opened"), scope=Scope.settings)
|
||||
enrollment_end = Date(help=_("Date that enrollment for this class is closed"), scope=Scope.settings)
|
||||
start = Date(
|
||||
help=_("Start time when this module is visible"),
|
||||
default=DEFAULT_START_DATE,
|
||||
scope=Scope.settings
|
||||
)
|
||||
end = Date(help=_("Date that this class ends"), scope=Scope.settings)
|
||||
certificate_available_date = Date(
|
||||
help=_("Date that certificates become available to learners"),
|
||||
scope=Scope.content
|
||||
)
|
||||
cosmetic_display_price = Integer(
|
||||
display_name=_("Cosmetic Course Display Price"),
|
||||
help=_(
|
||||
"The cost displayed to students for enrolling in the course. If a paid course registration price is "
|
||||
"set by an administrator in the database, that price will be displayed instead of this one."
|
||||
),
|
||||
default=0,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
advertised_start = String(
|
||||
display_name=_("Course Advertised Start"),
|
||||
help=_(
|
||||
"Enter the text that you want to use as the advertised starting time frame for the course, "
|
||||
"such as \"Winter 2018\". If you enter null for this value, the start date that you have set "
|
||||
"for this course is used."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
pre_requisite_courses = List(
|
||||
display_name=_("Pre-Requisite Courses"),
|
||||
help=_("Pre-Requisite Course key if this course has a pre-requisite course"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
grading_policy = Dict(
|
||||
help=_("Grading policy definition for this class"),
|
||||
default=DEFAULT_GRADING_POLICY,
|
||||
scope=Scope.content
|
||||
)
|
||||
show_calculator = Boolean(
|
||||
display_name=_("Show Calculator"),
|
||||
help=_("Enter true or false. When true, students can see the calculator in the course."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
display_name = String(
|
||||
help=_("Enter the name of the course as it should appear in the course list."),
|
||||
default="Empty",
|
||||
display_name=_("Course Display Name"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
course_edit_method = String(
|
||||
display_name=_("Course Editor"),
|
||||
help=_('Enter the method by which this course is edited ("XML" or "Studio").'),
|
||||
default="Studio",
|
||||
scope=Scope.settings,
|
||||
deprecated=True # Deprecated because someone would not edit this value within Studio.
|
||||
)
|
||||
tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[])
|
||||
end_of_course_survey_url = String(
|
||||
display_name=_("Course Survey URL"),
|
||||
help=_("Enter the URL for the end-of-course survey. If your course does not have a survey, enter null."),
|
||||
scope=Scope.settings,
|
||||
deprecated=True # We wish to remove this entirely, TNL-3399
|
||||
)
|
||||
discussion_blackouts = List(
|
||||
display_name=_("Discussion Blackout Dates"),
|
||||
help=_(
|
||||
'Enter pairs of dates between which students cannot post to discussion forums. Inside the provided '
|
||||
'brackets, enter an additional set of square brackets surrounding each pair of dates you add. '
|
||||
'Format each pair of dates as ["YYYY-MM-DD", "YYYY-MM-DD"]. To specify times as well as dates, '
|
||||
'format each pair as ["YYYY-MM-DDTHH:MM", "YYYY-MM-DDTHH:MM"]. Be sure to include the "T" between '
|
||||
'the date and time. For example, an entry defining two blackout periods looks like this, including '
|
||||
'the outer pair of square brackets: [["2015-09-15", "2015-09-21"], ["2015-10-01", "2015-10-08"]] '
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
discussion_topics = Dict(
|
||||
display_name=_("Discussion Topic Mapping"),
|
||||
help=_(
|
||||
'Enter discussion categories in the following format: "CategoryName": '
|
||||
'{"id": "i4x-InstitutionName-CourseNumber-course-CourseRun"}. For example, one discussion '
|
||||
'category may be "Lydian Mode": {"id": "i4x-UniversityX-MUS101-course-2015_T1"}. The "id" '
|
||||
'value for each category must be unique. In "id" values, the only special characters that are '
|
||||
'supported are underscore, hyphen, and period. You can also specify a category as the default '
|
||||
'for new posts in the Discussion page by setting its "default" attribute to true. For example, '
|
||||
'"Lydian Mode": {"id": "i4x-UniversityX-MUS101-course-2015_T1", "default": true}.'
|
||||
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
discussion_sort_alpha = Boolean(
|
||||
display_name=_("Discussion Sorting Alphabetical"),
|
||||
scope=Scope.settings, default=False,
|
||||
help=_(
|
||||
"Enter true or false. If true, discussion categories and subcategories are sorted alphabetically. "
|
||||
"If false, they are sorted chronologically by creation date and time."
|
||||
)
|
||||
)
|
||||
discussions_settings = Dict(
|
||||
display_name=_("Discussions Plugin Settings"),
|
||||
scope=Scope.settings,
|
||||
help=_("Settings for discussions plugins."),
|
||||
default={
|
||||
"enable_in_context": True,
|
||||
"enable_graded_units": False,
|
||||
"unit_level_visibility": False,
|
||||
}
|
||||
)
|
||||
announcement = Date(
|
||||
display_name=_("Course Announcement Date"),
|
||||
help=_("Enter the date to announce your course."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
cohort_config = Dict(
|
||||
display_name=_("Cohort Configuration"),
|
||||
help=_(
|
||||
"Enter policy keys and values to enable the cohort feature, define automated student assignment to "
|
||||
"groups, or identify any course-wide discussion topics as private to cohort members."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
is_new = Boolean(
|
||||
display_name=_("Course Is New"),
|
||||
help=_(
|
||||
"Enter true or false. If true, the course appears in the list of new courses, and a New! "
|
||||
"badge temporarily appears next to the course image."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
mobile_available = Boolean(
|
||||
display_name=_("Mobile Course Available"),
|
||||
help=_("Enter true or false. If true, the course will be available to mobile devices."),
|
||||
default=DEFAULT_MOBILE_AVAILABLE,
|
||||
scope=Scope.settings
|
||||
)
|
||||
video_upload_pipeline = Dict(
|
||||
display_name=_("Video Upload Credentials"),
|
||||
help=_(
|
||||
"Enter the unique identifier for your course's video files provided by {platform_name}."
|
||||
).format(platform_name=settings.PLATFORM_NAME),
|
||||
scope=Scope.settings
|
||||
)
|
||||
no_grade = Boolean(
|
||||
display_name=_("Course Not Graded"),
|
||||
help=_("Enter true or false. If true, the course will not be graded."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
disable_progress_graph = Boolean(
|
||||
display_name=_("Disable Progress Graph"),
|
||||
help=_("Enter true or false. If true, students cannot view the progress graph."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
pdf_textbooks = List(
|
||||
display_name=_("PDF Textbooks"),
|
||||
help=_("List of dictionaries containing pdf_textbook configuration"), scope=Scope.settings
|
||||
)
|
||||
html_textbooks = List(
|
||||
display_name=_("HTML Textbooks"),
|
||||
help=_(
|
||||
"For HTML textbooks that appear as separate tabs in the course, enter the name of the tab (usually "
|
||||
"the title of the book) as well as the URLs and titles of each chapter in the book."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
remote_gradebook = Dict(
|
||||
display_name=_("Remote Gradebook"),
|
||||
help=_(
|
||||
"Enter the remote gradebook mapping. Only use this setting when "
|
||||
"REMOTE_GRADEBOOK_URL has been specified."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
enable_ccx = Boolean(
|
||||
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content. CCX Coach is
|
||||
# a role created by a course Instructor to enable a person (the "Coach") to manage the custom course for
|
||||
# his students.
|
||||
display_name=_("Enable CCX"),
|
||||
help=_(
|
||||
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content. CCX Coach is
|
||||
# a role created by a course Instructor to enable a person (the "Coach") to manage the custom course for
|
||||
# his students.
|
||||
"Allow course instructors to assign CCX Coach roles, and allow coaches to manage "
|
||||
"Custom Courses on {platform_name}. When false, Custom Courses cannot be created, "
|
||||
"but existing Custom Courses will be preserved."
|
||||
).format(platform_name=settings.PLATFORM_NAME),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
ccx_connector = String(
|
||||
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
|
||||
display_name=_("CCX Connector URL"),
|
||||
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content.
|
||||
help=_(
|
||||
"URL for CCX Connector application for managing creation of CCXs. (optional)."
|
||||
" Ignored unless 'Enable CCX' is set to 'true'."
|
||||
),
|
||||
scope=Scope.settings, default=""
|
||||
)
|
||||
allow_anonymous = Boolean(
|
||||
display_name=_("Allow Anonymous Discussion Posts"),
|
||||
help=_("Enter true or false. If true, students can create discussion posts that are anonymous to all users."),
|
||||
scope=Scope.settings, default=True
|
||||
)
|
||||
allow_anonymous_to_peers = Boolean(
|
||||
display_name=_("Allow Anonymous Discussion Posts to Peers"),
|
||||
help=_(
|
||||
"Enter true or false. If true, students can create discussion posts that are anonymous to other "
|
||||
"students. This setting does not make posts anonymous to course staff."
|
||||
),
|
||||
scope=Scope.settings, default=False
|
||||
)
|
||||
advanced_modules = List(
|
||||
display_name=_("Advanced Module List"),
|
||||
help=_("Enter the names of the advanced modules to use in your course."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
has_children = True
|
||||
show_timezone = Boolean(
|
||||
help=_(
|
||||
"True if timezones should be shown on dates in the course. "
|
||||
"Deprecated in favor of due_date_display_format."
|
||||
),
|
||||
scope=Scope.settings, default=True
|
||||
)
|
||||
due_date_display_format = String(
|
||||
display_name=_("Due Date Display Format"),
|
||||
help=_(
|
||||
"Enter the format for due dates. The default is Mon DD, YYYY. Enter \"%m-%d-%Y\" for MM-DD-YYYY, "
|
||||
"\"%d-%m-%Y\" for DD-MM-YYYY, \"%Y-%m-%d\" for YYYY-MM-DD, or \"%Y-%d-%m\" for YYYY-DD-MM."
|
||||
),
|
||||
scope=Scope.settings, default=None
|
||||
)
|
||||
enrollment_domain = String(
|
||||
display_name=_("External Login Domain"),
|
||||
help=_("Enter the external login method students can use for the course."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
certificates_show_before_end = Boolean(
|
||||
display_name=_("Certificates Downloadable Before End"),
|
||||
help=_(
|
||||
"Enter true or false. If true, students can download certificates before the course ends, if they've "
|
||||
"met certificate requirements."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=False,
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
certificates_display_behavior = String(
|
||||
display_name=_("Certificates Display Behavior"),
|
||||
help=_(
|
||||
"This field, together with certificate_available_date will determine when a "
|
||||
"user can see their certificate for the course"
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=CertificatesDisplayBehaviors.END.value,
|
||||
)
|
||||
course_image = String(
|
||||
display_name=_("Course About Page Image"),
|
||||
help=_(
|
||||
"Edit the name of the course image file. You must upload this file on the Files & Uploads page. "
|
||||
"You can also set the course image on the Settings & Details page."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
# Ensure that courses imported from XML keep their image
|
||||
default="images_course_image.jpg",
|
||||
hide_on_enabled_publisher=True
|
||||
)
|
||||
banner_image = String(
|
||||
display_name=_("Course Banner Image"),
|
||||
help=_(
|
||||
"Edit the name of the banner image file. "
|
||||
"You can set the banner image on the Settings & Details page."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
# Ensure that courses imported from XML keep their image
|
||||
default="images_course_image.jpg"
|
||||
)
|
||||
video_thumbnail_image = String(
|
||||
display_name=_("Course Video Thumbnail Image"),
|
||||
help=_(
|
||||
"Edit the name of the video thumbnail image file. "
|
||||
"You can set the video thumbnail image on the Settings & Details page."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
# Ensure that courses imported from XML keep their image
|
||||
default="images_course_image.jpg"
|
||||
)
|
||||
issue_badges = Boolean(
|
||||
display_name=_("Issue Open Badges"),
|
||||
help=_(
|
||||
"Issue Open Badges badges for this course. Badges are generated when certificates are created."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
## Course level Certificate Name overrides.
|
||||
cert_name_short = String(
|
||||
help=_(
|
||||
'Use this setting only when generating PDF certificates. '
|
||||
'Between quotation marks, enter the short name of the type of certificate that '
|
||||
'students receive when they complete the course. For instance, "Certificate".'
|
||||
),
|
||||
display_name=_("Certificate Name (Short)"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
cert_name_long = String(
|
||||
help=_(
|
||||
'Use this setting only when generating PDF certificates. '
|
||||
'Between quotation marks, enter the long name of the type of certificate that students '
|
||||
'receive when they complete the course. For instance, "Certificate of Achievement".'
|
||||
),
|
||||
display_name=_("Certificate Name (Long)"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
cert_html_view_enabled = Boolean(
|
||||
display_name=_("Certificate Web/HTML View Enabled"),
|
||||
help=_("If true, certificate Web/HTML views are enabled for the course."),
|
||||
scope=Scope.settings,
|
||||
default=True,
|
||||
deprecated=True
|
||||
)
|
||||
cert_html_view_overrides = Dict(
|
||||
# Translators: This field is the container for course-specific certificate configuration values
|
||||
display_name=_("Certificate Web/HTML View Overrides"),
|
||||
# Translators: These overrides allow for an alternative configuration of the certificate web view
|
||||
help=_("Enter course-specific overrides for the Web/HTML template parameters here (JSON format)"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
# Specific certificate information managed via Studio (should eventually fold other cert settings into this)
|
||||
certificates = Dict(
|
||||
# Translators: This field is the container for course-specific certificate configuration values
|
||||
display_name=_("Certificate Configuration"),
|
||||
# Translators: These overrides allow for an alternative configuration of the certificate web view
|
||||
help=_("Enter course-specific configuration information here (JSON format)"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
# An extra property is used rather than the wiki_slug/number because
|
||||
# there are courses that change the number for different runs. This allows
|
||||
# courses to share the same css_class across runs even if they have
|
||||
# different numbers.
|
||||
#
|
||||
# TODO get rid of this as soon as possible or potentially build in a robust
|
||||
# way to add in course-specific styling. There needs to be a discussion
|
||||
# about the right way to do this, but arjun will address this ASAP. Also
|
||||
# note that the courseware template needs to change when this is removed.
|
||||
css_class = String(
|
||||
display_name=_("CSS Class for Course Reruns"),
|
||||
help=_("Allows courses to share the same css class across runs even if they have different numbers."),
|
||||
scope=Scope.settings, default="",
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
# TODO: This is a quick kludge to allow CS50 (and other courses) to
|
||||
# specify their own discussion forums as external links by specifying a
|
||||
# "discussion_link" in their policy JSON file. This should later get
|
||||
# folded in with Syllabus, Course Info, and additional Custom tabs in a
|
||||
# more sensible framework later.
|
||||
discussion_link = String(
|
||||
display_name=_("Discussion Forum External Link"),
|
||||
help=_("Allows specification of an external link to replace discussion forums."),
|
||||
scope=Scope.settings,
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
# TODO: same as above, intended to let internal CS50 hide the progress tab
|
||||
# until we get grade integration set up.
|
||||
# Explicit comparison to True because we always want to return a bool.
|
||||
hide_progress_tab = Boolean(
|
||||
display_name=_("Hide Progress Tab"),
|
||||
help=_("Allows hiding of the progress tab."),
|
||||
scope=Scope.settings,
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
display_organization = String(
|
||||
display_name=_("Course Organization Display String"),
|
||||
help=_(
|
||||
"Enter the course organization that you want to appear in the course. This setting overrides the "
|
||||
"organization that you entered when you created the course. To use the organization that you entered "
|
||||
"when you created the course, enter null."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
display_coursenumber = String(
|
||||
display_name=_("Course Number Display String"),
|
||||
help=_(
|
||||
"Enter the course number that you want to appear in the course. This setting overrides the course "
|
||||
"number that you entered when you created the course. To use the course number that you entered when "
|
||||
"you created the course, enter null."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
|
||||
max_student_enrollments_allowed = Integer(
|
||||
display_name=_("Course Maximum Student Enrollment"),
|
||||
help=_(
|
||||
"Enter the maximum number of students that can enroll in the course. To allow an unlimited number of "
|
||||
"students, enter null."
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
allow_public_wiki_access = Boolean(
|
||||
display_name=_("Allow Public Wiki Access"),
|
||||
help=_(
|
||||
"Enter true or false. If true, students can view the course wiki even "
|
||||
"if they're not enrolled in the course."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
invitation_only = Boolean(
|
||||
display_name=_("Invitation Only"),
|
||||
help=_("Whether to restrict enrollment to invitation by the course staff."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
course_survey_name = String(
|
||||
display_name=_("Pre-Course Survey Name"),
|
||||
help=_("Name of SurveyForm to display as a pre-course survey to the user."),
|
||||
default=None,
|
||||
scope=Scope.settings,
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
course_survey_required = Boolean(
|
||||
display_name=_("Pre-Course Survey Required"),
|
||||
help=_(
|
||||
"Specify whether students must complete a survey before they can view your course content. If you "
|
||||
"set this value to true, you must add a name for the survey to the Course Survey Name setting above."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
deprecated=True
|
||||
)
|
||||
|
||||
catalog_visibility = String(
|
||||
display_name=_("Course Visibility In Catalog"),
|
||||
help=_(
|
||||
# Translators: the quoted words 'both', 'about', and 'none' must be
|
||||
# left untranslated. Leave them as English words.
|
||||
"Defines the access permissions for showing the course in the course catalog. This can be set to one "
|
||||
"of three values: 'both' (show in catalog and allow access to about page), 'about' (only allow access "
|
||||
"to about page), 'none' (do not show in catalog and do not allow access to an about page)."
|
||||
),
|
||||
default=DEFAULT_COURSE_VISIBILITY_IN_CATALOG,
|
||||
scope=Scope.settings,
|
||||
values=[
|
||||
{"display_name": "Both", "value": CATALOG_VISIBILITY_CATALOG_AND_ABOUT},
|
||||
{"display_name": "About", "value": CATALOG_VISIBILITY_ABOUT},
|
||||
{"display_name": "None", "value": CATALOG_VISIBILITY_NONE},
|
||||
],
|
||||
)
|
||||
|
||||
entrance_exam_enabled = Boolean(
|
||||
display_name=_("Entrance Exam Enabled"),
|
||||
help=_(
|
||||
"Specify whether students must complete an entrance exam before they can view your course content. "
|
||||
"Note, you must enable Entrance Exams for this course setting to take effect."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
# Note: Although users enter the entrance exam minimum score
|
||||
# as a percentage value, it is internally converted and stored
|
||||
# as a decimal value less than 1.
|
||||
entrance_exam_minimum_score_pct = Float(
|
||||
display_name=_("Entrance Exam Minimum Score (%)"),
|
||||
help=_(
|
||||
"Specify a minimum percentage score for an entrance exam before students can view your course content. "
|
||||
"Note, you must enable Entrance Exams for this course setting to take effect."
|
||||
),
|
||||
default=65,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
entrance_exam_id = String(
|
||||
display_name=_("Entrance Exam ID"),
|
||||
help=_("Content module identifier (location) of entrance exam."),
|
||||
default=None,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
social_sharing_url = String(
|
||||
display_name=_("Social Media Sharing URL"),
|
||||
help=_(
|
||||
"If dashboard social sharing and custom course URLs are enabled, you can provide a URL "
|
||||
"(such as the URL to a course About page) that social media sites can link to. URLs must "
|
||||
"be fully qualified. For example: http://www.edx.org/course/Introduction-to-MOOCs-ITM001"
|
||||
),
|
||||
default=None,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
language = String(
|
||||
display_name=_("Course Language"),
|
||||
help=_("Specify the language of your course."),
|
||||
default=None,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
teams_configuration = TeamsConfigField(
|
||||
display_name=_("Teams Configuration"),
|
||||
# Translators: please don't translate "id".
|
||||
help=_(
|
||||
'Configure team sets, limit team sizes, and set visibility settings using JSON. See '
|
||||
'<a target="_blank" href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/'
|
||||
'course_features/teams/teams_setup.html#enable-and-configure-teams">teams '
|
||||
'configuration documentation</a> for help and examples.'
|
||||
),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
enable_proctored_exams = Boolean(
|
||||
display_name=_("Enable Proctored Exams"),
|
||||
help=_(
|
||||
"Enter true or false. If this value is true, proctored exams are enabled in your course. "
|
||||
"Note that enabling proctored exams will also enable timed exams."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
proctoring_provider = ProctoringProvider(
|
||||
display_name=_("Proctoring Provider"),
|
||||
help=_(
|
||||
"Enter the proctoring provider you want to use for this course run. "
|
||||
"Choose from the following options: {available_providers}."),
|
||||
help_format_args=dict(
|
||||
# Put the available providers into a format variable so that translators
|
||||
# don't translate them.
|
||||
available_providers=(
|
||||
', '.join(get_available_providers())
|
||||
),
|
||||
),
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
proctoring_escalation_email = EmailString(
|
||||
display_name=_("Proctortrack Exam Escalation Contact"),
|
||||
help=_(
|
||||
"Required if 'proctortrack' is selected as your proctoring provider. "
|
||||
"Enter an email address to be contacted by the support team whenever there are escalations "
|
||||
"(e.g. appeals, delayed reviews, etc.)."
|
||||
),
|
||||
default=None,
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
allow_proctoring_opt_out = Boolean(
|
||||
display_name=_("Allow Opting Out of Proctored Exams"),
|
||||
help=_(
|
||||
"Enter true or false. If this value is true, learners can choose to take proctored exams "
|
||||
"without proctoring. If this value is false, all learners must take the exam with proctoring. "
|
||||
"This setting only applies if proctored exams are enabled for the course."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
create_zendesk_tickets = Boolean(
|
||||
display_name=_("Create Zendesk Tickets For Suspicious Proctored Exam Attempts"),
|
||||
help=_(
|
||||
"Enter true or false. If this value is true, a Zendesk ticket will be created for suspicious attempts."
|
||||
),
|
||||
default=True,
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
enable_timed_exams = Boolean(
|
||||
display_name=_("Enable Timed Exams"),
|
||||
help=_(
|
||||
"Enter true or false. If this value is true, timed exams are enabled in your course. "
|
||||
"Regardless of this setting, timed exams are enabled if Enable Proctored Exams is set to true."
|
||||
),
|
||||
default=SPECIAL_EXAMS_ENABLED,
|
||||
scope=Scope.settings,
|
||||
deprecated=EXAM_SETTINGS_HTML_VIEW_ENABLED
|
||||
)
|
||||
|
||||
minimum_grade_credit = Float(
|
||||
display_name=_("Minimum Grade for Credit"),
|
||||
help=_(
|
||||
"The minimum grade that a learner must earn to receive credit in the course, "
|
||||
"as a decimal between 0.0 and 1.0. For example, for 75%, enter 0.75."
|
||||
),
|
||||
default=0.8,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
self_paced = Boolean(
|
||||
display_name=_("Self Paced"),
|
||||
help=_(
|
||||
"Set this to \"true\" to mark this course as self-paced. Self-paced courses do not have "
|
||||
"due dates for assignments, and students can progress through the course at any rate before "
|
||||
"the course ends."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
enable_subsection_gating = Boolean(
|
||||
display_name=_("Enable Subsection Prerequisites"),
|
||||
help=_(
|
||||
"Enter true or false. If this value is true, you can hide a "
|
||||
"subsection until learners earn a minimum score in another, "
|
||||
"prerequisite subsection."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
learning_info = List(
|
||||
display_name=_("Course Learning Information"),
|
||||
help=_("Specify what student can learn from the course."),
|
||||
default=[],
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
course_visibility = String(
|
||||
display_name=_("Course Visibility For Unenrolled Learners"),
|
||||
help=_(
|
||||
# Translators: the quoted words 'private', 'public_outline', and 'public'
|
||||
# must be left untranslated. Leave them as English words.
|
||||
"Defines the access permissions for unenrolled learners. This can be set to one of three values: "
|
||||
"'private' (default visibility, only allowed for enrolled students), 'public_outline' "
|
||||
"(allow access to course outline) and 'public' (allow access to both outline and course content)."
|
||||
),
|
||||
default=COURSE_VISIBILITY_PRIVATE,
|
||||
scope=Scope.settings,
|
||||
values=[
|
||||
{"display_name": "private", "value": COURSE_VISIBILITY_PRIVATE},
|
||||
{"display_name": "public_outline", "value": COURSE_VISIBILITY_PUBLIC_OUTLINE},
|
||||
{"display_name": "public", "value": COURSE_VISIBILITY_PUBLIC},
|
||||
],
|
||||
)
|
||||
|
||||
"""
|
||||
instructor_info dict structure:
|
||||
{
|
||||
"instructors": [
|
||||
{
|
||||
"name": "",
|
||||
"title": "",
|
||||
"organization": "",
|
||||
"image": "",
|
||||
"bio": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
instructor_info = Dict(
|
||||
display_name=_("Course Instructor"),
|
||||
help=_("Enter the details for Course Instructor"),
|
||||
default={
|
||||
"instructors": []
|
||||
},
|
||||
scope=Scope.settings
|
||||
)
|
||||
allow_unsupported_xblocks = Boolean(
|
||||
display_name=_("Add Unsupported Problems and Tools"),
|
||||
help=_(
|
||||
"Enter true or false. If true, you can add unsupported problems and tools to your course in Studio. "
|
||||
"Unsupported problems and tools are not recommended for use in courses due to non-compliance with one or "
|
||||
"more of the base requirements, such as testing, accessibility, internationalization, and documentation."
|
||||
),
|
||||
scope=Scope.settings, default=False
|
||||
)
|
||||
highlights_enabled_for_messaging = Boolean(
|
||||
display_name=_("Highlights Enabled for Messaging"),
|
||||
help=_(
|
||||
"Enter true or false. If true, any highlights associated with content in the course will be messaged "
|
||||
"to learners at their scheduled time."
|
||||
),
|
||||
scope=Scope.settings, default=False
|
||||
)
|
||||
|
||||
course_wide_js = List(
|
||||
display_name=_("Course-wide Custom JS"),
|
||||
help=_('Enter Javascript resource URLs you want to be loaded globally throughout the course pages.'),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
course_wide_css = List(
|
||||
display_name=_("Course-wide Custom CSS"),
|
||||
help=_('Enter CSS resource URLs you want to be loaded globally throughout the course pages.'),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
other_course_settings = Dict(
|
||||
display_name=_("Other Course Settings"),
|
||||
help=_(
|
||||
"Any additional information about the course that the platform needs or that allows integration with "
|
||||
"external systems such as CRM software. Enter a dictionary of values in JSON format, such as "
|
||||
"{ \"my_custom_setting\": \"value\", \"other_setting\": \"value\" }"
|
||||
),
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
|
||||
class CourseBlock(
|
||||
CourseFields,
|
||||
SequenceBlock,
|
||||
LicenseMixin,
|
||||
): # pylint: disable=abstract-method
|
||||
"""
|
||||
The Course XBlock.
|
||||
"""
|
||||
resources_dir = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Expects the same arguments as XModuleDescriptor.__init__
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
|
||||
self._gating_prerequisites = None
|
||||
|
||||
if self.wiki_slug is None:
|
||||
self.wiki_slug = self.location.course
|
||||
|
||||
if self.due_date_display_format is None and self.show_timezone is False:
|
||||
# For existing courses with show_timezone set to False (and no due_date_display_format specified),
|
||||
# set the due_date_display_format to what would have been shown previously (with no timezone).
|
||||
# Then remove show_timezone so that if the user clears out the due_date_display_format,
|
||||
# they get the default date display.
|
||||
self.due_date_display_format = "DATE_TIME"
|
||||
del self.show_timezone
|
||||
|
||||
# NOTE: relies on the modulestore to call set_grading_policy() right after
|
||||
# init. (Modulestore is in charge of figuring out where to load the policy from)
|
||||
|
||||
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
|
||||
# disable the syllabus content for courses that do not provide a syllabus
|
||||
if self.system.resources_fs is None:
|
||||
self.syllabus_present = False
|
||||
else:
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
|
||||
self._grading_policy = {}
|
||||
self.set_grading_policy(self.grading_policy)
|
||||
|
||||
if self.discussion_topics == {}:
|
||||
self.discussion_topics = {_('General'): {'id': self.location.html_id()}}
|
||||
|
||||
try:
|
||||
if not getattr(self, "tabs", []):
|
||||
CourseTabList.initialize_default(self)
|
||||
except InvalidTabsException as err:
|
||||
raise type(err)(f'{str(err)} For course: {str(self.id)}') # lint-amnesty, pylint: disable=line-too-long
|
||||
|
||||
if not settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
self.set_default_certificate_available_date()
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
"""
|
||||
The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is
|
||||
missing, it reverts to the default.
|
||||
"""
|
||||
if course_policy is None:
|
||||
course_policy = {}
|
||||
|
||||
# Load the global settings as a dictionary
|
||||
grading_policy = self.grading_policy
|
||||
# BOY DO I HATE THIS grading_policy CODE ACROBATICS YET HERE I ADD MORE (dhm)--this fixes things persisted w/
|
||||
# defective grading policy values (but not None)
|
||||
if 'GRADER' not in grading_policy:
|
||||
grading_policy['GRADER'] = CourseFields.grading_policy.default['GRADER']
|
||||
if 'GRADE_CUTOFFS' not in grading_policy:
|
||||
grading_policy['GRADE_CUTOFFS'] = CourseFields.grading_policy.default['GRADE_CUTOFFS']
|
||||
|
||||
# Override any global settings with the course settings
|
||||
grading_policy.update(course_policy)
|
||||
|
||||
# Here is where we should parse any configurations, so that we can fail early
|
||||
# Use setters so that side effecting to .definitions works
|
||||
self.raw_grader = grading_policy['GRADER'] # used for cms access
|
||||
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
def set_default_certificate_available_date(self):
|
||||
if (not self.certificate_available_date) and self.end:
|
||||
self.certificate_available_date = self.end + timedelta(days=2)
|
||||
|
||||
@classmethod
|
||||
def read_grading_policy(cls, paths, system):
|
||||
"""Load a grading policy from the specified paths, in order, if it exists."""
|
||||
# Default to a blank policy dict
|
||||
policy_str = '{}'
|
||||
|
||||
for policy_path in paths:
|
||||
if not system.resources_fs.exists(policy_path):
|
||||
continue
|
||||
log.debug(f"Loading grading policy from {policy_path}")
|
||||
try:
|
||||
with system.resources_fs.open(policy_path) as grading_policy_file:
|
||||
policy_str = grading_policy_file.read()
|
||||
# if we successfully read the file, stop looking at backups
|
||||
break
|
||||
except OSError:
|
||||
msg = f"Unable to load course settings file from '{policy_path}'"
|
||||
log.warning(msg)
|
||||
|
||||
return policy_str
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, id_generator):
|
||||
instance = super().from_xml(xml_data, system, id_generator)
|
||||
|
||||
# bleh, have to parse the XML here to just pull out the url_name attribute
|
||||
# I don't think it's stored anywhere in the instance.
|
||||
if isinstance(xml_data, str):
|
||||
xml_data = xml_data.encode('ascii', 'ignore')
|
||||
course_file = BytesIO(xml_data)
|
||||
xml_obj = etree.parse(course_file, parser=edx_xml_parser).getroot()
|
||||
|
||||
policy_dir = None
|
||||
url_name = xml_obj.get('url_name', xml_obj.get('slug'))
|
||||
if url_name:
|
||||
policy_dir = 'policies/' + url_name
|
||||
|
||||
# Try to load grading policy
|
||||
paths = ['grading_policy.json']
|
||||
if policy_dir:
|
||||
paths = [policy_dir + '/grading_policy.json'] + paths
|
||||
|
||||
try:
|
||||
policy = json.loads(cls.read_grading_policy(paths, system))
|
||||
except ValueError:
|
||||
system.error_tracker("Unable to decode grading policy as json")
|
||||
policy = {}
|
||||
|
||||
# now set the current instance. set_grading_policy() will apply some inheritance rules
|
||||
instance.set_grading_policy(policy)
|
||||
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
textbooks = []
|
||||
for textbook in xml_object.findall("textbook"):
|
||||
textbooks.append((textbook.get('title'), textbook.get('book_url')))
|
||||
xml_object.remove(textbook)
|
||||
|
||||
# Load the wiki tag if it exists
|
||||
wiki_slug = None
|
||||
wiki_tag = xml_object.find("wiki")
|
||||
if wiki_tag is not None:
|
||||
wiki_slug = wiki_tag.attrib.get("slug", default=None)
|
||||
xml_object.remove(wiki_tag)
|
||||
|
||||
definition, children = super().definition_from_xml(xml_object, system)
|
||||
definition['textbooks'] = textbooks
|
||||
definition['wiki_slug'] = wiki_slug
|
||||
|
||||
# load license if it exists
|
||||
definition = LicenseMixin.parse_license_from_xml(definition, xml_object)
|
||||
|
||||
return definition, children
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = super().definition_to_xml(resource_fs)
|
||||
|
||||
if self.textbooks:
|
||||
textbook_xml_object = etree.Element('textbook')
|
||||
for textbook in self.textbooks: # lint-amnesty, pylint: disable=not-an-iterable
|
||||
textbook_xml_object.set('title', textbook.title)
|
||||
textbook_xml_object.set('book_url', textbook.book_url)
|
||||
|
||||
xml_object.append(textbook_xml_object)
|
||||
|
||||
if self.wiki_slug is not None:
|
||||
wiki_xml_object = etree.Element('wiki')
|
||||
wiki_xml_object.set('slug', self.wiki_slug)
|
||||
xml_object.append(wiki_xml_object)
|
||||
|
||||
# handle license specifically. Default the course to have a license
|
||||
# of "All Rights Reserved", if a license is not explicitly set.
|
||||
self.add_license_to_xml(xml_object, default="all-rights-reserved")
|
||||
|
||||
return xml_object
|
||||
|
||||
def has_ended(self):
|
||||
"""
|
||||
Returns True if the current time is after the specified course end date.
|
||||
Returns False if there is no end date specified.
|
||||
"""
|
||||
return course_metadata_utils.has_course_ended(self.end)
|
||||
|
||||
def has_started(self):
|
||||
return course_metadata_utils.has_course_started(self.start)
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
return grader_from_conf(self.raw_grader)
|
||||
|
||||
@property
|
||||
def raw_grader(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
# force the caching of the xblock value so that it can detect the change
|
||||
# pylint: disable=pointless-statement
|
||||
self.grading_policy['GRADER']
|
||||
return self._grading_policy['RAW_GRADER']
|
||||
|
||||
@raw_grader.setter
|
||||
def raw_grader(self, value):
|
||||
# NOTE WELL: this change will not update the processed graders.
|
||||
# If we need that, this needs to call grader_from_conf.
|
||||
self._grading_policy['RAW_GRADER'] = value
|
||||
self.grading_policy['GRADER'] = value
|
||||
|
||||
@property
|
||||
def grade_cutoffs(self):
|
||||
return self._grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@grade_cutoffs.setter
|
||||
def grade_cutoffs(self, value):
|
||||
self._grading_policy['GRADE_CUTOFFS'] = value
|
||||
|
||||
# XBlock fields don't update after mutation
|
||||
policy = self.grading_policy
|
||||
policy['GRADE_CUTOFFS'] = value
|
||||
self.grading_policy = policy
|
||||
|
||||
@property
|
||||
def lowest_passing_grade(self):
|
||||
return min(self._grading_policy['GRADE_CUTOFFS'].values())
|
||||
|
||||
@property
|
||||
def is_cohorted(self):
|
||||
"""
|
||||
Return whether the course is cohorted.
|
||||
|
||||
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
|
||||
"""
|
||||
config = self.cohort_config
|
||||
if config is None:
|
||||
return False
|
||||
|
||||
return bool(config.get("cohorted"))
|
||||
|
||||
@property
|
||||
def auto_cohort(self):
|
||||
"""
|
||||
Return whether the course is auto-cohorted.
|
||||
|
||||
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
|
||||
"""
|
||||
if not self.is_cohorted:
|
||||
return False
|
||||
|
||||
return bool(self.cohort_config.get(
|
||||
"auto_cohort", False))
|
||||
|
||||
@property
|
||||
def auto_cohort_groups(self):
|
||||
"""
|
||||
Return the list of groups to put students into. Returns [] if not
|
||||
specified. Returns specified list even if is_cohorted and/or auto_cohort are
|
||||
false.
|
||||
|
||||
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
|
||||
"""
|
||||
if self.cohort_config is None:
|
||||
return []
|
||||
else:
|
||||
return self.cohort_config.get("auto_cohort_groups", [])
|
||||
|
||||
@property
|
||||
def top_level_discussion_topic_ids(self):
|
||||
"""
|
||||
Return list of topic ids defined in course policy.
|
||||
"""
|
||||
topics = self.discussion_topics
|
||||
return [d["id"] for d in topics.values()]
|
||||
|
||||
@property
|
||||
def cohorted_discussions(self):
|
||||
"""
|
||||
Return the set of discussions that is explicitly cohorted. It may be
|
||||
the empty set. Note that all inline discussions are automatically
|
||||
cohorted based on the course's is_cohorted setting.
|
||||
|
||||
Note: No longer used. See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
|
||||
"""
|
||||
config = self.cohort_config
|
||||
if config is None:
|
||||
return set()
|
||||
|
||||
return set(config.get("cohorted_discussions", []))
|
||||
|
||||
@property
|
||||
def always_cohort_inline_discussions(self):
|
||||
"""
|
||||
This allow to change the default behavior of inline discussions cohorting. By
|
||||
setting this to 'True', all inline discussions are cohorted. The default value is
|
||||
now `False`, meaning that inline discussions are not cohorted unless their discussion IDs
|
||||
are specifically listed as cohorted.
|
||||
|
||||
Note: No longer used except to get the initial value when cohorts are first enabled on a course
|
||||
(and for migrating old courses). See openedx.core.djangoapps.course_groups.models.CourseCohortSettings.
|
||||
"""
|
||||
config = self.cohort_config
|
||||
if config is None:
|
||||
# This value sets the default for newly created courses.
|
||||
return False
|
||||
|
||||
return bool(config.get("always_cohort_inline_discussions", False))
|
||||
|
||||
@property
|
||||
def is_newish(self):
|
||||
"""
|
||||
Returns if the course has been flagged as new. If
|
||||
there is no flag, return a heuristic value considering the
|
||||
announcement and the start dates.
|
||||
"""
|
||||
flag = self.is_new
|
||||
if flag is None:
|
||||
# Use a heuristic if the course has not been flagged
|
||||
announcement, start, now = course_metadata_utils.sorting_dates(
|
||||
self.start, self.advertised_start, self.announcement
|
||||
)
|
||||
if announcement and (now - announcement).days < 30:
|
||||
# The course has been announced for less that month
|
||||
return True
|
||||
elif (now - start).days < 1:
|
||||
# The course has not started yet
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
elif isinstance(flag, str):
|
||||
return flag.lower() in ['true', 'yes', 'y']
|
||||
else:
|
||||
return bool(flag)
|
||||
|
||||
@property
|
||||
def sorting_score(self):
|
||||
"""
|
||||
Returns a tuple that can be used to sort the courses according
|
||||
the how "new" they are. The "newness" score is computed using a
|
||||
heuristic that takes into account the announcement and
|
||||
(advertised) start dates of the course if available.
|
||||
|
||||
The lower the number the "newer" the course.
|
||||
"""
|
||||
return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
|
||||
|
||||
@staticmethod
|
||||
def make_id(org, course, url_name):
|
||||
return '/'.join([org, course, url_name])
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""Return the course_id for this course"""
|
||||
return self.location.course_key
|
||||
|
||||
@property
|
||||
def start_date_is_still_default(self):
|
||||
"""
|
||||
Checks if the start date set for the course is still default, i.e. .start has not been modified,
|
||||
and .advertised_start has not been set.
|
||||
"""
|
||||
return course_metadata_utils.course_start_date_is_default(
|
||||
self.start,
|
||||
self.advertised_start
|
||||
)
|
||||
|
||||
def get_discussion_blackout_datetimes(self):
|
||||
"""
|
||||
Get a list of dicts with start and end fields with datetime values from
|
||||
the discussion_blackouts setting
|
||||
"""
|
||||
|
||||
blackout_dates = self.discussion_blackouts
|
||||
date_proxy = Date()
|
||||
if blackout_dates and type(blackout_dates[0]) not in (list, tuple):
|
||||
blackout_dates = [blackout_dates]
|
||||
|
||||
try:
|
||||
ret = [
|
||||
{"start": date_proxy.from_json(start), "end": date_proxy.from_json(end)}
|
||||
for start, end
|
||||
in [blackout_date for blackout_date in blackout_dates if blackout_date]
|
||||
]
|
||||
for blackout in ret:
|
||||
if not blackout["start"] or not blackout["end"]:
|
||||
raise ValueError
|
||||
return ret
|
||||
except (TypeError, ValueError):
|
||||
log.info(
|
||||
"Error parsing discussion_blackouts %s for course %s",
|
||||
blackout_dates,
|
||||
self.id
|
||||
)
|
||||
return []
|
||||
|
||||
@property
|
||||
def forum_posts_allowed(self):
|
||||
"""
|
||||
Return whether forum posts are allowed by the discussion_blackouts
|
||||
setting
|
||||
"""
|
||||
blackouts = self.get_discussion_blackout_datetimes()
|
||||
now = datetime.now(utc)
|
||||
for blackout in blackouts:
|
||||
if blackout["start"] <= now <= blackout["end"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
"""
|
||||
Returns this course's number.
|
||||
|
||||
This is a "number" in the sense of the "course numbers" that you see at
|
||||
lots of universities. For example, given a course
|
||||
"Intro to Computer Science" with the course key "edX/CS-101/2014", the
|
||||
course number would be "CS-101"
|
||||
"""
|
||||
return course_metadata_utils.number_for_course_location(self.location)
|
||||
|
||||
@property
|
||||
def display_number_with_default(self):
|
||||
"""
|
||||
Return a display course number if it has been specified, otherwise return the 'course' that is in the location
|
||||
"""
|
||||
if self.display_coursenumber:
|
||||
return self.display_coursenumber
|
||||
|
||||
return self.number
|
||||
|
||||
@property
|
||||
def org(self):
|
||||
return self.location.org
|
||||
|
||||
@property
|
||||
def display_org_with_default(self):
|
||||
"""
|
||||
Return a display organization if it has been specified, otherwise return the 'org' that is in the location
|
||||
"""
|
||||
if self.display_organization:
|
||||
return self.display_organization
|
||||
|
||||
return self.org
|
||||
|
||||
@property
|
||||
def video_pipeline_configured(self):
|
||||
"""
|
||||
Returns whether the video pipeline advanced setting is configured for this course.
|
||||
"""
|
||||
return VideoUploadsEnabledByDefault.feature_enabled(course_id=self.id) or (
|
||||
self.video_upload_pipeline is not None and
|
||||
'course_video_upload_token' in self.video_upload_pipeline
|
||||
)
|
||||
|
||||
def clean_id(self, padding_char='='):
|
||||
"""
|
||||
Returns a unique deterministic base32-encoded ID for the course.
|
||||
The optional padding_char parameter allows you to override the "=" character used for padding.
|
||||
"""
|
||||
return course_metadata_utils.clean_course_key(self.location.course_key, padding_char)
|
||||
|
||||
@property
|
||||
def teams_enabled(self):
|
||||
"""
|
||||
Alias to `self.teams_configuration.is_enabled`, for convenience.
|
||||
|
||||
Returns bool.
|
||||
"""
|
||||
return self.teams_configuration.is_enabled # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def teamsets(self):
|
||||
"""
|
||||
Alias to `self.teams_configuration.teamsets`, for convenience.
|
||||
|
||||
Returns list[TeamsetConfig].
|
||||
"""
|
||||
return self.teams_configuration.teamsets # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def teamsets_by_id(self):
|
||||
"""
|
||||
Alias to `self.teams_configuration.teamsets_by_id`, for convenience.
|
||||
|
||||
Returns dict[str: TeamsetConfig].
|
||||
"""
|
||||
return self.teams_configuration.teamsets_by_id
|
||||
|
||||
def set_user_partitions_for_scheme(self, partitions, scheme):
|
||||
"""
|
||||
Set the user partitions for a particular scheme.
|
||||
|
||||
Preserves partitions associated with other schemes.
|
||||
|
||||
Arguments:
|
||||
scheme (object): The user partition scheme.
|
||||
|
||||
Returns:
|
||||
list of `UserPartition`
|
||||
|
||||
"""
|
||||
other_partitions = [
|
||||
p for p in self.user_partitions # pylint: disable=access-member-before-definition
|
||||
if p.scheme != scheme
|
||||
]
|
||||
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init
|
||||
|
||||
@property
|
||||
def can_toggle_course_pacing(self):
|
||||
"""
|
||||
Whether or not the course can be set to self-paced at this time.
|
||||
|
||||
Returns:
|
||||
bool: False if the course has already started or no start date set, True otherwise.
|
||||
"""
|
||||
if not self.start:
|
||||
return False
|
||||
return datetime.now(utc) <= self.start
|
||||
|
||||
|
||||
class CourseSummary:
|
||||
"""
|
||||
A lightweight course summary class, which constructs split/mongo course summary without loading
|
||||
the course. It is used at cms for listing courses to global staff user.
|
||||
"""
|
||||
course_info_fields = ['display_name', 'display_coursenumber', 'display_organization', 'end']
|
||||
|
||||
def __init__(self, course_locator, display_name="Empty", display_coursenumber=None, display_organization=None,
|
||||
end=None):
|
||||
"""
|
||||
Initialize and construct course summary
|
||||
|
||||
Arguments:
|
||||
course_locator (CourseLocator): CourseLocator object of the course.
|
||||
|
||||
display_name (unicode): display name of the course. When you create a course from console, display_name
|
||||
isn't set (course block has no key `display_name`). "Empty" name is returned when we load the course.
|
||||
If `display_name` isn't present in the course block, use the `Empty` as default display name.
|
||||
We can set None as a display_name in Course Advance Settings; Do not use "Empty" when display_name is
|
||||
set to None.
|
||||
|
||||
display_coursenumber (unicode|None): Course number that is specified & appears in the courseware
|
||||
|
||||
display_organization (unicode|None): Course organization that is specified & appears in the courseware
|
||||
|
||||
end (unicode|None): Course end date. Must contain timezone.
|
||||
|
||||
"""
|
||||
self.display_coursenumber = display_coursenumber
|
||||
self.display_organization = display_organization
|
||||
self.display_name = display_name
|
||||
|
||||
self.id = course_locator # pylint: disable=invalid-name
|
||||
self.location = course_locator.make_usage_key('course', 'course')
|
||||
self.end = end
|
||||
if end is not None and not isinstance(end, datetime):
|
||||
self.end = dateutil.parser.parse(end)
|
||||
|
||||
@property
|
||||
def display_org_with_default(self):
|
||||
"""
|
||||
Return a display organization if it has been specified, otherwise return the 'org' that
|
||||
is in the location
|
||||
"""
|
||||
if self.display_organization:
|
||||
return self.display_organization
|
||||
return self.location.org
|
||||
|
||||
@property
|
||||
def display_number_with_default(self):
|
||||
"""
|
||||
Return a display course number if it has been specified, otherwise return the 'course' that
|
||||
is in the location
|
||||
"""
|
||||
if self.display_coursenumber:
|
||||
return self.display_coursenumber
|
||||
return self.location.course
|
||||
|
||||
def has_ended(self):
|
||||
"""
|
||||
Returns whether the course has ended.
|
||||
"""
|
||||
try:
|
||||
return course_metadata_utils.has_course_ended(self.end)
|
||||
except TypeError as e:
|
||||
log.warning(
|
||||
"Course '{course_id}' has an improperly formatted end date '{end_date}'. Error: '{err}'.".format(
|
||||
course_id=str(self.id), end_date=self.end, err=e
|
||||
)
|
||||
)
|
||||
modified_end = self.end.replace(tzinfo=utc)
|
||||
return course_metadata_utils.has_course_ended(modified_end)
|
||||
@@ -1,192 +0,0 @@
|
||||
/* TODO: move top-level variables to a common _variables.scss.
|
||||
* NOTE: These variables were only added here because when this was integrated with the CMS,
|
||||
* SASS compilation errors were triggered because the CMS didn't have the same variables defined
|
||||
* that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS.
|
||||
* -Abarrett and Vshnayder
|
||||
*/
|
||||
$annotatable--border-color: $gray-l3;
|
||||
$annotatable--body-font-size: em(14);
|
||||
|
||||
.annotatable-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.annotatable-header {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.annotatable-section {
|
||||
position: relative;
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid $annotatable--border-color;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
&.shaded { background-color: #ededed; }
|
||||
|
||||
.annotatable-section-title {
|
||||
font-weight: bold;
|
||||
a { font-weight: normal; }
|
||||
}
|
||||
|
||||
.annotatable-section-body {
|
||||
border-top: 1px solid $annotatable--border-color;
|
||||
margin-top: 0.5em;
|
||||
padding-top: 0.5em;
|
||||
|
||||
@include clearfix();
|
||||
}
|
||||
|
||||
ul.instructions-template {
|
||||
list-style: disc;
|
||||
margin-left: 4em;
|
||||
b { font-weight: bold; }
|
||||
i { font-style: italic; }
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
white-space: pre;
|
||||
font-family: Courier New, monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.annotatable-toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin: 2px 1em 2px 0;
|
||||
&.expanded::after { content: " \2191"; }
|
||||
&.collapsed::after { content: " \2193"; }
|
||||
}
|
||||
|
||||
.annotatable-span {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
display: inline;
|
||||
|
||||
$highlight_index: 0;
|
||||
|
||||
@each $highlight in (
|
||||
(yellow rgba(255, 255,10, 0.3) rgba(255, 255,10, 0.9)),
|
||||
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
|
||||
(orange rgba(255,165,0, 0.3) rgba(255,165,0, 0.9)),
|
||||
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
|
||||
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
|
||||
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
|
||||
|
||||
$highlight_index: $highlight_index + 1;
|
||||
$marker: nth($highlight, 1);
|
||||
$color: nth($highlight, 2);
|
||||
$selected_color: nth($highlight, 3);
|
||||
|
||||
@if $highlight_index == 1 {
|
||||
&.highlight {
|
||||
background-color: $color;
|
||||
&.selected { background-color: $selected_color; }
|
||||
}
|
||||
}
|
||||
|
||||
&.highlight-#{$marker} {
|
||||
background-color: $color;
|
||||
&.selected { background-color: $selected_color; }
|
||||
}
|
||||
}
|
||||
|
||||
&.hide {
|
||||
cursor: none;
|
||||
background-color: inherit;
|
||||
|
||||
.annotatable-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.annotatable-comment {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tooltip.qtip.ui-tooltip {
|
||||
font-size: $annotatable--body-font-size;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1em;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: $white;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
.ui-tooltip-titlebar {
|
||||
font-size: em(16);
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
border: none;
|
||||
|
||||
.ui-tooltip-title {
|
||||
padding: ($baseline/4) 0;
|
||||
border-bottom: 2px solid #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ui-tooltip-icon {
|
||||
right: 10px;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.ui-state-hover {
|
||||
color: inherit;
|
||||
border: 1px solid $gray-l3;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tooltip-content {
|
||||
color: inherit;
|
||||
font-size: em(14);
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
padding: 0 ($baseline/2) ($baseline/2) ($baseline/2);
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
p {
|
||||
color: inherit;
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tooltip.qtip.ui-tooltip-annotatable {
|
||||
max-width: 375px;
|
||||
|
||||
.ui-tooltip-content {
|
||||
padding: 0 ($baseline/2);
|
||||
|
||||
.annotatable-comment {
|
||||
display: block;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
max-height: 225px;
|
||||
overflow: auto;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.annotatable-reply {
|
||||
display: block;
|
||||
border-top: 2px solid #333;
|
||||
padding: ($baseline/4) 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
height: 0;
|
||||
width: 0;
|
||||
margin-left: -($baseline/4);
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
@@ -1,1742 +0,0 @@
|
||||
// capa - styling
|
||||
// ====================
|
||||
|
||||
// Table of Contents
|
||||
// * +Variables - Capa
|
||||
// * +Extends - Capa
|
||||
// * +Mixins - Status Icon - Capa
|
||||
// * +Resets - Deprecate Please
|
||||
// * +Problem - Base
|
||||
// * +Problem - Choice Group
|
||||
// * +Problem - Misc, Unclassified Mess
|
||||
// * +Problem - Text Input, Numerical Input
|
||||
// * +Problem - Option Input (Dropdown)
|
||||
// * +Problem - CodeMirror
|
||||
// * +Problem - Misc, Unclassified Mess Part 2
|
||||
// * +Problem - Rubric
|
||||
// * +Problem - Annotation
|
||||
// * +Problem - Choice Text Group
|
||||
// * +Problem - Image Input Overrides
|
||||
// * +Problem - Annotation Problem Overrides
|
||||
|
||||
// +Variables - Capa
|
||||
// ====================
|
||||
$annotation-yellow: rgba(255, 255, 10, 0.3);
|
||||
$color-copy-tip: rgb(100, 100, 100);
|
||||
|
||||
// FontAwesome Icon code
|
||||
// ====================
|
||||
$checkmark-icon: '\f00c'; // .fa-check
|
||||
$cross-icon: '\f00d'; // .fa-close
|
||||
$asterisk-icon: '\f069'; // .fa-asterisk
|
||||
|
||||
|
||||
@import '../../../../../static/sass/edx-pattern-library-shims/base/variables';
|
||||
|
||||
// +Extends - Capa
|
||||
// ====================
|
||||
// Duplicated from _mixins.scss due to xmodule compilation, inheritance issues
|
||||
%use-font-awesome {
|
||||
font-family: FontAwesome;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
speak: none;
|
||||
}
|
||||
|
||||
// +Mixins - Status Icon - Capa
|
||||
// ====================
|
||||
@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d") {
|
||||
.status-icon {
|
||||
&::after {
|
||||
@extend %use-font-awesome;
|
||||
|
||||
color: $color;
|
||||
font-size: 1.2em;
|
||||
content: $fontAwesomeIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Resets - Deprecate Please
|
||||
// ====================
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
|
||||
&.problem-header {
|
||||
display: inline-block;
|
||||
|
||||
section.staff {
|
||||
margin-top: ($baseline*1.5);
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
width: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.explanation-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
%feedback-hint {
|
||||
margin-top: ($baseline / 4);
|
||||
|
||||
.icon {
|
||||
@include margin-right($baseline / 4);
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-hint-incorrect {
|
||||
@extend %feedback-hint;
|
||||
|
||||
.icon {
|
||||
color: $incorrect;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-hint-partially-correct,
|
||||
.feedback-hint-correct {
|
||||
@extend %feedback-hint;
|
||||
|
||||
.icon {
|
||||
color: $correct;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-hint-text {
|
||||
color: $color-copy-tip;
|
||||
}
|
||||
|
||||
.problem-hint {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint-label {
|
||||
display: inline-block;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.feedback-hint-multi .hint-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
iframe[seamless] {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0 none transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
color: darken($error-color, 11%);
|
||||
}
|
||||
|
||||
div.problem-progress {
|
||||
display: inline-block;
|
||||
color: $gray-d1;
|
||||
font-size: em(14);
|
||||
}
|
||||
|
||||
// +Problem - Base
|
||||
// ====================
|
||||
div.problem {
|
||||
padding-top: $baseline;
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
|
||||
canvas,
|
||||
img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
input.math {
|
||||
direction: ltr; // Equations are always English
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
|
||||
+ p {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.question-description {
|
||||
color: $gray-d1;
|
||||
font-size: $small-font-size;
|
||||
}
|
||||
|
||||
form > label, .problem-group-label {
|
||||
display: block;
|
||||
margin-bottom: $baseline;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
-webkit-font-smoothing: initial;
|
||||
}
|
||||
|
||||
.problem-group-label + .question-description {
|
||||
margin-top: -$baseline;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// CAPA gap spacing between problem parts
|
||||
// can not use the & + & since .problem is nested deeply in .xmodule_display.xmodule_CapaModule
|
||||
.wrapper-problem-response + .wrapper-problem-response,
|
||||
.wrapper-problem-response + p {
|
||||
margin-top: ($baseline * 1.5);
|
||||
}
|
||||
|
||||
// Choice Group - silent class
|
||||
%choicegroup-base {
|
||||
@include clearfix();
|
||||
|
||||
min-width: 100px;
|
||||
width: auto !important;
|
||||
width: 100px;
|
||||
|
||||
label {
|
||||
box-sizing: border-box;
|
||||
|
||||
display: inline-block;
|
||||
clear: both;
|
||||
margin-bottom: ($baseline/2);
|
||||
border: 2px solid $gray-l4;
|
||||
border-radius: 3px;
|
||||
padding: ($baseline/2);
|
||||
width: 100%;
|
||||
|
||||
&::after {
|
||||
@include margin-left($baseline*0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.indicator-container {
|
||||
min-height: 1px;
|
||||
width: 25px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="radio"],
|
||||
input[type="checkbox"] {
|
||||
@include margin($baseline/4);
|
||||
@include margin-right($baseline/2);
|
||||
}
|
||||
|
||||
input {
|
||||
&:focus,
|
||||
&:hover {
|
||||
& + label {
|
||||
border: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
&:focus,
|
||||
&:hover {
|
||||
& + label.choicegroup_correct {
|
||||
@include status-icon($correct, $checkmark-icon);
|
||||
|
||||
border: 2px solid $correct;
|
||||
}
|
||||
|
||||
& + label.choicegroup_partially-correct {
|
||||
@include status-icon($partially-correct, $asterisk-icon);
|
||||
|
||||
border: 2px solid $partially-correct;
|
||||
}
|
||||
|
||||
& + label.choicegroup_incorrect {
|
||||
@include status-icon($incorrect, $cross-icon);
|
||||
|
||||
border: 2px solid $incorrect;
|
||||
}
|
||||
|
||||
& + label.choicegroup_submitted {
|
||||
border: 2px solid $submitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Choice Group
|
||||
// ====================
|
||||
div.problem {
|
||||
.choicegroup {
|
||||
@extend %choicegroup-base;
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label {
|
||||
@include padding($baseline/2);
|
||||
@include padding-left($baseline*2.3);
|
||||
|
||||
position: relative;
|
||||
font-size: $base-font-size;
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="radio"],
|
||||
input[type="checkbox"] {
|
||||
@include left(em(9));
|
||||
|
||||
position: absolute;
|
||||
top: 0.35em;
|
||||
width: $baseline*1.1;
|
||||
height: $baseline*1.1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
legend {
|
||||
margin-bottom: $baseline;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
legend + .question-description {
|
||||
margin-top: -$baseline;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Status Indicators
|
||||
// ====================
|
||||
// Summary status indicators shown after the input area
|
||||
div.problem {
|
||||
.indicator-container {
|
||||
@include margin-left($baseline*0.75);
|
||||
|
||||
.status {
|
||||
width: $baseline;
|
||||
|
||||
// CASE: correct answer
|
||||
&.correct {
|
||||
@include status-icon($correct, $checkmark-icon);
|
||||
}
|
||||
|
||||
// CASE: partially correct answer
|
||||
&.partially-correct {
|
||||
@include status-icon($partially-correct, $asterisk-icon);
|
||||
}
|
||||
|
||||
// CASE: incorrect answer
|
||||
&.incorrect {
|
||||
@include status-icon($incorrect, $cross-icon);
|
||||
}
|
||||
|
||||
&.submitted,
|
||||
&.unsubmitted,
|
||||
&.unanswered {
|
||||
.status-icon {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Misc, Unclassified Mess
|
||||
// ====================
|
||||
div.problem {
|
||||
ol.enumerate {
|
||||
li {
|
||||
&::before {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.solution-span {
|
||||
> span {
|
||||
margin: $baseline 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.targeted-feedback-span {
|
||||
> span {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// known classes using this div: .indicator-container, moved to section above
|
||||
div {
|
||||
|
||||
// TO-DO: Styling used by advanced capa problem types. Should be synced up to use .status class
|
||||
p {
|
||||
&.answer {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
span.clarification i {
|
||||
font-style: normal;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.correct, &.ui-icon-check {
|
||||
input {
|
||||
border-color: $correct;
|
||||
}
|
||||
}
|
||||
|
||||
&.partially-correct, &.ui-icon-check {
|
||||
input {
|
||||
border-color: $partially-correct;
|
||||
}
|
||||
}
|
||||
|
||||
&.processing {
|
||||
input {
|
||||
border-color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
&.ui-icon-close {
|
||||
input {
|
||||
border-color: $incorrect;
|
||||
}
|
||||
}
|
||||
|
||||
&.incorrect, &.incomplete {
|
||||
input {
|
||||
border-color: $incorrect;
|
||||
}
|
||||
}
|
||||
|
||||
&.submitted, &.ui-icon-check {
|
||||
input {
|
||||
border-color: $submitted;
|
||||
}
|
||||
}
|
||||
|
||||
p.answer {
|
||||
display: inline-block;
|
||||
margin-top: ($baseline / 2);
|
||||
margin-bottom: 0;
|
||||
|
||||
&::before {
|
||||
@extend %t-strong;
|
||||
|
||||
display: inline;
|
||||
content: "Answer: ";
|
||||
|
||||
}
|
||||
|
||||
&:empty {
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.equation {
|
||||
clear: both;
|
||||
margin-top: 3px;
|
||||
|
||||
.MathJax_Display {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
img.loading {
|
||||
@include padding-left($baseline/2);
|
||||
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
|
||||
&.MathJax_CHTML, &.MathJax, &.MathJax_SVG {
|
||||
padding: 6px;
|
||||
min-width: 30px;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hides equation previews in symbolic response problems when printing
|
||||
[id^='display'].equation {
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
//TO-DO: review and deprecate all these styles within span {}
|
||||
span {
|
||||
&.ui-icon-bullet {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: url('#{$static-path}/images/unanswered-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.processing, &.ui-icon-processing {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
|
||||
&.ui-icon-check {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/correct-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.incomplete, &.ui-icon-close {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center center no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.reload {
|
||||
@include float(right);
|
||||
|
||||
margin: ($baseline/2);
|
||||
}
|
||||
|
||||
|
||||
.grader-status {
|
||||
@include clearfix();
|
||||
|
||||
margin: $baseline/2 0;
|
||||
padding: $baseline/2;
|
||||
border-radius: 5px;
|
||||
background: $gray-l6;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
margin: -7px 7px 0 0;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
.grading {
|
||||
margin: 0px 7px 0 0;
|
||||
padding-left: 25px;
|
||||
background: url('#{$static-path}/images/info-icon.png') left center no-repeat;
|
||||
text-indent: 0px;
|
||||
}
|
||||
|
||||
p {
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
text-transform: capitalize;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&.file {
|
||||
margin-top: $baseline;
|
||||
padding: $baseline 0 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #eee;
|
||||
background: $white;
|
||||
|
||||
p.debug {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.evaluation {
|
||||
p {
|
||||
margin-bottom: ($baseline/5);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.feedback-on-feedback {
|
||||
margin-right: $baseline;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.evaluation-response {
|
||||
header {
|
||||
text-align: right;
|
||||
|
||||
a {
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-scoring {
|
||||
.scoring-list {
|
||||
margin-left: 3px;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
display:inline;
|
||||
margin-left: 50px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-message-container {
|
||||
margin: $baseline 0px ;
|
||||
}
|
||||
}
|
||||
|
||||
div.inline {
|
||||
> span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-bottom: lh();
|
||||
margin-left: .75em;
|
||||
margin-left: .75rem;
|
||||
list-style: disc outside none;
|
||||
}
|
||||
|
||||
ol {
|
||||
margin-bottom: lh();
|
||||
margin-left: .75em;
|
||||
margin-left: .75rem;
|
||||
list-style: decimal outside none;
|
||||
}
|
||||
|
||||
dl {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
dl dt {
|
||||
@extend %t-strong;
|
||||
|
||||
}
|
||||
|
||||
dl dd {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: .5em;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: lh(0.5);
|
||||
line-height: 1.4em;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: lh();
|
||||
}
|
||||
|
||||
table {
|
||||
margin: lh() 0;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
|
||||
td, th {
|
||||
&.cont-justified-left {
|
||||
text-align: left !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
|
||||
&.cont-justified-right {
|
||||
text-align: right !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
|
||||
&.cont-justified-center {
|
||||
text-align: center !important; // nasty, but needed to override the bad specificity of the xmodule css selectorsstyles
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
@extend %t-strong;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
caption, th, td {
|
||||
padding: .25em .75em .25em 0;
|
||||
padding: .25rem .75rem .25rem 0;
|
||||
}
|
||||
|
||||
caption {
|
||||
margin-bottom: .75em;
|
||||
margin-bottom: .75rem;
|
||||
padding: .75em 0;
|
||||
padding: .75rem 0;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
tr, td, th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
margin: 0 2px;
|
||||
padding: 0px 5px;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-l6;
|
||||
white-space: nowrap;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 6px $baseline/2;
|
||||
border: 1px solid $gray-l3;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-l6;
|
||||
font-size: .9em;
|
||||
line-height: 1.4;
|
||||
|
||||
> code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Text Input, Numerical Input
|
||||
// ====================
|
||||
.problem {
|
||||
.capa_inputtype.textline, .inputtype.formulaequationinput {
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
|
||||
border: 2px solid $gray-l4;
|
||||
border-radius: 3px;
|
||||
min-width: 160px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
margin-top: ($baseline/2);
|
||||
background: none;
|
||||
}
|
||||
|
||||
// CASE: incorrect answer
|
||||
> .incorrect {
|
||||
input {
|
||||
border: 2px solid $incorrect;
|
||||
}
|
||||
|
||||
.status {
|
||||
@include status-icon($incorrect, $cross-icon);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: partially correct answer
|
||||
> .partially-correct {
|
||||
input {
|
||||
border: 2px solid $partially-correct;
|
||||
}
|
||||
|
||||
.status {
|
||||
@include status-icon($partially-correct, $asterisk-icon);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: correct answer
|
||||
> .correct {
|
||||
input {
|
||||
border: 2px solid $correct;
|
||||
}
|
||||
|
||||
.status {
|
||||
@include status-icon($correct, $checkmark-icon);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: submitted, correctness withheld
|
||||
> .submitted {
|
||||
input {
|
||||
border: 2px solid $submitted;
|
||||
}
|
||||
|
||||
.status {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: unanswered and unsubmitted
|
||||
> .unanswered, > .unsubmitted {
|
||||
input {
|
||||
border: 2px solid $gray-l4;
|
||||
}
|
||||
|
||||
.status {
|
||||
.status-icon {
|
||||
&::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.inputtype.formulaequationinput {
|
||||
> div {
|
||||
input {
|
||||
direction: ltr;
|
||||
@include text-align(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trailing_text {
|
||||
@include margin-right($baseline/2);
|
||||
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// +Problem - Option Input (Dropdown)
|
||||
// ====================
|
||||
.problem {
|
||||
.inputtype.option-input {
|
||||
margin: (-$baseline/2) 0 $baseline;
|
||||
|
||||
.indicator-container {
|
||||
display: inline-block;
|
||||
|
||||
.status.correct::after,
|
||||
.status.partially-correct::after,
|
||||
.status.incorrect::after,
|
||||
.status.submitted::after,
|
||||
.status.unanswered::after {
|
||||
@include margin-left(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - CodeMirror
|
||||
// ====================
|
||||
div.problem {
|
||||
.CodeMirror {
|
||||
border: 1px solid black;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
resize: none;
|
||||
|
||||
.cm-tab {
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAYAAAAkuj5RAAAAAXNSR0IArs4c6QAAAGFJREFUSMft1LsRQFAQheHPowAKoACx3IgEKtaEHujDjORSgWTH/ZOdnZOcM/sgk/kFFWY0qV8foQwS4MKBCS3qR6ixBJvElOobYAtivseIE120FaowJPN75GMu8j/LfMwNjh4HUpwg4LUAAAAASUVORK5CYII=);
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
|
||||
&.CodeMirror-cursor {
|
||||
@extend %ui-depth1;
|
||||
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
border-right: none;
|
||||
border-left: 1px solid $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror-focused pre.CodeMirror-cursor {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.CodeMirror-code pre {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.capa-message {
|
||||
display: inline-block;
|
||||
color: $gray-d1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
// +Problem - Actions
|
||||
// ====================
|
||||
div.problem .action {
|
||||
min-height: $baseline;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
display: -ms-flexbox;
|
||||
-ms-flex-align: start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.problem-action-buttons-wrapper {
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
padding-bottom: $baseline;
|
||||
}
|
||||
|
||||
.problem-action-button-wrapper {
|
||||
@include border-right(1px solid $gray-300);
|
||||
@include padding(0, 13px); // to create a 26px gap, which is an a11y recommendation
|
||||
|
||||
display: inline-block;
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.problem-action-btn {
|
||||
border: none;
|
||||
max-width: 110px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: $primary !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: $baseline / 10;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-attempt-container {
|
||||
padding-bottom: $baseline;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (max-width: $bp-screen-lg) {
|
||||
max-width: 100%;
|
||||
padding-bottom: $baseline;
|
||||
}
|
||||
|
||||
.submit {
|
||||
@include margin-right($baseline / 2);
|
||||
@include float(left);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.submit-cta-description {
|
||||
color: $primary;
|
||||
font-size: small;
|
||||
padding-right: $baseline / 2;
|
||||
}
|
||||
.submit-cta-link-button {
|
||||
color: $primary;
|
||||
padding-right: $baseline / 4;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-feedback {
|
||||
@include margin-right($baseline / 2);
|
||||
|
||||
margin-top: $baseline / 2;
|
||||
display: inline-block;
|
||||
color: $gray-d1;
|
||||
font-size: $medium-font-size;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: middle;
|
||||
|
||||
&.cta-enabled {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// +Problem - Misc, Unclassified Mess Part 2
|
||||
// ====================
|
||||
div.problem {
|
||||
hr {
|
||||
float: none;
|
||||
clear: both;
|
||||
margin: 0 0 .75rem;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border: none;
|
||||
background: #ddd;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#{$all-text-inputs} {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// this supports a deprecated element and should be removed once the center tag is removed
|
||||
center {
|
||||
display: block;
|
||||
margin: lh() 0;
|
||||
padding: lh();
|
||||
border: 1px solid $gray-l3;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.detailed-solution > p {
|
||||
margin: 0;
|
||||
|
||||
&:first-child {
|
||||
@extend %t-strong;
|
||||
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.detailed-targeted-feedback,
|
||||
.detailed-targeted-feedback-partially-correct,
|
||||
.detailed-targeted-feedback-correct {
|
||||
> p {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
|
||||
&:first-child {
|
||||
@extend %t-strong;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.capa_alert {
|
||||
margin-top: $baseline;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid $warning-color;
|
||||
border-radius: 3px;
|
||||
background: $warning-color-accent;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.notification {
|
||||
@include float(left);
|
||||
|
||||
margin-top: $baseline / 2;
|
||||
padding: ($baseline / 2.5) ($baseline / 2) ($baseline / 5) ($baseline / 2);
|
||||
line-height: $base-line-height;
|
||||
|
||||
&.success {
|
||||
@include notification-by-type($success);
|
||||
}
|
||||
|
||||
&.error {
|
||||
@include notification-by-type($danger);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@include notification-by-type($warning);
|
||||
}
|
||||
|
||||
&.general {
|
||||
@include notification-by-type($general-color-accent);
|
||||
}
|
||||
|
||||
&.problem-hint {
|
||||
border: 1px solid $uxpl-gray-background;
|
||||
border-radius: 6px;
|
||||
|
||||
.icon {
|
||||
@include margin-right(3 * $baseline / 4);
|
||||
|
||||
color: $uxpl-gray-dark;
|
||||
}
|
||||
|
||||
li {
|
||||
color: $uxpl-gray-base;
|
||||
|
||||
strong {
|
||||
color: $uxpl-gray-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include float(left);
|
||||
|
||||
position: relative;
|
||||
top: $baseline / 5;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
display: inline-block;
|
||||
width: flex-grid(7,10);
|
||||
// Make notification tall enough that when the "Review" button is displayed,
|
||||
// the notification does not grow in height.
|
||||
margin-bottom: 8px;
|
||||
|
||||
ol {
|
||||
list-style: none outside none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li:not(:last-child) {
|
||||
margin-bottom: $baseline / 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notification-btn-wrapper {
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
@include float(right);
|
||||
|
||||
padding: ($baseline / 10) ($baseline / 4);
|
||||
min-width: ($baseline * 3);
|
||||
display: block;
|
||||
clear: both;
|
||||
|
||||
&:first-child {
|
||||
margin-bottom: $baseline / 4;
|
||||
}
|
||||
}
|
||||
|
||||
// override default button hover
|
||||
button {
|
||||
&:hover {
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.btn-default {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.btn-brand {
|
||||
&:hover {
|
||||
background-color: $btn-brand-focus-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.review-btn {
|
||||
color: $blue; // notification type has other colors
|
||||
&.sr {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
div.capa_reset {
|
||||
padding: 25px;
|
||||
border: 1px solid $error-color;
|
||||
background-color: lighten($error-color, 25%);
|
||||
border-radius: 3px;
|
||||
font-size: 1em;
|
||||
margin-top: $baseline/2;
|
||||
margin-bottom: $baseline/2;
|
||||
}
|
||||
|
||||
.capa_reset>h2 {
|
||||
color: #a00;
|
||||
}
|
||||
|
||||
.capa_reset li {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.hints {
|
||||
border: 1px solid $gray-l3;
|
||||
|
||||
h3 {
|
||||
@extend %t-strong;
|
||||
|
||||
padding: 9px;
|
||||
border-bottom: 1px solid #e3e3e3;
|
||||
background: #eee;
|
||||
text-shadow: 0 1px 0 $white;
|
||||
font-size: em(16);
|
||||
}
|
||||
|
||||
div {
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
a {
|
||||
display: block;
|
||||
padding: 9px;
|
||||
background: $gray-l6;
|
||||
box-shadow: inset 0 0 0 1px $white;
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
padding: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test {
|
||||
padding-top: 18px;
|
||||
|
||||
header {
|
||||
margin-bottom: 12px;
|
||||
|
||||
h3 {
|
||||
@extend %t-strong;
|
||||
|
||||
color: #aaa;
|
||||
font-style: normal;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> section {
|
||||
position: relative;
|
||||
margin-bottom: ($baseline/2);
|
||||
padding: 9px 9px $baseline;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
background: $white;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.shortform {
|
||||
margin-bottom: .6em;
|
||||
}
|
||||
|
||||
a.full {
|
||||
@include position(absolute, 0 0 1px 0);
|
||||
box-sizing: border-box;
|
||||
|
||||
display: block;
|
||||
padding: ($baseline/5);
|
||||
background: $gray-l4;
|
||||
text-align: right;
|
||||
font-size: 1em;
|
||||
|
||||
&.full-top {
|
||||
@include position(absolute, 1px 0 auto 0);
|
||||
}
|
||||
|
||||
&.full-bottom {
|
||||
@include position(absolute, auto 0 1px 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.external-grader-message {
|
||||
section {
|
||||
padding-top: ($baseline*1.5);
|
||||
padding-left: $baseline;
|
||||
background-color: #fafafa;
|
||||
color: #2c2c2c;
|
||||
font-size: 1em;
|
||||
font-family: monospace;
|
||||
|
||||
header {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.shortform {
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
.longform {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.result-errors {
|
||||
margin: ($baseline/4);
|
||||
padding: ($baseline/2) ($baseline/2) ($baseline/2) ($baseline*2);
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center left no-repeat;
|
||||
|
||||
li {
|
||||
color: #b00;
|
||||
}
|
||||
}
|
||||
|
||||
.result-output {
|
||||
margin: $baseline/4;
|
||||
padding: $baseline 0 ($baseline*0.75) 50px;
|
||||
border-top: 1px solid #ddd;
|
||||
border-left: $baseline solid #fafafa;
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 24pt;
|
||||
}
|
||||
}
|
||||
|
||||
.result-correct {
|
||||
background: url('#{$static-path}/images/correct-icon.png') left 20px no-repeat;
|
||||
|
||||
.result-actual-output {
|
||||
color: #090;
|
||||
}
|
||||
}
|
||||
|
||||
.result-partially-correct {
|
||||
background: url('#{$static-path}/images/partially-correct-icon.png') left 20px no-repeat;
|
||||
|
||||
.result-actual-output {
|
||||
color: #090;
|
||||
}
|
||||
}
|
||||
|
||||
.result-incorrect {
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') left 20px no-repeat;
|
||||
|
||||
.result-actual-output {
|
||||
color: #b00;
|
||||
}
|
||||
}
|
||||
|
||||
.markup-text{
|
||||
margin: ($baseline/4);
|
||||
padding: $baseline 0 15px 50px;
|
||||
border-top: 1px solid #ddd;
|
||||
border-left: 20px solid #fafafa;
|
||||
|
||||
bs {
|
||||
color: #b00;
|
||||
}
|
||||
|
||||
bg {
|
||||
color: #bda046;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// +Problem - Rubric
|
||||
// ====================
|
||||
div.problem {
|
||||
.rubric {
|
||||
tr {
|
||||
margin: ($baseline/2) 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
td {
|
||||
margin: ($baseline/2) 0;
|
||||
padding: $baseline 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
margin: ($baseline/4);
|
||||
padding: ($baseline/4);
|
||||
}
|
||||
|
||||
label,
|
||||
.view-only {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 3px;
|
||||
padding: ($baseline*0.75);
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
background-color: $gray-l3;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
.grade {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: ($baseline/2);
|
||||
}
|
||||
|
||||
.selected-grade {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type=radio]:checked + label {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[class='score-selection'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Annotation
|
||||
// ====================
|
||||
div.problem {
|
||||
.annotation-input {
|
||||
margin: 0 0 1em 0;
|
||||
border: 1px solid $gray-l3;
|
||||
border-radius: 1em;
|
||||
|
||||
.annotation-header {
|
||||
@extend %t-strong;
|
||||
|
||||
padding: .5em 1em;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
}
|
||||
|
||||
.annotation-body { padding: .5em 1em; }
|
||||
|
||||
a.annotation-return {
|
||||
float: right;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a.annotation-return::after { content: " \2191" }
|
||||
|
||||
.block, ul.tags {
|
||||
margin: .5em 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block-highlight {
|
||||
padding: .5em;
|
||||
border: 1px solid darken($annotation-yellow, 10%);
|
||||
background-color: $annotation-yellow;
|
||||
color: #333;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.block-comment { font-style: italic; }
|
||||
|
||||
ul.tags {
|
||||
display: block;
|
||||
margin-left: 1em;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 1em 0 0 0;
|
||||
|
||||
.tag {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
display: inline-block;
|
||||
margin-left: ($baseline*2);
|
||||
border: 1px solid rgb(102,102,102);
|
||||
|
||||
&.selected {
|
||||
background-color: $annotation-yellow;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-status {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
.tag-status, .tag { padding: .25em .5em; }
|
||||
}
|
||||
}
|
||||
|
||||
textarea.comment {
|
||||
$num-lines-to-show: 5;
|
||||
$line-height: 1.4em;
|
||||
$padding: .2em;
|
||||
|
||||
padding: $padding (2 * $padding);
|
||||
width: 100%;
|
||||
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
|
||||
line-height: $line-height;
|
||||
}
|
||||
|
||||
.answer-annotation { display: block; margin: 0; }
|
||||
|
||||
/* for debugging the input value field. enable the debug flag on the inputtype */
|
||||
.debug-value {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
border: 1px solid $black;
|
||||
background-color: #999;
|
||||
color: $white;
|
||||
|
||||
input[type="text"] { width: 100%; }
|
||||
|
||||
pre { background-color: $gray-l3; color: $black; }
|
||||
|
||||
&::before {
|
||||
@extend %t-strong;
|
||||
|
||||
display: block;
|
||||
content: "debug input value";
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Choice Text Group
|
||||
// ====================
|
||||
div.problem {
|
||||
.choicetextgroup {
|
||||
@extend %choicegroup-base;
|
||||
|
||||
input[type="text"]{
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
label.choicetextgroup_correct, section.choicetextgroup_correct {
|
||||
@extend label.choicegroup_correct;
|
||||
|
||||
input[type="text"] {
|
||||
border-color: $correct;
|
||||
}
|
||||
}
|
||||
|
||||
label.choicetextgroup_partially-correct, section.choicetextgroup_partially-correct {
|
||||
@extend label.choicegroup_partially-correct;
|
||||
|
||||
input[type="text"] {
|
||||
border-color: $partially-correct;
|
||||
}
|
||||
}
|
||||
|
||||
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect {
|
||||
@extend label.choicegroup_incorrect;
|
||||
}
|
||||
|
||||
label.choicetextgroup_submitted, section.choicetextgroup_submitted {
|
||||
@extend label.choicegroup_submitted;
|
||||
}
|
||||
|
||||
label.choicetextgroup_show_correct, section.choicetextgroup_show_correct {
|
||||
&::after {
|
||||
@include margin-left($baseline*0.75);
|
||||
|
||||
content: url('#{$static-path}/images/correct-icon.png');
|
||||
}
|
||||
}
|
||||
|
||||
span.mock_label {
|
||||
cursor : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Image Input Overrides
|
||||
// ====================
|
||||
|
||||
// NOTE: temporary override until image inputs use same base html structure as other common capa input types.
|
||||
div.problem .imageinput.capa_inputtype {
|
||||
.status {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
|
||||
&.unsubmitted,
|
||||
&.unanswered {
|
||||
.status-icon {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.correct {
|
||||
@include status-icon($correct, $checkmark-icon);
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
@include status-icon($incorrect, $cross-icon);
|
||||
}
|
||||
|
||||
.partially-correct {
|
||||
@include status-icon($partially-correct, $asterisk-icon);
|
||||
}
|
||||
|
||||
.submitted {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
// +Problem - Annotation Problem Overrides
|
||||
// ====================
|
||||
|
||||
// NOTE: temporary override until annotation problem inputs use same base html structure as other common capa input types.
|
||||
div.problem .annotation-input {
|
||||
.tag-status {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
|
||||
&.unsubmitted,
|
||||
&.unanswered {
|
||||
.status-icon {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.status-message {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.correct {
|
||||
@include status-icon($correct, $checkmark-icon);
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
@include status-icon($incorrect, $cross-icon);
|
||||
}
|
||||
|
||||
.partially-correct {
|
||||
@include status-icon($partially-correct, $asterisk-icon);
|
||||
}
|
||||
|
||||
.submitted {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
// Loading Spinner
|
||||
// ====================
|
||||
.problems-wrapper .loading-spinner {
|
||||
text-align: center;
|
||||
color: $gray-d1;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.CodeMirror {
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #3c3c3c;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// This is shared CSS between the xmodule problem editor and the xmodule HTML editor.
|
||||
.editor {
|
||||
position: relative;
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-bar {
|
||||
@include clearfix();
|
||||
@include linear-gradient(top, #d4dee8, #c9d5e2);
|
||||
|
||||
position: relative;
|
||||
padding: ($baseline/4);
|
||||
border-bottom-color: #a5aaaf;
|
||||
|
||||
button {
|
||||
display: inline-block;
|
||||
|
||||
@include float(left);
|
||||
|
||||
padding: 3px ($baseline/2) 5px;
|
||||
margin-left: 7px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
|
||||
.icon {
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: rgba(255, 255, 255, .5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
|
||||
@include right(10px);
|
||||
@include text-align(left);
|
||||
@include direction();
|
||||
|
||||
li {
|
||||
@include float(left);
|
||||
@include margin-right($baseline/4);
|
||||
|
||||
&:last-child {
|
||||
@include margin-right(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: block;
|
||||
height: 24px;
|
||||
padding: 7px 20px 3px;
|
||||
border: 1px solid #a5aaaf;
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
@include linear-gradient(top, $transparent 87%, rgba(0, 0, 0, .06));
|
||||
|
||||
background-color: #e5ecf3;
|
||||
font-size: 13px;
|
||||
color: #3c3c3c;
|
||||
box-shadow: 1px -1px 1px rgba(0, 0, 0, .05);
|
||||
|
||||
&.current {
|
||||
background: $white;
|
||||
border-bottom-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// HTML component display:
|
||||
* {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: $body-color;
|
||||
font: normal 2em/1.4em $font-family-sans-serif;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@include margin(0, 0, 1.416em, 0);
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #646464;
|
||||
font: normal 1.2em/1.2em $font-family-sans-serif;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@include margin(0, 0, ($baseline/2), 0);
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.416em;
|
||||
font-size: 1em;
|
||||
line-height: 1.6em !important;
|
||||
color: $body-color;
|
||||
}
|
||||
|
||||
em,
|
||||
i {
|
||||
font-style: italic;
|
||||
|
||||
span {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
font-weight: bold;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
p + p,
|
||||
ul + p,
|
||||
ol + p {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em ($baseline*2);
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
// Using the lower level Bi App Sass mixin to avoid @padding conflicts with bourbon.
|
||||
@include bi-app-compact(padding, 0, 0, 0, 1em);
|
||||
|
||||
margin: 1em 0;
|
||||
color: $body-color;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.708em;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal outside none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc outside none;
|
||||
}
|
||||
|
||||
a {
|
||||
&:link,
|
||||
&:visited,
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 1em 0;
|
||||
color: $body-color;
|
||||
font-family: monospace, serif;
|
||||
font-size: 1em;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $body-color;
|
||||
font-family: monospace, serif;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: $baseline 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 16px;
|
||||
|
||||
td,
|
||||
th {
|
||||
margin: $baseline 0;
|
||||
padding: ($baseline/2);
|
||||
border: 1px solid $gray-l3;
|
||||
font-size: 14px;
|
||||
|
||||
&.cont-justified-left {
|
||||
text-align: left !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
|
||||
&.cont-justified-right {
|
||||
text-align: right !important; // nasty, but needed to override the bad specificity of the xmodule css selectors
|
||||
}
|
||||
|
||||
&.cont-justified-center {
|
||||
text-align: center !important; // nasty, but needed to override the bad specificity of the xmodule css selectorsstyles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
background: #eee;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
// image modal
|
||||
// --------------------
|
||||
|
||||
// modal - image zoom, fill window
|
||||
.wrapper-modal-image {
|
||||
.modal-ui-icon {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
position: absolute;
|
||||
display: block;
|
||||
padding: ($baseline/4) 7px;
|
||||
border-radius: 5px;
|
||||
opacity: 0.9;
|
||||
background: $white;
|
||||
color: $black;
|
||||
border: 2px solid $black;
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
i {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.image-link {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
.action-fullscreen {
|
||||
display: none;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
&:hover .action-fullscreen {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.image-modal {
|
||||
@extend %ui-fake-link;
|
||||
@extend %ui-depth5;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
|
||||
.image-content {
|
||||
position: relative;
|
||||
top: 2.5%;
|
||||
display: block;
|
||||
height: 95%;
|
||||
width: 95%;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.action-close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.image-controls {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
.image-control {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.modal-ui-icon {
|
||||
position: relative;
|
||||
|
||||
&.action-zoom-in {
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
|
||||
&.action-zoom-out {
|
||||
margin-left: ($baseline/4);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.image-is-fit-to-screen {
|
||||
display: block;
|
||||
|
||||
// !important used here to override jQuery.
|
||||
.image-content .image-wrapper {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
|
||||
img {
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.image-is-zoomed {
|
||||
display: block;
|
||||
|
||||
.image-content .image-wrapper {
|
||||
img {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// HTML component editor:
|
||||
.html-editor {
|
||||
@include clearfix();
|
||||
|
||||
.CodeMirror {
|
||||
box-sizing: border-box;
|
||||
|
||||
height: 435px;
|
||||
}
|
||||
|
||||
.is-inactive {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
h2.problem-header {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
div.problem-progress {
|
||||
display: inline-block;
|
||||
padding-left: ($baseline/4);
|
||||
color: #666;
|
||||
font-weight: 100;
|
||||
font-size: em(16);
|
||||
}
|
||||
|
||||
|
||||
div.lti {
|
||||
// align center
|
||||
margin: 0 auto;
|
||||
|
||||
.wrapper-lti-link {
|
||||
@include font-size(14);
|
||||
|
||||
background-color: $sidebar-color;
|
||||
padding: $baseline;
|
||||
|
||||
.lti-link {
|
||||
margin-bottom: 0;
|
||||
text-align: right;
|
||||
|
||||
.link_lti_new_window {
|
||||
@extend .gray-button;
|
||||
|
||||
@include font-size(13);
|
||||
@include line-height(14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.ltiLaunchForm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
iframe.ltiLaunchFrame {
|
||||
width: 100%;
|
||||
height: 800px;
|
||||
display: block;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
h4.problem-feedback-label {
|
||||
font-weight: 100;
|
||||
font-size: em(16);
|
||||
font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif;
|
||||
}
|
||||
|
||||
div.problem-feedback {
|
||||
margin-top: ($baseline/4);
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
div.poll_question {
|
||||
@media print {
|
||||
display: block;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
|
||||
canvas, img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
color: #fe57a1;
|
||||
font-size: 1.9em;
|
||||
|
||||
&.problem-header {
|
||||
div.staff {
|
||||
margin-top: ($baseline*1.5);
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
width: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.poll_answer {
|
||||
margin-bottom: $baseline;
|
||||
|
||||
&.short {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.question {
|
||||
height: auto;
|
||||
clear: both;
|
||||
min-height: 30px;
|
||||
|
||||
&.short {
|
||||
clear: none;
|
||||
width: 30%;
|
||||
display: inline;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.button {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
-webkit-appearance: none;
|
||||
-webkit-background-clip: padding-box;
|
||||
-webkit-border-image: none;
|
||||
-webkit-box-align: center;
|
||||
-webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-rtl-ordering: logical;
|
||||
-webkit-user-select: text;
|
||||
-webkit-writing-mode: horizontal-tb;
|
||||
background-clip: padding-box;
|
||||
background-color: rgb(238, 238, 238);
|
||||
background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210));
|
||||
border-bottom-color: rgb(202, 202, 202);
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-width: 1px;
|
||||
border-left-color: rgb(202, 202, 202);
|
||||
border-left-style: solid;
|
||||
border-left-width: 1px;
|
||||
border-right-color: rgb(202, 202, 202);
|
||||
border-right-style: solid;
|
||||
border-right-width: 1px;
|
||||
border-top-color: rgb(202, 202, 202);
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
border-top-style: solid;
|
||||
border-top-width: 1px;
|
||||
box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
|
||||
box-sizing: border-box;
|
||||
color: rgb(51, 51, 51);
|
||||
|
||||
/* display: inline-block; */
|
||||
display: inline;
|
||||
float: left;
|
||||
font-family: 'Open Sans', Verdana, Geneva, sans-serif;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
font-weight: bold;
|
||||
letter-spacing: normal;
|
||||
line-height: 25.59375px;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-indent: 0px;
|
||||
text-shadow: rgb(248, 248, 248) 0px 1px 0px;
|
||||
text-transform: none;
|
||||
vertical-align: top;
|
||||
white-space: pre-line;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
word-spacing: 0px;
|
||||
writing-mode: lr-tb;
|
||||
}
|
||||
|
||||
.button.answered {
|
||||
-webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
|
||||
background-color: rgb(29, 157, 217);
|
||||
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
|
||||
border-bottom-color: rgb(13, 114, 162);
|
||||
border-left-color: rgb(13, 114, 162);
|
||||
border-right-color: rgb(13, 114, 162);
|
||||
border-top-color: rgb(13, 114, 162);
|
||||
box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
|
||||
color: rgb(255, 255, 255);
|
||||
text-shadow: rgb(7, 103, 148) 0px 1px 0px;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
display: inline;
|
||||
float: left;
|
||||
width: 80%;
|
||||
text-align: left;
|
||||
min-height: 30px;
|
||||
margin-left: $baseline;
|
||||
height: auto;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
&.short {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
min-height: 40px;
|
||||
margin-top: $baseline;
|
||||
clear: both;
|
||||
|
||||
&.short {
|
||||
margin-top: 0;
|
||||
clear: none;
|
||||
display: inline;
|
||||
float: right;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 75%;
|
||||
height: 20px;
|
||||
border: 1px solid black;
|
||||
display: inline;
|
||||
float: left;
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
&.short {
|
||||
width: 65%;
|
||||
height: 20px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.percent {
|
||||
background-color: gray;
|
||||
width: 0;
|
||||
height: 20px;
|
||||
|
||||
&.short { }
|
||||
}
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 80px;
|
||||
display: inline;
|
||||
float: right;
|
||||
height: 28px;
|
||||
text-align: right;
|
||||
|
||||
&.short {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poll_answer.answered {
|
||||
-webkit-box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset;
|
||||
background-color: rgb(29, 157, 217);
|
||||
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
|
||||
border-bottom-color: rgb(13, 114, 162);
|
||||
border-left-color: rgb(13, 114, 162);
|
||||
border-right-color: rgb(13, 114, 162);
|
||||
border-top-color: rgb(13, 114, 162);
|
||||
box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset;
|
||||
color: rgb(255, 255, 255);
|
||||
text-shadow: rgb(7, 103, 148) 0 1px 0;
|
||||
}
|
||||
|
||||
.button.reset-button {
|
||||
clear: both;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
.editor-bar {
|
||||
.editor-tabs {
|
||||
.advanced-toggle {
|
||||
height: auto;
|
||||
margin-top: -4px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
color: $link-color;
|
||||
|
||||
&.current {
|
||||
border: 1px solid $lightGrey !important;
|
||||
border-radius: 3px !important;
|
||||
background: $lightGrey !important;
|
||||
color: $darkGrey !important;
|
||||
pointer-events: none;
|
||||
cursor: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.simple-editor-cheatsheet {
|
||||
position: absolute;
|
||||
top: 41px;
|
||||
@include left(70%);
|
||||
width: 0;
|
||||
border-left: 1px solid $gray-l2;
|
||||
|
||||
background-color: $lightGrey;
|
||||
overflow: hidden;
|
||||
|
||||
&.shown {
|
||||
width: 30%;
|
||||
height: 92%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.cheatsheet-wrapper {
|
||||
padding: 5%;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 7px;
|
||||
margin-left: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.row {
|
||||
@include clearfix();
|
||||
|
||||
padding-bottom: 5px !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.col {
|
||||
display: block;
|
||||
|
||||
&.sample {
|
||||
margin-right: 30px;
|
||||
|
||||
.icon {
|
||||
height: ($baseline * 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.problem-editor {
|
||||
// adding padding to simple editor only - adjacent selector is needed since there are no toggles for CodeMirror
|
||||
.markdown-box + .CodeMirror {
|
||||
padding: 10px;
|
||||
width: 69%;
|
||||
}
|
||||
}
|
||||
|
||||
.problem-editor-icon {
|
||||
display: inline-block;
|
||||
width: 26px;
|
||||
height: 21px;
|
||||
vertical-align: middle;
|
||||
color: $body-color;
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
$seq-nav-border-color: $border-color !default;
|
||||
$seq-nav-hover-color: rgb(245, 245, 245) !default;
|
||||
$seq-nav-link-color: $link-color !default;
|
||||
$seq-nav-icon-color: rgb(10, 10, 10) !default;
|
||||
$seq-nav-icon-color-muted: rgb(90, 90, 90) !default;
|
||||
$seq-nav-tooltip-color: rgb(51, 51, 51) !default;
|
||||
$seq-nav-height: 50px;
|
||||
|
||||
// repeated extends - needed since LMS styling was referenced
|
||||
.block-link {
|
||||
@include border-left(1px solid $seq-nav-border-color);
|
||||
|
||||
display: block;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.topbar {
|
||||
@include clearfix();
|
||||
|
||||
border-bottom: 1px solid $seq-nav-border-color;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
&.block-link {
|
||||
@include border-left(1px solid $seq-nav-border-color);
|
||||
|
||||
display: block;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%ui-clear-button {
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
background-position: center 14px;
|
||||
background-repeat: no-repeat;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-clip: border-box;
|
||||
box-shadow: none;
|
||||
box-sizing: content-box;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
|
||||
// ====================
|
||||
|
||||
.sequence-nav {
|
||||
@extend .topbar;
|
||||
|
||||
margin: 0 auto $baseline;
|
||||
position: relative;
|
||||
border-bottom: none;
|
||||
z-index: 0;
|
||||
height: $seq-nav-height;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sequence-list-wrapper {
|
||||
@extend %ui-depth2;
|
||||
|
||||
position: relative;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
|
||||
@include media-breakpoint-down(xs) {
|
||||
white-space: nowrap;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
min-width: 40px;
|
||||
flex-grow: 1;
|
||||
border-color: $seq-nav-border-color;
|
||||
border-width: 1px;
|
||||
border-top-style: solid;
|
||||
|
||||
&:not(:last-child) {
|
||||
@include border-right-style(solid);
|
||||
}
|
||||
|
||||
button {
|
||||
@extend %ui-fake-link;
|
||||
@extend %ui-clear-button;
|
||||
|
||||
width: 100%;
|
||||
height: ($seq-nav-height - 1);
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
text-align: center;
|
||||
border-color: $seq-nav-border-color;
|
||||
border-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
box-sizing: border-box;
|
||||
overflow: visible; // for tooltip - IE11 uses 'hidden' by default if width/height is specified
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
line-height: 100%; // This matches the height of the <a> its within (the parent) to get vertical centering.
|
||||
font-size: 110%;
|
||||
color: $seq-nav-icon-color-muted;
|
||||
}
|
||||
|
||||
.fa-bookmark {
|
||||
color: $seq-nav-link-color;
|
||||
}
|
||||
|
||||
//video
|
||||
&.seq_video {
|
||||
.icon::before {
|
||||
content: "\f008"; // .fa-film
|
||||
}
|
||||
}
|
||||
|
||||
//other
|
||||
&.seq_other {
|
||||
.icon::before {
|
||||
content: "\f02d"; // .fa-book
|
||||
}
|
||||
}
|
||||
|
||||
//vertical
|
||||
&.seq_vertical {
|
||||
.icon::before {
|
||||
content: "\f00b"; // .fa-tasks
|
||||
}
|
||||
}
|
||||
|
||||
//problems
|
||||
&.seq_problem {
|
||||
.icon::before {
|
||||
content: "\f044"; // .fa-pencil-square-o
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-tooltip {
|
||||
@include text-align(left);
|
||||
|
||||
@extend %ui-depth2;
|
||||
|
||||
margin-top: 12px;
|
||||
background: $seq-nav-tooltip-color;
|
||||
color: $white;
|
||||
font-family: $font-family-sans-serif;
|
||||
line-height: lh();
|
||||
right: 0; // Should not be RTLed, tooltips do not move in RTL
|
||||
padding: 6px;
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
text-shadow: 0 -1px 0 $black;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
|
||||
&:empty {
|
||||
background: none;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include transform(rotate(45deg));
|
||||
@include right(18px);
|
||||
|
||||
background: $seq-nav-tooltip-color;
|
||||
content: " ";
|
||||
display: block;
|
||||
height: 10px;
|
||||
right: 18px; // Not RTLed, positions tooltips relative to seq nav item
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
width: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.touch-based-device & ol li button:hover p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-nav-button {
|
||||
@extend %ui-depth3;
|
||||
|
||||
display: block;
|
||||
top: 0;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
height: 100%;
|
||||
text-shadow: none; // overrides default button text-shadow
|
||||
background: none; // overrides default button gradient
|
||||
background-color: theme-color("inverse");
|
||||
border-color: $seq-nav-border-color;
|
||||
box-shadow: none;
|
||||
font-size: inherit;
|
||||
font-weight: normal;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
span:not(:last-child) {
|
||||
@include padding-right($baseline / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-nav-button-label {
|
||||
display: none;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
&.button-previous {
|
||||
order: -999;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include left(0);
|
||||
@include border-top-left-radius(3px);
|
||||
@include border-top-right-radius(0);
|
||||
@include border-bottom-right-radius(0);
|
||||
@include border-bottom-left-radius(3px);
|
||||
}
|
||||
}
|
||||
|
||||
&.button-next {
|
||||
order: 999;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
@include right(0);
|
||||
@include border-top-left-radius(0);
|
||||
@include border-top-right-radius(3px);
|
||||
@include border-bottom-right-radius(3px);
|
||||
@include border-bottom-left-radius(0);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.seq_contents {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sequence-bottom {
|
||||
position: relative;
|
||||
height: 45px;
|
||||
margin: lh(2) auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.sequence-nav-button {
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:last-of-type {
|
||||
@include border-left(none);
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#seq_content {
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
// hover and active states
|
||||
.sequence-nav-button,
|
||||
.sequence-nav button {
|
||||
&.focused,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
padding-top: 2px;
|
||||
background-color: theme-color("primary");
|
||||
|
||||
.icon {
|
||||
color: theme-color("inverse");
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
border-bottom: 3px solid $seq-nav-link-color;
|
||||
background-color: theme-color("inverse");
|
||||
|
||||
.icon {
|
||||
color: $seq-nav-icon-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.editor {
|
||||
@include clearfix();
|
||||
|
||||
.CodeMirror {
|
||||
box-sizing: border-box;
|
||||
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 379px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-top: 1px solid #8891a1;
|
||||
background: $white;
|
||||
color: #3c3c3c;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// styles duped from _unit.scss - Edit Header (Component Name, Mode-Editor, Mode-Settings)
|
||||
|
||||
|
||||
.tabs-wrapper {
|
||||
padding-top: 0;
|
||||
position: relative;
|
||||
|
||||
.wrapper-comp-settings {
|
||||
// set visibility to metadata editor
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-single-tab-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.editor-with-tabs {
|
||||
@include clearfix();
|
||||
|
||||
position: relative;
|
||||
|
||||
|
||||
.edit-header {
|
||||
box-sizing: border-box;
|
||||
|
||||
padding: 18px $baseline;
|
||||
top: 0 !important; // ugly override for second level tab override
|
||||
right: 0;
|
||||
background-color: $blue;
|
||||
border-bottom: 1px solid $blue-d2;
|
||||
color: $white;
|
||||
|
||||
//Component Name
|
||||
.component-name {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
|
||||
em {
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/4);
|
||||
font-weight: 400;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
//Nav-Edit Modes
|
||||
.editor-tabs {
|
||||
list-style: none;
|
||||
right: 0;
|
||||
top: ($baseline/4);
|
||||
position: absolute;
|
||||
padding: 12px ($baseline*0.75);
|
||||
|
||||
.inner_tab_wrap {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
|
||||
a.tab {
|
||||
@include font-size(14);
|
||||
@include linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
|
||||
|
||||
border: 1px solid $blue-d1;
|
||||
border-radius: 3px;
|
||||
padding: ($baseline/4) ($baseline);
|
||||
background-color: $blue;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
|
||||
&.current {
|
||||
@include linear-gradient($blue, $blue);
|
||||
|
||||
color: $blue-d1;
|
||||
box-shadow: inset 0 1px 2px 1px $shadow-l1;
|
||||
background-color: $blue-d4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
box-shadow: inset 0 1px 2px 1px $shadow;
|
||||
background-image: linear-gradient(#009fe6, #009fe6) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-inactive {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comp-subtitles-entry {
|
||||
text-align: center;
|
||||
|
||||
.file-upload {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comp-subtitles-import-list {
|
||||
> li {
|
||||
display: block;
|
||||
margin: $baseline/2 0;
|
||||
}
|
||||
|
||||
.blue-button {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
width: 70%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.component-tab {
|
||||
background: $white;
|
||||
position: relative;
|
||||
border-top: 1px solid #8891a1;
|
||||
|
||||
&#advanced {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.blue-button {
|
||||
@include blue-button;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
$a11y--gray: rgb(127, 127, 127);
|
||||
$a11y--blue: rgb(0, 159, 230);
|
||||
$a11y--gray-d1: shade($gray, 20%);
|
||||
$a11y--gray-l2: tint($gray, 40%);
|
||||
$a11y--gray-l3: tint($gray, 60%);
|
||||
$a11y--blue-s1: saturate($blue, 15%);
|
||||
|
||||
%use-font-awesome {
|
||||
font-family: FontAwesome;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: inline-block;
|
||||
speak: none;
|
||||
}
|
||||
|
||||
.a11y-menu-container {
|
||||
position: relative;
|
||||
|
||||
&.open {
|
||||
.a11y-menu-list {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.a11y-menu-list {
|
||||
@extend %ui-depth3;
|
||||
|
||||
top: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
background-color: $white;
|
||||
border: 1px solid #eee;
|
||||
|
||||
li {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
color: $white;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $a11y--gray-l2;
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $a11y--gray-d1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
a {
|
||||
color: $a11y--blue;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
box-shadow: none;
|
||||
border-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Video track button specific styles
|
||||
|
||||
.video-tracks {
|
||||
.a11y-menu-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
border-left: 1px solid #eee;
|
||||
|
||||
&.open {
|
||||
> a {
|
||||
background-color: $action-primary-active-bg;
|
||||
color: $very-light-text;
|
||||
|
||||
&::after {
|
||||
color: $very-light-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@include font-size(12);
|
||||
|
||||
display: block;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background-color: $very-light-text;
|
||||
padding: ($baseline*0.75) ($baseline*1.25) ($baseline*0.75) ($baseline*0.75);
|
||||
color: $a11y--gray-l2;
|
||||
min-width: 1.5em;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::after {
|
||||
@extend %use-font-awesome;
|
||||
|
||||
content: "\f0d7";
|
||||
position: absolute;
|
||||
right: ($baseline*0.5);
|
||||
top: 33%;
|
||||
color: $lighter-base-font-color;
|
||||
}
|
||||
}
|
||||
|
||||
.a11y-menu-list {
|
||||
right: 0;
|
||||
|
||||
li {
|
||||
font-size: em(14);
|
||||
|
||||
a {
|
||||
border: 0;
|
||||
display: block;
|
||||
padding: lh(0.5);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.contextmenu,
|
||||
.submenu {
|
||||
@extend %ui-depth5;
|
||||
|
||||
border: 1px solid #333;
|
||||
background: $white;
|
||||
color: #333;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: none;
|
||||
outline: none;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-opened {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-item,
|
||||
.submenu-item {
|
||||
border-top: 1px solid $gray-l3;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
outline: none;
|
||||
|
||||
& > span {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: #333;
|
||||
color: $white;
|
||||
|
||||
& > span {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-item {
|
||||
position: relative;
|
||||
padding: ($baseline/4) $baseline ($baseline/4) ($baseline/2);
|
||||
|
||||
&::after {
|
||||
content: '\25B6';
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
line-height: 25px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.is-opened {
|
||||
background: #333;
|
||||
color: $white;
|
||||
|
||||
& > span {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
& > .submenu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
pointer-events: none;
|
||||
color: $gray-l3;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@extend %ui-depth4;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -1,1001 +0,0 @@
|
||||
// This is for A Font Garde
|
||||
// It loads the icon font only when it's available.
|
||||
// ---
|
||||
// It is scoped to the video player for now, but we
|
||||
// will eventually want to move this to the main font
|
||||
// sheet, globally, so it applies to all use cases.
|
||||
|
||||
// --------
|
||||
// Defaults: what displays if the icon font doesn't load.
|
||||
// --------
|
||||
|
||||
// the html target is necessary for xblocks and xmodules, but works across the board
|
||||
|
||||
$secondary-dark: rgb(142, 62, 99); // UXPL secondary dark
|
||||
$secondary-base: rgb(203, 89, 141); // UXPL secondary base
|
||||
$secondary-light: rgb(219, 139, 175); // UXPL secondary light
|
||||
$cool-dark: rgb(79, 89, 93); // UXPL cool dark
|
||||
|
||||
& {
|
||||
margin-bottom: ($baseline*1.5);
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video {
|
||||
@include clearfix();
|
||||
|
||||
background: rgb(245, 245, 245); // UXPL grayscale x-back
|
||||
display: block;
|
||||
margin: 0 -12px;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.is-initialized {
|
||||
.video-wrapper {
|
||||
.spinner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: video pre-roll state
|
||||
&.is-pre-roll {
|
||||
.slider {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
content: "";
|
||||
width: 100%;
|
||||
padding-top: 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tc-wrapper {
|
||||
@include clearfix();
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.focus_grabber {
|
||||
position: relative;
|
||||
display: inline;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.downloads-heading {
|
||||
margin: 1em 0 0;
|
||||
}
|
||||
|
||||
.wrapper-downloads {
|
||||
@include media-breakpoint-up(md) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.hd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrapper-download-video,
|
||||
.wrapper-download-transcripts,
|
||||
.wrapper-handouts,
|
||||
.branding {
|
||||
flex: 1;
|
||||
margin-top: $baseline;
|
||||
|
||||
@include padding-right($baseline);
|
||||
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wrapper-download-video {
|
||||
.video-sources {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-download-transcripts {
|
||||
.list-download-transcripts {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
.transcript-option {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.branding {
|
||||
@include padding-right(0);
|
||||
|
||||
.host-tag {
|
||||
position: absolute;
|
||||
left: -9999em;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
color: $body-color;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: ($baseline*2);
|
||||
padding: ($baseline/4) 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
@include float(left);
|
||||
@include margin-right(flex-gutter(9));
|
||||
|
||||
width: flex-grid(6, 9);
|
||||
background-color: black;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.btn-play {
|
||||
color: theme-color("primary");
|
||||
}
|
||||
|
||||
.btn-play::after {
|
||||
background: theme-color("inverse");
|
||||
}
|
||||
}
|
||||
|
||||
.video-player-pre,
|
||||
.video-player-post {
|
||||
height: 50px;
|
||||
background-color: rgb(17, 16, 16) // UXPL grayscale black;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@include transform(translate(-50%, -50%));
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 30px;
|
||||
border-radius: 25%;
|
||||
|
||||
&::after {
|
||||
@include animation(rotateCW 3s infinite linear);
|
||||
|
||||
content: '';
|
||||
display: block;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 7px solid white;
|
||||
border-top-color: transparent;
|
||||
border-radius: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
@include transform(translate(-50%, -50%));
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 46%;
|
||||
left: 50%;
|
||||
font-size: 4em;
|
||||
cursor: pointer;
|
||||
opacity: 0.1;
|
||||
|
||||
&::after {
|
||||
background: $white;
|
||||
position: absolute;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
content: '';
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.closed-captions {
|
||||
@include left(5%);
|
||||
|
||||
position: absolute;
|
||||
width: 90%;
|
||||
box-sizing: border-box;
|
||||
top: 70%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.closed-captions.is-visible {
|
||||
max-height: ($baseline * 3);
|
||||
border-radius: ($baseline / 5);
|
||||
padding: 8px ($baseline / 2) 8px ($baseline * 1.5);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: $yellow;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
top: 50%;
|
||||
|
||||
@include left($baseline);
|
||||
|
||||
margin-top: -0.6em;
|
||||
font-family: 'FontAwesome';
|
||||
content: "\f142";
|
||||
color: $white;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.is-dragging {
|
||||
background: rgba(0, 0, 0, 1);
|
||||
cursor: move;
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
overflow: hidden;
|
||||
min-height: 158px;
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.video-error,
|
||||
.video-hls-error {
|
||||
padding: ($baseline / 5);
|
||||
background: black;
|
||||
color: white !important; // the pattern library headings shim is more scoped
|
||||
}
|
||||
|
||||
object,
|
||||
iframe,
|
||||
video {
|
||||
@include left(0);
|
||||
|
||||
display: block;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
text-align: center;
|
||||
color: white;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
@include clearfix();
|
||||
|
||||
position: relative;
|
||||
border: 0;
|
||||
background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
|
||||
color: rgb(240, 243, 245); // UXPL grayscale-cool xx-light
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
ul,
|
||||
div {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
%video-control {
|
||||
@extend %t-strong;
|
||||
@extend %t-title7;
|
||||
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: ($baseline / 2) ($baseline / 1.5);
|
||||
background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
color: rgb(207, 216, 220); // UXPL grayscale-cool light
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: darken(rgb(40, 44, 46), 7%); // UXPL secondary
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.is-active,
|
||||
&.active {
|
||||
color: rgb(14, 166, 236); // UXPL primary accent
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
@extend %video-control;
|
||||
|
||||
.icon {
|
||||
width: 1em;
|
||||
|
||||
&.icon-hd {
|
||||
// except when it's text, like with HD
|
||||
// otherwise it's shifted to the right because "HD" is wider than 1em
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider {
|
||||
@include clearfix();
|
||||
@include transform-origin(bottom left);
|
||||
@include transition(height 0.7s ease-in-out 0s);
|
||||
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
height: ($baseline / 4);
|
||||
margin-left: 0;
|
||||
border: 1px solid $cool-dark;
|
||||
border-radius: 0;
|
||||
background: $cool-dark;
|
||||
|
||||
.ui-widget-header {
|
||||
background: $secondary-dark;
|
||||
border: 1px solid $secondary-dark;
|
||||
box-shadow: none;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.ui-corner-all.slider-range {
|
||||
opacity: 0.3;
|
||||
background-color: #1e91d3;
|
||||
}
|
||||
|
||||
.ui-slider-handle {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
@include transform-origin(bottom left);
|
||||
@include transition(all 0.7s ease-in-out 0s);
|
||||
|
||||
box-sizing: border-box;
|
||||
top: -1px;
|
||||
height: ($baseline / 4);
|
||||
width: ($baseline / 4);
|
||||
margin-left: -($baseline / 8); // center-center causes the control to be beyond the end of the sider
|
||||
border: 1px solid $secondary-base;
|
||||
border-radius: ($baseline / 5);
|
||||
padding: 0;
|
||||
background: $secondary-base;
|
||||
box-shadow: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: $secondary-light;
|
||||
border-color: $secondary-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vcr {
|
||||
@include float(left);
|
||||
|
||||
list-style: none;
|
||||
|
||||
@include border-right(1px solid rgb(40, 44, 46)); // UXPL grayscale-cool x-dark
|
||||
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
@include margin-right(lh(0.5));
|
||||
|
||||
font-size: em(14);
|
||||
}
|
||||
|
||||
.video_control {
|
||||
&:focus {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.skip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.vidtime {
|
||||
@extend %t-strong;
|
||||
@extend %t-title7;
|
||||
|
||||
@include padding-left(lh(0.75));
|
||||
|
||||
display: inline-block;
|
||||
color: rgb(207, 216, 220); // UXPL grayscale-cool light
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
@include padding-left(lh(0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-controls {
|
||||
@include float(right);
|
||||
@include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark
|
||||
|
||||
.volume,
|
||||
.add-fullscreen,
|
||||
.grouped-controls,
|
||||
.auto-advance,
|
||||
.quality-control {
|
||||
@include border-left(1px dotted rgb(79, 89, 93)); // UXPL grayscale-cool x-dark
|
||||
}
|
||||
|
||||
.speed-button,
|
||||
.volume > .control,
|
||||
.add-fullscreen,
|
||||
.auto-advance,
|
||||
.quality-control,
|
||||
.toggle-transcript {
|
||||
&:focus {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
position: relative;
|
||||
|
||||
.menu {
|
||||
@include transition(none);
|
||||
|
||||
@extend %ui-depth1;
|
||||
|
||||
position: absolute;
|
||||
display: none;
|
||||
bottom: ($baseline * 2);
|
||||
|
||||
@include right(0); // right-align menus since this whole collection is on the right
|
||||
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
background-color: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
|
||||
|
||||
.speed-option,
|
||||
.control-lang {
|
||||
@include text-align(left);
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: lh(0.5);
|
||||
background: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
|
||||
box-shadow: none;
|
||||
color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
|
||||
overflow: hidden;
|
||||
text-shadow: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: rgb(79, 89, 93); // UXPL grayscale-cool dark
|
||||
color: rgb(252, 252, 252); // UXPL grayscale white
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.speed-option,
|
||||
.control-lang {
|
||||
@include border-left($baseline/10 solid rgb(14, 166, 236));
|
||||
|
||||
font-weight: $font-bold;
|
||||
color: rgb(14, 166, 236); // UXPL primary accent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-opened {
|
||||
.menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speeds,
|
||||
.lang,
|
||||
.grouped-controls {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.speeds {
|
||||
&.is-opened {
|
||||
.control {
|
||||
.icon {
|
||||
@include ltr {
|
||||
@include transform(rotate(-90deg));
|
||||
}
|
||||
|
||||
@include rtl {
|
||||
@include transform(rotate(90deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-button {
|
||||
.label {
|
||||
@include padding(0 ($baseline/3) 0 0);
|
||||
|
||||
font-family: $font-family-sans-serif;
|
||||
color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
position: absolute;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
@include padding(0, lh(0.5), 0, 0);
|
||||
|
||||
color: rgb(231, 236, 238); // UXPL grayscale-cool x-light
|
||||
font-weight: bold;
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
padding: 0 lh(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lang {
|
||||
.language-menu {
|
||||
width: $baseline;
|
||||
padding: ($baseline / 2) 0;
|
||||
}
|
||||
|
||||
.control {
|
||||
.icon {
|
||||
@include rtl {
|
||||
@include transform(rotate(-180deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-opened {
|
||||
.control {
|
||||
.icon {
|
||||
@include ltr {
|
||||
@include transform(rotate(90deg));
|
||||
}
|
||||
|
||||
@include rtl {
|
||||
@include transform(rotate(90deg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.volume {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
&.is-opened {
|
||||
.volume-slider-container {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:first-child) > a {
|
||||
@include border-left(none);
|
||||
}
|
||||
|
||||
.volume-slider-container {
|
||||
@include transition(none);
|
||||
|
||||
@extend %ui-depth1;
|
||||
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: ($baseline * 2);
|
||||
|
||||
@include right(0);
|
||||
|
||||
width: 41px;
|
||||
height: 120px;
|
||||
background-color: rgb(40, 44, 46); // UXPL grayscale-cool x-dark
|
||||
|
||||
.volume-slider {
|
||||
height: 100px;
|
||||
width: ($baseline / 4);
|
||||
margin: 14px auto;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid $cool-dark;
|
||||
background: $cool-dark;
|
||||
|
||||
.ui-slider-handle {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
@include transition(height $tmg-s2 ease-in-out 0s, width $tmg-s2 ease-in-out 0s);
|
||||
@include left(-5px);
|
||||
|
||||
box-sizing: border-box;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
border: 1px solid $secondary-base;
|
||||
border-radius: ($baseline / 5);
|
||||
padding: 0;
|
||||
background: $secondary-base;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: $secondary-light;
|
||||
border-color: $secondary-light;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-slider-range {
|
||||
background: $secondary-dark;
|
||||
border: 1px solid $secondary-dark;
|
||||
left: -1px;
|
||||
bottom: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quality-control {
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
|
||||
&.active {
|
||||
color: rgb(14, 166, 236); // UXPL primary accent
|
||||
}
|
||||
|
||||
&.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-transcript {
|
||||
&.is-active {
|
||||
color: rgb(14, 166, 236); // UXPL primary accent
|
||||
}
|
||||
}
|
||||
|
||||
.lang {
|
||||
& > .hide-subtitles {
|
||||
@include transition(none);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.video-controls {
|
||||
.slider {
|
||||
height: ($baseline / 1.5);
|
||||
|
||||
.ui-slider-handle {
|
||||
height: ($baseline / 1.5);
|
||||
width: ($baseline / 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.video-fullscreen {
|
||||
.closed-captions {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
&.closed .closed-captions {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitles {
|
||||
@include float(left);
|
||||
|
||||
overflow: auto;
|
||||
max-height: 460px;
|
||||
width: flex-grid(3, 9);
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
visibility: visible;
|
||||
|
||||
a {
|
||||
color: #0074b5;
|
||||
}
|
||||
|
||||
.subtitles-menu {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0 3px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
margin-bottom: 8px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #0074b5; // AA compliant
|
||||
line-height: lh();
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.current {
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
outline: #000 dotted thin;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.spacing:last-of-type {
|
||||
position: relative;
|
||||
|
||||
.transcript-end {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.closed {
|
||||
.video-wrapper {
|
||||
width: flex-grid(9, 9);
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.video-wrapper .video-controls.html5 {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.video-wrapper .video-player-pre,
|
||||
.video-wrapper .video-player-post {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.video-wrapper .video-player {
|
||||
h3 {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitles {
|
||||
@extend .is-hidden;
|
||||
}
|
||||
|
||||
.subtitles.html5 {
|
||||
@extend %ui-depth0;
|
||||
|
||||
background-color: rgba(243, 243, 243, 0.8);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
width: 275px;
|
||||
padding: 0 $baseline;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.video-fullscreen {
|
||||
@extend %ui-depth4;
|
||||
|
||||
background: rgba(#000, 0.95);
|
||||
border: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
border-radius: 0;
|
||||
|
||||
&.closed {
|
||||
.tc-wrapper {
|
||||
.video-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-wrapper .video-player-pre,
|
||||
.video-wrapper .video-player-post {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.video-wrapper .video-player {
|
||||
h3 {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-wrapper {
|
||||
@include clearfix();
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: static;
|
||||
|
||||
.video-wrapper {
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
|
||||
@include margin-right(0);
|
||||
|
||||
vertical-align: middle;
|
||||
|
||||
object,
|
||||
iframe,
|
||||
video {
|
||||
position: absolute;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
@extend %ui-depth4;
|
||||
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitles {
|
||||
height: 100%;
|
||||
width: 25%;
|
||||
padding: lh();
|
||||
|
||||
box-sizing: border-box;
|
||||
@include transition(none);
|
||||
|
||||
background: $black;
|
||||
visibility: visible;
|
||||
|
||||
li {
|
||||
color: #aaa;
|
||||
|
||||
&.current {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-touch {
|
||||
.tc-wrapper {
|
||||
.video-wrapper {
|
||||
object,
|
||||
iframe,
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-pre-roll {
|
||||
@extend %ui-depth3;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100%;
|
||||
background-color: $black;
|
||||
|
||||
&.is-html5 {
|
||||
background-size: 15%;
|
||||
}
|
||||
|
||||
.btn-play.btn-pre-roll {
|
||||
padding: $baseline;
|
||||
border: none;
|
||||
border-radius: $baseline;
|
||||
background: $black-t2;
|
||||
box-shadow: none;
|
||||
|
||||
&::after {
|
||||
// the button class, ties to functionality, also uses an icon font
|
||||
// we're overriding it here so we can use our image instead
|
||||
display: none;
|
||||
}
|
||||
|
||||
img {
|
||||
height: ($baseline * 4);
|
||||
width: ($baseline * 4);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.input-cloud {
|
||||
margin: ($baseline/4);
|
||||
}
|
||||
|
||||
.result_cloud_section {
|
||||
display: none;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
.result_cloud_section.active {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-top: 1em;
|
||||
|
||||
h3 {
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.your_words{
|
||||
font-size: 0.85em;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Public data structures for this app.
|
||||
|
||||
See OEP-49 for details
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CertificatesDisplayBehaviors(str, Enum):
|
||||
"""
|
||||
Options for the certificates_display_behavior field of a course
|
||||
|
||||
end: Certificates are available at the end of the course
|
||||
end_with_date: Certificates are available after the certificate_available_date (post course end)
|
||||
early_no_info: Certificates are available immediately after earning them.
|
||||
|
||||
Only in affect for instructor based courses.
|
||||
"""
|
||||
END = "end"
|
||||
END_WITH_DATE = "end_with_date"
|
||||
EARLY_NO_INFO = "early_no_info"
|
||||
|
||||
@classmethod
|
||||
def includes_value(cls, value):
|
||||
return value in set(item.value for item in cls)
|
||||
@@ -1,60 +0,0 @@
|
||||
1. Move Due Dates to Relational Database
|
||||
----------------------------------------
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
We want an authoritative and readily-accessible place to track due dates for sections/units in a course. We also want to override due dates per learner, and record the reason for the override. The current implementation stores due dates in the XBlock (in mongodb). If you want to know all of the due dates in a course, you have to walk the entire course structure and pull out each date, which is an inefficient operation.
|
||||
|
||||
There is currently a way to override due dates per learner, using `IDDE <https://github.com/mitodl/ccx-idde-overrides-slides/blob/master/markdown/slides.md#individual-due-date-extensions-idde>`_, but this relies on a generic field-override system which doesn't know anything about the underlying data, and enabling this feature results in a database query for every xblock access, for every course on the platform. IDDE also does not allow for an audit trail on due date extensions, nor does it allow instructors to record a reason for the extension.
|
||||
|
||||
|
||||
Decisions
|
||||
---------
|
||||
|
||||
1. Create a new pluggable Django app responsible for dates and date overrides
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This approach anticipates other date-related functions in the future, such as APIs for returning a calendar view of upcoming dates.
|
||||
|
||||
- The app will contain at least these models:
|
||||
+ ``DatePolicy``: contains course_key, absolute date and a time delta
|
||||
+ ``ContentDate``: contains DatePolicy id and content id + field
|
||||
+ ``UserDate``: contains user id, DatePolicy id, and absolute date + relative date. It will also record an audit trail for the override.
|
||||
- The app will hook in to the ``LmsModuleSystem`` with a custom ``FieldData`` implementation.
|
||||
+ If the date exists in the relational database (whether in UserDate or ContentDate), it'll use that date; otherwise, it'll use the one stored in the XBlock.
|
||||
- The app will expose a REST API for retrieving and updating dates in the database.
|
||||
|
||||
2. Create an instructor interface for setting student due dates
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The existing IDDE instructor interface may be used, or a modern micro-frontend can be included from the new pluggable Django app.
|
||||
|
||||
3. All dates for a course/learner will be retrieved using one query, cached for the current request.
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
It's important that the underlying implementation can efficiently retrieve all due dates for the course, otherwise it's not an improvement over IDDE.
|
||||
|
||||
For instance, to retrieve all dates for a given user and course
|
||||
|
||||
::
|
||||
|
||||
select cd.location, datep.date, datep.delta, ud.date, ud.delta
|
||||
from content_date cd
|
||||
left join date_policy datep on datep.id = cd.policy_id
|
||||
left join user_date ud on ud.policy_id = datep.id
|
||||
order by cd.location, ud.date, ud.delta
|
||||
where datep.course_key = "course_key"
|
||||
and ud.user_id = 1234
|
||||
|
||||
|
||||
4. Dates will be copied to the relational database when the course is published.
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The app will listen for the ``SignalHandler.course_published`` signal in Studio and will create/modify ``DatePolicies`` and ``ContentDates`` as required.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
Remove MongoDB as a Dependency
|
||||
------------------------------
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
The edx-platform repo uses MongoDB for storing content (the ModuleStore and ContentStore interfaces). MongoDB was chosen early on in the Open edX project because:
|
||||
|
||||
* **Course content is very freeform**, and XBlock is a pluggable interface. So while the data stored for videos, problems, crystallography simulations, and circuit schematic editors might have some overlap, individual XBlock are permitted to extend that storage any way they like. At the time, this struck us as a more document-database-centric than SQL.
|
||||
* **MongoDB’s GridFS** seemed liked a good solution for the static assets that need to be managed on a per-course basis (e.g. PDFs).
|
||||
* **MySQL at the time did not support JSON fields.** This would have made certain search operations more cumbersome. (MySQL itself was chosen because PostgreSQL was not available on RDS at the time).
|
||||
|
||||
Since that time, several things have changed:
|
||||
|
||||
#. We've been trying to reduce the complexity of the stack needed to run Open edX.
|
||||
#. We've switched over from the original ``DraftModuleStore`` to ``DraftVersioningModuleStore``. The latter drops most query patterns and mostly uses MongoDB as a simple key value store.
|
||||
#. We've adopted django-storages for pluggable file/blob storage.
|
||||
|
||||
Decisions
|
||||
=========
|
||||
|
||||
All usage of MongoDB in edx-platform will be removed or replaced in the following ways:
|
||||
|
||||
* Old Mongo (``DraftModuleStore``) will be removed as a storage backend altogether. This is already covered under DEPR-58, and affects only old style courses with course keys in the "Org/Course/Run" format (as opposed to the "course-v1:Org+Course+Run" format).
|
||||
* ContentStore storage of course static assets will be moved to django-storages, allowing for pluggable backends (e.g. files, S3, GridFS).
|
||||
* Split Modulestore (``DraftVersioningModuleStore``) will be moved to use the Django ORM for the active version lookup, and django-storages to replace key-value storage of what are structure and definition documents today.
|
||||
|
||||
Note that this does not include the use of MongoDB for the forums experience. While I still believe that removing MongoDB for that service is desirable, it's beyond the scope of this particular ADR.
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Descriptors for XBlocks/Xmodules, that provide editing of atrributes"""
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from pkg_resources import resource_string
|
||||
from xblock.fields import Scope, String
|
||||
|
||||
from xmodule.mako_module import MakoModuleDescriptor, MakoTemplateBlockBase
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditingFields:
|
||||
"""Contains specific template information (the raw data body)"""
|
||||
data = String(scope=Scope.content, default='')
|
||||
|
||||
|
||||
class EditingMixin(EditingFields, MakoTemplateBlockBase):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It does not
|
||||
perform any validation on its definition---just passes it along to the browser.
|
||||
|
||||
This class is intended to be used as a mixin.
|
||||
"""
|
||||
resources_dir = None
|
||||
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
"""
|
||||
`data` should not be editable in the Studio settings editor.
|
||||
"""
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
non_editable_fields.append(self.fields['data'])
|
||||
return non_editable_fields
|
||||
|
||||
# cdodge: a little refactoring here, since we're basically doing the same thing
|
||||
# here as with our parent class, let's call into it to get the basic fields
|
||||
# set and then add our additional fields. Trying to keep it DRY.
|
||||
def get_context(self):
|
||||
_context = MakoTemplateBlockBase.get_context(self)
|
||||
# Add our specific template information (the raw data body)
|
||||
_context.update({'data': self.data})
|
||||
return _context
|
||||
|
||||
|
||||
class EditingDescriptor(EditingMixin, MakoModuleDescriptor): # lint-amnesty, pylint: disable=abstract-method
|
||||
pass
|
||||
|
||||
|
||||
class TabsEditingMixin(EditingFields, MakoTemplateBlockBase):
|
||||
"""
|
||||
Common code between TabsEditingDescriptor and XBlocks converted from XModules.
|
||||
"""
|
||||
|
||||
mako_template = "widgets/tabs-aggregator.html"
|
||||
css = {'scss': [resource_string(__name__, 'css/tabs/tabs.scss')]}
|
||||
js = {'js': [resource_string(
|
||||
__name__, 'js/src/tabs/tabs-aggregator.js')]}
|
||||
js_module_name = "TabsEditingDescriptor"
|
||||
tabs = []
|
||||
|
||||
def get_context(self):
|
||||
_context = MakoTemplateBlockBase.get_context(self)
|
||||
_context.update({
|
||||
'tabs': self.tabs,
|
||||
'html_id': self.location.html_id(), # element_id
|
||||
'data': self.data,
|
||||
})
|
||||
return _context
|
||||
|
||||
@classmethod
|
||||
def get_css(cls): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
# load every tab's css
|
||||
for tab in cls.tabs:
|
||||
tab_styles = tab.get('css', {})
|
||||
for css_type, css_content in tab_styles.items():
|
||||
if css_type in cls.css:
|
||||
cls.css[css_type].extend(css_content)
|
||||
else:
|
||||
cls.css[css_type] = css_content
|
||||
return cls.css
|
||||
|
||||
|
||||
class TabsEditingDescriptor(TabsEditingMixin, MakoModuleDescriptor): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It does not
|
||||
perform any validation on its definition---just passes it along to the browser.
|
||||
|
||||
This class is intended to be used as a mixin.
|
||||
|
||||
Engine (module_edit.js) wants for metadata editor
|
||||
template to be always loaded, so don't forget to include
|
||||
settings tab in your module descriptor.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class XMLEditingDescriptor(EditingDescriptor): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
Module that provides a raw editing view of its data as XML. It does not perform
|
||||
any validation of its definition
|
||||
"""
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/codemirror/codemirror.scss')]}
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/raw/edit/xml.js')]}
|
||||
js_module_name = "XMLEditingDescriptor"
|
||||
|
||||
|
||||
class MetadataOnlyEditingDescriptor(EditingDescriptor): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
Module which only provides an editing interface for the metadata, it does
|
||||
not expose a UI for editing the module data
|
||||
"""
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/raw/edit/metadata-only.js')]}
|
||||
js_module_name = "MetadataOnlyEditingDescriptor"
|
||||
|
||||
mako_template = "widgets/metadata-only-edit.html"
|
||||
|
||||
|
||||
class JSONEditingDescriptor(EditingDescriptor): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
Module that provides a raw editing view of its data as XML. It does not perform
|
||||
any validation of its definition
|
||||
"""
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/codemirror/codemirror.scss')]}
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/raw/edit/json.js')]}
|
||||
js_module_name = "JSONEditingDescriptor"
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
Utilities related to edXNotes.
|
||||
"""
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def edxnotes(cls):
|
||||
"""
|
||||
Conditional decorator that loads edxnotes only when they exist.
|
||||
"""
|
||||
if "lms.djangoapps.edxnotes" in sys.modules:
|
||||
from lms.djangoapps.edxnotes.decorators import edxnotes as notes
|
||||
return notes(cls)
|
||||
else:
|
||||
return cls
|
||||
@@ -1,246 +0,0 @@
|
||||
"""
|
||||
Modules that get shown to the users when an error has occurred while
|
||||
loading or rendering other modules
|
||||
"""
|
||||
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.field_data import DictFieldData
|
||||
from xblock.fields import Scope, ScopeIds, String
|
||||
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.modulestore import EdxJSONEncoder
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: This is not the most beautiful design in the world, but there's no good
|
||||
# way to tell if the module is being used in a staff context or not. Errors that get discovered
|
||||
# at course load time are turned into ErrorBlock objects, and automatically hidden from students.
|
||||
# Unfortunately, we can also have errors when loading modules mid-request, and then we need to decide
|
||||
# what to show, and the logic for that belongs in the LMS (e.g. in get_module), so the error handler
|
||||
# decides whether to create a staff or not-staff module.
|
||||
|
||||
|
||||
class ErrorFields:
|
||||
"""
|
||||
XBlock fields used by the ErrorBlocks
|
||||
"""
|
||||
contents = String(scope=Scope.content)
|
||||
error_msg = String(scope=Scope.content)
|
||||
display_name = String(scope=Scope.settings)
|
||||
|
||||
|
||||
@XBlock.needs('mako')
|
||||
class ErrorBlock(
|
||||
ErrorFields,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
): # pylint: disable=abstract-method
|
||||
"""
|
||||
Module that gets shown to staff when there has been an error while
|
||||
loading or rendering other modules
|
||||
"""
|
||||
|
||||
resources_dir = None
|
||||
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Return a fragment that contains the html for the student view.
|
||||
"""
|
||||
fragment = Fragment(self.runtime.service(self, 'mako').render_template('module-error.html', {
|
||||
'staff_access': True,
|
||||
'data': self.contents,
|
||||
'error': self.error_msg,
|
||||
}))
|
||||
return fragment
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Show empty edit view since this is not editable.
|
||||
"""
|
||||
return Fragment('')
|
||||
|
||||
@classmethod
|
||||
def _construct(cls, system, contents, error_msg, location, for_parent=None):
|
||||
"""
|
||||
Build a new ErrorBlock using ``system``.
|
||||
|
||||
Arguments:
|
||||
system (:class:`DescriptorSystem`): The :class:`DescriptorSystem` used
|
||||
to construct the XBlock that had an error.
|
||||
contents (unicode): An encoding of the content of the xblock that had an error.
|
||||
error_msg (unicode): A message describing the error.
|
||||
location (:class:`UsageKey`): The usage key of the XBlock that had an error.
|
||||
for_parent (:class:`XBlock`): Optional. The parent of this error block.
|
||||
"""
|
||||
|
||||
if error_msg is None:
|
||||
# this string is not marked for translation because we don't have
|
||||
# access to the user context, and this will only be seen by staff
|
||||
error_msg = 'Error not available'
|
||||
|
||||
if location.block_type == 'error':
|
||||
location = location.replace(
|
||||
# Pick a unique url_name -- the sha1 hash of the contents.
|
||||
# NOTE: We could try to pull out the url_name of the errored descriptor,
|
||||
# but url_names aren't guaranteed to be unique between descriptor types,
|
||||
# and ErrorBlock can wrap any type. When the wrapped module is fixed,
|
||||
# it will be written out with the original url_name.
|
||||
name=hashlib.sha1(contents.encode('utf8')).hexdigest()
|
||||
)
|
||||
|
||||
# real metadata stays in the content, but add a display name
|
||||
field_data = DictFieldData({
|
||||
'error_msg': str(error_msg),
|
||||
'contents': contents,
|
||||
'location': location,
|
||||
'category': 'error'
|
||||
})
|
||||
return system.construct_xblock_from_class(
|
||||
cls,
|
||||
# The error module doesn't use scoped data, and thus doesn't need
|
||||
# real scope keys
|
||||
ScopeIds(None, 'error', location, location),
|
||||
field_data,
|
||||
for_parent=for_parent,
|
||||
)
|
||||
|
||||
def get_context(self):
|
||||
return {
|
||||
'module': self,
|
||||
'data': self.contents,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_data, system, location, error_msg='Error not available'): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
try:
|
||||
json_string = json.dumps(json_data, skipkeys=False, indent=4, cls=EdxJSONEncoder)
|
||||
except: # pylint: disable=bare-except
|
||||
json_string = repr(json_data)
|
||||
|
||||
return cls._construct(
|
||||
system,
|
||||
json_string,
|
||||
error_msg,
|
||||
location=location
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_descriptor(cls, descriptor, error_msg=None):
|
||||
return cls._construct(
|
||||
descriptor.runtime,
|
||||
str(descriptor),
|
||||
error_msg,
|
||||
location=descriptor.location,
|
||||
for_parent=descriptor.get_parent() if descriptor.has_cached_parent else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, id_generator, # pylint: disable=arguments-differ
|
||||
error_msg=None):
|
||||
'''Create an instance of this descriptor from the supplied data.
|
||||
|
||||
Does not require that xml_data be parseable--just stores it and exports
|
||||
as-is if not.
|
||||
|
||||
Takes an extra, optional, parameter--the error that caused an
|
||||
issue. (should be a string, or convert usefully into one).
|
||||
'''
|
||||
|
||||
try:
|
||||
# If this is already an error tag, don't want to re-wrap it.
|
||||
xml_obj = etree.fromstring(xml_data)
|
||||
if xml_obj.tag == 'error':
|
||||
xml_data = xml_obj.text
|
||||
error_node = xml_obj.find('error_msg')
|
||||
if error_node is not None:
|
||||
error_msg = error_node.text
|
||||
else:
|
||||
error_msg = None
|
||||
|
||||
except etree.XMLSyntaxError:
|
||||
# Save the error to display later--overrides other problems
|
||||
error_msg = exc_info_to_str(sys.exc_info())
|
||||
|
||||
return cls._construct(system, xml_data, error_msg, location=id_generator.create_definition('error'))
|
||||
|
||||
@classmethod
|
||||
def parse_xml(cls, node, runtime, keys, id_generator): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
|
||||
"""
|
||||
# It'd be great to not reserialize and deserialize the xml
|
||||
xml = etree.tostring(node).decode('utf-8')
|
||||
block = cls.from_xml(xml, runtime, id_generator)
|
||||
return block
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
'''
|
||||
If the definition data is invalid xml, export it wrapped in an "error"
|
||||
tag. If it is valid, export without the wrapper.
|
||||
|
||||
NOTE: There may still be problems with the valid xml--it could be
|
||||
missing required attributes, could have the wrong tags, refer to missing
|
||||
files, etc. That would just get re-wrapped on import.
|
||||
'''
|
||||
try:
|
||||
xml = etree.fromstring(self.contents)
|
||||
return etree.tostring(xml, encoding='unicode')
|
||||
except etree.XMLSyntaxError:
|
||||
# still not valid.
|
||||
root = etree.Element('error')
|
||||
root.text = self.contents
|
||||
err_node = etree.SubElement(root, 'error_msg')
|
||||
err_node.text = self.error_msg
|
||||
return etree.tostring(root, encoding='unicode')
|
||||
|
||||
def add_xml_to_node(self, node):
|
||||
"""
|
||||
Export this :class:`XModuleDescriptor` as XML, by setting attributes on the provided
|
||||
`node`.
|
||||
"""
|
||||
xml_string = self.export_to_xml(self.runtime.export_fs)
|
||||
exported_node = etree.fromstring(xml_string)
|
||||
node.tag = exported_node.tag
|
||||
node.text = exported_node.text
|
||||
node.tail = exported_node.tail
|
||||
|
||||
for key, value in exported_node.items():
|
||||
if key == 'url_name' and value == 'course' and key in node.attrib:
|
||||
# if url_name is set in ExportManager then do not override it here.
|
||||
continue
|
||||
node.set(key, value)
|
||||
|
||||
node.extend(list(exported_node))
|
||||
|
||||
|
||||
class NonStaffErrorBlock(ErrorBlock): # pylint: disable=abstract-method
|
||||
"""
|
||||
Block that gets shown to students when there has been an error while
|
||||
loading or rendering other blocks.
|
||||
"""
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Return a fragment that contains the html for the student view.
|
||||
"""
|
||||
fragment = Fragment(self.runtime.service(self, 'mako').render_template('module-error.html', {
|
||||
'staff_access': False,
|
||||
'data': '',
|
||||
'error': '',
|
||||
}))
|
||||
return fragment
|
||||
@@ -1,85 +0,0 @@
|
||||
"""
|
||||
error_tracker: A hook for tracking errors in loading XBlocks.
|
||||
|
||||
Used for example to get a list of all non-fatal problems on course
|
||||
load, and display them to the user.
|
||||
|
||||
Patterns for using the error handler:
|
||||
try:
|
||||
x = access_some_resource()
|
||||
check_some_format(x)
|
||||
except SomeProblem as err:
|
||||
msg = 'Grommet {0} is broken: {1}'.format(x, str(err))
|
||||
log.warning(msg) # don't rely on tracker to log
|
||||
# NOTE: we generally don't want content errors logged as errors
|
||||
error_tracker = self.runtime.service(self, 'error_tracker')
|
||||
if error_tracker:
|
||||
error_tracker(msg)
|
||||
# work around
|
||||
return 'Oops, couldn't load grommet'
|
||||
|
||||
OR, if not in an exception context:
|
||||
|
||||
if not check_something(thingy):
|
||||
msg = "thingy {0} is broken".format(thingy)
|
||||
log.critical(msg)
|
||||
error_tracker = self.runtime.service(self, 'error_tracker')
|
||||
if error_tracker:
|
||||
error_tracker(msg)
|
||||
|
||||
NOTE: To avoid duplication, do not call the tracker on errors
|
||||
that you're about to re-raise---let the caller track them.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from collections import namedtuple
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ErrorLog = namedtuple('ErrorLog', 'tracker errors')
|
||||
|
||||
|
||||
def exc_info_to_str(exc_info):
|
||||
"""Given some exception info, convert it into a string using
|
||||
the traceback.format_exception() function.
|
||||
"""
|
||||
return ''.join(traceback.format_exception(*exc_info))
|
||||
|
||||
|
||||
def in_exception_handler():
|
||||
'''Is there an active exception?'''
|
||||
return sys.exc_info() != (None, None, None)
|
||||
|
||||
|
||||
def make_error_tracker():
|
||||
'''Return an ErrorLog (named tuple), with fields (tracker, errors), where
|
||||
the logger appends a tuple (message, exception_str) to the errors on every
|
||||
call. exception_str is in the format returned by traceback.format_exception.
|
||||
|
||||
error_list is a simple list. If the caller modifies it, info
|
||||
will be lost.
|
||||
'''
|
||||
errors = []
|
||||
|
||||
def error_tracker(msg):
|
||||
'''Log errors'''
|
||||
exc_str = ''
|
||||
if in_exception_handler():
|
||||
exc_str = exc_info_to_str(sys.exc_info())
|
||||
|
||||
# don't display irrelevant gunicorn sync error
|
||||
if (('python2.7/site-packages/gunicorn/workers/sync.py' in exc_str) and
|
||||
('[Errno 11] Resource temporarily unavailable' in exc_str)):
|
||||
exc_str = ''
|
||||
|
||||
errors.append((msg, exc_str))
|
||||
|
||||
return ErrorLog(error_tracker, errors)
|
||||
|
||||
|
||||
def null_error_tracker(msg): # lint-amnesty, pylint: disable=unused-argument
|
||||
'''A dummy error tracker that just ignores the messages'''
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
@@ -1,54 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
class InvalidDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessingError(Exception):
|
||||
'''
|
||||
An error occurred while processing a request to the XModule.
|
||||
For example: if an exception occurs while checking a capa problem.
|
||||
'''
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class InvalidVersionError(Exception):
|
||||
"""
|
||||
Tried to save an item with a location that a store cannot support (e.g., draft version
|
||||
for a non-leaf node)
|
||||
"""
|
||||
def __init__(self, location):
|
||||
super().__init__()
|
||||
self.location = location
|
||||
|
||||
|
||||
class SerializationError(Exception):
|
||||
"""
|
||||
Thrown when a module cannot be exported to XML
|
||||
"""
|
||||
def __init__(self, location, msg):
|
||||
super().__init__(msg)
|
||||
self.location = location
|
||||
|
||||
|
||||
class UndefinedContext(Exception):
|
||||
"""
|
||||
Tried to access an xmodule field which needs a different context (runtime) to have a value.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class HeartbeatFailure(Exception):
|
||||
"""
|
||||
Raised when heartbeat fails.
|
||||
"""
|
||||
|
||||
def __init__(self, msg, service):
|
||||
"""
|
||||
In addition to a msg, provide the name of the service.
|
||||
"""
|
||||
self.service = service
|
||||
super().__init__(msg)
|
||||
@@ -1,302 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import dateutil.parser
|
||||
from pytz import UTC
|
||||
from xblock.fields import JSONField
|
||||
from xblock.scorable import Score
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Date(JSONField):
|
||||
'''
|
||||
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
|
||||
'''
|
||||
# See note below about not defaulting these
|
||||
CURRENT_YEAR = datetime.datetime.now(UTC).year
|
||||
PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC)
|
||||
PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC)
|
||||
|
||||
MUTABLE = False
|
||||
|
||||
def _parse_date_wo_default_month_day(self, field):
|
||||
"""
|
||||
Parse the field as an iso string but prevent dateutils from defaulting the day or month while
|
||||
allowing it to default the other fields.
|
||||
"""
|
||||
# It's not trivial to replace dateutil b/c parsing timezones as Z, +03:30, -400 is hard in python
|
||||
# however, we don't want dateutil to default the month or day (but some tests at least expect
|
||||
# us to default year); so, we'll see if dateutil uses the defaults for these the hard way
|
||||
result = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED1)
|
||||
result_other = dateutil.parser.parse(field, default=self.PREVENT_DEFAULT_DAY_MON_SEED2)
|
||||
if result != result_other:
|
||||
log.warning(f"Field {self.name} is missing month or day")
|
||||
return None
|
||||
if result.tzinfo is None:
|
||||
result = result.replace(tzinfo=UTC)
|
||||
return result
|
||||
|
||||
def from_json(self, field): # lint-amnesty, pylint: disable=arguments-differ
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, complain
|
||||
if it doesn't parse.
|
||||
Return None if not present or invalid.
|
||||
"""
|
||||
if field is None:
|
||||
return field
|
||||
elif field == "":
|
||||
return None
|
||||
elif isinstance(field, str):
|
||||
return self._parse_date_wo_default_month_day(field)
|
||||
elif isinstance(field, int) or isinstance(field, float): # lint-amnesty, pylint: disable=consider-merging-isinstance
|
||||
return datetime.datetime.fromtimestamp(field / 1000, UTC)
|
||||
elif isinstance(field, time.struct_time):
|
||||
return datetime.datetime.fromtimestamp(time.mktime(field), UTC)
|
||||
elif isinstance(field, datetime.datetime):
|
||||
return field
|
||||
else:
|
||||
msg = "Field {} has bad value '{}'".format(
|
||||
self.name, field)
|
||||
raise TypeError(msg)
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
Convert a time struct to a string
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, time.struct_time):
|
||||
# struct_times are always utc
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', value)
|
||||
elif isinstance(value, datetime.datetime):
|
||||
if value.tzinfo is None or value.utcoffset().total_seconds() == 0:
|
||||
if value.year < 1900:
|
||||
# strftime doesn't work for pre-1900 dates, so use
|
||||
# isoformat instead
|
||||
return value.isoformat()
|
||||
# isoformat adds +00:00 rather than Z
|
||||
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
return value.isoformat()
|
||||
else:
|
||||
raise TypeError(f"Cannot convert {value!r} to json")
|
||||
|
||||
enforce_type = from_json
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$') # lint-amnesty, pylint: disable=line-too-long
|
||||
|
||||
|
||||
class Timedelta(JSONField): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
|
||||
MUTABLE = False
|
||||
|
||||
def from_json(self, time_str): # lint-amnesty, pylint: disable=arguments-differ
|
||||
"""
|
||||
time_str: A string with the following components:
|
||||
<D> day[s] (optional)
|
||||
<H> hour[s] (optional)
|
||||
<M> minute[s] (optional)
|
||||
<S> second[s] (optional)
|
||||
|
||||
Returns a datetime.timedelta parsed from the string
|
||||
"""
|
||||
if time_str is None:
|
||||
return None
|
||||
|
||||
if isinstance(time_str, datetime.timedelta):
|
||||
return time_str
|
||||
|
||||
parts = TIMEDELTA_REGEX.match(time_str)
|
||||
if not parts:
|
||||
return
|
||||
parts = parts.groupdict()
|
||||
time_params = {}
|
||||
for (name, param) in parts.items():
|
||||
if param:
|
||||
time_params[name] = int(param)
|
||||
return datetime.timedelta(**time_params)
|
||||
|
||||
def to_json(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
values = []
|
||||
for attr in ('days', 'hours', 'minutes', 'seconds'):
|
||||
cur_value = getattr(value, attr, 0)
|
||||
if cur_value > 0:
|
||||
values.append("%d %s" % (cur_value, attr))
|
||||
return ' '.join(values)
|
||||
|
||||
def enforce_type(self, value):
|
||||
"""
|
||||
Ensure that when set explicitly the Field is set to a timedelta
|
||||
"""
|
||||
if isinstance(value, datetime.timedelta) or value is None:
|
||||
return value
|
||||
|
||||
return self.from_json(value)
|
||||
|
||||
|
||||
class RelativeTime(JSONField):
|
||||
"""
|
||||
Field for start_time and end_time video module properties.
|
||||
|
||||
It was decided, that python representation of start_time and end_time
|
||||
should be python datetime.timedelta object, to be consistent with
|
||||
common time representation.
|
||||
|
||||
At the same time, serialized representation should be "HH:MM:SS"
|
||||
This format is convenient to use in XML (and it is used now),
|
||||
and also it is used in frond-end studio editor of video module as format
|
||||
for start and end time fields.
|
||||
|
||||
In database we previously had float type for start_time and end_time fields,
|
||||
so we are checking it also.
|
||||
|
||||
Python object of RelativeTime is datetime.timedelta.
|
||||
JSONed representation of RelativeTime is "HH:MM:SS"
|
||||
"""
|
||||
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
|
||||
MUTABLE = False
|
||||
|
||||
@classmethod
|
||||
def isotime_to_timedelta(cls, value):
|
||||
"""
|
||||
Validate that value in "HH:MM:SS" format and convert to timedelta.
|
||||
|
||||
Validate that user, that edits XML, sets proper format, and
|
||||
that max value that can be used by user is "23:59:59".
|
||||
"""
|
||||
try:
|
||||
obj_time = time.strptime(value, '%H:%M:%S')
|
||||
except ValueError as e:
|
||||
raise ValueError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
"Incorrect RelativeTime value {!r} was set in XML or serialized. "
|
||||
"Original parse message is {}".format(value, str(e))
|
||||
)
|
||||
return datetime.timedelta(
|
||||
hours=obj_time.tm_hour,
|
||||
minutes=obj_time.tm_min,
|
||||
seconds=obj_time.tm_sec
|
||||
)
|
||||
|
||||
def from_json(self, value):
|
||||
"""
|
||||
Convert value is in 'HH:MM:SS' format to datetime.timedelta.
|
||||
|
||||
If not value, returns 0.
|
||||
If value is float (backward compatibility issue), convert to timedelta.
|
||||
"""
|
||||
if not value:
|
||||
return datetime.timedelta(seconds=0)
|
||||
|
||||
if isinstance(value, datetime.timedelta):
|
||||
return value
|
||||
|
||||
# We've seen serialized versions of float in this field
|
||||
if isinstance(value, float):
|
||||
return datetime.timedelta(seconds=value)
|
||||
|
||||
if isinstance(value, str):
|
||||
return self.isotime_to_timedelta(value)
|
||||
|
||||
msg = f"RelativeTime Field {self.name} has bad value '{value!r}'"
|
||||
raise TypeError(msg)
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
Convert datetime.timedelta to "HH:MM:SS" format.
|
||||
|
||||
If not value, return "00:00:00"
|
||||
|
||||
Backward compatibility: check if value is float, and convert it. No exceptions here.
|
||||
|
||||
If value is not float, but is exceed 23:59:59, raise exception.
|
||||
"""
|
||||
if not value:
|
||||
return "00:00:00"
|
||||
|
||||
if isinstance(value, float): # backward compatibility
|
||||
value = min(value, 86400)
|
||||
return self.timedelta_to_string(datetime.timedelta(seconds=value))
|
||||
|
||||
if isinstance(value, datetime.timedelta):
|
||||
if value.total_seconds() > 86400: # sanity check
|
||||
raise ValueError(
|
||||
"RelativeTime max value is 23:59:59=86400.0 seconds, "
|
||||
"but {} seconds is passed".format(value.total_seconds())
|
||||
)
|
||||
return self.timedelta_to_string(value)
|
||||
|
||||
raise TypeError(f"RelativeTime: cannot convert {value!r} to json")
|
||||
|
||||
def timedelta_to_string(self, value):
|
||||
"""
|
||||
Makes first 'H' in str representation non-optional.
|
||||
|
||||
str(timedelta) has [H]H:MM:SS format, which is not suitable
|
||||
for front-end (and ISO time standard), so we force HH:MM:SS format.
|
||||
"""
|
||||
stringified = str(value)
|
||||
if len(stringified) == 7:
|
||||
stringified = '0' + stringified
|
||||
return stringified
|
||||
|
||||
def enforce_type(self, value):
|
||||
"""
|
||||
Ensure that when set explicitly the Field is set to a timedelta
|
||||
"""
|
||||
if isinstance(value, datetime.timedelta) or value is None:
|
||||
return value
|
||||
|
||||
return self.from_json(value)
|
||||
|
||||
|
||||
class ScoreField(JSONField):
|
||||
"""
|
||||
Field for blocks that need to store a Score. XBlocks that implement
|
||||
the ScorableXBlockMixin may need to store their score separately
|
||||
from their problem state, specifically for use in staff override
|
||||
of problem scores.
|
||||
"""
|
||||
MUTABLE = False
|
||||
|
||||
def from_json(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, Score):
|
||||
return value
|
||||
|
||||
if set(value) != {'raw_earned', 'raw_possible'}:
|
||||
raise TypeError('Scores must contain only a raw earned and raw possible value. Got {}'.format(
|
||||
set(value)
|
||||
))
|
||||
|
||||
raw_earned = value['raw_earned']
|
||||
raw_possible = value['raw_possible']
|
||||
|
||||
if raw_possible < 0:
|
||||
raise ValueError(
|
||||
'Error deserializing field of type {}: Expected a positive number for raw_possible, got {}.'.format(
|
||||
self.display_name,
|
||||
raw_possible,
|
||||
)
|
||||
)
|
||||
|
||||
if not (0 <= raw_earned <= raw_possible): # lint-amnesty, pylint: disable=superfluous-parens
|
||||
raise ValueError(
|
||||
'Error deserializing field of type {}: Expected raw_earned between 0 and {}, got {}.'.format(
|
||||
self.display_name,
|
||||
raw_possible,
|
||||
raw_earned
|
||||
)
|
||||
)
|
||||
|
||||
return Score(raw_earned, raw_possible)
|
||||
|
||||
enforce_type = from_json
|
||||
@@ -1,503 +0,0 @@
|
||||
"""
|
||||
Code used to calculate learner grades.
|
||||
"""
|
||||
|
||||
|
||||
import abc
|
||||
import inspect
|
||||
import logging
|
||||
import random
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
from pytz import UTC
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from xmodule.util.misc import get_short_labeler
|
||||
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
|
||||
class ScoreBase(metaclass=abc.ABCMeta):
|
||||
"""
|
||||
Abstract base class for encapsulating fields of values scores.
|
||||
"""
|
||||
|
||||
def __init__(self, graded, first_attempted):
|
||||
"""
|
||||
Fields common to all scores include:
|
||||
|
||||
:param graded: Whether or not this module is graded
|
||||
:type graded: bool
|
||||
|
||||
:param first_attempted: When the module was first attempted, or None
|
||||
:type first_attempted: datetime|None
|
||||
"""
|
||||
self.graded = graded
|
||||
self.first_attempted = first_attempted
|
||||
|
||||
def __eq__(self, other):
|
||||
if type(other) is type(self):
|
||||
return self.__dict__ == other.__dict__
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.__dict__})"
|
||||
|
||||
|
||||
class ProblemScore(ScoreBase):
|
||||
"""
|
||||
Encapsulates the fields of a Problem's score.
|
||||
"""
|
||||
def __init__(self, raw_earned, raw_possible, weighted_earned, weighted_possible, weight, *args, **kwargs):
|
||||
"""
|
||||
In addition to the fields in ScoreBase, arguments include:
|
||||
|
||||
:param raw_earned: Raw points earned on this problem
|
||||
:type raw_earned: int|float|None
|
||||
|
||||
:param raw_possible: Raw points possible to earn on this problem
|
||||
:type raw_possible: int|float|None
|
||||
|
||||
:param weighted_earned: Weighted value of the points earned
|
||||
:type weighted_earned: int|float|None
|
||||
|
||||
:param weighted_possible: Weighted possible points on this problem
|
||||
:type weighted_possible: int|float|None
|
||||
|
||||
:param weight: Weight of this problem
|
||||
:type weight: int|float|None
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.raw_earned = float(raw_earned) if raw_earned is not None else None
|
||||
self.raw_possible = float(raw_possible) if raw_possible is not None else None
|
||||
self.earned = float(weighted_earned) if weighted_earned is not None else None
|
||||
self.possible = float(weighted_possible) if weighted_possible is not None else None
|
||||
self.weight = weight
|
||||
|
||||
|
||||
class AggregatedScore(ScoreBase):
|
||||
"""
|
||||
Encapsulates the fields of a Subsection's score.
|
||||
"""
|
||||
def __init__(self, tw_earned, tw_possible, *args, **kwargs):
|
||||
"""
|
||||
In addition to the fields in ScoreBase, also includes:
|
||||
|
||||
:param tw_earned: Total aggregated sum of all weighted earned values
|
||||
:type tw_earned: int|float|None
|
||||
|
||||
:param tw_possible: Total aggregated sum of all weighted possible values
|
||||
:type tw_possible: int|float|None
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.earned = float(tw_earned) if tw_earned is not None else None
|
||||
self.possible = float(tw_possible) if tw_possible is not None else None
|
||||
|
||||
|
||||
def float_sum(iterable):
|
||||
"""
|
||||
Sum the elements of the iterable, and return the result as a float.
|
||||
"""
|
||||
return float(sum(iterable))
|
||||
|
||||
|
||||
def aggregate_scores(scores):
|
||||
"""
|
||||
scores: A list of ProblemScore objects
|
||||
returns: A tuple (all_total, graded_total).
|
||||
all_total: An AggregatedScore representing the total score summed over all input scores
|
||||
graded_total: An AggregatedScore representing the score summed over all graded input scores
|
||||
"""
|
||||
total_correct_graded = float_sum(score.earned for score in _iter_graded(scores))
|
||||
total_possible_graded = float_sum(score.possible for score in _iter_graded(scores))
|
||||
first_attempted_graded = _min_or_none(
|
||||
score.first_attempted for score in _iter_graded(scores) if score.first_attempted
|
||||
)
|
||||
|
||||
total_correct = float_sum(score.earned for score in scores)
|
||||
total_possible = float_sum(score.possible for score in scores)
|
||||
first_attempted = _min_or_none(
|
||||
score.first_attempted for score in scores if score.first_attempted
|
||||
)
|
||||
|
||||
# regardless of whether it is graded
|
||||
all_total = AggregatedScore(total_correct, total_possible, False, first_attempted=first_attempted)
|
||||
|
||||
# selecting only graded things
|
||||
graded_total = AggregatedScore(
|
||||
total_correct_graded,
|
||||
total_possible_graded,
|
||||
True,
|
||||
first_attempted=first_attempted_graded
|
||||
)
|
||||
|
||||
return all_total, graded_total
|
||||
|
||||
|
||||
def invalid_args(func, argdict):
|
||||
"""
|
||||
Given a function and a dictionary of arguments, returns a set of arguments
|
||||
from argdict that aren't accepted by func
|
||||
"""
|
||||
args, _, keywords, _, _, _, _ = inspect.getfullargspec(func)
|
||||
if keywords:
|
||||
return set() # All accepted
|
||||
return set(argdict) - set(args)
|
||||
|
||||
|
||||
def grader_from_conf(conf):
|
||||
"""
|
||||
This creates a CourseGrader from a configuration (such as in course_settings.py).
|
||||
The conf can simply be an instance of CourseGrader, in which case no work is done.
|
||||
More commonly, the conf is a list of dictionaries. A WeightedSubsectionsGrader
|
||||
with AssignmentFormatGraders will be generated. Every dictionary should contain
|
||||
the parameters for making an AssignmentFormatGrader, in addition to a 'weight' key.
|
||||
"""
|
||||
if isinstance(conf, CourseGrader):
|
||||
return conf
|
||||
|
||||
subgraders = []
|
||||
for subgraderconf in conf:
|
||||
subgraderconf = subgraderconf.copy()
|
||||
weight = subgraderconf.pop("weight", 0)
|
||||
try:
|
||||
if 'min_count' in subgraderconf:
|
||||
subgrader_class = AssignmentFormatGrader
|
||||
else:
|
||||
raise ValueError("Configuration has no appropriate grader class.")
|
||||
|
||||
bad_args = invalid_args(subgrader_class.__init__, subgraderconf)
|
||||
if bad_args:
|
||||
log.warning("Invalid arguments for a subgrader: %s", bad_args)
|
||||
for key in bad_args:
|
||||
del subgraderconf[key]
|
||||
|
||||
subgrader = subgrader_class(**subgraderconf)
|
||||
subgraders.append((subgrader, subgrader.category, weight))
|
||||
|
||||
except (TypeError, ValueError) as error:
|
||||
# Add info and re-raise
|
||||
msg = ("Unable to parse grader configuration:\n " +
|
||||
str(subgraderconf) +
|
||||
"\n Error was:\n " + str(error))
|
||||
raise ValueError(msg).with_traceback(sys.exc_info()[2])
|
||||
|
||||
return WeightedSubsectionsGrader(subgraders)
|
||||
|
||||
|
||||
class CourseGrader(metaclass=abc.ABCMeta):
|
||||
"""
|
||||
A course grader takes the totaled scores for each graded section (that a student has
|
||||
started) in the course. From these scores, the grader calculates an overall percentage
|
||||
grade. The grader should also generate information about how that score was calculated,
|
||||
to be displayed in graphs or charts.
|
||||
|
||||
A grader has one required method, grade(), which is passed a grade_sheet. The grade_sheet
|
||||
contains scores for all graded section that the student has started. If a student has
|
||||
a score of 0 for that section, it may be missing from the grade_sheet. The grade_sheet
|
||||
is keyed by section format. Each value is a list of Score namedtuples for each section
|
||||
that has the matching section format.
|
||||
|
||||
The grader outputs a dictionary with the following keys:
|
||||
- percent: Contains a float value, which is the final percentage score for the student.
|
||||
- section_breakdown: This is a list of dictionaries which provide details on sections
|
||||
that were graded. These are used for display in a graph or chart. The format for a
|
||||
section_breakdown dictionary is explained below.
|
||||
- grade_breakdown: This is a dict of dictionaries, keyed by category, which provide details on
|
||||
the contributions of the final percentage grade. This is a higher level breakdown, for when the
|
||||
grade is constructed of a few very large sections (such as Homeworks, Labs, a Midterm, and a Final).
|
||||
The format for a grade_breakdown is explained below. This section is optional.
|
||||
|
||||
A dictionary in the section_breakdown list has the following keys:
|
||||
percent: A float percentage for the section.
|
||||
label: A short string identifying the section. Preferably fixed-length. E.g. "HW 3".
|
||||
detail: A string explanation of the score. E.g. "Homework 1 - Ohms Law - 83% (5/6)"
|
||||
category: A string identifying the category. Items with the same category are grouped together
|
||||
in the display (for example, by color).
|
||||
prominent: A boolean value indicating that this section should be displayed as more prominent
|
||||
than other items.
|
||||
|
||||
A dictionary in the grade_breakdown dict has the following keys:
|
||||
percent: A float percentage in the breakdown. All percents should add up to the final percentage.
|
||||
detail: A string explanation of this breakdown. E.g. "Homework - 10% of a possible 15%"
|
||||
category: A string identifying the category. Items with the same category are grouped together
|
||||
in the display (for example, by color).
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
'''Given a grade sheet, return a dict containing grading information'''
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class WeightedSubsectionsGrader(CourseGrader):
|
||||
"""
|
||||
This grader takes a list of tuples containing (grader, category_name, weight) and computes
|
||||
a final grade by totalling the contribution of each sub grader and multiplying it by the
|
||||
given weight. For example, the sections may be
|
||||
|
||||
[ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30),
|
||||
(finalGrader, "Final", 0.40) ]
|
||||
|
||||
All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
|
||||
composed using the score from each grader.
|
||||
|
||||
Note that the sum of the weights is not taken into consideration. If the weights add up to
|
||||
a value > 1, the student may end up with a percent > 100%. This allows for sections that
|
||||
are extra credit.
|
||||
"""
|
||||
def __init__(self, subgraders): # pylint: disable=super-init-not-called
|
||||
self.subgraders = subgraders
|
||||
|
||||
@property
|
||||
def sum_of_weights(self): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
result = 0
|
||||
for _, _, weight in self.subgraders:
|
||||
result += weight
|
||||
return result
|
||||
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
total_percent = 0.0
|
||||
section_breakdown = []
|
||||
grade_breakdown = OrderedDict()
|
||||
|
||||
for subgrader, assignment_type, weight in self.subgraders:
|
||||
subgrade_result = subgrader.grade(grade_sheet, generate_random_scores)
|
||||
|
||||
weighted_percent = subgrade_result['percent'] * weight
|
||||
section_detail = _("{assignment_type} = {weighted_percent:.2%} of a possible {weight:.2%}").format(
|
||||
assignment_type=assignment_type,
|
||||
weighted_percent=weighted_percent,
|
||||
weight=weight)
|
||||
|
||||
total_percent += weighted_percent
|
||||
section_breakdown += subgrade_result['section_breakdown']
|
||||
grade_breakdown[assignment_type] = {
|
||||
'percent': weighted_percent,
|
||||
'detail': section_detail,
|
||||
'category': assignment_type,
|
||||
}
|
||||
|
||||
return {
|
||||
'percent': total_percent,
|
||||
'section_breakdown': section_breakdown,
|
||||
'grade_breakdown': grade_breakdown
|
||||
}
|
||||
|
||||
|
||||
class AssignmentFormatGrader(CourseGrader):
|
||||
"""
|
||||
Grades all sections matching the format 'type' with an equal weight. A specified
|
||||
number of lowest scores can be dropped from the calculation. The minimum number of
|
||||
sections in this format must be specified (even if those sections haven't been
|
||||
written yet).
|
||||
|
||||
min_count defines how many assignments are expected throughout the course. Placeholder
|
||||
scores (of 0) will be inserted if the number of matching sections in the course is < min_count.
|
||||
If there number of matching sections in the course is > min_count, min_count will be ignored.
|
||||
|
||||
show_only_average is to suppress the display of each assignment in this grader and instead
|
||||
only show the total score of this grader in the breakdown.
|
||||
|
||||
hide_average is to suppress the display of the total score in this grader and instead
|
||||
only show each assignment in this grader in the breakdown.
|
||||
|
||||
If there is only a single assignment in this grader, then it returns only one entry for the
|
||||
grader. Since the assignment and the total are the same, the total is returned but is not
|
||||
labeled as an average.
|
||||
|
||||
category should be presentable to the user, but may not appear. When the grade breakdown is
|
||||
displayed, scores from the same category will be similar (for example, by color).
|
||||
|
||||
section_type is a string that is the type of a singular section. For example, for Labs it
|
||||
would be "Lab". This defaults to be the same as category.
|
||||
|
||||
short_label is similar to section_type, but shorter. For example, for Homework it would be
|
||||
"HW".
|
||||
|
||||
starting_index is the first number that will appear. For example, starting_index=3 and
|
||||
min_count = 2 would produce the labels "Assignment 3", "Assignment 4"
|
||||
|
||||
"""
|
||||
def __init__( # lint-amnesty, pylint: disable=super-init-not-called
|
||||
self,
|
||||
type, # pylint: disable=redefined-builtin
|
||||
min_count,
|
||||
drop_count,
|
||||
category=None,
|
||||
section_type=None,
|
||||
short_label=None,
|
||||
show_only_average=False,
|
||||
hide_average=False,
|
||||
starting_index=1
|
||||
):
|
||||
self.type = type
|
||||
self.min_count = min_count
|
||||
self.drop_count = drop_count
|
||||
self.category = category or self.type
|
||||
self.section_type = section_type or self.type
|
||||
self.short_label = short_label or self.type
|
||||
self.show_only_average = show_only_average
|
||||
self.starting_index = starting_index
|
||||
self.hide_average = hide_average
|
||||
|
||||
def total_with_drops(self, breakdown):
|
||||
"""
|
||||
Calculates total score for a section while dropping lowest scores
|
||||
"""
|
||||
# Create an array of tuples with (index, mark), sorted by mark['percent'] descending
|
||||
sorted_breakdown = sorted(enumerate(breakdown), key=lambda x: -x[1]['percent'])
|
||||
|
||||
# A list of the indices of the dropped scores
|
||||
dropped_indices = []
|
||||
if self.drop_count > 0:
|
||||
dropped_indices = [x[0] for x in sorted_breakdown[-self.drop_count:]]
|
||||
aggregate_score = 0
|
||||
for index, mark in enumerate(breakdown):
|
||||
if index not in dropped_indices:
|
||||
aggregate_score += mark['percent']
|
||||
|
||||
if len(breakdown) - self.drop_count > 0:
|
||||
aggregate_score /= len(breakdown) - self.drop_count
|
||||
|
||||
return aggregate_score, dropped_indices
|
||||
|
||||
def grade(self, grade_sheet, generate_random_scores=False):
|
||||
scores = list(grade_sheet.get(self.type, {}).values())
|
||||
breakdown = []
|
||||
labeler = get_short_labeler(self.short_label)
|
||||
for i in range(max(int(float(self.min_count)), len(scores))):
|
||||
if i < len(scores) or generate_random_scores:
|
||||
if generate_random_scores: # for debugging!
|
||||
earned = random.randint(2, 15)
|
||||
possible = random.randint(earned, 15)
|
||||
section_name = _("Generated")
|
||||
|
||||
else:
|
||||
earned = scores[i].graded_total.earned
|
||||
possible = scores[i].graded_total.possible
|
||||
section_name = scores[i].display_name
|
||||
|
||||
percentage = scores[i].percent_graded
|
||||
summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})"
|
||||
summary = summary_format.format(
|
||||
index=i + self.starting_index,
|
||||
section_type=self.section_type,
|
||||
name=section_name,
|
||||
percent=percentage,
|
||||
earned=float(earned),
|
||||
possible=float(possible)
|
||||
)
|
||||
else:
|
||||
percentage = 0.0
|
||||
# Translators: "Homework 1 - Unreleased - 0% (?/?)" The section has not been released for viewing.
|
||||
summary = _("{section_type} {index} Unreleased - 0% (?/?)").format(
|
||||
index=i + self.starting_index,
|
||||
section_type=self.section_type
|
||||
)
|
||||
short_label = labeler(i + self.starting_index)
|
||||
|
||||
breakdown.append({'percent': percentage, 'label': short_label,
|
||||
'detail': summary, 'category': self.category})
|
||||
|
||||
total_percent, dropped_indices = self.total_with_drops(breakdown)
|
||||
|
||||
for dropped_index in dropped_indices:
|
||||
breakdown[dropped_index]['mark'] = {
|
||||
'detail': _("The lowest {drop_count} {section_type} scores are dropped.").format(
|
||||
drop_count=self.drop_count,
|
||||
section_type=self.section_type
|
||||
)
|
||||
}
|
||||
|
||||
if len(breakdown) == 1:
|
||||
# if there is only one entry in a section, suppress the existing individual entry and the average,
|
||||
# and just display a single entry for the section.
|
||||
total_detail = "{section_type} = {percent:.0%}".format(
|
||||
percent=total_percent,
|
||||
section_type=self.section_type,
|
||||
)
|
||||
total_label = f"{self.short_label}"
|
||||
breakdown = [{'percent': total_percent, 'label': total_label,
|
||||
'detail': total_detail, 'category': self.category, 'prominent': True}, ]
|
||||
else:
|
||||
# Translators: "Homework Average = 0%"
|
||||
total_detail = _("{section_type} Average = {percent:.0%}").format(
|
||||
percent=total_percent,
|
||||
section_type=self.section_type
|
||||
)
|
||||
# Translators: Avg is short for Average
|
||||
total_label = _("{short_label} Avg").format(short_label=self.short_label)
|
||||
|
||||
if self.show_only_average:
|
||||
breakdown = []
|
||||
|
||||
if not self.hide_average:
|
||||
breakdown.append({'percent': total_percent, 'label': total_label,
|
||||
'detail': total_detail, 'category': self.category, 'prominent': True})
|
||||
|
||||
return {
|
||||
'percent': total_percent,
|
||||
'section_breakdown': breakdown,
|
||||
# No grade_breakdown here
|
||||
}
|
||||
|
||||
|
||||
def _iter_graded(scores):
|
||||
"""
|
||||
Yield the scores that belong to explicitly graded blocks
|
||||
"""
|
||||
return (score for score in scores if score.graded)
|
||||
|
||||
|
||||
def _min_or_none(itr):
|
||||
"""
|
||||
Return the lowest value in itr, or None if itr is empty.
|
||||
|
||||
In python 3, this is just min(itr, default=None)
|
||||
"""
|
||||
try:
|
||||
return min(itr)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ShowCorrectness:
|
||||
"""
|
||||
Helper class for determining whether correctness is currently hidden for a block.
|
||||
|
||||
When correctness is hidden, this limits the user's access to the correct/incorrect flags, messages, problem scores,
|
||||
and aggregate subsection and course grades.
|
||||
"""
|
||||
|
||||
# Constants used to indicate when to show correctness
|
||||
ALWAYS = "always"
|
||||
PAST_DUE = "past_due"
|
||||
NEVER = "never"
|
||||
|
||||
@classmethod
|
||||
def correctness_available(cls, show_correctness='', due_date=None, has_staff_access=False):
|
||||
"""
|
||||
Returns whether correctness is available now, for the given attributes.
|
||||
"""
|
||||
if show_correctness == cls.NEVER:
|
||||
return False
|
||||
elif has_staff_access:
|
||||
# This is after the 'never' check because course staff can see correctness
|
||||
# unless the sequence/problem explicitly prevents it
|
||||
return True
|
||||
elif show_correctness == cls.PAST_DUE:
|
||||
# Is it now past the due date?
|
||||
return (due_date is None or
|
||||
due_date < datetime.now(UTC))
|
||||
|
||||
# else: show_correctness == cls.ALWAYS
|
||||
return True
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
The Hidden XBlock.
|
||||
"""
|
||||
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xmodule.raw_module import RawMixin
|
||||
from xmodule.xml_module import XmlMixin
|
||||
from xmodule.x_module import (
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
|
||||
@XBlock.needs("i18n")
|
||||
class HiddenDescriptor(
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
XModuleToXBlockMixin,
|
||||
XModuleMixin,
|
||||
):
|
||||
"""
|
||||
XBlock class loaded by the runtime when another XBlock type has been disabled
|
||||
or an unknown XBlock type is included in a course import.
|
||||
|
||||
The class name includes 'Descriptor' because this used to be an XModule and the class path is specified in the
|
||||
modulestore config in a number of places.
|
||||
"""
|
||||
HIDDEN = True
|
||||
has_author_view = True
|
||||
|
||||
resources_dir = None
|
||||
|
||||
def author_view(self, _context):
|
||||
"""
|
||||
Return the author view.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
content = _(
|
||||
'ERROR: "{block_type}" is an unknown component type. This component will be hidden in LMS.'
|
||||
).format(block_type=self.scope_ids.block_type)
|
||||
fragment.add_content(content)
|
||||
return fragment
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
# User should not be able to edit unknown types.
|
||||
fragment = Fragment()
|
||||
return fragment
|
||||
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Return the student view.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
return fragment
|
||||
@@ -1,17 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
from lxml import etree
|
||||
|
||||
|
||||
def check_html(html):
|
||||
'''
|
||||
Check whether the passed in html string can be parsed by lxml.
|
||||
Return bool success.
|
||||
'''
|
||||
parser = etree.HTMLParser()
|
||||
try:
|
||||
etree.fromstring(html, parser)
|
||||
return True
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return False
|
||||
@@ -1,513 +0,0 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from datetime import datetime
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from django.conf import settings
|
||||
from fs.errors import ResourceNotFound
|
||||
from lxml import etree
|
||||
from path import Path as path
|
||||
from web_fragments.fragment import Fragment
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Boolean, List, Scope, String
|
||||
from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.editing_module import EditingMixin
|
||||
from xmodule.edxnotes_utils import edxnotes
|
||||
from xmodule.html_checker import check_html
|
||||
from xmodule.stringify import stringify_children
|
||||
from xmodule.util.misc import escape_html_characters
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin, name_to_pathname
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
@XBlock.needs("i18n")
|
||||
@XBlock.needs("mako")
|
||||
@XBlock.needs("user")
|
||||
class HtmlBlockMixin( # lint-amnesty, pylint: disable=abstract-method
|
||||
XmlMixin, EditingMixin,
|
||||
XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin,
|
||||
):
|
||||
"""
|
||||
The HTML XBlock mixin.
|
||||
This provides the base class for all Html-ish blocks (including the HTML XBlock).
|
||||
"""
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
# it'd be nice to have a useful default but it screws up other things; so,
|
||||
# use display_name_with_default for those
|
||||
default=_("Text")
|
||||
)
|
||||
data = String(help=_("Html contents to display for this module"), default="", scope=Scope.content)
|
||||
source_code = String(
|
||||
help=_("Source code for LaTeX documents. This feature is not well-supported."),
|
||||
scope=Scope.settings
|
||||
)
|
||||
use_latex_compiler = Boolean(
|
||||
help=_("Enable LaTeX templates?"),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
editor = String(
|
||||
help=_(
|
||||
"Select Visual to enter content and have the editor automatically create the HTML. Select Raw to edit "
|
||||
"HTML directly. If you change this setting, you must save the component and then re-open it for editing."
|
||||
),
|
||||
display_name=_("Editor"),
|
||||
default="visual",
|
||||
values=[
|
||||
{"display_name": _("Visual"), "value": "visual"},
|
||||
{"display_name": _("Raw"), "value": "raw"}
|
||||
],
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA = 'ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA'
|
||||
|
||||
@XBlock.supports("multi_device")
|
||||
def student_view(self, _context):
|
||||
"""
|
||||
Return a fragment that contains the html for the student view
|
||||
"""
|
||||
fragment = Fragment(self.get_html())
|
||||
add_webpack_to_fragment(fragment, 'HtmlBlockPreview')
|
||||
shim_xmodule_js(fragment, 'HTMLModule')
|
||||
return fragment
|
||||
|
||||
@XBlock.supports("multi_device")
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Returns a fragment that contains the html for the preview view
|
||||
"""
|
||||
return self.student_view(context)
|
||||
|
||||
def student_view_data(self, context=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Return a JSON representation of the student_view of this XBlock.
|
||||
"""
|
||||
if getattr(settings, 'FEATURES', {}).get(self.ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA, False):
|
||||
return {'enabled': True, 'html': self.get_html()}
|
||||
else:
|
||||
return {
|
||||
'enabled': False,
|
||||
'message': f'To enable, set FEATURES["{self.ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA}"]'
|
||||
}
|
||||
|
||||
def get_html(self):
|
||||
""" Returns html required for rendering the block. """
|
||||
if self.data:
|
||||
data = self.data
|
||||
user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
|
||||
if user_id:
|
||||
data = data.replace("%%USER_ID%%", user_id)
|
||||
data = data.replace("%%COURSE_ID%%", str(self.scope_ids.usage_id.context_key))
|
||||
return data
|
||||
return self.data
|
||||
|
||||
def studio_view(self, _context):
|
||||
"""
|
||||
Return the studio view.
|
||||
"""
|
||||
fragment = Fragment(
|
||||
self.runtime.service(self, 'mako').render_template(self.mako_template, self.get_context())
|
||||
)
|
||||
add_webpack_to_fragment(fragment, 'HtmlBlockStudio')
|
||||
shim_xmodule_js(fragment, 'HTMLEditingDescriptor')
|
||||
return fragment
|
||||
|
||||
preview_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/html/display.js'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.js'),
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
resource_string(__name__, 'js/src/html/imageModal.js'),
|
||||
resource_string(__name__, 'js/common_static/js/vendor/draggabilly.js'),
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
preview_view_css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
|
||||
|
||||
uses_xmodule_styles_setup = True
|
||||
requires_per_student_anonymous_id = True
|
||||
|
||||
mako_template = "widgets/html-edit.html"
|
||||
resources_dir = None
|
||||
filename_extension = "xml"
|
||||
template_dir_name = "html"
|
||||
show_in_read_only_mode = True
|
||||
|
||||
studio_view_js = {
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/html/edit.js')
|
||||
],
|
||||
'xmodule_js': resource_string(__name__, 'js/src/xmodule.js'),
|
||||
}
|
||||
studio_view_css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/html/edit.scss')
|
||||
]
|
||||
}
|
||||
|
||||
# VS[compat] TODO (cpennington): Delete this method once all fall 2012 course
|
||||
# are being edited in the cms
|
||||
@classmethod
|
||||
def backcompat_paths(cls, filepath):
|
||||
"""
|
||||
Get paths for html and xml files.
|
||||
"""
|
||||
if filepath.endswith('.html.xml'):
|
||||
filepath = filepath[:-9] + '.html' # backcompat--look for html instead of xml
|
||||
if filepath.endswith('.html.html'):
|
||||
filepath = filepath[:-5] # some people like to include .html in filenames..
|
||||
candidates = []
|
||||
while os.sep in filepath:
|
||||
candidates.append(filepath)
|
||||
_, _, filepath = filepath.partition(os.sep)
|
||||
|
||||
# also look for .html versions instead of .xml
|
||||
new_candidates = []
|
||||
for candidate in candidates:
|
||||
if candidate.endswith('.xml'):
|
||||
new_candidates.append(candidate[:-4] + '.html')
|
||||
return candidates + new_candidates
|
||||
|
||||
@classmethod
|
||||
def filter_templates(cls, template, course):
|
||||
"""
|
||||
Filter template that contains 'latex' from templates.
|
||||
|
||||
Show them only if use_latex_compiler is set to True in
|
||||
course settings.
|
||||
"""
|
||||
return 'latex' not in template['template_id'] or course.use_latex_compiler
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
an override to add in specific rendering context, in this case we need to
|
||||
add in a base path to our c4x content addressing scheme
|
||||
"""
|
||||
_context = EditingMixin.get_context(self)
|
||||
# Add some specific HTML rendering context when editing HTML modules where we pass
|
||||
# the root /c4x/ url for assets. This allows client-side substitutions to occur.
|
||||
_context.update({
|
||||
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(self.location.course_key),
|
||||
'enable_latex_compiler': self.use_latex_compiler,
|
||||
'editor': self.editor
|
||||
})
|
||||
return _context
|
||||
|
||||
# NOTE: html descriptors are special. We do not want to parse and
|
||||
# export them ourselves, because that can break things (e.g. lxml
|
||||
# adds body tags when it exports, but they should just be html
|
||||
# snippets that will be included in the middle of pages.
|
||||
|
||||
@classmethod
|
||||
def load_definition(cls, xml_object, system, location, id_generator): # lint-amnesty, pylint: disable=arguments-differ
|
||||
'''Load a descriptor from the specified xml_object:
|
||||
|
||||
If there is a filename attribute, load it as a string, and
|
||||
log a warning if it is not parseable by etree.HTMLParser.
|
||||
|
||||
If there is not a filename attribute, the definition is the body
|
||||
of the xml_object, without the root tag (do not want <html> in the
|
||||
middle of a page)
|
||||
|
||||
Args:
|
||||
xml_object: an lxml.etree._Element containing the definition to load
|
||||
system: the modulestore system or runtime which caches data
|
||||
location: the usage id for the block--used to compute the filename if none in the xml_object
|
||||
id_generator: used by other impls of this method to generate the usage_id
|
||||
'''
|
||||
filename = xml_object.get('filename')
|
||||
if filename is None:
|
||||
definition_xml = copy.deepcopy(xml_object)
|
||||
cls.clean_metadata_from_xml(definition_xml)
|
||||
return {'data': stringify_children(definition_xml)}, []
|
||||
else:
|
||||
# html is special. cls.filename_extension is 'xml', but
|
||||
# if 'filename' is in the definition, that means to load
|
||||
# from .html
|
||||
# 'filename' in html pointers is a relative path
|
||||
# (not same as 'html/blah.html' when the pointer is in a directory itself)
|
||||
pointer_path = "{category}/{url_path}".format(
|
||||
category='html',
|
||||
url_path=name_to_pathname(location.block_id)
|
||||
)
|
||||
base = path(pointer_path).dirname()
|
||||
# log.debug("base = {0}, base.dirname={1}, filename={2}".format(base, base.dirname(), filename))
|
||||
filepath = f"{base}/{filename}.html"
|
||||
# log.debug("looking for html file for {0} at {1}".format(location, filepath))
|
||||
|
||||
# VS[compat]
|
||||
# TODO (cpennington): If the file doesn't exist at the right path,
|
||||
# give the class a chance to fix it up. The file will be written out
|
||||
# again in the correct format. This should go away once the CMS is
|
||||
# online and has imported all current (fall 2012) courses from xml
|
||||
if not system.resources_fs.exists(filepath):
|
||||
|
||||
candidates = cls.backcompat_paths(filepath)
|
||||
# log.debug("candidates = {0}".format(candidates))
|
||||
for candidate in candidates:
|
||||
if system.resources_fs.exists(candidate):
|
||||
filepath = candidate
|
||||
break
|
||||
|
||||
try:
|
||||
with system.resources_fs.open(filepath, encoding='utf-8') as infile:
|
||||
html = infile.read()
|
||||
# Log a warning if we can't parse the file, but don't error
|
||||
if not check_html(html) and len(html) > 0:
|
||||
msg = f"Couldn't parse html in {filepath}, content = {html}"
|
||||
log.warning(msg)
|
||||
system.error_tracker("Warning: " + msg)
|
||||
|
||||
definition = {'data': html}
|
||||
|
||||
# TODO (ichuang): remove this after migration
|
||||
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
|
||||
definition['filename'] = [filepath, filename]
|
||||
|
||||
return definition, []
|
||||
|
||||
except ResourceNotFound as err:
|
||||
msg = 'Unable to load file contents at path {}: {} '.format(
|
||||
filepath, err)
|
||||
# add more info and re-raise
|
||||
raise Exception(msg).with_traceback(sys.exc_info()[2])
|
||||
|
||||
@classmethod
|
||||
def parse_xml_new_runtime(cls, node, runtime, keys):
|
||||
"""
|
||||
Parse XML in the new blockstore-based runtime. Since it doesn't yet
|
||||
support loading separate .html files, the HTML data is assumed to be in
|
||||
a CDATA child or otherwise just inline in the OLX.
|
||||
"""
|
||||
block = runtime.construct_xblock_from_class(cls, keys)
|
||||
block.data = stringify_children(node)
|
||||
# Attributes become fields.
|
||||
for name, value in node.items():
|
||||
cls._set_field_if_present(block, name, value, {})
|
||||
return block
|
||||
|
||||
# TODO (vshnayder): make export put things in the right places.
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
''' Write <html filename="" [meta-attrs="..."]> to filename.xml, and the html
|
||||
string to filename.html.
|
||||
'''
|
||||
|
||||
# Write html to file, return an empty tag
|
||||
pathname = name_to_pathname(self.url_name)
|
||||
filepath = '{category}/{pathname}.html'.format(
|
||||
category=self.category,
|
||||
pathname=pathname
|
||||
)
|
||||
|
||||
resource_fs.makedirs(os.path.dirname(filepath), recreate=True)
|
||||
with resource_fs.open(filepath, 'wb') as filestream:
|
||||
html_data = self.data.encode('utf-8')
|
||||
filestream.write(html_data)
|
||||
|
||||
# write out the relative name
|
||||
relname = path(pathname).basename()
|
||||
|
||||
elt = etree.Element('html')
|
||||
elt.set("filename", relname)
|
||||
return elt
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
"""
|
||||
`use_latex_compiler` should not be editable in the Studio settings editor.
|
||||
"""
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
non_editable_fields.append(HtmlBlockMixin.use_latex_compiler)
|
||||
return non_editable_fields
|
||||
|
||||
def index_dictionary(self):
|
||||
xblock_body = super().index_dictionary()
|
||||
# Removing script and style
|
||||
html_content = re.sub(
|
||||
re.compile(
|
||||
r"""
|
||||
<script>.*?</script> |
|
||||
<style>.*?</style>
|
||||
""",
|
||||
re.DOTALL |
|
||||
re.VERBOSE),
|
||||
"",
|
||||
self.data
|
||||
)
|
||||
html_content = escape_html_characters(html_content)
|
||||
html_body = {
|
||||
"html_content": html_content,
|
||||
"display_name": self.display_name,
|
||||
}
|
||||
if "content" in xblock_body:
|
||||
xblock_body["content"].update(html_body)
|
||||
else:
|
||||
xblock_body["content"] = html_body
|
||||
xblock_body["content_type"] = "Text"
|
||||
return xblock_body
|
||||
|
||||
|
||||
@edxnotes
|
||||
class HtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
This is the actual HTML XBlock.
|
||||
Nothing extra is required; this is just a wrapper to include edxnotes support.
|
||||
"""
|
||||
|
||||
|
||||
class AboutFields: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
display_name = String(
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
default="overview",
|
||||
)
|
||||
data = String(
|
||||
help=_("Html contents to display for this module"),
|
||||
default="",
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
class AboutBlock(AboutFields, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
These pieces of course content are treated as HtmlBlocks but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
template_dir_name = "about"
|
||||
|
||||
|
||||
class StaticTabFields:
|
||||
"""
|
||||
The overrides for Static Tabs
|
||||
"""
|
||||
display_name = String(
|
||||
display_name=_("Display Name"),
|
||||
help=_("The display name for this component."),
|
||||
scope=Scope.settings,
|
||||
default="Empty",
|
||||
)
|
||||
course_staff_only = Boolean(
|
||||
display_name=_("Hide Page From Learners"),
|
||||
help=_("If you select this option, only course team members with"
|
||||
" the Staff or Admin role see this page."),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
data = String(
|
||||
default=textwrap.dedent("""\
|
||||
<p>Add the content you want students to see on this page.</p>
|
||||
"""),
|
||||
scope=Scope.content,
|
||||
help=_("HTML for the additional pages")
|
||||
)
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
class StaticTabBlock(StaticTabFields, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
These pieces of course content are treated as HtmlBlocks but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
template_dir_name = None
|
||||
|
||||
|
||||
class CourseInfoFields:
|
||||
"""
|
||||
Field overrides
|
||||
"""
|
||||
items = List(
|
||||
help=_("List of course update items"),
|
||||
default=[],
|
||||
scope=Scope.content
|
||||
)
|
||||
data = String(
|
||||
help=_("Html contents to display for this module"),
|
||||
default="<ol></ol>",
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
|
||||
@XBlock.tag("detached")
|
||||
@XBlock.needs('replace_urls')
|
||||
class CourseInfoBlock(CourseInfoFields, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
These pieces of course content are treated as HtmlBlock but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
# statuses
|
||||
STATUS_VISIBLE = 'visible'
|
||||
STATUS_DELETED = 'deleted'
|
||||
TEMPLATE_DIR = 'courseware'
|
||||
|
||||
template_dir_name = None
|
||||
|
||||
def get_html(self):
|
||||
""" Returns html required for rendering XModule. """
|
||||
|
||||
# When we switch this to an XBlock, we can merge this with student_view,
|
||||
# but for now the XModule mixin requires that this method be defined.
|
||||
data = super().get_html()
|
||||
if data != "":
|
||||
return data
|
||||
else:
|
||||
# This should no longer be called on production now that we are using a separate updates page
|
||||
# and using a fragment HTML file - it will be called in tests until those are removed.
|
||||
course_updates = self.order_updates(self.items)
|
||||
context = {
|
||||
'visible_updates': course_updates[:3],
|
||||
'hidden_updates': course_updates[3:],
|
||||
}
|
||||
return self.runtime.service(self, 'mako').render_template(
|
||||
f"{self.TEMPLATE_DIR}/course_updates.html",
|
||||
context,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def order_updates(self, updates): # lint-amnesty, pylint: disable=bad-classmethod-argument
|
||||
"""
|
||||
Returns any course updates in reverse chronological order.
|
||||
"""
|
||||
sorted_updates = [update for update in updates if update.get('status') == self.STATUS_VISIBLE]
|
||||
sorted_updates.sort(
|
||||
key=lambda item: (self.safe_parse_date(item['date']), item['id']),
|
||||
reverse=True
|
||||
)
|
||||
return sorted_updates
|
||||
|
||||
@staticmethod
|
||||
def safe_parse_date(date):
|
||||
"""
|
||||
Since this is used solely for ordering purposes, use today's date as a default
|
||||
"""
|
||||
try:
|
||||
return datetime.strptime(date, '%B %d, %Y')
|
||||
except ValueError: # occurs for ill-formatted date values
|
||||
return datetime.today()
|
||||
@@ -1 +0,0 @@
|
||||
../../../../../common/static/js/RequireJS-namespace-undefine.js
|
||||
@@ -1 +0,0 @@
|
||||
../../../../static/
|
||||
@@ -1,34 +0,0 @@
|
||||
<section class='xblock xblock-student_view xmodule_display xmodule_AnnotatableModule' data-type='Annotatable'>
|
||||
<div class="annotatable-wrapper">
|
||||
<div class="annotatable-header">
|
||||
<h3 class="hd hd-2 annotatable-title">First Annotation Exercise</h2>
|
||||
</div>
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-section-title">
|
||||
Instructions
|
||||
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
|
||||
</div>
|
||||
<div class="annotatable-section-body annotatable-instructions">
|
||||
<div><p>The main goal of this exercise is to start practicing the art of slow reading.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-section-title">
|
||||
Guided Discussion
|
||||
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotatable-content">
|
||||
|87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist. <br/>
|
||||
|88 <span data-problem-id="0" data-comment-body="Agamemnon says..." class="annotatable-span highlight" data-comment-title="Your Title Here">They are the ones who</span><br/>
|
||||
|100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said: <br/>
|
||||
|101 <span data-problem-id="1" data-comment-body="When Zeus speaks..." class="annotatable-span highlight">“hear me, all gods and all goddesses,</span><br/>
|
||||
|113 but he swore a great oath.
|
||||
<span data-problem-id="2" data-comment-body="How is the ‘veering off-course’ ..." class="annotatable-span highlight">And right then and there</span><br/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></div>
|
||||
<div class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></div>
|
||||
<div class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="choicegroup">
|
||||
<label for="input_1_1_1"><input type="checkbox" name="input_1_1" id="input_1_1_1" value="1"> One</label>
|
||||
<label for="input_1_1_2" class="choicegroup_correct"><input type="checkbox" name="input_1_1" id="input_1_1_2" value="2"> Two <span class="status correct"></span></label>
|
||||
<label for="input_1_1_3"><input type="checkbox" name="input_1_1" id="input_1_1_3" value="3"> Three</label>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<div id="textbox_101" class="capa_inputtype textbox cminput">
|
||||
<label class="problem-group-label" for="cm-textarea-101">question label here</label>
|
||||
<textarea rows="40" cols="80" name="input_101"
|
||||
aria-label="python editor"
|
||||
aria-describedby="answer_101"
|
||||
id="input_101"
|
||||
tabindex="0"
|
||||
data-mode="python"
|
||||
data-tabsize="4"
|
||||
data-linenums="true"
|
||||
>write some awesome code</textarea>
|
||||
<span class="cm-editor-exit-message capa-message" id="cm-editor-exit-message-101">
|
||||
Press ESC then TAB or click outside of the code editor to exit
|
||||
</span>
|
||||
|
||||
<div class="grader-status" tabindex="-1">
|
||||
<span id="status_101" class="correct" aria-describedby="input_101">
|
||||
<span class="status sr">correct</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:11
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:9.576244,
|
||||
XXXXXXXXT114-V015600_0_0.ts
|
||||
#EXTINF:8.842178,
|
||||
XXXXXXXXT114-V015600_0_1.ts
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_0_2.ts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:11
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:9.576244,
|
||||
XXXXXXXXT114-V015600_1_0.ts
|
||||
#EXTINF:9.042378,
|
||||
XXXXXXXXT114-V015600_1_1.ts
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_1_2.ts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:11
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:9.576244,
|
||||
XXXXXXXXT114-V015600_2_0.ts
|
||||
#EXTINF:9.042378,
|
||||
XXXXXXXXT114-V015600_2_1.ts
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_2_2.ts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:11
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_3_0.ts
|
||||
#EXTINF:9.009011,
|
||||
XXXXXXXXT114-V015600_3_1.ts
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_3_2.ts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-TARGETDURATION:11
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_4_0.ts
|
||||
#EXTINF:9.009011,
|
||||
XXXXXXXXT114-V015600_4_1.ts
|
||||
#EXTINF:9.609611,
|
||||
XXXXXXXXT114-V015600_4_2.ts
|
||||
#EXT-X-ENDLIST
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=264787,RESOLUTION=1280x720
|
||||
XXXXXXXXT114-V015600_1_/XXXXXXXXT114-V015600_1_.m3u8
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=328415,RESOLUTION=1920x1080
|
||||
XXXXXXXXT114-V015600_0_/XXXXXXXXT114-V015600_0_.m3u8
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=70750,RESOLUTION=640x360
|
||||
XXXXXXXXT114-V015600_3_/XXXXXXXXT114-V015600_3_.m3u8
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=148269,RESOLUTION=960x540
|
||||
XXXXXXXXT114-V015600_2_/XXXXXXXXT114-V015600_2_.m3u8
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=41276,RESOLUTION=640x360
|
||||
XXXXXXXXT114-V015600_4_/XXXXXXXXT114-V015600_4_.m3u8
|
||||
@@ -1,10 +0,0 @@
|
||||
<div class="test-component">
|
||||
<div class="wrapper-comp-editor" id="editor-tab" data-base-asset-url="/c4x/foo/bar/asset/" data-editor="visual">
|
||||
<section class="html-editor editor">
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce"><p>original visual text</p></textarea>
|
||||
<textarea name="" class="edit-box">raw text</textarea>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<div class="test-component">
|
||||
<div class="wrapper-comp-editor" id="editor-tab" data-editor="raw">
|
||||
<section class="html-editor editor">
|
||||
<div class="row">
|
||||
<textarea name="" class="edit-box">raw text</textarea>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<!-- ${id} = 12345 -->
|
||||
<!-- ${width} = 300 -->
|
||||
<!-- ${height} = 400 -->
|
||||
|
||||
<div class="imageinput capa_inputtype" id="inputtype_12345">
|
||||
<input
|
||||
type="hidden"
|
||||
class="imageinput"
|
||||
src=""
|
||||
name="input_12345"
|
||||
id="input_12345"
|
||||
value=""
|
||||
/>
|
||||
|
||||
<div style="position:relative;">
|
||||
<div
|
||||
id="imageinput_12345"
|
||||
style="width: 300px; height: 400px; position: relative; left: 0; top: 0; visibility: hidden;"
|
||||
>
|
||||
<!-- image will go here -->
|
||||
</div>
|
||||
<div id="answer_12345" data-width="100" data-height="100"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- status == 'unsubmitted' -->
|
||||
<span
|
||||
class="unanswered"
|
||||
style="display: inline-block;"
|
||||
id="status_12345"
|
||||
aria-describedby="input_12345"
|
||||
>
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,35 +0,0 @@
|
||||
<!-- ${id} = 12345 -->
|
||||
<!-- ${width} = 300 -->
|
||||
<!-- ${height} = 400 -->
|
||||
|
||||
<div class="imageinput capa_inputtype" id="inputtype_<%-id%>">
|
||||
<input
|
||||
type="hidden"
|
||||
class="imageinput"
|
||||
src=""
|
||||
name="input_<%-id%>"
|
||||
id="input_<%-id%>"
|
||||
value=""
|
||||
/>
|
||||
|
||||
<div style="position:relative;">
|
||||
<div
|
||||
id="imageinput_<%-id%>"
|
||||
style="width: <%-width%>px; height: <%-height%>px; position: relative; left: 0; top: 0; visibility: hidden;"
|
||||
>
|
||||
<!-- image will go here -->
|
||||
</div>
|
||||
<div id="answer_<%-id%>" data-width="100" data-height="100"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- status == 'unsubmitted' -->
|
||||
<span
|
||||
class="unanswered"
|
||||
style="display: inline-block;"
|
||||
id="status_<%-id%>"
|
||||
aria-describedby="input_<%-id%>"
|
||||
>
|
||||
<span class="sr">Status: unanswered</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"content": "Video 1",
|
||||
"type": "video",
|
||||
"title": "Video 1"
|
||||
}, {
|
||||
"content": "Video 2",
|
||||
"type": "video",
|
||||
"title": "Video 2"
|
||||
}, {
|
||||
"content": "Sample Problem",
|
||||
"type": "problem",
|
||||
"title": "Sample Problem"
|
||||
}
|
||||
]
|
||||
@@ -1,53 +0,0 @@
|
||||
<h3 class="hd hd-3 problem-header">Custom Javascript Display and Grading</h3>
|
||||
|
||||
<div class="problem">
|
||||
<div>
|
||||
<span>
|
||||
<section data-processed="true" data-sop="false" data-setstate="WebGLDemo.setState"
|
||||
data-getstate="WebGLDemo.getState"
|
||||
data-stored=""
|
||||
data="WebGLDemo.getGrade" class="jsinput"
|
||||
id="inputtype_1_">
|
||||
<div id="status_1_" class="correct">
|
||||
<iframe width="400" height="400" frameborder="0" src="https://studio.edx.org/c4x/edX/DemoX/asset/webGLDemo.html"
|
||||
seamless="seamless"
|
||||
sandbox="allow-scripts allow-popups allow-same-origin allow-forms allow-pointer-lock"
|
||||
id="iframe_1_"
|
||||
name="iframe_1_">
|
||||
</iframe>
|
||||
<input type="hidden"
|
||||
value=""
|
||||
waitfor="" id="input_1_1"
|
||||
name="input_1_1">
|
||||
</div>
|
||||
</section>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<section data-processed="true" data-sop="false" data-setstate="WebGLDemo.setState"
|
||||
data-getstate="WebGLDemo.getState"
|
||||
data-stored=""
|
||||
data="WebGLDemo.getGrade" class="jsinput"
|
||||
id="inputtype_1_">
|
||||
<div id="status_1_" class="incorrect">
|
||||
<iframe width="400" height="400" frameborder="0"
|
||||
src="https://studio.edx.org/c4x/edX/DemoX/asset/webGLDemo.html" seamless="seamless"
|
||||
sandbox="allow-scripts allow-popups allow-same-origin allow-forms allow-pointer-lock"
|
||||
id="iframe_1_"
|
||||
name="iframe_1_">
|
||||
</iframe>
|
||||
<input type="hidden"
|
||||
value=""
|
||||
id="input_1_2"
|
||||
name="input_1_2">
|
||||
</div>
|
||||
</section>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="action">
|
||||
<input type="hidden" value="Custom Javascript Display and Grading" name="problem_id">
|
||||
<input type="button" value="Reset" class="reset">
|
||||
<button class="show"><span aria-hidden="true" class="show-label">Show Answer</span> <span class="sr">Reveal Answer</span></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,37 +0,0 @@
|
||||
<div class="lti-wrapper">
|
||||
<div
|
||||
id="lti_id"
|
||||
class="lti"
|
||||
data-open_in_a_new_page="true"
|
||||
data-ajax_url="www.example.com/ajax_url"
|
||||
>
|
||||
|
||||
<form
|
||||
action=""
|
||||
name="ltiLaunchForm"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="_blank"
|
||||
enctype="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="user_id" value="student" />
|
||||
<input name="oauth_nonce" value="28347958723982798572" />
|
||||
<input name="oauth_timestamp" value="2389479832" />
|
||||
<input name="oauth_consumer_key" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="role" value="student" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="oauth_signature" value="89ru3289r3ry283y3r82ryr38yr" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="context_id" value="testorg/testcoursename/2014" />
|
||||
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user