Revert "Revert "refactor: move xmodule folder to root""

This commit is contained in:
Muhammad Umar Khan
2022-06-20 18:20:06 +05:00
committed by GitHub
parent 46d848be41
commit a389a9ff10
487 changed files with 216 additions and 339 deletions

View File

@@ -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',
],
},
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &amp; 1 \\ 1 &amp; 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>

View File

@@ -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/>`_

View File

@@ -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

View File

@@ -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()

View File

@@ -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();
}
});
});
};

View File

@@ -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);
});
},
});
});
}

View File

@@ -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

View File

@@ -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', {});
});
};

View File

@@ -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;
}

View File

@@ -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 {};
};

View File

@@ -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);
});
};

View File

@@ -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 {};
};

View File

@@ -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')
});
};

View File

@@ -1,10 +0,0 @@
module.exports = {
extends: 'eslint-config-edx',
root: true,
settings: {
'import/resolver': 'webpack',
},
overrides: {
excludedFiles: 'public/js/*',
},
};

View File

@@ -1 +0,0 @@
public

View File

@@ -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

View File

@@ -1,3 +0,0 @@
const WordCloudMain = require('xmodule/assets/word_cloud/src/js/word_cloud_main.js');
window.WordCloud = WordCloudMain.default;

View File

@@ -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);
}
}

View File

@@ -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: '_',
},
};

View File

@@ -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()

View File

@@ -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)

View File

@@ -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>

View File

@@ -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)

View File

@@ -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()

View File

@@ -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|&nbsp;|//)+",
" ",
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

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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="&#95;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)

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -1,5 +0,0 @@
.CodeMirror {
background: #fff;
font-size: 13px;
color: #3c3c3c;
}

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}
}
}
}
}

View File

@@ -1,14 +0,0 @@
// HTML component editor:
.html-editor {
@include clearfix();
.CodeMirror {
box-sizing: border-box;
height: 435px;
}
.is-inactive {
display: none;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}
}

View File

@@ -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%;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.
* **MongoDBs 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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -1 +0,0 @@
../../../../../common/static/js/RequireJS-namespace-undefine.js

View File

@@ -1 +0,0 @@
../../../../static/

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
}
]

View File

@@ -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>

View File

@@ -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