def test_none(self): # test for NoneType for to_string and from_string methods test_field = String(enforce_type=True) result_to_string = test_field.to_string(None) self.assertEquals(result_to_string, '') result_from_string = test_field.from_string(None) self.assertEquals(result_from_string, '')
class MarkdownXBlock(StudioEditableXBlockMixin, XBlockWithSettingsMixin, XBlock): """ This XBlock provides content editing in Markdown and displays it in HTML. """ display_name = String(display_name=_('Display Name'), help=_('The display name for this component.'), scope=Scope.settings, default=_('Markdown')) data = String(help=_('The Markdown content for this module'), default=u'', scope=Scope.content) editor = 'markdown' editable_fields = ('display_name', ) @staticmethod def resource_string(path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode('utf8') @XBlock.supports('multi_device') def student_view(self, context=None): # pylint: disable=unused-argument """ Return a fragment that contains the html for the student view. """ frag = Fragment() frag.content = xblock_loader.render_django_template( 'static/html/lms.html', {'self': self}) frag.add_css( self.resource_string('public/plugins/codesample/css/prism.css')) frag.add_javascript( self.resource_string('public/plugins/codesample/js/prism.js')) frag.add_css(self.resource_string('static/css/pygments.css')) return frag def studio_view(self, context=None): # pylint: disable=unused-argument """ Return a fragment that contains the html for the Studio view. """ frag = Fragment() settings_fields = self.get_editable_fields() settings_page = loader.render_django_template( 'templates/studio_edit.html', {'fields': settings_fields}) context = { 'self': self, 'settings_page': settings_page, } frag.content = xblock_loader.render_django_template( 'static/html/studio.html', context) self.add_stylesheets(frag) self.add_scripts(frag) js_data = { 'editor': self.editor, 'skin_url': self.runtime.local_resource_url(self, 'public/skin'), 'external_plugins': self.get_editor_plugins() } frag.initialize_js('MarkdownXBlock', js_data) return frag @XBlock.json_handler def update_content(self, data, suffix=''): # pylint: disable=unused-argument """ Update the saved HTML data with the new HTML passed in the JSON 'content' field. """ self.data = data['content'] return {'content': self.data} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ('MarkdownXBlock', """<vertical_demo> <markdown data=" # This is h1 ## This is h2 ``` This is a code block ``` * This is * an unordered * list This is a regular paragraph 1. This is 1. an ordered 1. list *This is italic* **This is bold** "/> </vertical_demo> """), ] def add_stylesheets(self, frag): """ A helper method to add all necessary styles to the fragment. :param frag: The fragment that will hold the scripts. """ frag.add_css(self.resource_string('static/css/html.css')) frag.add_css( self.resource_string( 'public/plugins/codemirror/codemirror-4.8/lib/codemirror.css')) def add_scripts(self, frag): """ A helper method to add all necessary scripts to the fragment. :param frag: The fragment that will hold the scripts. """ frag.add_javascript( self.resource_string('static/js/tinymce/tinymce.min.js')) frag.add_javascript( self.resource_string( 'static/js/tinymce/themes/modern/theme.min.js')) frag.add_javascript(self.resource_string('static/js/html.js')) frag.add_javascript(loader.load_unicode('public/studio_edit.js')) code_mirror_dir = 'public/plugins/codemirror/codemirror-4.8/' frag.add_javascript( self.resource_string(code_mirror_dir + 'lib/codemirror.js')) frag.add_javascript( self.resource_string(code_mirror_dir + 'mode/markdown/markdown.js')) def get_editor_plugins(self): """ This method will generate a list of external plugins urls to be used in TinyMCE editor. These plugins should live in `public` directory for us to generate URLs for. const PLUGINS_DIR = "/resource/html5/public/plugins/"; const EXTERNAL_PLUGINS = PLUGINS.map(function(p) { return PLUGINS_DIR + p + "/plugin.min.js" }); :return: A list of URLs """ plugin_path = 'public/plugins/{plugin}/plugin.min.js' plugins = [ 'codesample', 'image', 'link', 'lists', 'textcolor', 'codemirror' ] return { plugin: self.runtime.local_resource_url(self, plugin_path.format(plugin=plugin)) for plugin in plugins } def substitute_keywords(self, html): """ Replaces all %%-encoded words using KEYWORD_FUNCTION_MAP mapping functions. Iterates through all keywords that must be substituted and replaces them by calling the corresponding functions stored in `keywords`. If the function throws a specified exception, the substitution is not performed. Functions stored in `keywords` must either: - return a replacement string - throw `KeyError` or `AttributeError`, `TypeError`. """ data = html system = getattr(self, 'system', None) if not system: # This shouldn't happen, but if `system` is missing, then skip substituting keywords. return data keywords = { '%%USER_ID%%': lambda: getattr(system, 'anonymous_student_id'), '%%COURSE_ID%%': lambda: getattr(system, 'course_id').html_id(), } for key, substitutor in keywords.items(): if key in data: try: data = data.replace(key, substitutor()) except (KeyError, AttributeError, TypeError): # Do not replace the keyword when substitutor is not present. pass return data @property def html(self): """ A property that returns the markdown content data as html. """ settings = get_xblock_settings() extras = settings.get("extras", DEFAULT_EXTRAS) safe_mode = settings.get("safe_mode", True) html = markdown2.markdown(self.data, extras=extras, safe_mode=safe_mode) html = self.substitute_keywords(html) return html def get_editable_fields(self): """ This method extracts the editable fields from this XBlock and returns them after validating them. Part of this method's copied from StudioEditableXBlockMixin#submit_studio_edits with some modifications.. :return: A list of the editable fields with the information that the template needs to render a form field for them. """ fields = [] # Build a list of all the fields that can be edited: for field_name in self.editable_fields: field = self.fields[field_name] # pylint: disable=unsubscriptable-object assert field.scope in (Scope.content, Scope.settings), ( 'Only Scope.content or Scope.settings fields can be used with ' 'StudioEditableXBlockMixin. Other scopes are for user-specific data and are ' 'not generally created/configured by content authors in Studio.' ) field_info = self._make_field_info(field_name, field) if field_info is not None: fields.append(field_info) return fields
class RecapXBlock(StudioEditableXBlockMixin, XBlock, XBlockWithSettingsMixin): """ RecapXblock allows users to download a PDF copy of their answers to supported Xblock types. Supported types include freetextresponse and "problem". """ display_name = String( display_name="Display Name", help="This is the name of the component", scope=Scope.settings, default="Recap" ) xblock_list = List( display_name="Problems", help="Component ID's of the XBlocks you wish to include in the summary.", allow_reset=False, scope=Scope.settings ) string_html = String( display_name="Layout", help="Include HTML formatting (introductory paragraphs or headings)that" " you would like to accompany the summary of questions and answers.", multiline_editor='html', default="<p>[[CONTENT]]</p>", scope=Scope.settings ) allow_download = Boolean( display_name="Allow Download", help="Allow the user to download a pdf summary", default=True, scope=Scope.settings, ) download_text = String( display_name="Download Button Text", help="Text to display on the download button", default="Download", scope=Scope.settings, ) html_file = String( display_name="HTML File.", help=""" Custom html template to wrap the content generated by the Xblock. If you have uploaded a file, the layout section will be deactivated. """, default=None, scope=Scope.settings, ) css_file = String( display_name="CSS File.", help="CSS file that allow to modify the style content for the student view.", default=None, scope=Scope.settings, ) editable_fields = ( 'display_name', 'xblock_list', 'html_file', 'css_file', 'string_html', 'allow_download', 'download_text', ) show_in_read_only_mode = True def get_recap_course_blocks(self, course_key): """ Retrieve all XBlocks in the course for a particular category. Returns only XBlocks that are published and haven't been deleted. """ # Note: we need to check if found components have been orphaned # due to a bug in split modulestore (PLAT-799). Once that bug # is resolved, we can skip the `_is_in_course_tree()` check entirely. return [ block for block in modulestore().get_items( course_key, qualifiers={"category": "recap"}, ) ] def validate_field_data(self, validation, data): """ Validate this block's field data. We are validating that the chosen freetextresponse xblocks ID's exist in the course """ for x_id, x_type in data.xblock_list: try: usage_key =\ self.scope_ids.usage_id.course_key.make_usage_key( x_type, x_id ) self.runtime.get_block(usage_key) except Exception as e: logger.warn(e) validation.add( ValidationMessage( ValidationMessage.ERROR, u"Component freetextresponse ID: {} does not exist.".format(x_id) ) ) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def get_blocks(self, xblock_list): for x_id, x_type in xblock_list: try: usage_key = \ self.scope_ids.usage_id.course_key.make_usage_key( x_type, x_id ) yield usage_key, x_type except InvalidKeyError: pass def get_blocks_filtering_list(self, xblock_list): filter_list = [] for x_id, x_type in xblock_list: try: usage_key = \ self.scope_ids.usage_id.course_key.make_usage_key( x_type, x_id ) filter_list.append(usage_key) except InvalidKeyError: pass return filter_list def get_submission_key(self, usage_key, user=None): """ Returns submission key needed for submissions api """ try: logger.info('Attempting to retrieve student item dictionary.') if not user: user = self.runtime.get_real_user(self.runtime.anonymous_student_id) student_item_dictionary = dict( student_id=user.id, course_id=str(usage_key.course_key), item_id=str(usage_key), item_type=usage_key.block_type, ) except AttributeError: student_item_dictionary = '' logger.error('Studio cannot access self.runtime.get_real_user') return student_item_dictionary def get_submission(self, usage_key, user=None): """ Returns submission from submissions api """ try: submission_key = self.get_submission_key(usage_key, user) submission = api.get_submissions(submission_key, limit=1) if submission is not None: logger.info( 'Attempting to retreive submission from submissions api.' ) value = submission[0]["answer"] except IndexError: logger.warn( 'IndexError: no submssion matched given student item dict.' ) value = _("Nothing to recap.") return value def get_display_answer(self, answer): """ Returns formatted answer or placeholder string """ answer_str = _("Nothing to recap.") if answer: answer_str = re.sub(r'\n+', '<div></div>', str(answer)) return answer_str @XBlock.supports("multi_device") def student_view(self, context=None): """ The primary view of the RecapXBlock seen in LMS """ blocks = [] for usage_key, xblock_type in self.get_blocks(self.xblock_list): block = self.runtime.get_block(usage_key) if hasattr(block, 'custom_report_format'): question = str(block.display_name) try: user = self.runtime.get_real_user(self.runtime.anonymous_student_id) except TypeError: user = None answer = block.custom_report_format( user=user, block=block, ) blocks.append((question, answer)) elif xblock_type == 'problem': answer = u"" question = u"" try: question = str(block.display_name) answer = self.get_submission(usage_key) if answer is None: answer = block.lcp.get_question_answer_text() blocks.append((question, answer)) except Exception as e: logger.warn(str(e)) answer = block.lcp.get_question_answer_text() blocks.append((question, answer)) layout = self.get_user_layout(blocks) idArray = self.scope_ids.usage_id._to_string().split('@') xblockId = idArray[len(idArray) - 1] context = { 'recap_answers_id': 'recap_answers_' + xblockId, 'recap_editor_id': 'recap_editor_' + xblockId, 'recap_cmd_id': 'recap_cmd_' + xblockId, 'blocks': blocks, 'layout': layout, 'allow_download': self.allow_download, 'download_text': self.download_text, } frag = Fragment( loader.render_django_template( "static/html/recap.html", context).format(self=self) ) if (self.css_file): frag.add_css(self.css_file) frag.add_css(self.resource_string("static/css/recap.css")) frag.add_javascript_url( self.runtime.local_resource_url( self, 'public/FileSaver.js/FileSaver.min.js' ) ) frag.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/jspdf.min.js' ) ) frag.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/html2canvas.min.js' ) ) frag.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/html2pdf.js' ) ) frag.add_javascript(self.resource_string("static/js/src/recap.js")) frag.initialize_js('RecapXBlock', { 'recap_answers_id': 'recap_answers_' + xblockId, 'recap_editor_id': 'recap_editor_' + xblockId, 'recap_cmd_id': 'recap_cmd_' + xblockId, }) return frag def get_blocks_list(self, user, block_list): blocks = [] for usage_key, xblock_type in self.get_blocks(block_list): try: block = self.runtime.get_block(usage_key) if hasattr(block, 'custom_report_format'): question = str(block.display_name) answer = block.custom_report_format( user=user, block=block, ) blocks.append((question, answer)) elif xblock_type == 'problem': answer = "" question = "" try: question = str(block.display_name) answer = self.get_submission(usage_key, user) blocks.append((question, answer)) except Exception as e: blocks.append((str(usage_key), str(e))) except Exception as e: logger.warn(str(e)) return blocks def get_user_layout(self, blocks, user=None): ''' For the Recap Instructor dashboard, get HTML layout of user's answers ''' def update_layout(layout, pattern, layout_type): ''' ''' block_sets = [] current = 0 for m in re.finditer(pattern, layout): try: title = blocks[int(m.group(1)) - 1][0] answer = blocks[int(m.group(1)) - 1][1] if layout_type == LayoutType.BLOCKS: subblocks = [] for x in range(current, current + int(m.group(1))): if len(blocks) > x: subblocks.append((blocks[x][0], blocks[x][1])) current += 1 answers = [block_layout.format(q, self.get_display_answer(a)) for q, a in subblocks] qa_str = str(''.join(answers)) elif layout_type == LayoutType.SINGLE_BLOCK: qa_str = str(block_layout).format(title, self.get_display_answer(answer)) elif layout_type == LayoutType.TITLE: qa_str = title elif layout_type == LayoutType.ANSWER: qa_str = str(self.get_display_answer(answer)) block_sets.append((m.start(0), m.end(0), qa_str)) except IndexError as error: logger.info('Update layout error: %s', error) pass for start, end, string in reversed(block_sets): layout = layout[0:start] + string + layout[end:] return layout template = self.html_file if self.html_file else self.string_html block_layout = ( '<p class="recap_question"><strong>{}</strong></p>' '<div class="recap_answer" ' 'style="page-break-before:always">{}</div>' ) qa_str = str( ''.join( str(block_layout).format( q, self.get_display_answer(a) ) for q, a in blocks ) ) layout = template.replace('[[CONTENT]]', qa_str) pattern = re.compile(r'\[\[SINGLEBLOCK\(([0-9]+)\)\.(TITLE)\]\]') layout = update_layout(layout, pattern, LayoutType.TITLE) pattern = re.compile(r'\[\[SINGLEBLOCK\(([0-9]+)\)\.(ANSWER)\]\]') layout = update_layout(layout, pattern, LayoutType.ANSWER) pattern = re.compile(r'\[\[SINGLEBLOCK\(([0-9]+)\)\]\]') layout = update_layout(layout, pattern, LayoutType.SINGLE_BLOCK) pattern = re.compile(r'\[\[BLOCKS\(([0-9]+)\)\]\]') return update_layout(layout, pattern, LayoutType.BLOCKS) def studio_view(self, context): """ Render a form for editing this XBlock """ frag = Fragment() context = { 'fields': [], 'xblock_list': self.xblock_list, } # Build a list of all the fields that can be edited: for field_name in self.editable_fields: field = self.fields[field_name] assert field.scope in (Scope.content, Scope.settings), ( "Only Scope.content or Scope.settings fields can be used with " "StudioEditableXBlockMixin. Other scopes are for user-specific data and are " "not generally created/configured by content authors in Studio." ) field_info = self._make_field_info(field_name, field) if field_info is not None: context["fields"].append(field_info) frag.content = loader.render_django_template("static/html/recap_edit.html", context) frag.add_javascript(loader.load_unicode("static/js/src/recap_edit.js")) frag.initialize_js('StudioEditableXBlockMixin') return frag def recap_blocks_listing_view(self, context=None): """This view is used in the Racap tab in the LMS Instructor Dashboard to display all available course Recap xblocks. Args: context: contains two items: "recap_items" - all course items with names and parents, example: [{"parent_name": "Vertical name", "name": "Recap Display Name", }, ...] Returns: (Fragment): The HTML Fragment for this XBlock. """ course_id = self.location.course_key recap_blocks = self.get_recap_course_blocks(course_id) recap_name_list = [] for block in recap_blocks: recap_name_list.append((block.display_name, block.xblock_list)) make_pdf_json = reverse('xblock_handler', args=[course_id, block.location, 'make_pdf_json']) refresh_table = reverse('xblock_handler', args=[course_id, block.location, 'refresh_table']) user = self.runtime.get_real_user(self.runtime.anonymous_student_id) lang_prefs = get_user_preference(user, LANGUAGE_KEY) context_dict = { "make_pdf_json": make_pdf_json, "refresh_table": refresh_table, "recap_name_list": recap_name_list, "lang_prefs": lang_prefs } instructor_dashboard_fragment = Fragment() instructor_dashboard_fragment.content = loader.render_django_template( 'static/html/recap_dashboard.html', context_dict ) instructor_dashboard_fragment.add_css( self.resource_string("static/css/recap.css") ) instructor_dashboard_fragment.add_css( self.resource_string("public/DataTables/css/jquery.dataTables.css") ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/FileSaver.js/FileSaver.min.js' ) ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/jspdf.min.js' ) ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/html2canvas.min.js' ) ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/jsPDF-1.3.2/html2pdf.js' ) ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/DataTables/js/jquery.dataTables.js' ) ) instructor_dashboard_fragment.add_javascript_url( self.runtime.local_resource_url( self, "public/recap_dashboard.js" ) ) instructor_dashboard_fragment.initialize_js('RecapDashboard') return instructor_dashboard_fragment @XBlock.json_handler def make_pdf_json(self, data, suffix=''): ''' This is a XBlock json handler for the async pdf download ''' user = User.objects.get(id=data['user_id']) which_blocks = ast.literal_eval(data['these_blocks']) blocks = self.get_blocks_list(user, which_blocks) html = self.get_user_layout(blocks, user) if self.css_file: html = '<style>{}</style>{}'.format(self.css_file, html) if "<h3>" in html: html = re.sub("<h3>(.*?)<\/h3>","<h3>{}</h3>".format(data['document_heading']), html) else: html = u"<h3>{}</h3> \n {}".format(str(data['document_heading']), str(html)) return {'html': html, 'user_name': user.username} @XBlock.json_handler def refresh_table(self, data, suffix=''): """ Complete HTML view of the XBlock, for refresh by client """ course_id = self.location.course_key recap_blocks = self.get_recap_course_blocks(course_id) selected_recap_index = data["recap_id"] block_ids = [] for block in recap_blocks: block_ids.append(self.get_blocks_filtering_list(block.xblock_list)) query_list = [] for block_id in block_ids[selected_recap_index]: query_list.append(Submission.objects.filter( student_item__item_id=block_id ).values_list('student_item__student_id', flat=True)) student_ids_intersection = list(set.intersection(*map(set, query_list))) downloadable_users = User.objects.filter( courseenrollment__course_id=course_id, courseenrollment__is_active=1, id__in=map(int, student_ids_intersection) ).values('username', 'email', 'id') return {'data': list(downloadable_users)} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("RecapXBlock", """<recap/> """), ("Multiple RecapXBlock", """<vertical_demo> <recap/> <recap/> <recap/> </vertical_demo> """), ]
class StaffGradedAssignmentXBlock(StudioEditableXBlockMixin, ShowAnswerXBlockMixin, XBlock): """ This block defines a Staff Graded Assignment. Students are shown a rubric and invited to upload a file which is then graded by staff. """ has_score = True icon_class = 'problem' STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB editable_fields = ('display_name', 'points', 'weight', 'file_types', 'showanswer', 'solution') display_name = String( display_name=_("Display Name"), default=_('Staff Graded Assignment'), scope=Scope.settings, help=_("This name appears in the horizontal navigation at the top of " "the page.")) file_types = String( display_name=_("Accepted filetypes"), default=_(''), scope=Scope.settings, help= _("Accepted filetypes, like: ^image\/(gif|jpe?g|png)$, ^application\/(pdf|document)$" )) weight = Float( display_name=_("Problem Weight"), help=_("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) points = Integer( display_name=_("Maximum score"), help=_("Maximum grade score given to assignment by staff."), default=100, scope=Scope.settings) staff_score = Integer( display_name=_("Score assigned by non-instructor staff"), help=_("Score will need to be approved by instructor before being " "published."), default=None, scope=Scope.settings) comment = String(display_name=_("Instructor comment"), default='', scope=Scope.user_state, help=_("Feedback given to student by instructor.")) annotated_sha1 = String( display_name=_("Annotated SHA1"), scope=Scope.user_state, default=None, help=_("sha1 of the annotated file uploaded by the instructor for " "this assignment.")) annotated_filename = String( display_name=_("Annotated file name"), scope=Scope.user_state, default=None, help=_("The name of the annotated file uploaded for this assignment.")) annotated_mimetype = String( display_name=_("Mime type of annotated file"), scope=Scope.user_state, default=None, help=_( "The mimetype of the annotated file uploaded for this assignment.") ) annotated_timestamp = DateTime( display_name=_("Timestamp"), scope=Scope.user_state, default=None, help=_("When the annotated file was uploaded")) @classmethod def student_upload_max_size(cls): """ returns max file size limit in system """ return getattr(settings, "STUDENT_FILEUPLOAD_MAX_SIZE", cls.STUDENT_FILEUPLOAD_MAX_SIZE) @classmethod def file_size_over_limit(cls, file_obj): """ checks if file size is under limit. """ file_obj.seek(0, os.SEEK_END) return file_obj.tell() > cls.student_upload_max_size() @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """ Override default serialization to handle <solution /> elements """ block = runtime.construct_xblock_from_class(cls, keys) for child in node: if child.tag == "solution": # convert child elements of <solution> into HTML for display block.solution = ''.join( etree.tostring(subchild) for subchild in child) # Attributes become fields. # Note that a solution attribute here will override any solution XML element for name, value in node.items(): # lxml has no iteritems cls._set_field_if_present(block, name, value, {}) return block def add_xml_to_node(self, node): """ Override default serialization to output solution field as a separate child element. """ super(StaffGradedAssignmentXBlock, self).add_xml_to_node(node) if 'solution' in node.attrib: # Try outputting it as an XML element if we can solution = node.attrib['solution'] wrapped = "<solution>{}</solution>".format(solution) try: child = etree.fromstring(wrapped) except: # pylint: disable=bare-except # Parsing exception, leave the solution as an attribute pass else: node.append(child) del node.attrib['solution'] @XBlock.json_handler def save_sga(self, data, suffix=''): # pylint: disable=unused-argument """ Persist block data when updating settings in studio. """ self.display_name = data.get('display_name', self.display_name) # Validate points before saving points = data.get('points', self.points) # Check that we are an int try: points = int(points) except ValueError: raise JsonHandlerError(400, 'Points must be an integer') # Check that we are positive if points < 0: raise JsonHandlerError(400, 'Points must be a positive integer') self.points = points # Validate weight before saving weight = data.get('weight', self.weight) # Check that weight is a float. if weight: try: weight = float(weight) except ValueError: raise JsonHandlerError(400, 'Weight must be a decimal number') # Check that we are positive if weight < 0: raise JsonHandlerError( 400, 'Weight must be a positive decimal number') self.weight = weight @XBlock.handler def upload_assignment(self, request, suffix=''): # pylint: disable=unused-argument, protected-access """ Save a students submission file. """ require(self.upload_allowed()) user = self.get_real_user() require(user) upload = request.params['assignment'] sha1 = get_sha1(upload.file) if self.file_size_over_limit(upload.file): raise JsonHandlerError( 413, 'Unable to upload file. Max size limit is {size}'.format( size=self.student_upload_max_size())) # Uploading an assignment represents a change of state with this user in this block, # so we need to ensure that the user has a StudentModule record, which represents that state. self.get_or_create_student_module(user) answer = { "sha1": sha1, "filename": upload.file.name, "mimetype": mimetypes.guess_type(upload.file.name)[0], "finalized": False } student_item_dict = self.get_student_item_dict() submissions_api.create_submission(student_item_dict, answer) path = self.file_storage_path(sha1, upload.file.name) log.info("Saving file: %s at path: %s for user: %s", upload.file.name, path, user.username) if default_storage.exists(path): # save latest submission default_storage.delete(path) default_storage.save(path, File(upload.file)) return Response(json_body=self.student_state()) @XBlock.handler def finalize_uploaded_assignment(self, request, suffix=''): # pylint: disable=unused-argument """ Finalize a student's uploaded submission. This prevents further uploads for the given block, and makes the submission available to instructors for grading """ submission_data = self.get_submission() require(self.upload_allowed(submission_data=submission_data)) # Editing the Submission record directly since the API doesn't support it submission = Submission.objects.get(uuid=submission_data['uuid']) if not submission.answer.get('finalized'): submission.answer['finalized'] = True submission.submitted_at = django_now() submission.save() return Response(json_body=self.student_state()) @XBlock.handler def staff_upload_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Save annotated assignment from staff. """ require(self.is_course_staff()) upload = request.params['annotated'] sha1 = get_sha1(upload.file) if self.file_size_over_limit(upload.file): raise JsonHandlerError( 413, 'Unable to upload file. Max size limit is {size}'.format( size=self.student_upload_max_size())) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) state['annotated_sha1'] = sha1 state['annotated_filename'] = filename = upload.file.name state['annotated_mimetype'] = mimetypes.guess_type(upload.file.name)[0] state['annotated_timestamp'] = utcnow().strftime( DateTime.DATETIME_FORMAT) path = self.file_storage_path(sha1, filename) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) module.state = json.dumps(state) module.save() log.info("staff_upload_annotated for course:%s module:%s student:%s ", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def download_assignment(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch student assignment from storage and return it. """ answer = self.get_submission()['answer'] path = self.file_storage_path(answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename']) @XBlock.handler def download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch assignment with staff annotations from storage and return it. """ path = self.file_storage_path( self.annotated_sha1, self.annotated_filename, ) return self.download(path, self.annotated_mimetype, self.annotated_filename) @XBlock.handler def staff_download(self, request, suffix=''): # pylint: disable=unused-argument """ Return an assignment file requested by staff. """ require(self.is_course_staff()) submission = self.get_submission(request.params['student_id']) answer = submission['answer'] path = self.file_storage_path(answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename'], require_staff=True) @XBlock.handler def staff_download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Return annotated assignment file requested by staff. """ require(self.is_course_staff()) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) path = self.file_storage_path(state['annotated_sha1'], state['annotated_filename']) return self.download(path, state['annotated_mimetype'], state['annotated_filename'], require_staff=True) @XBlock.handler def get_staff_grading_data(self, request, suffix=''): # pylint: disable=unused-argument """ Return the html for the staff grading view """ require(self.is_course_staff()) return Response(json_body=self.staff_grading_data()) @XBlock.handler def enter_grade(self, request, suffix=''): # pylint: disable=unused-argument """ Persist a score for a student given by staff. """ require(self.is_course_staff()) score = request.params.get('grade', None) module = self.get_student_module(request.params['module_id']) if not score: return Response(json_body=self.validate_score_message( module.course_id, module.student.username)) state = json.loads(module.state) try: score = int(score) except ValueError: return Response(json_body=self.validate_score_message( module.course_id, module.student.username)) if self.is_instructor(): uuid = request.params['submission_id'] submissions_api.set_score(uuid, score, self.max_score()) else: state['staff_score'] = score state['comment'] = request.params.get('comment', '') module.state = json.dumps(state) module.save() log.info("enter_grade for course:%s module:%s student:%s", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def remove_grade(self, request, suffix=''): # pylint: disable=unused-argument """ Reset a students score request by staff. """ require(self.is_course_staff()) student_id = request.params['student_id'] submissions_api.reset_score(student_id, self.block_course_id, self.block_id) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) state['staff_score'] = None state['comment'] = '' state['annotated_sha1'] = None state['annotated_filename'] = None state['annotated_mimetype'] = None state['annotated_timestamp'] = None module.state = json.dumps(state) module.save() log.info("remove_grade for course:%s module:%s student:%s", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def prepare_download_submissions(self, request, suffix=''): # pylint: disable=unused-argument """ Runs a async task that collects submissions in background and zip them. """ # pylint: disable=no-member require(self.is_course_staff()) user = self.get_real_user() require(user) zip_file_ready = False location = six.text_type(self.location) if self.is_zip_file_available(user): log.info( "Zip file already available for block: %s for instructor: %s", location, user.username) assignments = self.get_sorted_submissions() if assignments: last_assignment_date = assignments[0]['timestamp'].astimezone( pytz.utc) zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) zip_file_time = get_file_modified_time_utc(zip_file_path) log.info( "Zip file modified time: %s, last zip file time: %s for block: %s for instructor: %s", zip_file_time, last_assignment_date, location, user.username) # if last zip file is older the last submission then recreate task if zip_file_time >= last_assignment_date: zip_file_ready = True # check if some one reset submission. If yes the recreate zip file assignment_count = len(assignments) zip_count = self.count_archive_files(user) log.info("Zip file content count: %s, assignment count: %s", zip_count, assignment_count) if zip_count != assignment_count: zip_file_ready = False log.info("Zip checking result: %s", zip_file_ready) if not zip_file_ready: log.info("Creating new zip file for block: %s for instructor: %s", location, user.username) zip_student_submissions.delay(self.block_course_id, self.block_id, location, user.username) return Response(json_body={"downloadable": zip_file_ready}) @XBlock.handler def download_submissions(self, request, suffix=''): # pylint: disable=unused-argument """ Api for downloading zip file which consist of all students submissions. """ # pylint: disable=no-member require(self.is_course_staff()) user = self.get_real_user() require(user) try: zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) zip_file_name = get_zip_file_name(user.username, self.block_course_id, self.block_id) return Response(app_iter=file_contents_iter(zip_file_path), content_type='application/zip', content_disposition="attachment; filename=" + zip_file_name) except IOError: return Response( "Sorry, submissions cannot be found. Press Collect ALL Submissions button or" " contact {} if you issue is consistent".format( settings.TECH_SUPPORT_EMAIL), status_code=404) @XBlock.handler def download_submissions_status(self, request, suffix=''): # pylint: disable=unused-argument """ returns True if zip file is available for download """ require(self.is_course_staff()) user = self.get_real_user() require(user) return Response( json_body={"zip_available": self.is_zip_file_available(user)}) def student_view(self, context=None): # pylint: disable=no-member """ The primary view of the StaffGradedAssignmentXBlock, shown to students when viewing courses. """ context = { "student_state": json.dumps(self.student_state()), "id": self.location.name.replace('.', '_'), "max_file_size": self.student_upload_max_size(), "support_email": settings.TECH_SUPPORT_EMAIL, "file_types": self.file_types } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/show.html', context)) fragment.add_css(_resource("static/css/edx_sga.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.add_javascript( _resource("static/js/src/jquery.tablesorter.min.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment def studio_view(self, context=None): # pylint: disable=useless-super-delegation """ Render a form for editing this XBlock """ # this method only exists to provide context=None for backwards compat return super(StaffGradedAssignmentXBlock, self).studio_view(context) def clear_student_state(self, *args, **kwargs): # pylint: disable=unused-argument """ For a given user, clears submissions and uploaded files for this XBlock. Staff users are able to delete a learner's state for a block in LMS. When that capability is used, the block's "clear_student_state" function is called if it exists. """ student_id = kwargs['user_id'] for submission in submissions_api.get_submissions( self.get_student_item_dict(student_id)): submission_file_sha1 = submission['answer'].get('sha1') submission_filename = submission['answer'].get('filename') submission_file_path = self.file_storage_path( submission_file_sha1, submission_filename) if default_storage.exists(submission_file_path): default_storage.delete(submission_file_path) submissions_api.reset_score(student_id, self.block_course_id, self.block_id, clear_state=True) def max_score(self): """ Return the maximum score possible. """ return self.points @reify def block_id(self): """ Return the usage_id of the block. """ return six.text_type(self.scope_ids.usage_id) @reify def block_course_id(self): """ Return the course_id of the block. """ return six.text_type(self.course_id) def get_student_item_dict(self, student_id=None): # pylint: disable=no-member """ Returns dict required by the submissions app for creating and retrieving submissions for a particular student. """ if student_id is None: student_id = self.xmodule_runtime.anonymous_student_id assert student_id != ('MOCK', "Forgot to call 'personalize' in test.") return { "student_id": student_id, "course_id": self.block_course_id, "item_id": self.block_id, "item_type": ITEM_TYPE, } def get_submission(self, student_id=None): """ Get student's most recent submission. """ submissions = submissions_api.get_submissions( self.get_student_item_dict(student_id)) if submissions: # If I understand docs correctly, most recent submission should # be first return submissions[0] def get_score(self, student_id=None): """ Return student's current score. """ score = submissions_api.get_score( self.get_student_item_dict(student_id)) if score: return score['points_earned'] @reify def score(self): """ Return score from submissions. """ return self.get_score() def update_staff_debug_context(self, context): # pylint: disable=no-member """ Add context info for the Staff Debug interface. """ published = self.start context['is_released'] = published and published < utcnow() context['location'] = self.location context['category'] = type(self).__name__ context['fields'] = [(name, field.read_from(self)) for name, field in self.fields.items()] def get_student_module(self, module_id): """ Returns a StudentModule that matches the given id Args: module_id (int): The module id Returns: StudentModule: A StudentModule object """ return StudentModule.objects.get(pk=module_id) def get_or_create_student_module(self, user): """ Gets or creates a StudentModule for the given user for this block Returns: StudentModule: A StudentModule object """ # pylint: disable=no-member student_module, created = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=user, defaults={ 'state': '{}', 'module_type': self.category, }) if created: log.info("Created student module %s [course: %s] [student: %s]", student_module.module_state_key, student_module.course_id, student_module.student.username) return student_module def student_state(self): """ Returns a JSON serializable representation of student's state for rendering in client view. """ submission = self.get_submission() if submission: uploaded = {"filename": submission['answer']['filename']} else: uploaded = None if self.annotated_sha1: annotated = {"filename": force_text(self.annotated_filename)} else: annotated = None score = self.score if score is not None: graded = {'score': score, 'comment': force_text(self.comment)} else: graded = None if self.answer_available(): solution = self.runtime.replace_urls(force_text(self.solution)) else: solution = '' # pylint: disable=no-member return { "display_name": force_text(self.display_name), "uploaded": uploaded, "annotated": annotated, "graded": graded, "max_score": self.max_score(), "upload_allowed": self.upload_allowed(submission_data=submission), "solution": solution, "base_asset_url": StaticContent.get_base_url_path_for_course_assets( self.location.course_key), } def staff_grading_data(self): """ Return student assignment information for display on the grading screen. """ def get_student_data(): # pylint: disable=no-member """ Returns a dict of student assignment information along with annotated file name, student id and module id, this information will be used on grading screen """ # Submissions doesn't have API for this, just use model directly. students = SubmissionsStudent.objects.filter( course_id=self.course_id, item_id=self.block_id) for student in students: submission = self.get_submission(student.student_id) if not submission: continue user = user_by_anonymous_id(student.student_id) student_module = self.get_or_create_student_module(user) state = json.loads(student_module.state) score = self.get_score(student.student_id) approved = score is not None if score is None: score = state.get('staff_score') needs_approval = score is not None else: needs_approval = False instructor = self.is_instructor() yield { 'module_id': student_module.id, 'student_id': student.student_id, 'submission_id': submission['uuid'], 'username': student_module.student.username, 'fullname': student_module.student.profile.name, 'filename': submission['answer']["filename"], 'timestamp': submission['created_at'].strftime( DateTime.DATETIME_FORMAT), 'score': score, 'approved': approved, 'needs_approval': instructor and needs_approval, 'may_grade': instructor or not approved, 'annotated': force_text(state.get("annotated_filename", '')), 'comment': force_text(state.get("comment", '')), 'finalized': is_finalized_submission(submission_data=submission) } return { 'assignments': list(get_student_data()), 'max_score': self.max_score(), 'display_name': force_text(self.display_name) } def get_sorted_submissions(self): """returns student recent assignments sorted on date""" assignments = [] submissions = submissions_api.get_all_submissions( self.course_id, self.block_id, ITEM_TYPE) for submission in submissions: if is_finalized_submission(submission_data=submission): assignments.append({ 'submission_id': submission['uuid'], 'filename': submission['answer']["filename"], 'timestamp': submission['submitted_at'] or submission['created_at'] }) assignments.sort(key=lambda assignment: assignment['timestamp'], reverse=True) return assignments def download(self, path, mime_type, filename, require_staff=False): """ Return a file from storage and return in a Response. """ try: content_disposition = "attachment; filename*=UTF-8''" content_disposition += six.moves.urllib.parse.quote( filename.encode('utf-8')) output = Response(app_iter=file_contents_iter(path), content_type=mime_type, content_disposition=content_disposition) return output except IOError: if require_staff: return Response("Sorry, assignment {} cannot be found at" " {}. Please contact {}".format( filename.encode('utf-8'), path, settings.TECH_SUPPORT_EMAIL), status_code=404) return Response("Sorry, the file you uploaded, {}, cannot be" " found. Please try uploading it again or contact" " course staff".format(filename.encode('utf-8')), status_code=404) def validate_score_message(self, course_id, username): # lint-amnesty, pylint: disable=missing-docstring # pylint: disable=no-member log.error( "enter_grade: invalid grade submitted for course:%s module:%s student:%s", course_id, self.location, username) return {"error": "Please enter valid grade"} def is_course_staff(self): # pylint: disable=no-member """ Check if user is course staff. """ return getattr(self.xmodule_runtime, 'user_is_staff', False) def is_instructor(self): # pylint: disable=no-member """ Check if user role is instructor. """ return self.xmodule_runtime.get_user_role() == 'instructor' def show_staff_grading_interface(self): """ Return if current user is staff and not in studio. """ in_studio_preview = self.scope_ids.user_id is None return self.is_course_staff() and not in_studio_preview def past_due(self): """ Return whether due date has passed. """ due = get_extended_due_date(self) try: graceperiod = self.graceperiod except AttributeError: # graceperiod and due are defined in InheritanceMixin # It's used automatically in edX but the unit tests will need to mock it out graceperiod = None if graceperiod is not None and due: close_date = due + graceperiod else: close_date = due if close_date is not None: return utcnow() > close_date return False def upload_allowed(self, submission_data=None): """ Return whether student is allowed to upload an assignment. """ submission_data = submission_data if submission_data is not None else self.get_submission( ) return (not self.past_due() and self.score is None and not is_finalized_submission(submission_data)) def file_storage_path(self, file_hash, original_filename): # pylint: disable=no-member """ Helper method to get the path of an uploaded file """ return get_file_storage_path(self.location, file_hash, original_filename) def is_zip_file_available(self, user): """ returns True if zip file exists. """ # pylint: disable=no-member zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) return True if default_storage.exists(zip_file_path) else False def count_archive_files(self, user): """ returns number of files archive in zip. """ # pylint: disable=no-member zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) with default_storage.open(zip_file_path, 'rb') as zip_file: with closing(ZipFile(zip_file)) as archive: return len(archive.infolist()) def get_real_user(self): """returns session user""" # pylint: disable=no-member return self.runtime.get_real_user( self.xmodule_runtime.anonymous_student_id) def correctness_available(self): """ For SGA is_correct just means the user submitted the problem, which we always know one way or the other """ return True def is_past_due(self): """ Is it now past this problem's due date? """ return self.past_due() def is_correct(self): """ For SGA we show the answer as soon as we know the user has given us their submission """ return self.has_attempted() def has_attempted(self): """ True if the student has already attempted this problem """ submission = self.get_submission() if not submission: return False return submission['answer']['finalized'] def can_attempt(self): """ True if the student can attempt the problem """ return not self.has_attempted() def runtime_user_is_staff(self): """ Is the logged in user a staff user? """ return self.is_course_staff()
class ImageExplorerBlock(XBlock): # pylint: disable=no-init """ XBlock that renders an image with tooltips """ display_name = String( display_name=_("Display Name"), help= _("This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default=_("Image Explorer")) _hotspot_coordinates_centered = Boolean( display_name=_("Hot Spots Coordinates Centered"), scope=Scope.settings, default=False, ) opened_hotspots = List( help=_("Store hotspots opened by student, for completion"), default=[], scope=Scope.user_state, ) data = String(help=_("XML contents to display for this module"), scope=Scope.content, default=textwrap.dedent("""\ <image_explorer schema_version='2'> <background src="//upload.wikimedia.org/wikipedia/commons/thumb/a/ac/MIT_Dome_night1_Edit.jpg/800px-MIT_Dome_night1_Edit.jpg" /> <description> <p> Enjoy using the Image Explorer. Click around the MIT Dome and see what you find! </p> </description> <hotspots> <hotspot x='48.8125%' y='8.3162%' item-id='hotspotA'> <feedback width='300' height='240'> <header> <p> This is where many pranks take place. Below are some of the highlights: </p> </header> <body> <ul> <li>Once there was a police car up here</li> <li>Also there was a Fire Truck put up there</li> </ul> </body> </feedback> </hotspot> <hotspot x='33.8125%' y='18.5831%' item-id="hotspotB"> <feedback width='440' height='400'> <header> <p> Watch the Red Line subway go around the dome </p> </header> <youtube video_id='dmoZXcuozFQ' width='400' height='300' /> </feedback> </hotspot> </hotspots> </image_explorer> """)) @property def hotspot_coordinates_centered(self): if self._hotspot_coordinates_centered: return True # hotspots are calculated from center for schema version > 1 xmltree = etree.fromstring(self.data) schema_version = int(xmltree.attrib.get('schema_version', 1)) return schema_version > 1 @XBlock.supports("multi_device") # Mark as mobile-friendly def student_view(self, context): """ Player view, displayed to the student """ xmltree = etree.fromstring(self.data) description = self._get_description(xmltree) hotspots = self._get_hotspots(xmltree) background = self._get_background(xmltree) has_youtube = False has_ooyala = False for hotspot in hotspots: width = 'width:{0}px'.format( hotspot.feedback.width ) if hotspot.feedback.width else 'width:300px' height = 'height:{0}px'.format( hotspot.feedback.height) if hotspot.feedback.height else '' max_height = '' if not hotspot.feedback.height: max_height = 'max-height:{0}px'.format(hotspot.feedback.max_height) if \ hotspot.feedback.max_height else 'max-height:300px' hotspot.reveal_style = 'style="{0};{1};{2}"'.format( width, height, max_height) if hotspot.feedback.youtube: has_youtube = True if hotspot.feedback.ooyala: has_ooyala = True context = { 'title': self.display_name, 'hotspot_coordinates_centered': self.hotspot_coordinates_centered, 'description_html': description, 'hotspots': hotspots, 'background': background, } fragment = Fragment() fragment.add_content( loader.render_django_template( '/templates/html/image_explorer.html', context=context, i18n_service=self.runtime.service(self, 'i18n'))) fragment.add_css_url( self.runtime.local_resource_url(self, 'public/css/image_explorer.css')) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/image_explorer.js')) if has_youtube: fragment.add_javascript_url('https://www.youtube.com/iframe_api') if has_ooyala: fragment.add_javascript_url( 'https://player.ooyala.com/v3/635104fd644c4170ae227af2de27deab?platform=html5-priority' ) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/ooyala_player.js')) fragment.initialize_js('ImageExplorerBlock') return fragment def student_view_data(self, context=None): """ Returns a JSON representation of the Image Explorer Xblock, that can be retrieved using Course Block API. """ xmltree = etree.fromstring(self.data) description = self._get_description(xmltree) background = self._get_background(xmltree) background['src'] = self._replace_static_from_url(background['src']) hotspots = self._get_hotspots(xmltree) return { 'description': description, 'background': background, 'hotspots': hotspots, } @XBlock.json_handler def publish_event(self, data, suffix=''): try: event_type = data.pop('event_type') except KeyError: return { 'result': 'error', 'message': self.ugettext('Missing event_type in JSON data') } data['user_id'] = self.scope_ids.user_id data['component_id'] = self._get_unique_id() self.runtime.publish(self, event_type, data) if event_type == 'xblock.image-explorer.hotspot.opened': self.register_progress(data['item_id']) return {'result': 'success'} def register_progress(self, hotspot_id): """ Registers the completion of an hotspot, identified by id """ xmltree = etree.fromstring(self.data) hotspots_ids = [h.item_id for h in self._get_hotspots(xmltree)] if not hotspots_ids \ or hotspot_id not in hotspots_ids \ or hotspot_id in self.opened_hotspots: return self.runtime.publish(self, 'progress', {}) self.opened_hotspots.append(hotspot_id) log.debug(u'Opened hotspots so far for {}: {}'.format( self._get_unique_id(), self.opened_hotspots)) opened_hotspots = [ h for h in hotspots_ids if h in self.opened_hotspots ] percent_completion = float(len(opened_hotspots)) / len(hotspots_ids) self.runtime.publish(self, 'grade', { 'value': percent_completion, 'max_value': 1, }) log.debug(u'Sending grade for {}: {}'.format(self._get_unique_id(), percent_completion)) def _get_unique_id(self): try: unique_id = self.location.name except AttributeError: # workaround for xblock workbench unique_id = 'workbench-workaround-id' return unique_id def studio_view(self, context): """ Editing view in Studio """ fragment = Fragment() fragment.add_content( loader.render_django_template( '/templates/html/image_explorer_edit.html', context={'self': self}, i18n_service=self.runtime.service(self, 'i18n'))) fragment.add_javascript_url( self.runtime.local_resource_url( self, 'public/js/image_explorer_edit.js')) fragment.initialize_js('ImageExplorerEditBlock') return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): self.display_name = submissions['display_name'] if submissions.get('hotspot_coordinates_centered', False): self._hotspot_coordinates_centered = True xml_content = submissions['data'] try: etree.parse(StringIO(xml_content)) self.data = xml_content except etree.XMLSyntaxError as e: return {'result': 'error', 'message': e.message} return { 'result': 'success', } def _get_background(self, xmltree): """ Parse the XML to get the information about the background image """ background = xmltree.find('background') return AttrDict({ 'src': background.get('src'), 'width': background.get('width'), 'height': background.get('height') }) def _replace_static_from_url(self, url): if not url: return url try: from static_replace import replace_static_urls except ImportError: return url url = '"{}"'.format(url) lms_relative_url = replace_static_urls(url, course_id=self.course_id) lms_relative_url = lms_relative_url.strip('"') return self._make_url_absolute(lms_relative_url) def _make_url_absolute(self, url): lms_base = settings.ENV_TOKENS.get('LMS_BASE') scheme = 'https' if settings.HTTPS == 'on' else 'http' lms_base = '{}://{}'.format(scheme, lms_base) return urljoin(lms_base, url) def _inner_content(self, tag): """ Helper met """ if tag is not None: return u''.join(html.tostring(e) for e in tag) return None def _get_description(self, xmltree): """ Parse the XML to get the description information """ description = xmltree.find('description') if description is not None: return self._inner_content(description) return None def _get_hotspots(self, xmltree): """ Parse the XML to get the hotspot information """ hotspots_element = xmltree.find('hotspots') hotspot_elements = hotspots_element.findall('hotspot') hotspots = [] for index, hotspot_element in enumerate(hotspot_elements): feedback_element = hotspot_element.find('feedback') feedback = AttrDict() feedback.width = feedback_element.get('width') feedback.height = feedback_element.get('height') feedback.max_height = feedback_element.get('max-height') feedback.header = self._inner_content( feedback_element.find('header')) feedback.side = hotspot_element.get('side', 'auto') feedback.body = None body_element = feedback_element.find('body') if body_element is not None: feedback.type = 'text' feedback.body = self._inner_content(body_element) feedback.youtube = None youtube_element = feedback_element.find('youtube') if youtube_element is not None: feedback.type = 'youtube' feedback.youtube = AttrDict() feedback.youtube.id = 'youtube-{}'.format(uuid.uuid4().hex) feedback.youtube.video_id = youtube_element.get('video_id') feedback.youtube.width = youtube_element.get('width') feedback.youtube.height = youtube_element.get('height') feedback.ooyala = None ooyala_element = feedback_element.find('ooyala') if ooyala_element is not None: feedback.type = 'ooyala' feedback.ooyala = AttrDict() feedback.ooyala.video_id = ooyala_element.get('video_id') feedback.ooyala.width = ooyala_element.get('width') feedback.ooyala.height = ooyala_element.get('height') hotspot = AttrDict() hotspot.item_id = hotspot_element.get('item-id') if hotspot.item_id is None: hotspot.item_id = 'hotspot' + str(index) hotspot.feedback = feedback hotspot.x = hotspot_element.get('x') if not hotspot.x.endswith('%'): hotspot.x += 'px' # px is deprecated as it is not responsive hotspot.y = hotspot_element.get('y') if not hotspot.y.endswith('%'): hotspot.y += 'px' # px is deprecated as it is not responsive hotspots.append(hotspot) return hotspots @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [("Image explorer scenario", "<vertical_demo><image-explorer/></vertical_demo>")]
class ConditionalBlock( SequenceMixin, MakoTemplateBlockBase, XmlMixin, XModuleDescriptorToXBlockMixin, 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
class OoyalaPlayerBlock(OoyalaPlayerMixin, XBlock): """ XBlock providing a video player for videos hosted on Ooyala """ display_name = String( display_name=_("Display Name"), help= _("This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default=_("Ooyala Player")) content_id = String(display_name=_("Content Id"), help=_("Identifier for the Content Id."), scope=Scope.content, default='VtZWc4ODE61SNu7RdCJTlDhHLJ23vl5d') transcript_file_id = String( display_name=_("3Play Transcript Id"), help=_("Identifier for the 3Play Transcript File"), scope=Scope.content, default='') cc_language_preference = String( display_name=_("Closed Captions Language"), help=_("User's preference for closed captions language"), scope=Scope.user_info, default='en') disable_cc_and_translations = Boolean( display_name=_("Turn Off Closed Captions and Translated transcripts"), help= _("Hides the CC button and transcript languages selection for this video" ), scope=Scope.settings, default=False) autoplay = Boolean( display_name=_("Enable Player Autoplay"), help=_("Set to True if you the player to automatically play."), scope=Scope.content, default=True) enable_player_token = Boolean( display_name=_("Enable Player Token"), help= _("Set to True if a player token is required, e.g. if streaming videos to the mobile app." ), scope=Scope.content, default=False) partner_code = String( display_name=_("Partner Code"), help=_( "Required for V4 Player. Also needed to generate a player token."), scope=Scope.content, default='') api_key = String(display_name="Api Key", help=_("Needed to generate a player token."), scope=Scope.content, default='') api_secret_key = String(display_name=_("Api SecRet Key"), help=_("Needed to generate a player token."), scope=Scope.content, default='') api_key_3play = String(display_name=_("3play Api Key"), help=_("3play Api Key for transcript."), scope=Scope.content, default='') width = String(display_name=_("Player Width"), help=_("The width of the player in pixels."), scope=Scope.content, default="100%") height = String(display_name=_("Player Height"), help=_('The height of the player in pixels.'), scope=Scope.content, default="100%") expiration_time = Integer( display_name=_("Expiration Time"), help=_( 'Expiration time in seconds. Needed to generate a player token.'), scope=Scope.content, default=600) fire_progress_event_on_student_view = Boolean( display_name=_("Fire Progress Event on Student View"), help= _('Set to True if you would like to get a progress event in the event stream when the user views this xBlock.' ), scope=Scope.content, default=True) xml_config = String(help=_("XML Configuration"), default='<ooyala>\n</ooyala>', scope=Scope.content) def _get_unique_id(self): try: unique_id = self.location.name except AttributeError: # workaround for xblock workbench unique_id = self.parent.replace('.', '-') + '-' + self.content_id return unique_id @XBlock.json_handler def publish_event(self, data, suffix=''): try: event_type = data.pop('event_type') except KeyError as e: return { 'result': 'error', 'message': self.ugettext('Missing event_type in JSON data') } data['content_id'] = self.content_id data['user_id'] = self.scope_ids.user_id self.runtime.publish(self, event_type, data) return {'result': 'success'} def studio_view(self, context): """ Editing view in Studio """ fragment = Fragment() fragment.add_content( render_template('/templates/html/ooyala_player_edit.html', { 'self': self, })) fragment.add_javascript_url( self.local_resource_url(self, 'public/js/ooyala_player_edit.js')) fragment.initialize_js('OoyalaPlayerEditBlock') return fragment @XBlock.json_handler def store_language_preference(self, data, suffix=''): """ Store user's cc language selection """ lang = data.get('lang') if lang: self.cc_language_preference = lang return {'result': 'success'} @XBlock.json_handler def load_transcript(self, data, suffix=''): """ Store user's cc language selection """ threeplay_id = data.get('threeplay_id') transcript_id = data.get('transcript_id') content = '' if threeplay_id: content = Transcript.get_transcript_by_threeplay_id( api_key=self.get_attribute_or_default('api_key_3play'), threeplay_id=threeplay_id, transcript_id=transcript_id, ) return {'content': content} @XBlock.json_handler def studio_submit(self, submissions, suffix=''): xml_config = submissions['xml_config'] try: etree.parse(StringIO(xml_config)) except etree.XMLSyntaxError as e: response = {'result': 'error', 'message': e.message} else: response = { 'result': 'success', } self.xml_config = xml_config self.display_name = submissions['display_name'] self.content_id = submissions['content_id'].strip() self.transcript_file_id = submissions['transcript_file_id'].strip() self.enable_player_token = submissions['enable_player_token'] self.partner_code = submissions['partner_code'] self.api_key = submissions['api_key'] self.api_secret_key = submissions['api_secret_key'] self.api_key_3play = submissions['api_key_3play'] self.expiration_time = submissions['expiration_time'] self.width = submissions['width'] self.height = submissions['height'] self.disable_cc_and_translations = submissions['cc_disable'] return response @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [("Ooyala scenario", "<vertical_demo><ooyala-player/></vertical_demo>")]
class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBlockMixin, QuestionMixin, XBlock, XBlockWithPreviewMixin, XBlockWithTranslationServiceMixin): """ An abstract class used for MCQ/MRQ blocks Must be a child of a MentoringBlock. Allow to display a tip/advice depending on the values entered by the student, and supports multiple types of multiple-choice set, with preset choices and author-defined values. """ question = String( display_name=_("Question"), help=_("Question to ask the student"), scope=Scope.content, default="", multiline_editor=True, ) editable_fields = ('question', 'weight', 'display_name', 'show_title') has_children = True answerable = True @lazy def html_id(self): """ A short, simple ID string used to uniquely identify this question. This is only used by templates for matching <input> and <label> elements. """ return uuid.uuid4().hex[:20] def student_view(self, context=None): name = getattr(self, "unmixed_class", self.__class__).__name__ template_path = 'templates/html/{}.html'.format(name.lower()) context = context.copy() if context else {} context['self'] = self context['custom_choices'] = self.custom_choices context['hide_header'] = context.get('hide_header', False) or not self.show_title fragment = Fragment(loader.render_template(template_path, context)) # If we use local_resource_url(self, ...) the runtime may insert many identical copies # of questionnaire.[css/js] into the DOM. So we use the mentoring block here if possible. block_with_resources = self.get_parent() from .mentoring import MentoringBlock # We use an inline import here to avoid a circular dependency with the .mentoring module. if not isinstance(block_with_resources, MentoringBlock): block_with_resources = self fragment.add_css_url( self.runtime.local_resource_url(block_with_resources, 'public/css/questionnaire.css')) fragment.add_javascript_url( self.runtime.local_resource_url(block_with_resources, 'public/js/questionnaire.js')) fragment.initialize_js(name) return fragment def mentoring_view(self, context=None): return self.student_view(context) @property def custom_choices(self): custom_choices = [] for child_id in self.children: if child_isinstance(self, child_id, ChoiceBlock): custom_choices.append(self.runtime.get_block(child_id)) return custom_choices @property def all_choice_values(self): return [c.value for c in self.custom_choices] @property def human_readable_choices(self): return [{ "display_name": mark_safe(c.content), "value": c.value } for c in self.custom_choices] @staticmethod def choice_values_provider(question): """ Get a list a {"display_name": "Choice Description", "value": value} objects for use with studio_view editor. """ return question.human_readable_choices def get_tips(self): """ Returns the tips contained in this block """ tips = [] for child_id in self.children: if child_isinstance(self, child_id, TipBlock): tips.append(self.runtime.get_block(child_id)) return tips def get_submission_display(self, submission): """ Get the human-readable version of a submission value """ for choice in self.custom_choices: if choice.value == submission: return choice.content return submission def get_author_edit_view_fragment(self, context): fragment = super(QuestionnaireAbstractBlock, self).author_edit_view(context) return fragment def author_edit_view(self, context): """ Add some HTML to the author view that allows authors to add choices and tips. """ from .mentoring import MentoringWithExplicitStepsBlock fragment = self.get_author_edit_view_fragment(context) # * Step Builder units can show review components in the Review Step. fragment.add_content( loader.render_template( 'templates/html/questionnaire_add_buttons.html', { 'show_review': isinstance(self.get_parent(), MentoringWithExplicitStepsBlock) })) fragment.add_css_url( self.runtime.local_resource_url(self, 'public/css/problem-builder.css')) fragment.add_css_url( self.runtime.local_resource_url( self, 'public/css/questionnaire-edit.css')) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/util.js')) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/mentoring_edit.js')) fragment.initialize_js('MentoringEditComponents') return fragment def validate_field_data(self, validation, data): """ Validate this block's field data. Instead of checking fields like self.name, check the fields set on data, e.g. data.name. This allows the same validation method to be re-used for the studio editor. Any errors found should be added to "validation". This method should not return any value or raise any exceptions. All of this XBlock's fields should be found in "data", even if they aren't being changed or aren't even set (i.e. are defaults). """ super(QuestionnaireAbstractBlock, self).validate_field_data(validation, data) def add_error(msg): validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) if not data.name: add_error(self._(u"A unique Question ID is required.")) elif ' ' in data.name: add_error(self._(u"Question ID should not contain spaces.")) def validate(self): """ Validates the state of this XBlock. """ validation = super(QuestionnaireAbstractBlock, self).validate() def add_error(msg): validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) # Validate the choice values: all_choice_values = self.all_choice_values all_choice_values_set = set(all_choice_values) if len(all_choice_values) != len(all_choice_values_set): add_error(self._(u"Some choice values are not unique.")) # Validate the tips: values_with_tips = set() for tip in self.get_tips(): values = set(tip.values) if values & values_with_tips: add_error( self._(u"Multiple tips configured for the same choice.")) break values_with_tips.update(values) return validation def get_review_tip(self): """ Get the text to show on the assessment review when the student gets this question wrong """ for child_id in self.children: if child_isinstance(self, child_id, MentoringMessageBlock): child = self.runtime.get_block(child_id) if child.type == "on-assessment-review-question": return child.content @property def message_formatted(self): """ Get the feedback message HTML, if any, formatted by the runtime """ if self.message: # For any HTML that we aren't 'rendering' through an XBlock view such as # student_view the runtime may need to rewrite URLs # e.g. converting '/static/x.png' to '/c4x/.../x.png' format_html = getattr(self.runtime, 'replace_urls', lambda html: html) return format_html(self.message) return ""
class EqualityCheckerBlock(CheckerBlock): """An XBlock that checks the equality of two student data fields.""" # Content: the problem will hook us up with our data. content = String(help="Message describing the equality test", scope=Scope.content, default="Equality test") # Student data left = Any(scope=Scope.user_state) right = Any(scope=Scope.user_state) attempted = Boolean(scope=Scope.user_state) def problem_view(self, context=None): """Renders the problem view. The view is specific to whether or not this problem was attempted, and, if so, if it was answered correctly. """ correct = self.left == self.right # TODO: I originally named this class="data", but that conflicted with # the CSS on the page! :( We might have to do something to namespace # things. # TODO: Should we have a way to spit out JSON islands full of data? # Note the horror of mixed Python-Javascript data below... content = string.Template(self.content).substitute(**context) result = Fragment(u""" <span class="mydata" data-attempted='{ecb.attempted}' data-correct='{correct}'> {content} <span class='indicator'></span> </span> """.format(ecb=self, content=content, correct=correct)) # TODO: This is a runtime-specific URL. But if each XBlock ships their # own copy of underscore.js, we won't be able to uniquify them. # Perhaps runtimes can offer a palette of popular libraries so that # XBlocks can refer to them in XBlock-standard ways? result.add_javascript_url( self.runtime.resource_url("js/vendor/underscore-min.js")) # TODO: The image tag here needs a magic URL, not a hard-coded one. format_data = { 'correct': self.runtime.local_resource_url(self, 'public/images/correct-icon.png'), 'incorrect': self.runtime.local_resource_url( self, 'public/images/incorrect-icon.png'), } result.add_resource( u""" <script type="text/template" id="xblock-equality-template"> <% if (attempted !== "True") {{ %> (Not attempted) <% }} else if (correct === "True") {{ %> <img src="{correct}"> <% }} else {{ %> <img src="{incorrect}"> <% }} %> </script> """.format(**format_data), "text/html") result.add_javascript(u""" function EqualityCheckerBlock(runtime, element) { var template = _.template($("#xblock-equality-template").html()); function render() { var data = $("span.mydata", element).data(); $("span.indicator", element).html(template(data)); } render(); return { handleCheck: function(result) { $("span.mydata", element) .data("correct", result ? "True" : "False") .data("attempted", "True"); render(); } } } """) result.initialize_js('EqualityCheckerBlock') return result def check(self, left, right): # pylint: disable=W0221 self.attempted = True self.left = left self.right = right event_data = {'value': 1 if left == right else 0, 'max_value': 1} self.runtime.publish(self, 'grade', event_data) return left == right
class StaffGradedAssignmentXBlock(XBlock): """ This block defines a Staff Graded Assignment. Students are shown a rubric and invited to upload a file which is then graded by staff. """ has_score = True icon_class = 'problem' display_name = String( default='Staff Graded Assignment', scope=Scope.settings, help="This name appears in the horizontal navigation at the top of " "the page.") weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) points = Float(display_name="Maximum score", help=("Maximum grade score given to assignment by staff."), values={ "min": 0, "step": .1 }, default=100, scope=Scope.settings) staff_score = Integer( display_name="Score assigned by non-instructor staff", help=("Score will need to be approved by instructor before being " "published."), default=None, scope=Scope.settings) comment = String(display_name="Instructor comment", default='', scope=Scope.user_state, help="Feedback given to student by instructor.") annotated_sha1 = String( display_name="Annotated SHA1", scope=Scope.user_state, default=None, help=("sha1 of the annotated file uploaded by the instructor for " "this assignment.")) annotated_filename = String( display_name="Annotated file name", scope=Scope.user_state, default=None, help="The name of the annotated file uploaded for this assignment.") annotated_mimetype = String( display_name="Mime type of annotated file", scope=Scope.user_state, default=None, help="The mimetype of the annotated file uploaded for this assignment." ) annotated_timestamp = DateTime(display_name="Timestamp", scope=Scope.user_state, default=None, help="When the annotated file was uploaded") def max_score(self): return self.points @reify def block_id(self): # cargo culted gibberish return self.scope_ids.usage_id def student_submission_id(self, id=None): """ Returns dict required by the submissions app for creating and retrieving submissions for a particular student. """ if id is None: id = self.xmodule_runtime.anonymous_student_id assert id != 'MOCK', "Forgot to call 'personalize' in test." return { "student_id": id, "course_id": self.course_id, "item_id": self.block_id, "item_type": 'sga', # ??? } def get_submission(self, id=None): """ Get student's most recent submission. """ submissions = submissions_api.get_submissions( self.student_submission_id(id)) if submissions: # If I understand docs correctly, most recent submission should # be first return submissions[0] def get_score(self, id=None): """ Get student's current score. """ score = submissions_api.get_score(self.student_submission_id(id)) if score: return score['points_earned'] @reify def score(self): return self.get_score() def student_view(self, context=None): """ The primary view of the StaffGradedAssignmentXBlock, shown to students when viewing courses. """ context = { "student_state": json.dumps(self.student_state()), "id": self.location.name.replace('.', '_') } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/show.html', context)) fragment.add_css(_resource("static/css/edx_sga.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment def update_staff_debug_context(self, context): published = self.start context['is_released'] = published and published < _now() context['location'] = self.location context['category'] = type(self).__name__ context['fields'] = [(name, field.read_from(self)) for name, field in self.fields.items()] def student_state(self): """ Returns a JSON serializable representation of student's state for rendering in client view. """ submission = self.get_submission() if submission: uploaded = {"filename": submission['answer']['filename']} else: uploaded = None if self.annotated_sha1: annotated = {"filename": self.annotated_filename} else: annotated = None score = self.score if score is not None: graded = {'score': score, 'comment': self.comment} else: graded = None return { "uploaded": uploaded, "annotated": annotated, "graded": graded, "max_score": self.max_score(), "upload_allowed": self.upload_allowed(), } def staff_grading_data(self): def get_student_data(): # Submissions doesn't have API for this, just use model directly students = SubmissionsStudent.objects.filter( course_id=self.course_id, item_id=self.block_id) for student in students: submission = self.get_submission(student.student_id) if not submission: continue user = user_by_anonymous_id(student.student_id) module, _ = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=user, defaults={ 'state': '{}', 'module_type': self.category, }) state = json.loads(module.state) score = self.get_score(student.student_id) approved = score is not None if score is None: score = state.get('staff_score') needs_approval = score is not None else: needs_approval = False instructor = self.is_instructor() yield { 'module_id': module.id, 'student_id': student.student_id, 'submission_id': submission['uuid'], 'username': module.student.username, 'fullname': module.student.profile.name, 'filename': submission['answer']["filename"], 'timestamp': submission['created_at'].strftime( DateTime.DATETIME_FORMAT), 'score': score, 'approved': approved, 'needs_approval': instructor and needs_approval, 'may_grade': instructor or not approved, 'annotated': state.get("annotated_filename"), 'comment': state.get("comment", ''), } return { 'assignments': list(get_student_data()), 'max_score': self.max_score(), } def studio_view(self, context=None): try: cls = type(self) def none_to_empty(x): return x if x is not None else '' edit_fields = ((field, none_to_empty(getattr(self, field.name)), validator) for field, validator in ((cls.display_name, 'string'), (cls.points, 'number'), (cls.weight, 'number'))) context = {'fields': edit_fields} fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/edit.html', context)) fragment.add_javascript(_resource("static/js/src/studio.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment except: # pragma: NO COVER log.error("Don't swallow my exceptions", exc_info=True) raise @XBlock.json_handler def save_sga(self, data, suffix=''): for name in ('display_name', 'points', 'weight'): setattr(self, name, data.get(name, getattr(self, name))) @XBlock.handler def upload_assignment(self, request, suffix=''): require(self.upload_allowed()) upload = request.params['assignment'] sha1 = _get_sha1(upload.file) answer = { "sha1": sha1, "filename": upload.file.name, "mimetype": mimetypes.guess_type(upload.file.name)[0], } student_id = self.student_submission_id() submissions_api.create_submission(student_id, answer) path = _file_storage_path(str(self.location), sha1, upload.file.name) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) return Response(json_body=self.student_state()) @XBlock.handler def staff_upload_annotated(self, request, suffix=''): require(self.is_course_staff()) upload = request.params['annotated'] module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['annotated_sha1'] = sha1 = _get_sha1(upload.file) state['annotated_filename'] = filename = upload.file.name state['annotated_mimetype'] = mimetypes.guess_type(upload.file.name)[0] state['annotated_timestamp'] = _now().strftime( DateTime.DATETIME_FORMAT) path = _file_storage_path(str(self.location), sha1, filename) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) module.state = json.dumps(state) module.save() return Response(json_body=self.staff_grading_data()) @XBlock.handler def download_assignment(self, request, suffix=''): answer = self.get_submission()['answer'] path = _file_storage_path(str(self.location), answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename']) @XBlock.handler def download_annotated(self, request, suffix=''): path = _file_storage_path(str(self.location), self.annotated_sha1, self.annotated_filename) return self.download(path, self.annotated_mimetype, self.annotated_filename) @XBlock.handler def staff_download(self, request, suffix=''): require(self.is_course_staff()) submission = self.get_submission(request.params['student_id']) answer = submission['answer'] path = _file_storage_path(str(self.location), answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename']) @XBlock.handler def staff_download_annotated(self, request, suffix=''): require(self.is_course_staff()) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) path = _file_storage_path(str(self.location), state['annotated_sha1'], state['annotated_filename']) return self.download(path, state['annotated_mimetype'], state['annotated_filename']) def download(self, path, mimetype, filename): BLOCK_SIZE = (1 << 10) * 8 # 8kb file = default_storage.open(path) app_iter = iter(partial(file.read, BLOCK_SIZE), '') return Response(app_iter=app_iter, content_type=mimetype, content_disposition="attachment; filename=" + filename) @XBlock.handler def get_staff_grading_data(self, request, suffix=''): require(self.is_course_staff()) return Response(json_body=self.staff_grading_data()) @XBlock.handler def enter_grade(self, request, suffix=''): require(self.is_course_staff()) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) score = int(request.params['grade']) if self.is_instructor(): uuid = request.params['submission_id'] submissions_api.set_score(uuid, score, self.max_score()) else: state['staff_score'] = score state['comment'] = request.params.get('comment', '') module.state = json.dumps(state) module.save() return Response(json_body=self.staff_grading_data()) @XBlock.handler def remove_grade(self, request, suffix=''): require(self.is_course_staff()) student_id = request.params['student_id'] submissions_api.reset_score(student_id, self.course_id, self.block_id) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['staff_score'] = None state['comment'] = '' state['annotated_sha1'] = None state['annotated_filename'] = None state['annotated_mimetype'] = None state['annotated_timestamp'] = None module.state = json.dumps(state) module.save() return Response(json_body=self.staff_grading_data()) def is_course_staff(self): return getattr(self.xmodule_runtime, 'user_is_staff', False) def is_instructor(self): return self.xmodule_runtime.get_user_role() == 'instructor' def show_staff_grading_interface(self): in_studio_preview = self.scope_ids.user_id is None return self.is_course_staff() and not in_studio_preview def past_due(self): due = get_extended_due_date(self) if due is not None: return _now() > due return False def upload_allowed(self): return not self.past_due() and self.score is None
class LibraryRoot(XBlock): """ The LibraryRoot is the root XBlock of a content library. All other blocks in the library are its children. It contains metadata such as the library's display_name. """ resources_dir = None display_name = String(help=_("The display name for this component."), default="Library", display_name=_("Library Display Name"), scope=Scope.settings) advanced_modules = List( display_name=_("Advanced Module List"), help=_( "Enter the names of the advanced components to use in your library." ), scope=Scope.settings, xml_node=True, ) show_children_previews = Boolean( display_name="Hide children preview", help="Choose if preview of library contents is shown", scope=Scope.user_state, default=True) has_children = True has_author_view = True advanced_modules = [ "agnosticcontentxblock", "google-document", "google-calendar", "drag-and-drop-v2", "problem-builder", "word_cloud", "survey", "done", "annotatable", "bibblio", "inline-dropdown", "freetextresponse", "recap", "badger", "edx_sga", "library_content", "poll" ] def __unicode__(self): return u"Library: {}".format(self.display_name) def __str__(self): return unicode(self).encode('utf-8') def author_view(self, context): """ Renders the Studio preview view. """ fragment = Fragment() self.render_children(context, fragment, can_reorder=False, can_add=True) return fragment def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument """ Renders the children of the module with HTML appropriate for Studio. Reordering is not supported. """ contents = [] paging = context.get('paging', None) children_count = len(self.children) # pylint: disable=no-member item_start, item_end = 0, children_count # TODO sort children if paging: page_number = paging.get('page_number', 0) raw_page_size = paging.get('page_size', None) page_size = raw_page_size if raw_page_size is not None else children_count item_start, item_end = page_size * page_number, page_size * ( page_number + 1) children_to_show = self.children[item_start:item_end] # pylint: disable=no-member force_render = context.get('force_render', None) context['can_move'] = False for child_key in children_to_show: # Children must have a separate context from the library itself. Make a copy. child_context = context.copy() child_context['show_preview'] = self.show_children_previews child_context['can_edit_visibility'] = False child = self.runtime.get_block(child_key) child_view_name = StudioEditableModule.get_preview_view_name(child) if unicode(child.location) == force_render: child_context['show_preview'] = True if child_context['show_preview']: rendered_child = self.runtime.render_child( child, child_view_name, child_context) else: rendered_child = self.runtime.render_child_placeholder( child, child_view_name, child_context) fragment.add_frag_resources(rendered_child) contents.append({ 'id': unicode(child.location), 'content': rendered_child.content, }) fragment.add_content( self.runtime.render_template( "studio_render_paged_children_view.html", { 'items': contents, 'xblock_context': context, 'can_add': can_add, 'first_displayed': item_start, 'total_children': children_count, 'displayed_children': len(children_to_show), 'previews': self.show_children_previews })) @property def display_org_with_default(self): """ Org display names are not implemented. This just provides API compatibility with CourseDescriptor. Always returns the raw 'org' field from the key. """ return self.scope_ids.usage_id.course_key.org @property def display_number_with_default(self): """ Display numbers are not implemented. This just provides API compatibility with CourseDescriptor. Always returns the raw 'library' field from the key. """ return self.scope_ids.usage_id.course_key.library @XBlock.json_handler def trigger_previews(self, request_body, suffix): # pylint: disable=unused-argument """ Enable or disable previews in studio for library children. """ self.show_children_previews = request_body.get( 'showChildrenPreviews', self.show_children_previews) return {'showChildrenPreviews': self.show_children_previews}
class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): """ An XBlock used to ask multiple-choice questions """ CATEGORY = 'pb-mcq' STUDIO_LABEL = _(u"Multiple Choice Question") message = String( display_name=_("Message"), help=_( "General feedback provided when submitting. " "(This is not shown if there is a more specific feedback tip for the choice selected by the learner.)" ), scope=Scope.content, default="") student_choice = String( # {Last input submitted by the student default="", scope=Scope.user_state, ) correct_choices = List( display_name=_("Correct Choice[s]"), help= _("Specify the value[s] that students may select for this question to be considered correct." ), scope=Scope.content, list_values_provider=QuestionnaireAbstractBlock.choice_values_provider, list_style='set', # Underered, unique items. Affects the UI editor. ) editable_fields = QuestionnaireAbstractBlock.editable_fields + ( 'message', 'correct_choices', ) def describe_choice_correctness(self, choice_value): if choice_value in self.correct_choices: if len(self.correct_choices) == 1: # Translators: This is an adjective, describing a choice as correct return self._(u"Correct") return self._(u"Acceptable") else: if len(self.correct_choices) == 1: return self._(u"Wrong") return self._(u"Not Acceptable") def calculate_results(self, submission): correct = submission in self.correct_choices tips_html = [] for tip in self.get_tips(): if submission in tip.values: tips_html.append(tip.render('mentoring_view').content) formatted_tips = None if tips_html: formatted_tips = loader.render_template( 'templates/html/tip_choice_group.html', { 'tips_html': tips_html, }) self.student_choice = submission if sub_api: # Also send to the submissions API: sub_api.create_submission(self.student_item_key, submission) return { 'submission': submission, 'message': self.message_formatted, 'status': 'correct' if correct else 'incorrect', 'tips': formatted_tips, 'weight': self.weight, 'score': 1 if correct else 0, } def get_results(self, previous_result): return self.calculate_results(previous_result['submission']) def get_last_result(self): return self.get_results({'submission': self.student_choice }) if self.student_choice else {} def submit(self, submission): log.debug(u'Received MCQ submission: "%s"', submission) result = self.calculate_results(submission) self.student_choice = submission log.debug(u'MCQ submission result: %s', result) return result def get_author_edit_view_fragment(self, context): """ The options for the 1-5 values of the Likert scale aren't child blocks but we want to show them in the author edit view, for clarity. """ fragment = Fragment(u"<p>{}</p>".format(self.question)) self.render_children(context, fragment, can_reorder=True, can_add=False) return fragment def validate_field_data(self, validation, data): """ Validate this block's field data. """ super(MCQBlock, self).validate_field_data(validation, data) def add_error(msg): validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) def choice_name(choice_value): for choice in self.human_readable_choices: if choice["value"] == choice_value: return choice["display_name"] return choice_value all_values = set(self.all_choice_values) correct = set(data.correct_choices) if all_values and not correct: add_error( self. _(u"You must indicate the correct answer[s], or the student will always get this question wrong." )) if len(correct) < len(data.correct_choices): add_error(self._(u"Duplicate correct choices set")) for val in (correct - all_values): add_error( self. _(u"A choice value listed as correct does not exist: {choice}" ).format(choice=choice_name(val)))
class RatingBlock(MCQBlock): """ An XBlock used to rate something on a five-point scale, e.g. Likert Scale """ CATEGORY = 'pb-rating' STUDIO_LABEL = _(u"Rating Question") low = String( display_name=_("Low"), help=_("Label for low ratings"), scope=Scope.content, default=_("Less"), ) high = String( display_name=_("High"), help=_("Label for high ratings"), scope=Scope.content, default=_("More"), ) FIXED_VALUES = ["1", "2", "3", "4", "5"] correct_choices = List( display_name=_("Accepted Choice[s]"), help= _("Specify the rating value[s] that students may select for this question to be considered correct." ), scope=Scope.content, default=FIXED_VALUES, list_values_provider=QuestionnaireAbstractBlock.choice_values_provider, list_style='set', # Underered, unique items. Affects the UI editor. ) editable_fields = MCQBlock.editable_fields + ('low', 'high') @property def all_choice_values(self): return self.FIXED_VALUES + [c.value for c in self.custom_choices] @property def human_readable_choices(self): display_names = [ "1 - {}".format(self.low), "2", "3", "4", "5 - {}".format(self.high) ] return [{ "display_name": dn, "value": val } for val, dn in zip(self.FIXED_VALUES, display_names)] + super( RatingBlock, self).human_readable_choices def get_author_edit_view_fragment(self, context): """ The options for the 1-5 values of the Likert scale aren't child blocks but we want to show them in the author edit view, for clarity. """ fragment = Fragment() fragment.add_content( loader.render_template( 'templates/html/ratingblock_edit_preview.html', { 'question': self.question, 'low': self.low, 'high': self.high, 'accepted_statuses': [None] + [self.describe_choice_correctness(c) for c in "12345"], })) self.render_children(context, fragment, can_reorder=True, can_add=False) return fragment @property def url_name(self): """ Get the url_name for this block. In Studio/LMS it is provided by a mixin, so we just defer to super(). In the workbench or any other platform, we use the name. """ try: return super(RatingBlock, self).url_name except AttributeError: return self.name def student_view(self, context): fragment = super(RatingBlock, self).student_view(context) rendering_for_studio = None if context: # Workbench does not provide context rendering_for_studio = context.get('author_edit_view') if rendering_for_studio: fragment.add_content( loader.render_template( 'templates/html/rating_edit_footer.html', {"url_name": self.url_name})) return fragment
class CourseFields(object): lti_passports = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings) textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) # This is should be scoped to content, but since it's defined in the policy # file, it is currently scoped to settings. user_partitions = UserPartitionList( help="List of user partitions of this course into groups, used e.g. for experiments", default=[], scope=Scope.settings ) 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=datetime(2030, 1, 1, tzinfo=UTC()), scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Dict(help="Grading policy definition for this class", default={"GRADER": [ { "type": "Homework", "min_count": 12, "drop_count": 2, "short_label": "HW", "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 }}, scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", default="Empty", display_name=_("Display Name"), scope=Scope.settings) course_edit_method = String(help="Method with which this course is edited.", default="Studio", scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[]) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings) discussion_sort_alpha = Boolean(scope=Scope.settings, default=False, help="Sort forum categories and subcategories alphabetically.") announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings) no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings) disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings) pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings) html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings) remote_gradebook = Dict(scope=Scope.settings) allow_anonymous = Boolean(scope=Scope.settings, default=True) allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False) advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) has_children = True checklists = List(scope=Scope.settings, default=[ {"short_description": _("Getting Started With Studio"), "items": [{"short_description": _("Add Course Team Members"), "long_description": _("Grant your collaborators permission to edit your course so you can work together."), "is_checked": False, "action_url": "ManageUsers", "action_text": _("Edit Course Team"), "action_external": False}, {"short_description": _("Set Important Dates for Your Course"), "long_description": _("Establish your course's student enrollment and launch dates on the Schedule and Details page."), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Details & Schedule"), "action_external": False}, {"short_description": _("Draft Your Course's Grading Policy"), "long_description": _("Set up your assignment types and grading policy even if you haven't created all your assignments."), "is_checked": False, "action_url": "SettingsGrading", "action_text": _("Edit Grading Settings"), "action_external": False}, {"short_description": _("Explore the Other Studio Checklists"), "long_description": _("Discover other available course authoring tools, and find help when you need it."), "is_checked": False, "action_url": "", "action_text": "", "action_external": False}]}, {"short_description": _("Draft a Rough Course Outline"), "items": [{"short_description": _("Create Your First Section and Subsection"), "long_description": _("Use your course outline to build your first Section and Subsection."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Set Section Release Dates"), "long_description": _("Specify the release dates for each Section in your course. Sections become visible to students on their release dates."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Designate a Subsection as Graded"), "long_description": _("Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Reordering Course Content"), "long_description": _("Use drag and drop to reorder the content in your course."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Renaming Sections"), "long_description": _("Rename Sections by clicking the Section name from the Course Outline."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Deleting Course Content"), "long_description": _("Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}, {"short_description": _("Add an Instructor-Only Section to Your Outline"), "long_description": _("Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False}]}, {"short_description": _("Explore edX's Support Tools"), "items": [{"short_description": _("Explore the Studio Help Forum"), "long_description": _("Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio."), "is_checked": False, "action_url": "http://help.edge.edx.org/", "action_text": _("Visit Studio Help"), "action_external": True}, {"short_description": _("Enroll in edX 101"), "long_description": _("Register for edX 101, edX's primer for course creation."), "is_checked": False, "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", "action_text": _("Register for edX 101"), "action_external": True}, {"short_description": _("Download the Studio Documentation"), "long_description": _("Download the searchable Studio reference documentation in PDF form."), "is_checked": False, "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", "action_text": _("Download Documentation"), "action_external": True}]}, {"short_description": _("Draft Your Course About Page"), "items": [{"short_description": _("Draft a Course Description"), "long_description": _("Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course."), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False}, {"short_description": _("Add Staff Bios"), "long_description": _("Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page."), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False}, {"short_description": _("Add Course FAQs"), "long_description": _("Include a short list of frequently asked questions about your course."), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False}, {"short_description": _("Add Course Prerequisites"), "long_description": _("Let students know what knowledge and/or skills they should have before they enroll in your course."), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False}]} ]) info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') show_timezone = Boolean( help="True if timezones should be shown on dates in the courseware. Deprecated in favor of due_date_display_format.", scope=Scope.settings, default=True ) due_date_display_format = String( help="Format supported by strftime for displaying due dates. Takes precedence over show_timezone.", scope=Scope.settings, default=None ) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", scope=Scope.settings) certificates_show_before_end = Boolean(help="True if students may download certificates before course end", scope=Scope.settings, default=False) course_image = String( help="Filename of the course image", scope=Scope.settings, # Ensure that courses imported from XML keep their image default="images_course_image.jpg" ) ## Course level Certificate Name overrides. cert_name_short = String( help="Sitewide name of completion statements given to students (short).", scope=Scope.settings, default="" ) cert_name_long = String( help="Sitewide name of completion statements given to students (long).", scope=Scope.settings, default="" ) # 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(help="DO NOT USE THIS", scope=Scope.settings, default="") # 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(help="DO NOT USE THIS", scope=Scope.settings) # 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(help="DO NOT USE THIS", scope=Scope.settings) display_organization = String(help="An optional display string for the course organization that will get rendered in the LMS", scope=Scope.settings) display_coursenumber = String(help="An optional display string for the course number that will get rendered in the LMS", scope=Scope.settings) max_student_enrollments_allowed = Integer(help="Limit the number of students allowed to enroll in this course.", scope=Scope.settings) allow_public_wiki_access = Boolean(help="Whether to allow an unenrolled user to view the Wiki", default=False, scope=Scope.settings)
class OpenClassroomXBlock(XBlock): """ An XBlock providing an embedded Open Classroom lesson. """ loader = ResourceLoader(__name__) _EVENT_NAME_EXPLORATION_LOADED = 'openclassroom.exploration.loaded' _EVENT_NAME_EXPLORATION_COMPLETED = 'openclassroom.exploration.completed' _EVENT_NAME_STATE_TRANSITION = 'openclassroom.exploration.state.changed' display_name = String(help=_("Display name of the component"), default=_("Open Classroom lesson"), scope=Scope.content) openclassroomid = String( help=_("ID of the Open Classroom lesson to embed"), default="2DB88aOgiXgD", scope=Scope.content) src = String(help=_("Source URL of the site"), default="https://lessons.openclassroom.edu.vn", scope=Scope.content) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def render_template(self, path, context): return self.loader.render_django_template( os.path.join('templates', path), context=Context(context), ) def get_translation_content(self): try: return self.resource_string( 'static/js/translations/{lang}/textjs.js'.format( lang=utils.translation.get_language(), )) except IOError: return self.resource_string('static/js/translations/en/textjs.js') def student_view(self, context=None): """ The primary view of the OpenClassroomXBlock, shown to students when viewing courses. """ frag = Fragment( self.render_template("openclassroom.html", { 'src': self.src, 'openclassroomid': self.openclassroomid, })) frag.add_javascript(self.get_translation_content()) frag.add_javascript( self.resource_string('static/js/openclassroom_player.js')) frag.add_javascript(self.resource_string("static/js/openclassroom.js")) frag.initialize_js('OpenClassroomXBlock') return frag def author_view(self, context=None): """ A view of the XBlock to show within the Studio preview. For some reason, the student_view() does not display, so we show a placeholder instead. """ frag = Fragment( self.render_template("openclassroom_preview.html", { 'src': self.src, 'openclassroomid': self.openclassroomid, })) frag.add_javascript(self.get_translation_content()) return frag def _log(self, event_name, payload): """ Logger for load, state transition and completion events. """ self.runtime.publish(self, event_name, payload) @XBlock.json_handler def on_exploration_loaded(self, data, suffix=''): """Called when an exploration has loaded.""" self._log( self._EVENT_NAME_EXPLORATION_LOADED, { 'exploration_id': self.openclassroomid, 'exploration_version': data['explorationVersion'], }) @XBlock.json_handler def on_state_transition(self, data, suffix=''): """Called when a state transition in the exploration has occurred.""" self._log( self._EVENT_NAME_STATE_TRANSITION, { 'exploration_id': self.openclassroomid, 'old_state_name': data['oldStateName'], 'new_state_name': data['newStateName'], 'exploration_version': data['explorationVersion'], }) @XBlock.json_handler def on_exploration_completed(self, data, suffix=''): """Called when the exploration has been completed.""" self._log( self._EVENT_NAME_EXPLORATION_COMPLETED, { 'exploration_id': self.openclassroomid, 'exploration_version': data['explorationVersion'], }) def studio_view(self, context): """ Create a fragment used to display the edit view in the Studio. """ frag = Fragment( self.render_template( "openclassroom_edit.html", { 'src': self.src, 'openclassroomid': self.openclassroomid or '', 'display_name': self.display_name, })) frag.add_javascript(self.get_translation_content()) js_str = pkg_resources.resource_string( __name__, "static/js/openclassroom_edit.js") frag.add_javascript(unicode(js_str)) frag.initialize_js('OpenClassroomXBlockEditor') return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Called when submitting the form in Studio. """ self.openclassroomid = data.get('openclassroomid') self.src = data.get('src') self.display_name = data.get('display_name') return {'result': 'success'} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("Open Classroom Embedding", """<vertical_demo> <openclassroom openclassroomid="0" src="https://lessons.openclassroom.edu.vn"/> </vertical_demo> """), ]
class CombinedOpenEndedFields(object): display_name = String( display_name="Display Name", help= "This name appears in the horizontal navigation at the top of the page.", default="Open Response Assessment", scope=Scope.settings) current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state) old_task_states = List(help=( "A list of lists of state dictionaries for student states that are saved." "This field is only populated if the instructor changes tasks after" "the module is created and students have attempted it (for example changes a self assessed problem to " "self and peer assessed."), scope=Scope.user_state) task_states = List( help="List of state dictionaries of each task within this module.", scope=Scope.user_state) state = String( help="Which step within the current task that the student is on.", default="initial", scope=Scope.user_state) graded = Boolean( display_name="Graded", help= 'Defines whether the student gets credit for grading this problem.', default=False, scope=Scope.settings) student_attempts = Integer( help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) ready_to_reset = Boolean( help="If the problem is ready to be reset or not.", default=False, scope=Scope.user_state) max_attempts = Integer( display_name="Maximum Attempts", help="The number of times the student can try to answer this problem.", default=1, scope=Scope.settings, values={"min": 1}) accept_file_upload = Boolean( display_name="Allow File Uploads", help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings) skip_spelling_checks = Boolean( display_name="Disable Quality Filter", help= "If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.", default=False, scope=Scope.settings) track_changes = Boolean( display_name="Peer Track Changes", help= ("EXPERIMENTAL FEATURE FOR PEER GRADING ONLY: " "If set to 'True', peer graders will be able to make changes to the student " "submission and those changes will be tracked and shown along with the graded feedback." ), default=False, scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " "can be set by an instructor, and will override the global due " "date if it is set to a date that is later than the global due " "date.", default=None, scope=Scope.user_state, ) graceperiod = Timedelta( help= "Amount of time after the due date that submissions will be accepted", scope=Scope.settings) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content, default=DEFAULT_DATA) weight = Float( display_name="Problem Weight", help= "Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.", scope=Scope.settings, values={ "min": 0, "step": ".1" }, default=1) min_to_calibrate = Integer( display_name="Minimum Peer Grading Calibrations", help= "The minimum number of calibration essays each student will need to complete for peer grading.", default=3, scope=Scope.settings, values={ "min": 1, "max": 20, "step": "1" }) max_to_calibrate = Integer( display_name="Maximum Peer Grading Calibrations", help= "The maximum number of calibration essays each student will need to complete for peer grading.", default=6, scope=Scope.settings, values={ "min": 1, "max": 20, "step": "1" }) peer_grader_count = Integer( display_name="Peer Graders per Response", help="The number of peers who will grade each submission.", default=3, scope=Scope.settings, values={ "min": 1, "step": "1", "max": 5 }) required_peer_grading = Integer( display_name="Required Peer Grading", help= "The number of other students each student making a submission will have to grade.", default=3, scope=Scope.settings, values={ "min": 1, "step": "1", "max": 5 }) peer_grade_finished_submissions_when_none_pending = Boolean( display_name='Allow "overgrading" of peer submissions', help= ("EXPERIMENTAL FEATURE. Allow students to peer grade submissions that already have the requisite number of graders, " "but ONLY WHEN all submissions they are eligible to grade already have enough graders. " "This is intended for use when settings for `Required Peer Grading` > `Peer Graders per Response`" ), default=False, scope=Scope.settings, ) markdown = String(help="Markdown source of this module", default=textwrap.dedent("""\ [prompt] <h3>Censorship in the Libraries</h3> <p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author </p> <p> Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. </p> [prompt] [rubric] + Ideas - Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus. - Attempts a main idea. Sometimes loses focus or ineffectively displays focus. - Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task. - Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task. + Content - Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic. - Includes little information and few or no details. Explores only one or two facets of the topic. - Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic. - Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic. + Organization - Ideas organized illogically, transitions weak, and response difficult to follow. - Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions. - Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions. + Style - Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns. - Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns). - Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences. + Voice - Demonstrates language and tone that may be inappropriate to task and reader. - Demonstrates an attempt to adjust language and tone to task and reader. - Demonstrates effective adjustment of language and tone to task and reader. [rubric] [tasks] (Self), ({4-12}AI), ({9-12}Peer) [tasks] """), scope=Scope.settings)
class ProblemBlock(XBlock): """A generalized container of InputBlocks and Checkers. """ script = String(help="Python code to compute values", scope=Scope.content, default="") seed = Integer(help="Random seed for this student", scope=Scope.user_state, default=0) problem_attempted = Boolean(help="Has the student attempted this problem?", scope=Scope.user_state, default=False) has_children = True @classmethod def parse_xml(cls, node, runtime, keys, id_generator): block = runtime.construct_xblock_from_class(cls, keys) # Find <script> children, turn them into script content. for child in node: if child.tag == "script": block.script += child.text else: block.runtime.add_node_as_child(block, child, id_generator) return block def set_student_seed(self): """Set a random seed for the student so they each have different but repeatable data.""" # Don't return zero, that's the default, and the sign that we should make a new seed. self.seed = int(time.time() * 1000) % 100 + 1 def calc_context(self, context): """If we have a script, run it, and return the resulting context.""" if self.script: # Seed the random number for the student if not self.seed: self.set_student_seed() random.seed(self.seed) script_vals = run_script(self.script) context = dict(context) context.update(script_vals) return context # The content controls how the Inputs attach to Graders def student_view(self, context=None): """Provide the default student view.""" if context is None: context = {} context = self.calc_context(context) result = Fragment() named_child_frags = [] # self.children is an attribute obtained from ChildrenModelMetaclass, so disable the # static pylint checking warning about this. for child_id in self.children: # pylint: disable=E1101 child = self.runtime.get_block(child_id) frag = self.runtime.render_child(child, "problem_view", context) result.add_fragment_resources(frag) named_child_frags.append((child.name, frag)) result.add_css(u""" .problem { border: solid 1px #888; padding: 3px; } """) result.add_content( self.runtime.render_template("problem.html", named_children=named_child_frags)) result.add_javascript(u""" function ProblemBlock(runtime, element) { function callIfExists(obj, fn) { if (typeof obj[fn] == 'function') { return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); } else { return undefined; } } function handleCheckResults(results) { $.each(results.submitResults || {}, function(input, result) { callIfExists(runtime.childMap(element, input), 'handleSubmit', result); }); $.each(results.checkResults || {}, function(checker, result) { callIfExists(runtime.childMap(element, checker), 'handleCheck', result); }); } // To submit a problem, call all the named children's submit() // function, collect their return values, and post that object // to the check handler. $(element).find('.check').bind('click', function() { var data = {}; var children = runtime.children(element); for (var i = 0; i < children.length; i++) { var child = children[i]; if (child.name !== undefined) { data[child.name] = callIfExists(child, 'submit'); } } var handlerUrl = runtime.handlerUrl(element, 'check') $.post(handlerUrl, JSON.stringify(data)).success(handleCheckResults); }); $(element).find('.rerandomize').bind('click', function() { var handlerUrl = runtime.handlerUrl(element, 'rerandomize'); $.post(handlerUrl, JSON.stringify({})); }); } """) result.initialize_js('ProblemBlock') return result @XBlock.json_handler def check(self, submissions, suffix=''): # pylint: disable=unused-argument """ Processess the `submissions` with each provided Checker. First calls the submit() method on each InputBlock. Then, for each Checker, finds the values it needs and passes them to the appropriate `check()` method. Returns a dictionary of 'submitResults': {input_name: user_submitted_results}, 'checkResults': {checker_name: results_passed_through_checker} """ self.problem_attempted = True context = self.calc_context({}) child_map = {} # self.children is an attribute obtained from ChildrenModelMetaclass, so disable the # static pylint checking warning about this. for child_id in self.children: # pylint: disable=E1101 child = self.runtime.get_block(child_id) if child.name: child_map[child.name] = child # For each InputBlock, call the submit() method with the browser-sent # input data. submit_results = {} for input_name, submission in submissions.items(): child = child_map[input_name] submit_results[input_name] = child.submit(submission) child.save() # For each Checker, find the values it wants, and pass them to its # check() method. checkers = list(self.runtime.querypath(self, "./checker")) check_results = {} for checker in checkers: arguments = checker.arguments kwargs = {} kwargs.update(arguments) for arg_name, arg_value in arguments.items(): if arg_value.startswith("."): values = list(self.runtime.querypath(self, arg_value)) # TODO: What is the specific promised semantic of the iterability # of the value returned by querypath? kwargs[arg_name] = values[0] elif arg_value.startswith("$"): kwargs[arg_name] = context.get(arg_value[1:]) elif arg_value.startswith("="): kwargs[arg_name] = int(arg_value[1:]) else: raise ValueError( u"Couldn't interpret checker argument: %r" % arg_value) result = checker.check(**kwargs) if checker.name: check_results[checker.name] = result return { 'submitResults': submit_results, 'checkResults': check_results, } @XBlock.json_handler def rerandomize(self, unused, suffix=''): # pylint: disable=unused-argument """Set a new random seed for the student.""" self.set_student_seed() return {'status': 'ok'} @staticmethod def workbench_scenarios(): """A few canned scenarios for display in the workbench.""" return [ ("problem with thumbs and textbox", """\ <problem_demo> <html_demo> <p>You have three constraints to satisfy:</p> <ol> <li>The upvotes and downvotes must be equal.</li> <li>You must enter the number of upvotes into the text field.</li> <li>The number of upvotes must be $numvotes.</li> </ol> </html_demo> <thumbs name='thumb'/> <textinput_demo name='vote_count' input_type='int'/> <script> # Compute the random answer. import random numvotes = random.randrange(2,5) </script> <equality_demo name='votes_equal' left='./thumb/@upvotes' right='./thumb/@downvotes'> Upvotes match downvotes </equality_demo> <equality_demo name='votes_named' left='./thumb/@upvotes' right='./vote_count/@student_input'> Number of upvotes matches entered string </equality_demo> <equality_demo name='votes_specified' left='./thumb/@upvotes' right='$numvotes'> Number of upvotes is $numvotes </equality_demo> </problem_demo> """), ("three problems 2", """ <vertical_demo> <attempts_scoreboard_demo/> <problem_demo> <html_demo><p>What is $a+$b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(2, 5) b = random.randint(1, 4) c = a + b </script> </problem_demo> <sidebar_demo> <problem_demo> <html_demo><p>What is $a × $b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(2, 6) b = random.randint(3, 7) c = a * b </script> </problem_demo> </sidebar_demo> <problem_demo> <html_demo><p>What is $a+$b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(3, 5) b = random.randint(2, 6) c = a + b </script> </problem_demo> </vertical_demo> """), ]
class AnnotatableBlock( RawMixin, XmlMixin, EditingMixin, XModuleDescriptorToXBlockMixin, XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin, ): """ Annotatable XBlock. """ data = String( help=_("XML data for the annotation"), scope=Scope.content, default=textwrap.dedent(HTML(u""" <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.system.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.system.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
class DragAndDropBlock(ScorableXBlockMixin, XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): """ XBlock that implements a friendly Drag-and-Drop problem """ CATEGORY = "drag-and-drop-v2" SOLUTION_CORRECT = "correct" SOLUTION_PARTIAL = "partial" SOLUTION_INCORRECT = "incorrect" GRADE_FEEDBACK_CLASSES = { SOLUTION_CORRECT: FeedbackMessages.MessageClasses.CORRECT_SOLUTION, SOLUTION_PARTIAL: FeedbackMessages.MessageClasses.PARTIAL_SOLUTION, SOLUTION_INCORRECT: FeedbackMessages.MessageClasses.INCORRECT_SOLUTION, } PROBLEM_FEEDBACK_CLASSES = { SOLUTION_CORRECT: FeedbackMessages.MessageClasses.CORRECT_SOLUTION, SOLUTION_PARTIAL: None, SOLUTION_INCORRECT: None } display_name = String( display_name=_("Title"), help= _("The title of the drag and drop problem. The title is displayed to learners." ), scope=Scope.settings, default=_("Drag and Drop"), enforce_type=True, ) mode = String( display_name=_("Mode"), help=_( "Standard mode: the problem provides immediate feedback each time " "a learner drops an item on a target zone. " "Assessment mode: the problem provides feedback only after " "a learner drops all available items on target zones."), scope=Scope.settings, values=[ { "display_name": _("Standard"), "value": Constants.STANDARD_MODE }, { "display_name": _("Assessment"), "value": Constants.ASSESSMENT_MODE }, ], default=Constants.STANDARD_MODE, enforce_type=True, ) 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."), scope=Scope.settings, default=None, enforce_type=True, ) show_title = Boolean( display_name=_("Show title"), help=_("Display the title to the learner?"), scope=Scope.settings, default=True, enforce_type=True, ) question_text = String( display_name=_("Problem text"), help= _("The description of the problem or instructions shown to the learner." ), scope=Scope.settings, default="", enforce_type=True, ) show_question_header = Boolean( display_name=_('Show "Problem" heading'), help=_('Display the heading "Problem" above the problem text?'), scope=Scope.settings, default=True, enforce_type=True, ) weight = Float( display_name=_("Problem Weight"), help=_("Defines the number of points the problem is worth."), scope=Scope.settings, default=1, enforce_type=True, ) item_background_color = String( display_name=_("Item background color"), help= _("The background color of draggable items in the problem (example: 'blue' or '#0000ff')." ), scope=Scope.settings, default="", enforce_type=True, ) item_text_color = String( display_name=_("Item text color"), help= _("Text color to use for draggable items (example: 'white' or '#ffffff')." ), scope=Scope.settings, default="", enforce_type=True, ) max_items_per_zone = Integer( display_name=_("Maximum items per zone"), help= _("This setting limits the number of items that can be dropped into a single zone." ), scope=Scope.settings, default=None, enforce_type=True, ) data = Dict( display_name=_("Problem data"), help= _("Information about zones, items, feedback, and background image for this problem. " "This information is derived from the input that a course author provides via the interactive editor " "when configuring the problem."), scope=Scope.content, default=DEFAULT_DATA, enforce_type=True, ) item_state = Dict( help= _("Information about current positions of items that a learner has dropped on the target image." ), scope=Scope.user_state, default={}, enforce_type=True, ) attempts = Integer( help=_("Number of attempts learner used"), scope=Scope.user_state, default=0, enforce_type=True, ) completed = Boolean( help= _("Indicates whether a learner has completed the problem at least once" ), scope=Scope.user_state, default=False, enforce_type=True, ) grade = Float(help=_( "DEPRECATED. Keeps maximum score achieved by student as a weighted value." ), scope=Scope.user_state, default=0) raw_earned = Float( help= _("Keeps maximum score achieved by student as a raw value between 0 and 1." ), scope=Scope.user_state, default=0, enforce_type=True, ) block_settings_key = 'drag-and-drop-v2' def max_score(self): # pylint: disable=no-self-use """ Return the problem's max score, which for DnDv2 always equals 1. Required by the grading system in the LMS. """ return 1 def get_score(self): """ Return the problem's current score as raw values. """ if self._get_raw_earned_if_set() is None: self.raw_earned = self._learner_raw_score() return Score(self.raw_earned, self.max_score()) def set_score(self, score): """ Sets the score on this block. Takes a Score namedtuple containing a raw score and possible max (for this block, we expect that this will always be 1). """ assert score.raw_possible == self.max_score() self.raw_earned = score.raw_earned def calculate_score(self): """ Returns a newly-calculated raw score on the problem for the learner based on the learner's current state. """ return Score(self._learner_raw_score(), self.max_score()) def has_submitted_answer(self): """ Returns True if the user has made a submission. """ return self.fields['raw_earned'].is_set_on( self) or self.fields['grade'].is_set_on(self) def weighted_grade(self): """ Returns the block's current saved grade multiplied by the block's weight- the number of points earned by the learner. """ return self.raw_earned * self.weight def _learner_raw_score(self): """ Calculate raw score for learner submission. As it is calculated as ratio of correctly placed (or left in bank in case of decoys) items to total number of items, it lays in interval [0..1] """ correct_count, total_count = self._get_item_stats() return correct_count / float(total_count) @staticmethod def _get_statici18n_js_url(): """ Returns the Javascript translation file for the currently selected language, if any found by `pkg_resources` """ lang_code = translation.get_language() if not lang_code: return None text_js = 'public/js/translations/{lang_code}/text.js' country_code = lang_code.split('-')[0] for code in (lang_code, country_code): if pkg_resources.resource_exists(loader.module_name, text_js.format(lang_code=code)): return text_js.format(lang_code=code) return None @XBlock.supports( "multi_device" ) # Enable this block for use in the mobile app via webview def student_view(self, context): """ Player view, displayed to the student """ fragment = Fragment() fragment.add_content( loader.render_django_template('/templates/html/drag_and_drop.html', i18n_service=self.i18n_service)) css_urls = ('public/css/drag_and_drop.css', ) js_urls = [ 'public/js/vendor/virtual-dom-1.3.0.min.js', 'public/js/drag_and_drop.js', ] statici18n_js_url = self._get_statici18n_js_url() if statici18n_js_url: js_urls.append(statici18n_js_url) for css_url in css_urls: fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) for js_url in js_urls: fragment.add_javascript_url( self.runtime.local_resource_url(self, js_url)) self.include_theme_files(fragment) fragment.initialize_js('DragAndDropBlock', self.student_view_data()) return fragment def student_view_data(self, context=None): """ Get the configuration data for the student_view. The configuration is all the settings defined by the author, except for correct answers and feedback. """ def items_without_answers(): """ Removes feedback and answer from items """ items = copy.deepcopy(self.data.get('items', '')) for item in items: del item['feedback'] # Use item.pop to remove both `item['zone']` and `item['zones']`; we don't have # a guarantee that either will be present, so we can't use `del`. Legacy instances # will have `item['zone']`, while current versions will have `item['zones']`. item.pop('zone', None) item.pop('zones', None) # Fall back on "backgroundImage" to be backward-compatible. image_url = item.get('imageURL') or item.get('backgroundImage') if image_url: item['expandedImageURL'] = self._expand_static_url( image_url) else: item['expandedImageURL'] = '' return items return { "block_id": unicode(self.scope_ids.usage_id), "display_name": self.display_name, "type": self.CATEGORY, "weight": self.weight, "mode": self.mode, "zones": self.zones, "max_attempts": self.max_attempts, "graded": getattr(self, 'graded', False), "weighted_max_score": self.max_score() * self.weight, "max_items_per_zone": self.max_items_per_zone, # SDK doesn't supply url_name. "url_name": getattr(self, 'url_name', ''), "display_zone_labels": self.data.get('displayLabels', False), "display_zone_borders": self.data.get('displayBorders', False), "items": items_without_answers(), "title": self.display_name, "show_title": self.show_title, "problem_text": self.question_text, "show_problem_header": self.show_question_header, "target_img_expanded_url": self.target_img_expanded_url, "target_img_description": self.target_img_description, "item_background_color": self.item_background_color or None, "item_text_color": self.item_text_color or None, "has_deadline_passed": self.has_submission_deadline_passed, # final feedback (data.feedback.finish) is not included - it may give away answers. } def studio_view(self, context): """ Editing view in Studio """ js_templates = loader.load_unicode('/templates/html/js_templates.html') # Get an 'id_suffix' string that is unique for this block. # We append it to HTML element ID attributes to ensure multiple instances of the DnDv2 block # on the same page don't share the same ID value. # We avoid using ID attributes in preference to classes, but sometimes we still need IDs to # connect 'for' and 'aria-describedby' attributes to the associated elements. id_suffix = self._get_block_id() js_templates = js_templates.replace('{{id_suffix}}', id_suffix) context = { 'js_templates': js_templates, 'id_suffix': id_suffix, 'fields': self.fields, 'self': self, 'data': urllib.quote(json.dumps(self.data)), } fragment = Fragment() fragment.add_content( loader.render_django_template( '/templates/html/drag_and_drop_edit.html', context=context, i18n_service=self.i18n_service)) css_urls = ('public/css/drag_and_drop_edit.css', ) js_urls = [ 'public/js/vendor/handlebars-v1.1.2.js', 'public/js/drag_and_drop_edit.js', ] statici18n_js_url = self._get_statici18n_js_url() if statici18n_js_url: js_urls.append(statici18n_js_url) for css_url in css_urls: fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) for js_url in js_urls: fragment.add_javascript_url( self.runtime.local_resource_url(self, js_url)) # Do a bit of manipulation so we get the appearance of a list of zone options on # items that still have just a single zone stored items = self.data.get('items', []) for item in items: zones = self.get_item_zones(item['id']) # Note that we appear to be mutating the state of the XBlock here, but because # the change won't be committed, we're actually just affecting the data that # we're going to send to the client, not what's saved in the backing store. item['zones'] = zones item.pop('zone', None) fragment.initialize_js( 'DragAndDropEditBlock', { 'data': self.data, 'target_img_expanded_url': self.target_img_expanded_url, 'default_background_image_url': self.default_background_image_url, }) return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): """ Handles studio save. """ self.display_name = submissions['display_name'] self.mode = submissions['mode'] self.max_attempts = submissions['max_attempts'] self.show_title = submissions['show_title'] self.question_text = submissions['problem_text'] self.show_question_header = submissions['show_problem_header'] self.weight = float(submissions['weight']) self.item_background_color = submissions['item_background_color'] self.item_text_color = submissions['item_text_color'] self.max_items_per_zone = self._get_max_items_per_zone(submissions) self.data = submissions['data'] return { 'result': 'success', } def _get_block_id(self): """ Return unique ID of this block. Useful for HTML ID attributes. Works both in LMS/Studio and workbench runtimes: - In LMS/Studio, use the location.html_id method. - In the workbench, use the usage_id. """ if hasattr(self, 'location'): return self.location.html_id() # pylint: disable=no-member else: return unicode(self.scope_ids.usage_id) @staticmethod def _get_max_items_per_zone(submissions): """ Parses Max items per zone value coming from editor. Returns: * None if invalid value is passed (i.e. not an integer) * None if value is parsed into zero or negative integer * Positive integer otherwise. Examples: * _get_max_items_per_zone(None) -> None * _get_max_items_per_zone('string') -> None * _get_max_items_per_zone('-1') -> None * _get_max_items_per_zone(-1) -> None * _get_max_items_per_zone('0') -> None * _get_max_items_per_zone('') -> None * _get_max_items_per_zone('42') -> 42 * _get_max_items_per_zone(42) -> 42 """ raw_max_items_per_zone = submissions.get('max_items_per_zone', None) # Entries that aren't numbers should be treated as null. We assume that if we can # turn it into an int, a number was submitted. try: max_attempts = int(raw_max_items_per_zone) if max_attempts > 0: return max_attempts else: return None except (ValueError, TypeError): return None @XBlock.json_handler def drop_item(self, item_attempt, suffix=''): """ Handles dropping item into a zone. """ self._validate_drop_item(item_attempt) if self.mode == Constants.ASSESSMENT_MODE: return self._drop_item_assessment(item_attempt) elif self.mode == Constants.STANDARD_MODE: return self._drop_item_standard(item_attempt) else: raise JsonHandlerError( 500, self.i18n_service.gettext( "Unknown DnDv2 mode {mode} - course is misconfigured"). format(self.mode)) @XBlock.json_handler def do_attempt(self, data, suffix=''): """ Checks submitted solution and returns feedback. Raises: * JsonHandlerError with 400 error code in standard mode. * JsonHandlerError with 409 error code if no more attempts left """ self._validate_do_attempt() self.attempts += 1 # pylint: disable=fixme # TODO: Refactor this method to "freeze" item_state and pass it to methods that need access to it. # These implicit dependencies between methods exist because most of them use `item_state` or other # fields, either as an "input" (i.e. read value) or as output (i.e. set value) or both. As a result, # incorrect order of invocation causes issues: self._mark_complete_and_publish_grade( ) # must happen before _get_feedback - sets grade correct = self._is_answer_correct( ) # must happen before manipulating item_state - reads item_state overall_feedback_msgs, misplaced_ids = self._get_feedback( include_item_feedback=True) misplaced_items = [] for item_id in misplaced_ids: # Don't delete misplaced item states on the final attempt. if self.attempts_remain: del self.item_state[item_id] misplaced_items.append(self._get_item_definition(int(item_id))) feedback_msgs = [ FeedbackMessage(item['feedback']['incorrect'], None) for item in misplaced_items ] return { 'correct': correct, 'attempts': self.attempts, 'grade': self._get_weighted_earned_if_set(), 'misplaced_items': list(misplaced_ids), 'feedback': self._present_feedback(feedback_msgs), 'overall_feedback': self._present_feedback(overall_feedback_msgs) } @XBlock.json_handler def publish_event(self, data, suffix=''): """ Handler to publish XBlock event from frontend """ try: event_type = data.pop('event_type') except KeyError: return { 'result': 'error', 'message': 'Missing event_type in JSON data' } self.runtime.publish(self, event_type, data) return {'result': 'success'} @XBlock.json_handler def reset(self, data, suffix=''): """ Resets problem to initial state """ self.item_state = {} return self._get_user_state() @XBlock.json_handler def show_answer(self, data, suffix=''): """ Returns correct answer in assessment mode. Raises: * JsonHandlerError with 400 error code in standard mode. * JsonHandlerError with 409 error code if there are still attempts left """ if self.mode != Constants.ASSESSMENT_MODE: raise JsonHandlerError( 400, self.i18n_service.gettext( "show_answer handler should only be called for assessment mode" )) if self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("There are attempts remaining")) return self._get_correct_state() @XBlock.json_handler def expand_static_url(self, url, suffix=''): """ AJAX-accessible handler for expanding URLs to static [image] files """ return {'url': self._expand_static_url(url)} @property def i18n_service(self): """ Obtains translation service """ i18n_service = self.runtime.service(self, "i18n") if i18n_service: return i18n_service else: return DummyTranslationService() @property def target_img_expanded_url(self): """ Get the expanded URL to the target image (the image items are dragged onto). """ if self.data.get("targetImg"): return self._expand_static_url(self.data["targetImg"]) else: return self.default_background_image_url @property def target_img_description(self): """ Get the description for the target image (the image items are dragged onto). """ return self.data.get("targetImgDescription", "") @property def default_background_image_url(self): """ The URL to the default background image, shown when no custom background is used """ return self.runtime.local_resource_url(self, "public/img/triangle.png") @property def attempts_remain(self): """ Checks if current student still have more attempts. """ return self.max_attempts is None or self.max_attempts == 0 or self.attempts < self.max_attempts @property def has_submission_deadline_passed(self): """ Returns a boolean indicating if the submission is past its deadline. Using the `has_deadline_passed` method from InheritanceMixin which gets added on the LMS/Studio, return if the submission is past its due date. If the method not found, which happens for pure DragAndDropXblock, return False which makes sure submission checks don't affect other functionality. """ if hasattr(self, "has_deadline_passed"): return self.has_deadline_passed() # pylint: disable=no-member else: return False @XBlock.handler def student_view_user_state(self, request, suffix=''): """ GET all user-specific data, and any applicable feedback """ data = self._get_user_state() return webob.Response(body=json.dumps(data), content_type='application/json') def _validate_do_attempt(self): """ Validates if `do_attempt` handler should be executed """ if self.mode != Constants.ASSESSMENT_MODE: raise JsonHandlerError( 400, self.i18n_service.gettext( "do_attempt handler should only be called for assessment mode" )) if not self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("Max number of attempts reached")) if self.has_submission_deadline_passed: raise JsonHandlerError( 409, self.i18n_service.gettext("Submission deadline has passed.")) def _get_feedback(self, include_item_feedback=False): """ Builds overall feedback for both standard and assessment modes """ answer_correctness = self._answer_correctness() is_correct = answer_correctness == self.SOLUTION_CORRECT if self.mode == Constants.STANDARD_MODE or not self.attempts: feedback_key = 'finish' if is_correct else 'start' return [ FeedbackMessage(self.data['feedback'][feedback_key], None) ], set() items = self._get_item_raw_stats() missing_ids = items.required - items.placed misplaced_ids = items.placed - items.correctly_placed feedback_msgs = [] def _add_msg_if_exists(ids_list, message_template, message_class): """ Adds message to feedback messages if corresponding items list is not empty """ if ids_list: message = message_template(len(ids_list), self.i18n_service.ngettext) feedback_msgs.append(FeedbackMessage(message, message_class)) if self.item_state or include_item_feedback: _add_msg_if_exists( items.correctly_placed, FeedbackMessages.correctly_placed, FeedbackMessages.MessageClasses.CORRECTLY_PLACED) # Misplaced items are not returned to the bank on the final attempt. if self.attempts_remain: misplaced_template = FeedbackMessages.misplaced_returned else: misplaced_template = FeedbackMessages.misplaced _add_msg_if_exists(misplaced_ids, misplaced_template, FeedbackMessages.MessageClasses.MISPLACED) _add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED) if self.attempts_remain and (misplaced_ids or missing_ids): problem_feedback_message = self.data['feedback']['start'] else: problem_feedback_message = self.data['feedback']['finish'] problem_feedback_class = self.PROBLEM_FEEDBACK_CLASSES.get( answer_correctness, None) grade_feedback_class = self.GRADE_FEEDBACK_CLASSES.get( answer_correctness, None) feedback_msgs.append( FeedbackMessage(problem_feedback_message, problem_feedback_class)) if self.weight > 0: if self.attempts_remain: grade_feedback_template = FeedbackMessages.GRADE_FEEDBACK_TPL else: grade_feedback_template = FeedbackMessages.FINAL_ATTEMPT_TPL feedback_msgs.append( FeedbackMessage( self.i18n_service.gettext(grade_feedback_template).format( score=self.weighted_grade()), grade_feedback_class)) return feedback_msgs, misplaced_ids @staticmethod def _present_feedback(feedback_messages): """ Transforms feedback messages into format expected by frontend code """ return [{ "message": msg.message, "message_class": msg.message_class } for msg in feedback_messages if msg.message] def _drop_item_standard(self, item_attempt): """ Handles dropping item to a zone in standard mode. """ item = self._get_item_definition(item_attempt['val']) is_correct = self._is_attempt_correct( item_attempt) # Student placed item in a correct zone if is_correct: # In standard mode state is only updated when attempt is correct self.item_state[str(item['id'])] = self._make_state_from_attempt( item_attempt, is_correct) self._mark_complete_and_publish_grade( ) # must happen before _get_feedback self._publish_item_dropped_event(item_attempt, is_correct) item_feedback_key = 'correct' if is_correct else 'incorrect' item_feedback = FeedbackMessage( self._expand_static_url(item['feedback'][item_feedback_key]), None) overall_feedback, __ = self._get_feedback() return { 'correct': is_correct, 'grade': self._get_weighted_earned_if_set(), 'finished': self._is_answer_correct(), 'overall_feedback': self._present_feedback(overall_feedback), 'feedback': self._present_feedback([item_feedback]) } def _drop_item_assessment(self, item_attempt): """ Handles dropping item into a zone in assessment mode """ if not self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("Max number of attempts reached")) item = self._get_item_definition(item_attempt['val']) is_correct = self._is_attempt_correct(item_attempt) if item_attempt['zone'] is None: self.item_state.pop(str(item['id']), None) self._publish_item_to_bank_event(item['id'], is_correct) else: # State is always updated in assessment mode to store intermediate item positions self.item_state[str(item['id'])] = self._make_state_from_attempt( item_attempt, is_correct) self._publish_item_dropped_event(item_attempt, is_correct) return {} def _validate_drop_item(self, item): """ Validates `drop_item` parameters. Assessment mode allows returning items to the bank, so validation is unnecessary. """ if self.mode != Constants.ASSESSMENT_MODE: zone = self._get_zone_by_uid(item['zone']) if not zone: raise JsonHandlerError(400, "Item zone data invalid.") @staticmethod def _make_state_from_attempt(attempt, correct): """ Converts "attempt" data coming from browser into "state" entry stored in item_state """ return {'zone': attempt['zone'], 'correct': correct} def _mark_complete_and_publish_grade(self): """ Helper method to update `self.completed` and submit grade event if appropriate conditions met. """ # pylint: disable=fixme # TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state) # This method implicitly depends on self.item_state (via _is_answer_correct and _learner_raw_score) # and also updates self.raw_earned if some conditions are met. As a result this method implies some order of # invocation: # * it should be called after learner-caused updates to self.item_state is applied # * it should be called before self.item_state cleanup is applied (i.e. returning misplaced items to item bank) # * it should be called before any method that depends on self.raw_earned (i.e. self._get_feedback) # Splitting it into a "clean" functions will allow to capture this implicit invocation order in caller method # and help avoid bugs caused by invocation order violation in future. # There's no going back from "completed" status to "incomplete" self.completed = self.completed or self._is_answer_correct( ) or not self.attempts_remain current_raw_earned = self._learner_raw_score() # ... and from higher grade to lower # if we have an old-style (i.e. unreliable) grade, override no matter what saved_raw_earned = self._get_raw_earned_if_set() if current_raw_earned is None or current_raw_earned > saved_raw_earned: self.raw_earned = current_raw_earned self._publish_grade(Score(self.raw_earned, self.max_score())) # and no matter what - emit progress event for current user self.runtime.publish(self, "progress", {}) def _publish_item_dropped_event(self, attempt, is_correct): """ Publishes item dropped event. """ item = self._get_item_definition(attempt['val']) # attempt should already be validated here - not doing the check for existing zone again zone = self._get_zone_by_uid(attempt['zone']) item_label = item.get("displayName") if not item_label: item_label = item.get("imageURL") self.runtime.publish( self, 'edx.drag_and_drop_v2.item.dropped', { 'item': item_label, 'item_id': item['id'], 'location': zone.get("title"), 'location_id': zone.get("uid"), 'is_correct': is_correct, }) def _publish_item_to_bank_event(self, item_id, is_correct): """ Publishes event when item moved back to the bank in assessment mode. """ item = self._get_item_definition(item_id) item_label = item.get("displayName") if not item_label: item_label = item.get("imageURL") self.runtime.publish( self, 'edx.drag_and_drop_v2.item.dropped', { 'item': item_label, 'item_id': item['id'], 'location': 'item bank', 'location_id': -1, 'is_correct': is_correct, }) def _is_attempt_correct(self, attempt): """ Check if the item was placed correctly. """ correct_zones = self.get_item_zones(attempt['val']) if correct_zones == [] and attempt[ 'zone'] is None and self.mode == Constants.ASSESSMENT_MODE: return True return attempt['zone'] in correct_zones def _expand_static_url(self, url): """ This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the only portable URL format for static files that works across export/import and reruns). This method is unfortunately a bit hackish since XBlock does not provide a low-level API for this. """ if hasattr(self.runtime, 'replace_urls'): url = self.runtime.replace_urls(u'"{}"'.format(url))[1:-1] elif hasattr(self.runtime, 'course_id'): # edX Studio uses a different runtime for 'studio_view' than 'student_view', # and the 'studio_view' runtime doesn't provide the replace_urls API. try: from static_replace import replace_static_urls # pylint: disable=import-error url = replace_static_urls( u'"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1] except ImportError: pass return url def _get_user_state(self): """ Get all user-specific data, and any applicable feedback """ item_state = self._get_item_state() # In assessment mode, we do not want to leak the correctness info for individual items to the frontend, # so we remove "correct" from all items when in assessment mode. if self.mode == Constants.ASSESSMENT_MODE: for item in item_state.values(): del item["correct"] overall_feedback_msgs, __ = self._get_feedback() if self.mode == Constants.STANDARD_MODE: is_finished = self._is_answer_correct() else: is_finished = not self.attempts_remain return { 'items': item_state, 'finished': is_finished, 'attempts': self.attempts, 'grade': self._get_weighted_earned_if_set(), 'overall_feedback': self._present_feedback(overall_feedback_msgs) } def _get_correct_state(self): """ Returns one of the possible correct states for the configured data. """ state = {} items = copy.deepcopy(self.data.get('items', [])) for item in items: zones = item.get('zones') # For backwards compatibility if zones is None: zones = [] zone = item.get('zone') if zone is not None and zone != 'none': zones.append(zone) if zones: zone = zones.pop() state[str(item['id'])] = { 'zone': zone, 'correct': True, } return {'items': state} def _get_item_state(self): """ Returns a copy of the user item state. Converts to a dict if data is stored in legacy tuple form. """ # IMPORTANT: this method should always return a COPY of self.item_state - it is called from # student_view_user_state handler and the data it returns is manipulated there to hide # correctness of items placed. state = {} migrator = StateMigration(self) for item_id, item in self.item_state.iteritems(): state[item_id] = migrator.apply_item_state_migrations( item_id, item) return state def _get_item_definition(self, item_id): """ Returns definition (settings) for item identified by `item_id`. """ return next(i for i in self.data['items'] if i['id'] == item_id) def get_item_zones(self, item_id): """ Returns a list of the zones that are valid options for the item. If the item is configured with a list of zones, return that list. If the item is configured with a single zone, encapsulate that zone's ID in a list and return the list. If the item is not configured with any zones, or if it's configured explicitly with no zones, return an empty list. """ item = self._get_item_definition(item_id) if item.get('zones') is not None: return item.get('zones') elif item.get('zone') is not None and item.get('zone') != 'none': return [item.get('zone')] else: return [] @property def zones(self): """ Get drop zone data, defined by the author. """ # Convert zone data from old to new format if necessary migrator = StateMigration(self) return [ migrator.apply_zone_migrations(zone) for zone in self.data.get('zones', []) ] def _get_zone_by_uid(self, uid): """ Given a zone UID, return that zone, or None. """ for zone in self.zones: if zone["uid"] == uid: return zone def _get_item_stats(self): """ Returns a tuple representing the number of correctly placed items, and the total number of items required (including decoy items). """ items = self._get_item_raw_stats() correct_count = len(items.correctly_placed) + len(items.decoy_in_bank) total_count = len(items.required) + len(items.decoy) return correct_count, total_count def _get_item_raw_stats(self): """ Returns a named tuple containing required, decoy, placed, correctly placed, and correctly unplaced decoy items. Returns: namedtuple: (required, placed, correctly_placed, decoy, decoy_in_bank) * required - IDs of items that must be placed on the board * placed - IDs of items actually placed on the board * correctly_placed - IDs of items that were placed correctly * decoy - IDs of decoy items * decoy_in_bank - IDs of decoy items that were unplaced """ item_state = self._get_item_state() all_items = set(str(item['id']) for item in self.data['items']) required = set(item_id for item_id in all_items if self.get_item_zones(int(item_id)) != []) placed = set(item_id for item_id in all_items if item_id in item_state) correctly_placed = set(item_id for item_id in placed if item_state[item_id]['correct']) decoy = all_items - required decoy_in_bank = set(item_id for item_id in decoy if item_id not in item_state) return ItemStats(required, placed, correctly_placed, decoy, decoy_in_bank) def _get_raw_earned_if_set(self): """ Returns student's grade if already explicitly set, otherwise returns None. This is different from self.raw_earned which returns 0 by default. """ if self.fields['raw_earned'].is_set_on(self): return self.raw_earned else: return None def _get_weighted_earned_if_set(self): """ Returns student's grade with the problem weight applied if set, otherwise None. """ if self.fields['raw_earned'].is_set_on(self): return self.weighted_grade() else: return None def _answer_correctness(self): """ Checks answer correctness: Returns: string: Correct/Incorrect/Partial * Correct: All items are at their correct place. * Partial: Some items are at their correct place. * Incorrect: None items are at their correct place. """ correct_count, total_count = self._get_item_stats() if correct_count == total_count: return self.SOLUTION_CORRECT elif correct_count == 0: return self.SOLUTION_INCORRECT else: return self.SOLUTION_PARTIAL def _is_answer_correct(self): """ Helper - checks if answer is correct Returns: bool: True if current answer is correct """ return self._answer_correctness() == self.SOLUTION_CORRECT @staticmethod def workbench_scenarios(): """ A canned scenario for display in the workbench. """ return [ ("Drag-and-drop-v2 standard", "<vertical_demo><drag-and-drop-v2/></vertical_demo>"), ("Drag-and-drop-v2 assessment", "<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='3'/></vertical_demo>" ), ]
class TrueOrFalseXBlock(XBlock): """ XBlock Settings """ display_name = String(display_name="Nombre del Componente", help="Nombre del componente", scope=Scope.settings, default="Preguntas Verdadero o Falso") questions = Dict(default={ '1': { 'question': 'Enunciado de ejemplo (Verdadero)', 'answer': True }, '2': { 'question': 'Enunciado de ejemplo (Falso)', 'answer': False } }, scope=Scope.settings, help="Lista de preguntas") weight = Float( display_name='Puntaje Máximo', help='Ingrese el puntaje máximo del ejercicio', default=1, values={ 'min': 0, 'step': 1 }, scope=Scope.settings, ) max_attempts = Integer( display_name='Intentos Permitidos', help= 'Ingrese la cantidad de intentos máximos permitidos para el ejercicio', default=2, values={ 'min': 1, 'step': 1 }, scope=Scope.settings, ) show_answer = String( display_name="Mostrar Respuestas", help= "Ingrese cuándo se habilita el botón para mostrar respuestas correctas", default="Finalizado", values=["Finalizado", "Ocultar"], scope=Scope.settings) has_score = True icon_class = "problem" """ Student state """ is_answered = Boolean(default=False, scope=Scope.user_state) student_answers = Dict(default={'1': '', '2': ''}, scope=Scope.user_state) score = Float( default=0.0, scope=Scope.user_state, ) attempts = Integer( default=0, scope=Scope.user_state, ) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def build_fragment( self, rendered_template, initialize_js_func, additional_css=[], additional_js=[], ): # pylint: disable=dangerous-default-value, too-many-arguments """ Creates a fragment for display. """ fragment = Fragment(rendered_template) for item in additional_css: url = self.runtime.local_resource_url(self, item) fragment.add_css_url(url) for item in additional_js: url = self.runtime.local_resource_url(self, item) fragment.add_javascript_url(url) settings = { 'image_path': self.runtime.local_resource_url(self, 'static/images/'), 'is_past_due': self.get_is_past_due() } fragment.initialize_js(initialize_js_func, json_args=settings) return fragment def student_view(self, context={}): """ Create a fragment used to display the student view in the LMS. """ # sort questions list questions_list = [[k, v] for k, v in list(self.questions.items())] questions_list = sorted(questions_list, key=lambda x: int(x[0])) # student status indicator_class = self.get_indicator_class() context.update({ 'xblock': self, 'no_more_attempts': self.max_attempts and self.max_attempts > 0 and self.attempts >= self.max_attempts, 'questions_list': questions_list, 'problem_progress': self.get_problem_progress(), 'indicator_class': indicator_class, 'image_path': self.runtime.local_resource_url(self, 'static/images/'), 'location': str(self.location).split('@')[-1], 'show_correctness': self.get_show_correctness(), 'is_past_due': self.get_is_past_due }) template = loader.render_django_template( 'static/html/trueorfalse.html', context=Context(context), i18n_service=self.runtime.service(self, 'i18n'), ) frag = self.build_fragment( template, initialize_js_func='TrueOrFalseXBlock', additional_css=[ 'static/css/trueorfalse.css', ], additional_js=[ 'static/js/src/trueorfalse.js', ], ) return frag def studio_view(self, context): """ Create a fragment used to display the edit view in the Studio. """ # sort questions list questions_list = [[k, v] for k, v in list(self.questions.items())] questions_list = sorted(questions_list, key=lambda x: int(x[0])) context.update({ 'field_display_name': self.fields['display_name'], 'field_questions': self.fields['questions'], 'field_weight': self.fields['weight'], 'field_show_answer': self.fields['show_answer'], 'field_max_attempts': self.fields['max_attempts'], 'xblock': self, 'questions_list': questions_list, 'location': self.location }) template = loader.render_django_template( 'static/html/studio.html', context=Context(context), i18n_service=self.runtime.service(self, 'i18n'), ) frag = self.build_fragment( template, initialize_js_func='TrueOrFalseEditBlock', additional_css=[ 'static/css/trueorfalse.css', ], additional_js=[ 'static/js/src/studio.js', ], ) return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Called when submitting the form in Studio. """ new_questions = {} questions = data.get('questions_list') for q in questions: answer = True if q['answer'] == 'false': answer = False new_questions[q['id_question']] = { 'question': q['question'], 'answer': answer } self.display_name = data.get('display_name') self.show_answer = data.get('show_answer') if data.get('weight') and int(data.get('weight')) >= 0: self.weight = int(data.get('weight')) if data.get('max_attempts') and int(data.get('max_attempts')) > 0: self.max_attempts = int(data.get('max_attempts')) self.questions = new_questions return {'result': 'success'} def get_indicator_class(self): indicator_class = 'unanswered' if self.is_answered and self.attempts: if self.score >= 1: indicator_class = 'correct' else: indicator_class = 'incorrect' return indicator_class def get_show_correctness(self): if hasattr(self, 'show_correctness'): if self.show_correctness == 'past_due': if self.is_past_due(): return "always" else: return "never" else: return self.show_correctness else: return "always" def get_is_past_due(self): if hasattr(self, 'show_correctness'): return self.is_past_due() else: return False def is_past_due(self): """ Determine if component is past-due """ # These values are pulled from platform. # They are defaulted to None for tests. due = getattr(self, 'due', None) graceperiod = getattr(self, 'graceperiod', None) # Calculate the current DateTime so we can compare the due date to it. # datetime.utcnow() returns timezone naive date object. now = datetime.datetime.utcnow() if due is not None: # Remove timezone information from platform provided due date. # Dates are stored as UTC timezone aware objects on platform. due = due.replace(tzinfo=None) if graceperiod is not None: # Compare the datetime objects (both have to be timezone naive) due = due + graceperiod return now > due return False def get_problem_progress(self): """ Returns a statement of progress for the XBlock, which depends on the user's current score """ calif = ' (no calificable)' if hasattr(self, 'graded') and self.graded: calif = ' (calificable)' if self.weight == 0: result = '0 puntos posibles' + calif elif self.attempts <= 0: if self.weight == 1: result = "1 punto posible" + calif else: result = str(self.weight) + " puntos posibles" + calif else: scaled_score = self.score * self.weight # No trailing zero and no scientific notation score_string = ('%.15f' % scaled_score).rstrip('0').rstrip('.') if self.weight == 1: result = str(score_string) + "/" + str( self.weight) + " punto" + calif else: result = str(score_string) + "/" + str( self.weight) + " puntos" + calif return result def max_score(self): """ Returns the configured number of possible points for this component. Arguments: None Returns: float: The number of possible points for this component """ return self.weight # handler para votar sí o no @XBlock.json_handler def responder(self, data, suffix=''): # pylint: disable=unused-argument """ Answer true or false """ # Avoid two answer at the same time if ((self.attempts + 1) <= self.max_attempts) or self.max_attempts <= 0: nuevas_resps = {} texto = "¡Respuesta Correcta!" buenas = 0.0 malas = 0.0 total = len(self.questions) for e in data['answers']: idpreg = e['name'] miresp = '' if e['value'] == 'verdadero': miresp = True nuevas_resps[idpreg] = 'verdadero' elif e['value'] == 'falso': miresp = False nuevas_resps[idpreg] = 'falso' if miresp != self.questions[idpreg]['answer']: texto = "Respuesta Incorrecta" malas += 1 else: buenas += 1 malas = (total - buenas) if malas > 0: texto = "Respuesta Incorrecta" if nuevas_resps: self.student_answers = nuevas_resps self.score = float(buenas / (malas + buenas)) if self.score > 0 and self.score < 1: texto = "Respuesta parcialmente correcta" ptje = float(self.weight) * self.score try: self.runtime.publish(self, 'grade', { 'value': ptje, 'max_value': self.weight }) self.attempts += 1 except IntegrityError: pass self.is_answered = True indicator_class = self.get_indicator_class() return { 'texto': texto, 'score': self.score, 'nro_de_intentos': self.max_attempts, 'intentos': self.attempts, 'indicator_class': indicator_class, 'show_correctness': self.get_show_correctness(), 'show_answers': self.show_answer, 'problem_progress': self.get_problem_progress() } else: return { 'texto': str('Error: El estado de este problema fue modificado, por favor recargue la página.' ), 'score': self.score, 'nro_de_intentos': self.max_attempts, 'intentos': self.attempts, 'indicator_class': self.get_indicator_class(), 'show_correctness': self.get_show_correctness(), 'show_answers': self.show_answer, 'problem_progress': self.get_problem_progress() } @XBlock.json_handler def mostrar_respuesta(self, data, suffix=''): """ Show correct/incorrect answers """ if (self.attempts >= self.max_attempts and self.show_answer == 'Finalizado') or self.show_answer == 'Mostrar': return {'preguntas': self.questions} else: return {} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("TrueOrFalseXBlock", """<trueorfalse/> """), ("Multiple TrueOrFalseXBlock", """<vertical_demo> <trueorfalse/> <trueorfalse/> <trueorfalse/> </vertical_demo> """), ]
class GenericXBlock(XBlock): """XBlock for testing pure xblock xml import""" has_children = True field1 = String(default="something", scope=Scope.user_state) field2 = Integer(scope=Scope.user_state)
class LmsBlockMixin(XBlockMixin): """ Mixin that defines fields common to all blocks used in the LMS """ hide_from_toc = Boolean( help=_("Whether to display this module in the table of contents"), default=False, scope=Scope.settings) format = String( # Translators: "TOC" stands for "Table of Contents" help=_("What format this module is in (used for deciding which " "grader to apply, and what to show in the TOC)"), scope=Scope.settings, ) chrome = String( display_name=_("Course Chrome"), # Translators: DO NOT translate the words in quotes here, they are # specific words for the acceptable values. help=_( "Enter the chrome, or navigation tools, to use for the XBlock in the LMS. Valid values are: \n" "\"chromeless\" -- to not use tabs or the accordion; \n" "\"tabs\" -- to use tabs only; \n" "\"accordion\" -- to use the accordion only; or \n" "\"tabs,accordion\" -- to use tabs and the accordion."), scope=Scope.settings, default=None, ) default_tab = String( display_name=_("Default Tab"), help= _("Enter the tab that is selected in the XBlock. If not set, the Course tab is selected." ), scope=Scope.settings, default=None, ) source_file = String(display_name=_("LaTeX Source File Name"), help=_("Enter the source file name for LaTeX."), scope=Scope.settings, deprecated=True) visible_to_staff_only = Boolean( help= _("If true, can be seen only by course staff, regardless of start date." ), default=False, scope=Scope.settings, ) group_access = GroupAccessDict( help= _("A dictionary that maps which groups can be shown this block. The keys " "are group configuration ids and the values are a list of group IDs. " "If there is no key for a group configuration or if the set of group IDs " "is empty then the block is considered visible to all. Note that this " "field is ignored if the block is visible_to_staff_only."), default={}, scope=Scope.settings, ) @lazy def merged_group_access(self): """ This computes access to a block's group_access rules in the context of its position within the courseware structure, in the form of a lazily-computed attribute. Each block's group_access rule is merged recursively with its parent's, guaranteeing that any rule in a parent block will be enforced on descendants, even if a descendant also defined its own access rules. The return value is always a dict, with the same structure as that of the group_access field. When merging access rules results in a case where all groups are denied access in a user partition (which effectively denies access to that block for all students), the special value False will be returned for that user partition key. """ parent = self.get_parent() if not parent: return self.group_access or {} merged_access = parent.merged_group_access.copy() if self.group_access is not None: for partition_id, group_ids in self.group_access.items(): if group_ids: # skip if the "local" group_access for this partition is None or empty. if partition_id in merged_access: if merged_access[partition_id] is False: # special case - means somewhere up the hierarchy, merged access rules have eliminated # all group_ids from this partition, so there's no possible intersection. continue # otherwise, if the parent defines group access rules for this partition, # intersect with the local ones. merged_access[partition_id] = list( set(merged_access[partition_id]).intersection( group_ids)) or False else: # add the group access rules for this partition to the merged set of rules. merged_access[partition_id] = group_ids return merged_access # Specified here so we can see what the value set at the course-level is. user_partitions = UserPartitionList(help=_( "The list of group configurations for partitioning students in content experiments." ), default=[], scope=Scope.settings) def _get_user_partition(self, user_partition_id): """ Returns the user partition with the specified id. Note that this method can return an inactive user partition. Raises `NoSuchUserPartitionError` if the lookup fails. """ for user_partition in self.runtime.service( self, 'partitions').course_partitions: if user_partition.id == user_partition_id: return user_partition raise NoSuchUserPartitionError( "could not find a UserPartition with ID [{}]".format( user_partition_id)) def _has_nonsensical_access_settings(self): """ Checks if a block's group access settings do not make sense. By nonsensical access settings, we mean a component's access settings which contradict its parent's access in that they restrict access to the component to a group that already will not be able to see that content. Note: This contradiction can occur when a component restricts access to the same partition but a different group than its parent, or when there is a parent access restriction but the component attempts to allow access to all learners. Returns: bool: True if the block's access settings contradict its parent's access settings. """ parent = self.get_parent() if not parent: return False parent_group_access = parent.group_access component_group_access = self.group_access for user_partition_id, parent_group_ids in parent_group_access.iteritems( ): component_group_ids = component_group_access.get(user_partition_id) if component_group_ids: return parent_group_ids and not set( component_group_ids).issubset(set(parent_group_ids)) else: return not component_group_access else: return False def validate(self): """ Validates the state of this xblock instance. """ _ = self.runtime.service(self, "i18n").ugettext validation = super(LmsBlockMixin, self).validate() has_invalid_user_partitions = False has_invalid_groups = False for user_partition_id, group_ids in self.group_access.iteritems(): try: user_partition = self._get_user_partition(user_partition_id) except NoSuchUserPartitionError: has_invalid_user_partitions = True else: # Skip the validation check if the partition has been disabled if user_partition.active: for group_id in group_ids: try: user_partition.get_group(group_id) except NoSuchUserPartitionGroupError: has_invalid_groups = True if has_invalid_user_partitions: validation.add( ValidationMessage(ValidationMessage.ERROR, INVALID_USER_PARTITION_VALIDATION)) if has_invalid_groups: validation.add( ValidationMessage(ValidationMessage.ERROR, INVALID_USER_PARTITION_GROUP_VALIDATION)) if self._has_nonsensical_access_settings(): validation.add( ValidationMessage(ValidationMessage.ERROR, NONSENSICAL_ACCESS_RESTRICTION)) return validation
class LibraryContentFields(object): """ Fields for the LibraryContentModule. Separated out for now because they need to be added to the module and the descriptor. """ # Please note the display_name of each field below is used in # common/test/acceptance/pages/studio/library.py:StudioLibraryContentXBlockEditModal # to locate input elements - keep synchronized display_name = String( display_name=_("Display Name"), help=_("The display name for this component."), default="Randomized Content Block", scope=Scope.settings, ) source_library_id = String( display_name=_("Library"), help=_("Select the library from which you want to draw content."), scope=Scope.settings, values_provider=lambda instance: instance.source_library_values(), ) source_library_version = String( # This is a hidden field that stores the version of source_library when we last pulled content from it display_name=_("Library Version"), scope=Scope.settings, ) mode = String( display_name=_("Mode"), help=_("Determines how content is drawn from the library"), default="random", values= [{ "display_name": _("Choose n at random"), "value": "random" } # Future addition: Choose a new random set of n every time the student refreshes the block, for self tests # Future addition: manually selected blocks ], scope=Scope.settings, ) max_count = Integer( display_name=_("Count"), help=_("Enter the number of components to display to each student."), default=1, scope=Scope.settings, ) capa_type = String( display_name=_("Problem Type"), help= _('Choose a problem type to fetch from the library. If "Any Type" is selected no filtering is applied.' ), default=ANY_CAPA_TYPE_VALUE, values=_get_capa_types(), scope=Scope.settings, ) selected = List( # This is a list of (block_type, block_id) tuples used to record # which random/first set of matching blocks was selected per user default=[], scope=Scope.user_state, ) has_children = True @property def source_library_key(self): """ Convenience method to get the library ID as a LibraryLocator and not just a string """ return LibraryLocator.from_string(self.source_library_id)
class TestableInheritingXBlock(XmlDescriptor): # lint-amnesty, pylint: disable=abstract-method """ An XBlock we can use in these tests. """ inherited = String(scope=Scope.settings, default="the default") not_inherited = String(scope=Scope.settings, default="nothing")
class RecommenderXBlock(HelperXBlock): """ This XBlock will show a set of recommended resources which may be helpful to students solving a given problem. The resources are provided and edited by students; they can also vote for useful resources and flag problematic ones. """ seen = Boolean( help= "Has the student interacted with the XBlock before? Used to show optional tutorial.", default=False, scope=Scope.user_info) version = String( help= "The version of this RecommenderXBlock. Used to simplify migrations.", default="recommender.v1.0", scope=Scope.content) intro_enabled = Boolean( help= "Should we show the users a short usage tutorial the first time they see the XBlock?", default=True, scope=Scope.content) # A dict of default recommendations supplied by the instructors to # seed the list with before students add new recommendations. # Also, useful for testing. # Usage: default_recommendations[index] = { # "id": (String) A unique ID. The ID is currently derived from # the URL, but this has changed and may change again # "title": (String) a 1-3 sentence summary description of a resource # "upvotes" : (Integer) number of upvotes, # "downvotes" : (Integer) number of downvotes, # "url" : (String) link to resource, # "description" : (String) the url of a resource's screenshot. # 'screenshot' would be a better name, but would # require a cumbersome data migration. # "descriptionText" : (String) a potentially longer overview of the resource } # we use url as key (index) of resource default_recommendations = JSONField( help= "Dict of instructor-supplied help resources to seed the resource list with.", default={}, scope=Scope.content) # A dict of recommendations provided by students. # Usage: the same as default_recommendations recommendations = JSONField(help="Current set of recommended resources", default={}, scope=Scope.user_state_summary) # A list of recommendations removed by course staff. This is used to filter out # cheats, give-aways, spam, etc. # Usage: the same as default_recommendations plus # removed_recommendations[index]['reason'] = (String) the reason why # course staff remove this resource removed_recommendations = Dict(help="Dict of removed resources", default={}, scope=Scope.user_state_summary) # A list of endorsed recommendations' ids -- the recommendations the course # staff marked as particularly helpful. # Usage: endorsed_recommendation_ids[index] = (String) id of a # endorsed resource endorsed_recommendation_ids = List(help="List of endorsed resources' ID", default=[], scope=Scope.user_state_summary) # A list of reasons why the resources were endorsed. # Usage: endorsed_recommendation_reasons[index] = (String) the reason # why the resource (id = endorsed_recommendation_ids[index]) is endorsed endorsed_recommendation_reasons = List( help="List of reasons why the resources are endorsed", default=[], scope=Scope.user_state_summary) # A dict of problematic recommendations which are flagged by users for review # by instructors. Used to remove spam, etc. # Usage: flagged_accum_resources[userId] = { # "problematic resource id": (String) reason why the resource is # flagged as problematic by that user } flagged_accum_resources = Dict( help= "Dict of potentially problematic resources which were flagged by users", default={}, scope=Scope.user_state_summary) # A list of recommendations' ids which a particular user upvoted, so users # cannot vote twice # Usage: upvoted_ids[index] = (String) id of a resource which was # upvoted by the current user upvoted_ids = List(help="List of resources' ids which user upvoted", default=[], scope=Scope.user_state) # A list of recommendations' ids which user downvoted, so users cannot vote twice. # Usage: downvoted_ids[index] = (String) id of a resource which was # downvoted by the current user downvoted_ids = List(help="List of resources' ids which user downvoted", default=[], scope=Scope.user_state) # A list of problematic recommendations' ids which user flagged. # Usage: flagged_ids[index] = (String) id of a problematic resource which # was flagged by the current user flagged_ids = List( help="List of problematic resources' ids which the user flagged", default=[], scope=Scope.user_state) # A list of reasons why the resources corresponding to those in flagged_ids were flagged # Usage: flagged_reasons[index] = (String) reason why the resource # 'flagged_ids[index]' was flagged by the current user as problematic flagged_reasons = List( help="List of reasons why the corresponding resources were flagged", default=[], scope=Scope.user_state) # The file system we used to store uploaded screenshots fs = Filesystem(help="File system for screenshots", scope=Scope.user_state_summary) client_configuration = Dict(help="Dict of customizable settings", default={ 'disable_dev_ux': True, 'entries_per_page': 5, 'page_span': 2 }, scope=Scope.content) # the dictionary keys for storing the content of a recommendation resource_content_fields = [ 'url', 'title', 'description', 'descriptionText' ] def _get_onetime_url(self, filename): """ Return one time url for uploaded screenshot We benchmarked this as less than 8ms on a sandbox machine. """ if filename.startswith('fs://'): return str( self.fs.get_url(filename.replace('fs://', ''), 1000 * 60 * 60 * 10)) else: return filename def _error_handler(self, error_msg, event, resource_id=None): """ Generate an error dictionary if something unexpected happens, such as a user upvoting a resource which no longer exists. We both log to this to the event logs, and return to the browser. """ result = {'error': error_msg} if resource_id is not None: result['id'] = resource_id tracker.emit(event, result) raise JsonHandlerError(400, result['error']) def _check_redundant_resource(self, resource_id, event_name, result): """ Check whether the submitted resource is redundant. If true, raise an exception and return a HTTP status code for the error. """ # check url for redundancy if resource_id in self.recommendations: result['error'] = self.ugettext( 'The resource you are attempting to provide already exists') for field in self.resource_content_fields: result['dup_' + field] = self.recommendations[resource_id][field] result['dup_id'] = self.recommendations[resource_id]['id'] tracker.emit(event_name, result) raise JsonHandlerError(409, result['error']) def _check_removed_resource(self, resource_id, event_name, result): """ Check whether the submitted resource is removed. If true, raise an exception and return a HTTP status code for the error. """ if resource_id in self.removed_recommendations: result['error'] = self.ugettext( 'The resource you are attempting to ' 'provide has been disallowed by the staff. ' 'Reason: ' + self.removed_recommendations[resource_id]['reason']) for field in self.resource_content_fields: result[ 'dup_' + field] = self.removed_recommendations[resource_id][field] result['dup_id'] = self.removed_recommendations[resource_id]['id'] tracker.emit(event_name, result) raise JsonHandlerError(405, result['error']) def _validate_resource(self, data_id, event): """ Validate whether the resource exists in the database. If not, generate the error message, and return to the browser for a given event, otherwise, return the stemmed id. """ resource_id = stem_url(data_id) if resource_id not in self.recommendations: msg = self.ugettext('The selected resource does not exist') self._error_handler(msg, event, resource_id) return resource_id def _check_upload_file(self, request, file_types, file_type_error_msg, event, file_size_threshold): """ Check the type and size of uploaded file. If the file type is unexpected or the size exceeds the threshold, log the error and return to browser, otherwise, return None. """ # Check invalid file types file_type_error = False file_type = [ ft for ft in file_types if any( str(request.POST['file'].file).lower().endswith(ext) for ext in file_types[ft]['extension']) ] # Check extension if not file_type: file_type_error = True else: file_type = file_type[0] # Check mimetypes if request.POST['file'].file.content_type not in file_types[ file_type]['mimetypes']: file_type_error = True else: if 'magic' in file_types[file_type]: # Check magic number headers = file_types[file_type]['magic'] if request.POST['file'].file.read( len(headers[0]) / 2).encode('hex') not in headers: file_type_error = True request.POST['file'].file.seek(0) if file_type_error: response = Response() tracker.emit(event, {'uploadedFileName': 'FILE_TYPE_ERROR'}) response.status = 415 response.body = json.dumps({'error': file_type_error_msg}) response.headers['Content-Type'] = 'application/json' return response # Check whether file size exceeds threshold (30MB) if request.POST['file'].file.size > file_size_threshold: response = Response() tracker.emit(event, {'uploadedFileName': 'FILE_SIZE_ERROR'}) response.status = 413 response.body = json.dumps({ 'error': self.ugettext('Size of uploaded file exceeds threshold') }) response.headers['Content-Type'] = 'application/json' return response return file_type def _raise_pyfs_error(self, event): """ Log and return an error if the pyfs is not properly set. """ response = Response() error = self.ugettext('The configuration of pyfs is not properly set') tracker.emit(event, {'uploadedFileName': 'IMPROPER_FS_SETUP'}) response.status = 404 response.body = json.dumps({'error': error}) response.headers['Content-Type'] = 'application/json' return response def _init_template_lookup(self): """ Initialize template_lookup by adding mappings between strings and urls. """ global template_lookup template_lookup = TemplateLookup() template_lookup.put_string( "recommenderstudio.html", self.resource_string("static/html/recommenderstudio.html")) template_lookup.put_string( "recommender.html", self.resource_string("static/html/recommender.html")) template_lookup.put_string( "resourcebox.html", self.resource_string("static/html/resourcebox.html")) def get_client_configuration(self): """ Return the parameters for client-side configuration settings. Returns: disable_dev_ux: feature flag for any new UX under development which should not appear in prod entries_per_page: the number of resources in each page page_span: page range in pagination control intro: whether to take users through a short usage tutorial the first time they see the RecommenderXBlock is_user_staff: whether the user is staff """ result = self.client_configuration.copy() result['is_user_staff'] = self.get_user_is_staff() result['intro'] = not self.seen and self.intro_enabled if not self.seen: # Mark the user who interacted with the XBlock first time as seen, # in order not to show the usage tutorial in future. self.seen = True tracker.emit('get_client_configuration', result) return result @XBlock.json_handler def set_client_configuration(self, data, _suffix=''): # pylint: disable=unused-argument """ Set the parameters for student-view, client side configurations. Args: data: dict in JSON format. Keys in data: disable_dev_ux: feature flag for any new UX under development which should not appear in prod entries_per_page: the number of resources in each page page_span: page range in pagination control intro_enable: Should we show the users a short usage tutorial the first time they see the XBlock? """ self.intro_enabled = data['intro_enable'] for key in ['disable_dev_ux', 'page_span', 'entries_per_page']: self.client_configuration[key] = data[key] tracker.emit('set_client_configuration', data) return {} @XBlock.json_handler def handle_vote(self, data, _suffix=''): # pylint: disable=unused-argument """ Add/Subtract a vote to a resource entry. Args: data: dict in JSON format data['id']: the ID of the resouce which was upvoted/downvoted data['event']: recommender_upvote or recommender_downvote Returns: result: dict in JSON format result['error']: error message generated if the process fails result['oldVotes']: original # of votes result['newVotes']: votes after this action result['toggle']: boolean indicator for whether the resource was switched from downvoted to upvoted """ resource_id = self._validate_resource(data['id'], data['event']) result = {} result['id'] = resource_id is_event_upvote = (data['event'] == 'recommender_upvote') result['oldVotes'] = (self.recommendations[resource_id]['upvotes'] - self.recommendations[resource_id]['downvotes']) upvoting_existing_upvote = is_event_upvote and resource_id in self.upvoted_ids downvoting_existing_downvote = not is_event_upvote and resource_id in self.downvoted_ids if upvoting_existing_upvote: # While the user is trying to upvote a resource which has been # upvoted, we restore the resource to unvoted self.upvoted_ids.remove(resource_id) self.recommendations[resource_id]['upvotes'] -= 1 elif downvoting_existing_downvote: # While the user is trying to downvote a resource which has # been downvoted, we restore the resource to unvoted self.downvoted_ids.remove(resource_id) self.recommendations[resource_id]['downvotes'] -= 1 elif is_event_upvote: # New upvote if resource_id in self.downvoted_ids: self.downvoted_ids.remove(resource_id) self.recommendations[resource_id]['downvotes'] -= 1 result['toggle'] = True self.upvoted_ids.append(resource_id) self.recommendations[resource_id]['upvotes'] += 1 else: # New downvote if resource_id in self.upvoted_ids: self.upvoted_ids.remove(resource_id) self.recommendations[resource_id]['upvotes'] -= 1 result['toggle'] = True self.downvoted_ids.append(resource_id) self.recommendations[resource_id]['downvotes'] += 1 result['newVotes'] = (self.recommendations[resource_id]['upvotes'] - self.recommendations[resource_id]['downvotes']) tracker.emit(data['event'], result) return result @XBlock.handler def upload_screenshot(self, request, _suffix=''): # pylint: disable=unused-argument """ Upload a screenshot for an entry of resource as a preview (typically to S3 or filesystem). Args: request: HTTP POST request request.POST['file'].file: the file to be uploaded Returns: response: HTTP response response.body (response.responseText): name of the uploaded file We validate that this is a valid JPG, GIF, or PNG by checking magic number, mimetype, and extension all correspond. We also limit to 30MB. We save the file under its MD5 hash to (1) avoid name conflicts, (2) avoid race conditions and (3) save space. """ # Check invalid file types image_types = { 'jpeg': { 'extension': [".jpeg", ".jpg"], 'mimetypes': ['image/jpeg', 'image/pjpeg'], 'magic': ["ffd8"] }, 'png': { 'extension': [".png"], 'mimetypes': ['image/png'], 'magic': ["89504e470d0a1a0a"] }, 'gif': { 'extension': [".gif"], 'mimetypes': ['image/gif'], 'magic': ["474946383961", "474946383761"] } } file_type_error_msg = 'Please upload an image in GIF/JPG/PNG' result = self._check_upload_file(request, image_types, file_type_error_msg, 'upload_screenshot', 31457280) if isinstance(result, Response): return result try: content = request.POST['file'].file.read() file_id = hashlib.md5(content).hexdigest() file_name = (file_id + '.' + result) fhwrite = self.fs.open(file_name, "wb") fhwrite.write(content) fhwrite.close() except IOError: return self._raise_pyfs_error('upload_screenshot') response = Response() response.body = json.dumps({'file_name': str("fs://" + file_name)}) response.headers['Content-Type'] = 'application/json' tracker.emit('upload_screenshot', {'uploadedFileName': response.body}) response.status = 200 return response @XBlock.json_handler def add_resource(self, data, _suffix=''): # pylint: disable=unused-argument """ Add a new resource entry. Args: data: dict in JSON format data[resource_content_field]: the resource to be added. Dictionary of description, etc. as defined above Returns: result: dict in JSON format result['error']: error message generated if the addition fails result[resource_content_field]: the content of the added resource """ # Construct new resource result = {} for field in self.resource_content_fields: result[field] = data[field] resource_id = stem_url(data['url']) self._check_redundant_resource(resource_id, 'add_resource', result) self._check_removed_resource(resource_id, 'add_resource', result) result['id'] = resource_id result['upvotes'] = 0 result['downvotes'] = 0 self.recommendations[resource_id] = dict(result) tracker.emit('add_resource', result) result["description"] = self._get_onetime_url(result["description"]) return result @XBlock.json_handler def edit_resource(self, data, _suffix=''): # pylint: disable=unused-argument """ Edit an entry of existing resource. Args: data: dict in JSON format data['id']: the ID of the edited resouce data[resource_content_field]: the content of the resource to be edited Returns: result: dict in JSON format result['error']: the error message generated when the edit fails result[old_resource_content_field]: the content of the resource before edited result[resource_content_field]: the content of the resource after edited """ resource_id = self._validate_resource(data['id'], 'edit_resource') result = {} result['id'] = resource_id result['old_id'] = resource_id for field in self.resource_content_fields: result['old_' + field] = self.recommendations[resource_id][field] # If the content in resource is unchanged (i.e., data[field] is # empty), return and log the content stored in the database # (self.recommendations), otherwise, return and log the edited # one (data[field]) if data[field] == "": result[field] = self.recommendations[resource_id][field] else: result[field] = data[field] ## Handle resource ID changes edited_resource_id = stem_url(data['url']) if edited_resource_id != resource_id: self._check_redundant_resource(edited_resource_id, 'edit_resource', result) self._check_removed_resource(edited_resource_id, 'edit_resource', result) self.recommendations[edited_resource_id] = deepcopy( self.recommendations[resource_id]) self.recommendations[edited_resource_id]['id'] = edited_resource_id result['id'] = edited_resource_id del self.recommendations[resource_id] # Handle all other changes for field in data: if field == 'id': continue if data[field] == "": continue self.recommendations[edited_resource_id][field] = data[field] tracker.emit('edit_resource', result) result["description"] = self._get_onetime_url(result["description"]) return result @XBlock.json_handler def flag_resource(self, data, _suffix=''): # pylint: disable=unused-argument """ Flag (or unflag) an entry of problematic resource and give the reason. This shows in a list for staff to review. Args: data: dict in JSON format data['id']: the ID of the problematic resouce data['isProblematic']: the boolean indicator for whether the resource is being flagged or unflagged. Only flagging works. data['reason']: the reason why the user believes the resource is problematic Returns: result: dict in JSON format result['reason']: the new reason result['oldReason']: the old reason result['id']: the ID of the problematic resouce result['isProblematic']: the boolean indicator for whether the resource is now flagged """ result = {} result['id'] = data['id'] result['isProblematic'] = data['isProblematic'] result['reason'] = data['reason'] user_id = self.get_user_id() # If already flagged, update the reason for the flag if data['isProblematic']: # If already flagged, update the reason if data['id'] in self.flagged_ids: result['oldReason'] = self.flagged_reasons[ self.flagged_ids.index(data['id'])] self.flagged_reasons[self.flagged_ids.index( data['id'])] = data['reason'] # Otherwise, flag it. else: self.flagged_ids.append(data['id']) self.flagged_reasons.append(data['reason']) if user_id not in self.flagged_accum_resources: self.flagged_accum_resources[user_id] = {} self.flagged_accum_resources[user_id][data['id']] = data['reason'] # Unflag resource. Currently unsupported. else: if data['id'] in self.flagged_ids: result['oldReason'] = self.flagged_reasons[ self.flagged_ids.index(data['id'])] result['reason'] = '' idx = self.flagged_ids.index(data['id']) del self.flagged_ids[idx] del self.flagged_reasons[idx] del self.flagged_accum_resources[user_id][data['id']] tracker.emit('flag_resource', result) return result @XBlock.json_handler def endorse_resource(self, data, _suffix=''): # pylint: disable=unused-argument """ Endorse an entry of resource. This shows the students the resource has the staff seal of approval. Args: data: dict in JSON format data['id']: the ID of the resouce to be endorsed Returns: result: dict in JSON format result['error']: the error message generated when the endorsement fails result['id']: the ID of the resouce to be endorsed result['status']: endorse the resource or undo it """ # Auth+auth if not self.get_user_is_staff(): msg = self.ugettext('Endorse resource without permission') self._error_handler(msg, 'endorse_resource') resource_id = self._validate_resource(data['id'], 'endorse_resource') result = {} result['id'] = resource_id # Unendorse previously endorsed resource if resource_id in self.endorsed_recommendation_ids: result['status'] = 'undo endorsement' endorsed_index = self.endorsed_recommendation_ids.index( resource_id) del self.endorsed_recommendation_ids[endorsed_index] del self.endorsed_recommendation_reasons[endorsed_index] # Endorse new resource else: result['reason'] = data['reason'] result['status'] = 'endorsement' self.endorsed_recommendation_ids.append(resource_id) self.endorsed_recommendation_reasons.append(data['reason']) tracker.emit('endorse_resource', result) return result @XBlock.json_handler def remove_resource(self, data, _suffix=''): """ Remove an entry of resource. This removes it from the student view, and prevents students from being able to add it back. Args: data: dict in JSON format data['id']: the ID of the resouce to be removed data['reason']: the reason why the resouce was removed Returns: result: dict in JSON format result['error']: the error message generated when the removal fails result['recommendation']: (Dict) the removed resource result['recommendation']['reason']: the reason why the resouce was removed """ # Auth+auth if not self.get_user_is_staff(): msg = self.ugettext( "You don't have the permission to remove this resource") self._error_handler(msg, 'remove_resource') resource_id = self._validate_resource(data['id'], 'remove_resource') # Grab a copy of the resource for the removed list # (swli: I reorganized the code a bit. First copy, then delete. This is more fault-tolerant) result = {} result['id'] = resource_id removed_resource = deepcopy(self.recommendations[resource_id]) removed_resource['reason'] = data['reason'] # Add it to removed resources and remove it from main resource list. self.removed_recommendations[resource_id] = removed_resource del self.recommendations[resource_id] # And return result['recommendation'] = removed_resource tracker.emit('remove_resource', result) return result @XBlock.json_handler def export_resources(self, _data, _suffix): # pylint: disable=unused-argument """ Export all resources from the Recommender. This is intentionally not limited to staff members (community contributions do not belong to the course staff). Sensitive information is exported *is* limited (flagged resources, and in the future, PII if any). """ result = {} result['export'] = { 'recommendations': self.recommendations, 'removed_recommendations': self.removed_recommendations, 'endorsed_recommendation_ids': self.endorsed_recommendation_ids, 'endorsed_recommendation_reasons': self.endorsed_recommendation_reasons, } if self.get_user_is_staff(): result['export'][ 'flagged_accum_resources'] = self.flagged_accum_resources tracker.emit('export_resources', result) return result @XBlock.handler def import_resources(self, request, _suffix=''): """ Import resources into the recommender. """ response = Response() response.headers['Content-Type'] = 'application/json' if not self.get_user_is_staff(): response.status = 403 response.body = json.dumps( {'error': self.ugettext('Only staff can import resources')}) tracker.emit('import_resources', {'Status': 'NOT_A_STAFF'}) return response # Check invalid file types file_types = { 'json': { 'extension': [".json"], 'mimetypes': ['application/json', 'text/json', 'text/x-json'] } } file_type_error_msg = self.ugettext( 'Please submit the JSON file obtained with the download resources button' ) result = self._check_upload_file(request, file_types, file_type_error_msg, 'import_resources', 31457280) if isinstance(result, Response): return result try: data = json.load(request.POST['file'].file) self.flagged_accum_resources = data['flagged_accum_resources'] self.endorsed_recommendation_reasons = data[ 'endorsed_recommendation_reasons'] self.endorsed_recommendation_ids = data[ 'endorsed_recommendation_ids'] if 'removed_recommendations' in data: self.removed_recommendations = data_structure_upgrade( data['removed_recommendations']) data['removed_recommendations'] = self.removed_recommendations self.recommendations = data_structure_upgrade( data['recommendations']) data['recommendations'] = self.recommendations tracker.emit('import_resources', { 'Status': 'SUCCESS', 'data': data }) response.body = json.dumps(data, sort_keys=True) response.status = 200 return response except (ValueError, KeyError): response.status = 415 response.body = json.dumps({ 'error': self.ugettext( 'Please submit the JSON file obtained with the download resources button' ) }) tracker.emit('import_resources', {'Status': 'FILE_FORMAT_ERROR'}) return response except IOError: return self._raise_pyfs_error('import_resources') @XBlock.json_handler def accum_flagged_resource(self, _data, _suffix=''): # pylint: disable=unused-argument """ Accumulate the flagged resource ids and reasons from all students """ if not self.get_user_is_staff(): msg = self.ugettext( 'Tried to access flagged resources without staff permission') self._error_handler(msg, 'accum_flagged_resource') result = {'flagged_resources': {}} for _, flagged_accum_resource_map in self.flagged_accum_resources.iteritems( ): for resource_id in flagged_accum_resource_map: if resource_id in self.removed_recommendations: continue if resource_id not in result['flagged_resources']: result['flagged_resources'][resource_id] = [] if flagged_accum_resource_map[resource_id] != '': result['flagged_resources'][resource_id].append( flagged_accum_resource_map[resource_id]) tracker.emit('accum_flagged_resource', result) return result def student_view(self, _context=None): # pylint: disable=unused-argument """ The primary view of the RecommenderXBlock, shown to students when viewing courses. """ self.recommendations = (data_structure_upgrade(self.recommendations) or data_structure_upgrade( self.default_recommendations) or {}) # Transition between two versions. In the previous version, there is # no endorsed_recommendation_reasons. Thus, we add empty reasons to # make the length of the two lists equal # # TODO: Go through old lists of resources in course, and remove this # code. The migration should be done. while len(self.endorsed_recommendation_ids) > len( self.endorsed_recommendation_reasons): self.endorsed_recommendation_reasons.append('') global template_lookup if not template_lookup: self._init_template_lookup() # Ideally, we'd estimate score based on votes, such that items with # 1 vote have a sensible ranking (rather than a perfect rating) # We pre-generate URLs for all resources. We benchmarked doing this # for 44 URLs, and the time per URL was about 8ms. The 44 URLs were # all of the images added by students over several problem sets. If # load continues to be as-is, pre-generation is not a performance # issue. If students make substantially more resources, we may want # to paginate, and generate in sets of 5-20 URLs per load. resources = [{ 'id': r['id'], 'title': r['title'], "votes": r['upvotes'] - r['downvotes'], 'url': r['url'], 'description': self._get_onetime_url(r['description']), 'descriptionText': r['descriptionText'] } for r in self.recommendations.values()] resources = sorted(resources, key=lambda r: r['votes'], reverse=True) frag = Fragment( template_lookup.get_template("recommender.html").render( resources=resources, upvoted_ids=self.upvoted_ids, downvoted_ids=self.downvoted_ids, endorsed_recommendation_ids=self.endorsed_recommendation_ids, endorsed_recommendation_reasons=self. endorsed_recommendation_reasons, flagged_ids=self.flagged_ids, flagged_reasons=self.flagged_reasons)) frag.add_css_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/themes/smoothness/jquery-ui.css" ) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/jquery-ui.min.js") frag.add_javascript_url( '//cdnjs.cloudflare.com/ajax/libs/mustache.js/0.8.1/mustache.min.js' ) frag.add_javascript_url( '//cdnjs.cloudflare.com/ajax/libs/intro.js/0.5.0/intro.min.js') frag.add_css(self.resource_string("static/css/tooltipster.css")) frag.add_css(self.resource_string("static/css/recommender.css")) frag.add_css(self.resource_string("static/css/introjs.css")) frag.add_javascript( self.resource_string("static/js/src/jquery.tooltipster.min.js")) frag.add_javascript(self.resource_string("static/js/src/cats.js")) frag.add_javascript( self.resource_string("static/js/src/recommender.js")) frag.initialize_js('RecommenderXBlock', self.get_client_configuration()) return frag def studio_view(self, _context=None): # pylint: disable=unused-argument """ The primary view of the RecommenderXBlock in studio. This is shown to course staff when editing a course in studio. """ global template_lookup if not template_lookup: self._init_template_lookup() frag = Fragment( template_lookup.get_template("recommenderstudio.html").render()) frag.add_css( pkg_resources.resource_string(__name__, "static/css/recommenderstudio.css")) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/jquery-ui.min.js") frag.add_javascript( pkg_resources.resource_string( __name__, "static/js/src/recommenderstudio.js")) frag.initialize_js('RecommenderXBlock') return frag def add_xml_to_node(self, node): """ Serialize the XBlock to XML for exporting. """ node.tag = 'recommender' node.set('intro_enabled', 'true' if (self.intro_enabled) else 'false') node.set( 'disable_dev_ux', 'true' if (self.client_configuration['disable_dev_ux']) else 'false') node.set('entries_per_page', str(self.client_configuration['entries_per_page'])) node.set('page_span', str(self.client_configuration['page_span'])) el = etree.SubElement(node, 'resources') ## Note: The line below does not work in edX platform. ## We should figure out if the appropriate scope is available during import/export ## TODO: Talk to Cale el.text = json.dumps(self.recommendations).encode("utf-8") @staticmethod def workbench_scenarios(): """ A test sample scenario for display in the workbench. """ return [ ("RecommenderXBlock", """ <vertical_demo> <html_demo><img class="question" src="http://people.csail.mit.edu/swli/edx/recommendation/img/pset.png"></img></html_demo> <recommender intro_enabled="true" disable_dev_ux="true" entries_per_page="2" page_span="1"> <resources> [ {"id": 1, "title": "Covalent bonding and periodic trends", "upvotes" : 15, "downvotes" : 5, "url" : "https://courses.edx.org/courses/MITx/3.091X/2013_Fall/courseware/SP13_Week_4/SP13_Periodic_Trends_and_Bonding/", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/videopage1.png", "descriptionText" : "short description for Covalent bonding and periodic trends"}, {"id": 2, "title": "Polar covalent bonds and electronegativity", "upvotes" : 10, "downvotes" : 7, "url" : "https://courses.edx.org/courses/MITx/3.091X/2013_Fall/courseware/SP13_Week_4/SP13_Covalent_Bonding/", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/videopage2.png", "descriptionText" : "short description for Polar covalent bonds and electronegativity"}, {"id": 3, "title": "Longest wavelength able to to break a C-C bond ...", "upvotes" : 1230, "downvotes" : 7, "url" : "https://answers.yahoo.com/question/index?qid=20081112142253AA1kQN1", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/dispage1.png", "descriptionText" : "short description for Longest wavelength able to to break a C-C bond ..."}, {"id": 4, "title": "Calculate the maximum wavelength of light for ...", "upvotes" : 10, "downvotes" : 3457, "url" : "https://answers.yahoo.com/question/index?qid=20100110115715AA6toHw", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/dispage2.png", "descriptionText" : "short description for Calculate the maximum wavelength of light for ..."}, {"id": 5, "title": "Covalent bond - wave mechanical concept", "upvotes" : 10, "downvotes" : 7, "url" : "http://people.csail.mit.edu/swli/edx/recommendation/img/textbookpage1.png", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/textbookpage1.png", "descriptionText" : "short description for Covalent bond - wave mechanical concept"}, {"id": 6, "title": "Covalent bond - Energetics of covalent bond", "upvotes" : 10, "downvotes" : 7, "url" : "http://people.csail.mit.edu/swli/edx/recommendation/img/textbookpage2.png", "description" : "http://people.csail.mit.edu/swli/edx/recommendation/img/textbookpage2.png", "descriptionText" : "short description for Covalent bond - Energetics of covalent bond"} ] </resources> </recommender> <recommender /> </vertical_demo> """), ] @classmethod def parse_xml(cls, node, runtime, keys, _id_generator): # pylint: disable=unused-argument """ Parse the XML for the XBlock. It is a list of dictionaries of default recommendations. """ block = runtime.construct_xblock_from_class(cls, keys) if node.tag != 'recommender': raise UpdateFromXmlError( "XML content must contain an 'recommender' root element.") if node.get('intro_enabled'): block.intro_enabled = (node.get('intro_enabled').lower().strip() not in ['false', '0', '']) if node.get('disable_dev_ux'): block.client_configuration['disable_dev_ux'] = ( node.get('disable_dev_ux').lower().strip() not in ['false', '0', '']) for tag in ['entries_per_page', 'page_span']: if node.get(tag): block.client_configuration[tag] = int(node.get(tag)) for child in node: if child.tag == 'resources' and child.text: lines = json.loads(child.text) block.default_recommendations = data_structure_upgrade(lines) return block
class LmsCompatibilityMixin: """ Extra fields and methods used by LMS/Studio. """ # Studio the default value for this field to show this XBlock # in the list of "Advanced Components" display_name = String(default="Open Response Assessment", scope=Scope.settings, help="Display name") start = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the start date of this assignment." ) due = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the due date of this assignment." ) weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) group_access = GroupAccessDict( help= ("A dictionary that maps which groups can be shown this block. The keys " "are group configuration ids and the values are a list of group IDs. " "If there is no key for a group configuration or if the set of group IDs " "is empty then the block is considered visible to all. Note that this " "field is ignored if the block is visible_to_staff_only."), default={}, scope=Scope.settings, ) icon_class = "problem" def has_dynamic_children(self): """Do we dynamically determine our children? No, we don't have any. The LMS wants to know this to see if it has to instantiate our module and query it to find the children, or whether it can just trust what's in the static (cheaper) children listing. """ return False @property def has_score(self): """Are we a scored type (read: a problem). Yes. For LMS Progress page/grades download purposes, we're always going to have a score, even if it's just 0 at the start. """ return True def max_score(self): """The maximum raw score of our problem. Called whenever the LMS knows that something is scorable, but finds no recorded raw score for it (i.e. the student hasn't done it). In that case, the LMS knows that the earned score is 0, but it doesn't know what to put in the denominator. So we supply it with the total number of points that it is possible for us to earn -- the sum of the highest pointed options from each criterion. Note that if we have already recorded a score in submissions, this method will never be called. So it's perfectly possible for us to have 10/10 on the progress page and a 12 returning from this method if our 10/10 score was earned in the past and the problem has changed since then. """ return sum( max(option["points"] for option in criterion["options"] ) if criterion["options"] else 0 for criterion in self.rubric_criteria)
class LaunchContainerXBlock(XBlock): """ Provide a Fragment with associated Javascript to display to Students a button that will launch a configurable external course Container via a call to Appsembler's container deploy API. """ display_name = String(help="Display name of the component", default="Container Launcher", scope=Scope.settings) project = String( display_name='Project name', default=u'(EDIT THIS COMPONENT TO SET PROJECT NAME)', scope=Scope.content, help=(u"The name of the project as defined for the " "Appsembler Virtual Labs (AVL) API."), ) project_friendly = String( display_name='Project Friendly name', default=u'', scope=Scope.content, help=(u"The name of the container's Project as displayed to the end " "user"), ) project_token = String( display_name='Project Token', default=u'', scope=Scope.content, help=(u"This is a unique token that can be found in the AVL dashboard") ) enable_container_resetting = Boolean( display_name='Enable container resetting', default=False, scope=Scope.content, help=(u"Enables students to reset/delete their container and start over") ) @property def wharf_url(self, force=False): """Determine which site we're on, then get the Wharf URL that said site has configured.""" # The complexities of Tahoe require that we check several places # for the site configuration, which itself contains the URL # of the AVL cluster associated with this site. # # If we are in Tahoe studio, the Site object associated with this request # will not be the one used within Tahoe. To get the proper domain # we rely on the "organization", which always equals `Site.name`. # If the organization value does not return a site object, we are probably on # the LMS side. In this case, we use `get_current_site()`, which _does_ # return the incorrect site object. If all this fails, we fallback # to the DEFAULT_WHARF_URL. try: # The name of the Site object will always match self.course_id.org. # See: https://git.io/vpilS site = Site.objects.get(name=self.course_id.org) except (Site.DoesNotExist, AttributeError): # Probably on the lms side. if get_current_site: site = get_current_site() # From the request. else: site = Site.objects.all().order_by('domain').first() url = cache.get(make_cache_key(site.domain)) if url: return url # Nothing in the cache. Go find the URL. site_wharf_url = None if hasattr(site, 'configuration'): site_wharf_url = site.configuration.get_value(WHARF_URL_KEY) elif siteconfig_helpers: # Rely on edX's helper, which will fall back to the microsites app. site_wharf_url = siteconfig_helpers.get_value(WHARF_URL_KEY) urls = ( # A SiteConfig object: this is the preferred implementation. ( 'SiteConfiguration', site_wharf_url ), # A string: the currently supported implementation. ( "ENV_TOKENS[{}]".format(WHARF_URL_KEY), settings.ENV_TOKENS.get(WHARF_URL_KEY) ), # A dict: the deprecated version. ( "ENV_TOKENS['LAUNCHCONTAINER_API_CONF']", settings.ENV_TOKENS.get('LAUNCHCONTAINER_API_CONF', {}).get('default') ), ) try: url = next((x[1] for x in urls if is_valid(x[1]))) except StopIteration: raise ImproperlyConfigured("No Virtual Labs URL was found, " "please contact your site administrator.") if not url: raise AssertionError("You must set a valid url for the launchcontainer XBlock. " "URLs attempted: {}".format(urls) ) cache.set(make_cache_key(site), url, CACHE_KEY_TIMEOUT) logger.debug("XBlock-launchcontainer urls attempted: {}".format(urls)) return url @property def wharf_delete_url(self): api_root = get_api_root_url(self.wharf_url) return "{}/isc/dashboard/userprojectdeployments/delete_user_deployments/".format(api_root) # TODO: Cache this property? @property def user_email(self): user = get_current_user() if hasattr(user, 'email') and user.email: return user.email user_service = self.runtime.service(self, 'user') user = user_service.get_current_user() email = user.emails[0] if type(user.emails) == list else user.email return email def student_view(self, context=None): """ The primary view of the LaunchContainerXBlock, shown to students when viewing courses. """ context = { 'enable_container_resetting': self.enable_container_resetting, 'project': self.project, 'project_friendly': self.project_friendly, 'project_token': self.project_token, 'user_email': self.user_email, 'API_url': self.wharf_url, 'API_delete_url': self.wharf_delete_url, } return _add_static(Fragment(), 'student', context) def studio_view(self, context=None): """ Return fragment for editing block in studio. """ try: cls = type(self) def none_to_empty(data): """ Return empty string if data is None else return data. """ return data if data is not None else '' edit_fields = ( (field, none_to_empty(getattr(self, field.name)), validator) for field, validator in ( (cls.project, 'string'), (cls.project_friendly, 'string'), (cls.project_token, 'string'), (cls.enable_container_resetting, 'boolean'), ) ) context = {'fields': edit_fields, 'API_url': self.wharf_url, 'API_delete_url': self.wharf_delete_url, 'user_email': self.user_email } return _add_static(Fragment(), 'studio', context) except: # noqa E722 # pragma: NO COVER # TODO: Handle all the errors and handle them well. logger.error("Don't swallow my exceptions", exc_info=True) raise @XBlock.json_handler def studio_submit(self, data, suffix=''): logger.info(u'Received data: {}'.format(data)) # TODO: This could use some better validation. try: self.enable_container_resetting = data['enable_container_resetting'] self.project = data['project'].strip() self.project_friendly = data['project_friendly'].strip() self.project_token = data['project_token'].strip() self.api_url = self.wharf_url self.api_delete_url = self.wharf_delete_url return {'result': 'success'} except Exception as e: return {'result': 'Error saving data:{0}'.format(str(e))} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("A single launchcontainer", """\ <vertical_demo> <launchcontainer/> </vertical_demo> """) ]
class GroupProjectBlock(XBlock): """ XBlock providing a group activity project for a group of students to collaborate upon """ display_name = String( display_name="Display Name", help= "This name appears in the horizontal navigation at the top of the page.", scope=Scope.settings, default="Group Project") weight = Float( display_name="Weight", help= "This is the maximum score that the user receives when he/she successfully completes the problem", scope=Scope.settings, default=1) item_state = Dict(help="JSON payload for assessment values", scope=Scope.user_state) with open(resource_filename(__name__, 'res/default.xml'), "r") as default_xml_file: default_xml = default_xml_file.read() data = String(display_name="", help="XML contents to display for this module", scope=Scope.content, default=textwrap.dedent(default_xml)) has_score = True def student_view(self, context): """ Player view, displayed to the student """ group_activity = GroupActivity.import_xml_string(self.data) # TODO: Replace with workgroup call to get real workgroup team_members = [{ "name": "Andy Parsons", "id": 1, "img": "/image/empty_avatar.png" }, { "name": "Jennifer Gormley", "id": 2, "img": "/image/empty_avatar.png" }, { "name": "Vishal Ghandi", "id": 3, "img": "/image/empty_avatar.png" }] # TODO: Replace with workgroup call to get assigned workgroups assess_groups = [{ "id": 101, "img": "/image/empty_avatar.png" }, { "id": 102, "img": "/image/empty_avatar.png" }, { "id": 103, "img": "/image/empty_avatar.png" }] context = { "group_activity": group_activity, "team_members": json.dumps(team_members), "assess_groups": json.dumps(assess_groups), } fragment = Fragment() fragment.add_content( render_template('/templates/html/group_project.html', context)) fragment.add_css(load_resource('public/css/group_project.css')) fragment.add_javascript(load_resource('public/js/group_project.js')) fragment.initialize_js('GroupProjectBlock') return fragment def studio_view(self, context): """ Editing view in Studio """ fragment = Fragment() fragment.add_content( render_template('/templates/html/group_project_edit.html', { 'self': self, })) fragment.add_javascript( load_resource('public/js/group_project_edit.js')) fragment.initialize_js('GroupProjectEditBlock') return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): self.display_name = submissions['display_name'] xml_content = submissions['data'] max_score = submissions['max_score'] if not max_score: # empty = default max_score = 1 else: try: # not an integer, then default max_score = int(max_score) except: max_score = 1 self.weight = max_score try: etree.parse(StringIO(xml_content)) self.data = xml_content except etree.XMLSyntaxError as e: return {'result': 'error', 'message': e.message} return { 'result': 'success', } @XBlock.json_handler def submit_peer_feedback(self, submissions, suffix=''): try: peer_id = submissions["peer_id"] del submissions["peer_id"] print "Peer Review for {}: {}".format(peer_id, submissions) # Then something like this needs to happen # user_id = get_user_id_for_this_session() # ??? # project_id = get_xblock_id_for_this_session() # api_manager.save_data_for_peer(user_id, peer_id, submissions) # or # for k,v in iteritems(submissions): # api_manager.save_data_for_peer(user_id, peer_id, k, v) except Exception as e: return { 'result': 'error', 'message': e.message, } return { 'result': 'success', 'msg': _('Thanks for your feedback'), } @XBlock.json_handler def submit_other_group_feedback(self, submissions, suffix=''): try: group_id = submissions["group_id"] del submissions["group_id"] print "Group Review for {}: {}".format(group_id, submissions) # Then something like this needs to happen # user_id = get_user_id_for_this_session() # ??? # project_id = get_xblock_id_for_this_session() # api_manager.save_data_for_group(user_id, group_id, submissions) # or # for k,v in iteritems(submissions): # api_manager.save_data_for_group(user_id, group_id, k, v) except Exception as e: return { 'result': 'error', 'msg': e.message, } return { 'result': 'success', 'msg': _('Thanks for your feedback'), } @XBlock.handler def load_peer_feedback(self, request, suffix=''): peer_id = request.GET["peer_id"] results = { 'peer_score': '5', 'peer_q1': 'A', 'peer_q2': 'BB', 'peer_q3': 'CCC', } return webob.response.Response(body=json.dumps(results)) @XBlock.handler def load_other_group_feedback(self, request, suffix=''): group_id = request.GET["group_id"] results = {} # results = { # 'other_team_comments': 'They Rocked!', # 'other_team_q1': '90', # 'other_team_q2': '95', # 'other_team_q3': '80', # } return webob.response.Response(body=json.dumps(results))
class CompletionBlock(SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, StudentViewUserStateMixin, XBlock): """ An XBlock used by students to indicate that they completed a given task. The student's answer is always considered "correct". """ CATEGORY = 'pb-completion' STUDIO_LABEL = _(u'Completion') USER_STATE_FIELDS = ['student_value'] answerable = True question = String( display_name=_('Question'), help= _('Mentions a specific activity and asks the student whether they completed it.' ), scope=Scope.content, default= _('Please indicate whether you attended the In Person Workshop session by (un-)checking the option below.' ), ) answer = String( display_name=_('Answer'), help=_( 'Represents the answer that the student can (un-)check ' 'to indicate whether they completed the activity that the question mentions.' ), scope=Scope.content, default=_('Yes, I attended the session.'), ) student_value = NullableBoolean( help=_("Records student's answer."), scope=Scope.user_state, default=None, ) editable_fields = ('display_name', 'show_title', 'question', 'answer') def mentoring_view(self, context): """ Main view of this block. """ context = context.copy() if context else {} context['question'] = self.question context['answer'] = self.answer context[ 'checked'] = self.student_value if self.student_value is not None else False context['title'] = self.display_name_with_default context['hide_header'] = context.get('hide_header', False) or not self.show_title html = loader.render_template('templates/html/completion.html', context) fragment = Fragment(html) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/completion.js')) fragment.initialize_js('CompletionBlock') return fragment student_view = mentoring_view preview_view = mentoring_view def student_view_data(self, context=None): """ Returns a JSON representation of the student_view of this XBlock, retrievable from the Course XBlock API. """ return { 'id': self.name, 'block_id': six.text_type(self.scope_ids.usage_id), 'display_name': self.display_name_with_default, 'type': self.CATEGORY, 'question': self.question, 'answer': self.answer, 'title': self.display_name_with_default, 'hide_header': not self.show_title, } def get_last_result(self): """ Return the current/last result in the required format """ if self.student_value is None: return {} return { 'submission': self.student_value, 'status': 'correct', 'tips': [], 'weight': self.weight, 'score': 1, } def get_results(self): """ Alias for get_last_result() """ return self.get_last_result() def submit(self, value): """ Persist answer submitted by student. """ log.debug(u'Received Completion submission: "%s"', value) self.student_value = value if sub_api: # Also send to the submissions API: sub_api.create_submission(self.student_item_key, value) result = self.get_last_result() log.debug(u'Completion submission result: %s', result) return result
class WorkspaceBlock(XBlock): """ An XBlock providing a responsive multimedia carousel and workspace """ display_name = String( help= "This name appears in horizontal navigation at the top of the page.", default="Workspace", scope=Scope.content) data = List( help= "This is the representation of the data items as a list of tuples ", default=[ ('img', 'http://met-content.bu.edu/etr2/content/images/Slide5.JPG', '100%', '96'), ('img', 'http://met-content.bu.edu/etr2/content/images/Slide6.JPG', '100%', '96'), ('img', 'http://met-content.bu.edu/etr2/content/images/Slide7.JPG', '100%', '96') ], scope=Scope.content) href = String(help="workspace url", default=None, scope=Scope.content) def student_view(self, context): """ Lab view, displayed to the student """ fragment = Fragment() context = {'items': self.data, 'url': self.href} fragment.add_content( render_template('/templates/html/workspace.html', context)) fragment.add_javascript( load_resource('public/js/jquery-ui-1.10.4.custom.js')) fragment.add_css(load_resource('public/css/responsive-carousel.css')) fragment.add_css( load_resource('public/css/responsive-carousel.slide.css')) fragment.add_javascript( load_resource('public/js/responsive-carousel.js')) fragment.add_css(load_resource("public/css/video-js.css")) fragment.add_javascript(load_resource("public/js/video.js")) fragment.add_javascript(load_resource('public/js/youtube.js')) fragment.add_javascript( "function WorkspaceBlock(runtime, element) { $('.carousel').carousel(); }" ) fragment.initialize_js("WorkspaceBlock") return fragment def studio_view(self, context): """ Studio edit view """ xml_data = self._build_xml(self.data) fragment = Fragment() fragment.add_content( render_template('templates/html/workspace_edit.html', { 'xml_data': xml_data, self: self })) fragment.add_javascript( load_resource('public/js/jquery-ui-1.10.4.custom.js')) fragment.add_javascript(load_resource('public/js/workspace_edit.js')) fragment.initialize_js('WorkspaceEditBlock') return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): self.display_name = submissions['display_name'] self.href = submissions['workspace_url'] xml_content = submissions['data'] try: etree.parse(StringIO(xml_content)) xmltree = etree.fromstring(xml_content) items_list = self._get_items(xmltree) self.data = items_list except etree.XMLSyntaxError as e: return {'result': 'error', 'message': e.message} return { 'result': 'success', } def _get_items(self, xmltree): """ Helper method """ items_elements = xmltree.getchildren() items = [] for item_element in items_elements: item_tag = item_element.tag item_src = item_element.get('src') item_width = item_element.get('width', '100%') item_height = item_element.get('height', '625') items.append((item_tag, item_src, item_width, item_height)) return items def _build_xml(self, items_list): """ Helper method """ xml = etree.Element('workspace') for item in items_list: tag = etree.SubElement(xml, item[0], src=item[1], width=item[2], height=item[3]) return etree.tostring(xml, pretty_print=True) @staticmethod def workbench_scenarios(): return [("workspace demo", "<workspace />")]
class GoogleCalendarBlock(XBlock, PublishEventMixin): """ XBlock providing a google calendar view for a specific calendar """ display_name = String( display_name=_("Display Name"), help= _("This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default="Google Calendar") calendar_id = String( display_name=_("Public Calendar ID"), help= _("Google provides an ID for publicly available calendars. In the Google Calendar, " "open Settings and copy the ID from the Calendar Address section into this field." ), scope=Scope.settings, default=DEFAULT_CALENDAR_ID) default_view = Integer( display_name=_("Default View"), help= _("The calendar view that students see by default. A student can change this view." ), scope=Scope.settings, default=1) views = [(0, 'Week'), (1, 'Month'), (2, 'Agenda')] # Context argument is specified for xblocks, but we are not using herein def student_view(self, context): # pylint: disable=unused-argument """ Player view, displayed to the student """ fragment = Fragment() fragment.add_content( RESOURCE_LOADER.render_django_template( CALENDAR_TEMPLATE, context={ "mode": self.views[self.default_view][1], "src": self.calendar_id, "title": self.display_name, "language": utils.translation.get_language(), }, i18n_service=self.runtime.service(self, "i18n"), )) fragment.add_css( RESOURCE_LOADER.load_unicode('public/css/google_calendar.css')) fragment.add_javascript( RESOURCE_LOADER.load_unicode('public/js/google_calendar.js')) fragment.initialize_js('GoogleCalendarBlock') return fragment # Context argument is specified for xblocks, but we are not using herein def studio_view(self, context): # pylint: disable=unused-argument """ Editing view in Studio """ fragment = Fragment() # Need to access protected members of fields to get their default value default_name = self.fields['display_name']._default # pylint: disable=protected-access,unsubscriptable-object fragment.add_content( RESOURCE_LOADER.render_template( CALENDAR_EDIT_TEMPLATE, { 'self': self, 'defaultName': default_name, 'defaultID': self.fields['calendar_id']._default # pylint: disable=protected-access,unsubscriptable-object })) fragment.add_javascript( RESOURCE_LOADER.load_unicode('public/js/google_calendar_edit.js')) fragment.add_css( RESOURCE_LOADER.load_unicode('public/css/google_edit.css')) fragment.initialize_js('GoogleCalendarEditBlock') return fragment # suffix argument is specified for xblocks, but we are not using herein @XBlock.json_handler def studio_submit(self, submissions, suffix=''): # pylint: disable=unused-argument """ Change the settings for this XBlock given by the Studio user """ if not isinstance(submissions, dict): LOG.error("submissions object from Studio is not a dict - %r", submissions) return {'result': 'error'} if 'display_name' in submissions: self.display_name = submissions['display_name'] if 'calendar_id' in submissions: self.calendar_id = submissions['calendar_id'] if 'default_view' in submissions: self.default_view = submissions['default_view'] return { 'result': 'success', } @staticmethod def workbench_scenarios(): """ A canned scenario for display in the workbench. """ return [("Google Calendar scenario", "<vertical_demo><google-calendar/></vertical_demo>")]