def test_field_name_defaults(): # Tests field display name default values attempts = Integer() attempts.__name__ = "max_problem_attempts" assert_equals('max_problem_attempts', attempts.display_name) class TestBlock(XBlock): """ Block for testing """ field_x = List() assert_equals("field_x", TestBlock.field_x.display_name)
def test_field_display_name(): attempts = Integer(display_name='Maximum Problem Attempts') attempts._name = "max_problem_attempts" assert_equals("Maximum Problem Attempts", attempts.display_name) boolean_field = Boolean(display_name="boolean field") assert_equals("boolean field", boolean_field.display_name) class TestBlock(XBlock): """ Block for testing """ field_x = List(display_name="Field Known as X") assert_equals("Field Known as X", TestBlock.field_x.display_name)
class BaseMentoringBlock( XBlock, XBlockWithTranslationServiceMixin, XBlockWithSettingsMixin, StudioEditableXBlockMixin, MessageParentMixin, StudentViewUserStateMixin, ExpandStaticURLMixin ): """ An XBlock that defines functionality shared by mentoring blocks. """ # Content show_title = Boolean( display_name=_("Show title"), help=_("Display the title?"), default=True, scope=Scope.content ) max_attempts = Integer( display_name=_("Max. attempts allowed"), help=_("Maximum number of times students are allowed to attempt the questions belonging to this block"), default=0, scope=Scope.content, enforce_type=True ) weight = Float( display_name=_("Weight"), help=_("Defines the maximum total grade of the block."), default=1, scope=Scope.settings, enforce_type=True ) # User state num_attempts = Integer( # Number of attempts a user has answered for this questions default=0, scope=Scope.user_state, enforce_type=True ) has_children = True has_score = True # The Problem/Step Builder XBlocks produce scores. (Their children do not send scores to the LMS.) icon_class = 'problem' block_settings_key = 'mentoring' options_key = 'options' @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 usage_id. """ try: return super(BaseMentoringBlock, self).url_name except AttributeError: return six.text_type(self.scope_ids.usage_id) @property def review_tips_json(self): return json.dumps(self.review_tips) @property def max_attempts_reached(self): return self.max_attempts > 0 and self.num_attempts >= self.max_attempts def get_content_titles(self): """ By default, each Sequential block in a course ("Subsection" in Studio parlance) will display the display_name of each descendant in a tooltip above the content. We don't want that - we only want to display one title for this mentoring block as a whole. Otherwise things like "Choice (yes) (Correct)" will appear in the tooltip. If this block has no title set, don't display any title. Then, if this is the only block in the unit, the unit's title will be used. (Why isn't it always just used?) """ has_explicitly_set_title = self.fields['display_name'].is_set_on(self) if has_explicitly_set_title: return [self.display_name] return [] def get_options(self): """ Get options settings for this block from settings service. Fall back on default options if xblock settings have not been customized at all or no customizations for options available. """ xblock_settings = self.get_xblock_settings(default={}) if xblock_settings and self.options_key in xblock_settings: return xblock_settings[self.options_key] return _default_options_config def get_option(self, option): """ Get value of a specific instance-wide `option`. """ return self.get_options().get(option) @XBlock.json_handler def view(self, data, suffix=''): """ Current HTML view of the XBlock, for refresh by client """ frag = self.student_view({}) return {'html': frag.content} @XBlock.json_handler def publish_event(self, data, suffix=''): """ Publish data for analytics purposes """ event_type = data.pop('event_type') if (event_type == 'grade'): # This handler can be called from the browser. Don't allow the browser to submit arbitrary grades ;-) raise JsonHandlerError(403, "Posting grade events from the browser is forbidden.") self.runtime.publish(self, event_type, data) return {'result': 'ok'} def author_preview_view(self, context): """ Child blocks can override this to add a custom preview shown to authors in Studio when not editing this block's children. """ fragment = self.student_view(context) fragment.add_content(loader.render_django_template('templates/html/mentoring_url_name.html', { "url_name": self.url_name })) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-edit.css')) return fragment def max_score(self): """ Maximum score. We scale all scores to a maximum of 1.0 so this is always 1.0 """ return 1.0
class MentoringBlock( StudentViewUserStateResultsTransformerMixin, I18NService, BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin, StepParentMixin, TranslationContentMixin ): """ An XBlock providing mentoring capabilities Composed of text, answers input fields, and a set of MRQ/MCQ with advices. A set of conditions on the provided answers and MCQ/MRQ choices will determine if the student is a) provided mentoring advices and asked to alter his answer, or b) is given the ok to continue. """ # Content USER_STATE_FIELDS = ['completed', 'num_attempts', 'student_results'] followed_by = String( display_name=_("Followed by"), help=_("url_name of the step after the current mentoring block in workflow."), default=None, scope=Scope.content ) enforce_dependency = Boolean( display_name=_("Enforce Dependency"), help=_("Should the next step be the current block to complete?"), default=False, scope=Scope.content, enforce_type=True ) display_submit = Boolean( display_name=_("Show Submit Button"), help=_("Allow submission of the current block?"), default=True, scope=Scope.content, enforce_type=True ) xml_content = String( display_name=_("XML content"), help=_("Not used for version 2. This field is here only to preserve the data needed to upgrade from v1 to v2."), default='', scope=Scope.content, multiline_editor=True ) # Settings display_name = String( display_name=_("Title (Display name)"), help=_("Title to display"), default=_("Problem Builder"), scope=Scope.settings ) feedback_label = String( display_name=_("Feedback Header"), help=_("Header for feedback messages"), default=_("Feedback"), scope=Scope.content ) # User state attempted = Boolean( # Has the student attempted this mentoring step? default=False, scope=Scope.user_state # TODO: Does anything use this 'attempted' field? May want to delete it. ) completed = Boolean( # Has the student completed this mentoring step? default=False, scope=Scope.user_state ) step = Integer( # Keep track of the student assessment progress. default=0, scope=Scope.user_state, enforce_type=True ) student_results = List( # Store results of student choices. default=[], scope=Scope.user_state ) extended_feedback = Boolean( help=_("Show extended feedback details when all attempts are used up."), default=False, Scope=Scope.content ) # Global user state next_step = String( # url_name of the next step the student must complete (global to all blocks) default='mentoring_first', scope=Scope.preferences ) editable_fields = ( 'display_name', 'followed_by', 'max_attempts', 'enforce_dependency', 'display_submit', 'feedback_label', 'weight', 'extended_feedback' ) @property def allowed_nested_blocks(self): """ Returns a list of allowed nested XBlocks. Each item can be either * An XBlock class * A NestedXBlockSpec If XBlock class is used it is assumed that this XBlock is enabled and allows multiple instances. NestedXBlockSpec allows explicitly setting disabled/enabled state, disabled reason (if any) and single/multiple instances """ additional_blocks = [] try: from xmodule.video_module.video_module import VideoBlock additional_blocks.append(NestedXBlockSpec( VideoBlock, category='video', label=_(u"Video") )) except ImportError: pass try: from imagemodal import ImageModal additional_blocks.append(NestedXBlockSpec( ImageModal, category='imagemodal', label=_(u"Image Modal") )) except ImportError: pass from .platform_dependencies import XBlockConfiguration if XBlockConfiguration: opt = XBlockConfiguration.objects.filter(name="pb-swipe") if opt.count() and opt.first().enabled: additional_blocks.append(SwipeBlock) try: from ooyala_player.ooyala_player import OoyalaPlayerBlock additional_blocks.append(NestedXBlockSpec( OoyalaPlayerBlock, category='ooyala-player', label=_(u"Ooyala Player") )) except ImportError: pass message_block_shims = [ NestedXBlockSpec( MentoringMessageBlock, category='pb-message', boilerplate=message_type, label=get_message_label(message_type), ) for message_type in ( 'completed', 'incomplete', 'max_attempts_reached', ) ] return [ NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'), MCQBlock, RatingBlock, MRQBlock, CompletionBlock, NestedXBlockSpec(None, category="html", label=self._("HTML")), AnswerRecapBlock, MentoringTableBlock, PlotBlock, SliderBlock ] + additional_blocks + message_block_shims def get_question_number(self, question_id): """ Get the step number of the question id """ for child_id in self.children: question = self.runtime.get_block(child_id) if isinstance(question, QuestionMixin) and (question.name == question_id): return question.step_number raise ValueError("Question ID in answer set not a step of this Mentoring Block!") def answer_mapper(self, answer_status): """ Create a JSON-dumpable object with readable key names from a list of student answers. """ answer_map = [] for answer in self.student_results: if answer[1]['status'] == answer_status: try: answer_map.append({ 'number': self.get_question_number(answer[0]), 'id': answer[0], 'details': answer[1], }) except ValueError: pass # The question has been deleted since the student answered it. return answer_map @property def score(self): """Compute the student score taking into account the weight of each step.""" steps = self.steps steps_map = {q.name: q for q in steps} total_child_weight = sum(float(step.weight) for step in steps) if total_child_weight == 0: return Score(0, 0, [], [], []) points_earned = 0 for q_name, q_details in self.student_results: question = steps_map.get(q_name) if question: points_earned += q_details['score'] * question.weight score = Decimal(points_earned) / Decimal(total_child_weight) correct = self.answer_mapper(CORRECT) incorrect = self.answer_mapper(INCORRECT) partially_correct = self.answer_mapper(PARTIAL) return Score( float(score), int(Decimal(score * 100).quantize(Decimal('1.'), rounding=ROUND_HALF_UP)), correct, incorrect, partially_correct ) @XBlock.supports("multi_device") # Mark as mobile-friendly def student_view(self, context): from .questionnaire import QuestionnaireAbstractBlock # Import here to avoid circular dependency # Migrate stored data if necessary self.migrate_fields() # Validate self.step: num_steps = len(self.steps) if self.step > num_steps: self.step = num_steps fragment = Fragment() child_content = u"" mcq_hide_previous_answer = self.get_option('pb_mcq_hide_previous_answer') for child_id in self.children: child = self.runtime.get_block(child_id) if child is None: # child should not be None but it can happen due to bugs or permission issues child_content += u"<p>[{}]</p>".format(self._(u"Error: Unable to load child component.")) elif not isinstance(child, MentoringMessageBlock): try: if mcq_hide_previous_answer and isinstance(child, QuestionnaireAbstractBlock): context['hide_prev_answer'] = True else: context['hide_prev_answer'] = False child_fragment = child.render('mentoring_view', context) except NoSuchViewError: if child.scope_ids.block_type == 'html' and getattr(self.runtime, 'is_author_mode', False): # html block doesn't support mentoring_view, and if we use student_view Studio will wrap # it in HTML that we don't want in the preview. So just render its HTML directly: child_fragment = Fragment(child.data) else: child_fragment = child.render('student_view', context) fragment.add_frag_resources(child_fragment) child_content += child_fragment.content fragment.add_content(loader.render_django_template('templates/html/mentoring.html', { 'self': self, 'title': self.display_name, 'show_title': self.show_title, 'child_content': child_content, 'missing_dependency_url': self.has_missing_dependency and self.next_step_url, }, i18n_service=self.i18n_service)) fragment.add_javascript(self.get_translation_content()) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js')) 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_standard_view.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js')) # Workbench doesn't have font awesome, so add it: if WorkbenchRuntime and isinstance(self.runtime, WorkbenchRuntime): fragment.add_css_url('//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css') fragment.initialize_js('MentoringBlock') if not self.display_submit: self.runtime.publish(self, 'progress', {}) return fragment def migrate_fields(self): """ Migrate data stored in the fields, when a format change breaks backward-compatibility with previous data formats """ # Partial answers replaced the `completed` with `status` in `self.student_results` if self.student_results and 'completed' in self.student_results[0][1]: # Rename the field and use the new value format (text instead of boolean) for result in self.student_results: result[1]['status'] = 'correct' if result[1]['completed'] else 'incorrect' del result[1]['completed'] @property def additional_publish_event_data(self): return { 'user_id': self.scope_ids.user_id, 'component_id': self.url_name, } @property def has_missing_dependency(self): """ Returns True if the student needs to complete another step before being able to complete the current one, and False otherwise """ return self.enforce_dependency and (not self.completed) and (self.next_step != self.url_name) @property def next_step_url(self): """ Returns the URL of the next step's page """ return '/jump_to_id/{}'.format(self.next_step) @property def hide_feedback(self): return self.get_option("pb_hide_feedback_if_attempts_remain") and not self.max_attempts_reached def get_message(self, completed): """ Get the message to display to a student following a submission. """ if completed: # Student has achieved a perfect score return self.get_message_content('completed') elif self.max_attempts_reached: # Student has not achieved a perfect score and cannot try again return self.get_message_content('max_attempts_reached') else: # Student did not achieve a perfect score but can try again: return self.get_message_content('incomplete') @property def review_tips(self): review_tips = [] return review_tips def show_extended_feedback(self): return self.extended_feedback and self.max_attempts_reached @XBlock.json_handler def get_results(self, queries, suffix=''): """ Gets detailed results in the case of extended feedback. Right now there are two ways to get results-- through the template upon loading up the mentoring block, or after submission of an AJAX request like in submit or get_results here. """ results, completed, show_message = self._get_standard_results() mentoring_completed = completed result = { 'results': results, 'completed': completed, 'step': self.step, 'max_attempts': self.max_attempts, 'num_attempts': self.num_attempts, } if show_message: result['message'] = self.get_message(mentoring_completed) return result def _get_standard_results(self): """ Gets previous submissions results as if submit was called with exactly the same values as last time. """ results = [] completed = True show_message = (not self.hide_feedback) and bool(self.student_results) # All children are visible simultaneously, so need to collect results for all of them for child in self.steps: child_result = child.get_last_result() results.append([child.name, child_result]) completed = completed and (child_result.get('status', None) == 'correct') return results, completed, show_message @XBlock.json_handler def submit(self, submissions, suffix=''): log.info(u'Received submissions: {}'.format(submissions)) # server-side check that the user is allowed to submit: if self.max_attempts_reached: raise JsonHandlerError(403, "Maximum number of attempts already reached.") if self.has_missing_dependency: raise JsonHandlerError( 403, "You need to complete all previous steps before being able to complete the current one." ) # This has now been attempted: self.attempted = True submit_results = [] previously_completed = self.completed completed = True for child in self.steps: if child.name and child.name in submissions: submission = submissions[child.name] child_result = child.submit(submission) submit_results.append([child.name, child_result]) child.save() completed = completed and (child_result['status'] == 'correct') if completed and self.next_step == self.url_name: self.next_step = self.followed_by # Update the score and attempts, unless the user had already achieved a perfect score ("completed"): if not previously_completed: # Update the results while self.student_results: self.student_results.pop() for result in submit_results: self.student_results.append(result) # Save the user's latest score self.runtime.publish(self, 'grade', { 'value': self.score.raw, 'max_value': self.max_score(), }) # Mark this as having used an attempt: if self.max_attempts > 0: self.num_attempts += 1 # Save the completion status. # Once it has been completed once, keep completion even if user changes values self.completed = bool(completed) or previously_completed message = self.get_message(completed) raw_score = self.score.raw self.runtime.publish(self, 'xblock.problem_builder.submitted', { 'num_attempts': self.num_attempts, 'submitted_answer': submissions, 'grade': raw_score, }) return { 'results': submit_results, 'completed': self.completed, 'message': message, 'max_attempts': self.max_attempts, 'num_attempts': self.num_attempts, } def feedback_dispatch(self, target_data, stringify): if self.show_extended_feedback(): if stringify: return json.dumps(target_data) else: return target_data def correct_json(self, stringify=True): return self.feedback_dispatch(self.score.correct, stringify) def incorrect_json(self, stringify=True): return self.feedback_dispatch(self.score.incorrect, stringify) def partial_json(self, stringify=True): return self.feedback_dispatch(self.score.partially_correct, stringify) @XBlock.json_handler def try_again(self, data, suffix=''): if self.max_attempts_reached: return { 'result': 'error', 'message': 'max attempts reached' } # reset self.step = 0 self.completed = False while self.student_results: self.student_results.pop() return { 'result': 'success' } def validate(self): """ Validates the state of this XBlock except for individual field values. """ validation = super(MentoringBlock, self).validate() a_child_has_issues = False message_types_present = set() for child_id in self.children: child = self.runtime.get_block(child_id) # Check if the child has any errors: if not child.validate().empty: a_child_has_issues = True # Ensure there is only one "message" block of each type: if isinstance(child, MentoringMessageBlock): msg_type = child.type if msg_type in message_types_present: validation.add(ValidationMessage( ValidationMessage.ERROR, self._(u"There should only be one '{msg_type}' message component.").format(msg_type=msg_type) )) message_types_present.add(msg_type) if a_child_has_issues: validation.add(ValidationMessage( ValidationMessage.ERROR, self._(u"A component inside this mentoring block has issues.") )) return validation def author_edit_view(self, context): """ Add some HTML to the author view that allows authors to add child blocks. """ local_context = context.copy() local_context['author_edit_view'] = True fragment = super(MentoringBlock, self).author_edit_view(local_context) fragment.add_content(loader.render_django_template('templates/html/mentoring_url_name.html', { 'url_name': self.url_name })) 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/problem-builder-edit.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-tinymce-content.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/container_edit.js')) fragment.initialize_js('ProblemBuilderContainerEdit') return fragment @staticmethod def workbench_scenarios(): """ Scenarios displayed by the workbench. Load them from external (private) repository """ return loader.load_scenarios_from_path('templates/xml') def student_view_data(self, context=None): """ Returns a JSON representation of the student_view of this XBlock, retrievable from the Course Block API. """ components = [] for child_id in self.children: block = self.runtime.get_block(child_id) if hasattr(block, 'student_view_data'): components.append(block.student_view_data()) return { 'block_id': six.text_type(self.scope_ids.usage_id), 'display_name': self.display_name, 'max_attempts': self.max_attempts, 'extended_feedback': self.extended_feedback, 'feedback_label': self.feedback_label, 'components': components, 'messages': { message_type: self.expand_static_url(self.get_message_content(message_type)) for message_type in ( 'completed', 'incomplete', 'max_attempts_reached', ) } }
class RandomizeFields(object): choice = Integer(help="Which random child was chosen", scope=Scope.user_state)
class StaffGradedAssignmentXBlock(StudioEditableXBlockMixin, ShowAnswerXBlockMixin, XBlockWithSettingsMixin, 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 = ('team_view', 'display_name', 'points', 'weight', 'showanswer', 'solution', 'activity_description') 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.")) 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")) team_view = Boolean( display_name=_("Team View"), scope=Scope.settings, default=False, help=_("This option allows to select the standard view or team view.")) activity_description = String( display_name=_("Activity Description"), scope=Scope.settings, default="Set your team activity description", help=_( "This contains the description of the activity for every team."), multiline_editor=True, resettable_editor=False) @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 team_download(self, request, suffix=''): # pylint: disable=unused-argument """ Return an assignment file requested by a team member. """ require(self.is_team_member(request.params['student_id'])) 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'], ) @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. """ require(self.is_course_staff()) user = self.get_real_user() require(user) zip_file_ready = False location = unicode(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", last_assignment_date, zip_file_time, 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 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. """ 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.encode('utf-8')) 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)}) @XBlock.handler def get_team_grading_data(self, request, suffix=''): # pylint: disable=unused-argument """ Return the html for the staff grading view """ return Response(json_body=self.team_grading_data()) 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 } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) elif self.team_view: return self.teams_view(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 teams_view(self, context=None): # pylint: disable=no-member """ The team view of the modify StaffGradedAssignmentXBlock, shown to students when the team option is activated. """ context["data_team_activity"] = { "description": self.activity_description, "name": self.display_name } fragment = Fragment() fragment.add_content( render_template( 'templates/staff_graded_assignment/teams_view.html', context)) fragment.add_css(_resource("static/css/sga_team_view.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.add_javascript( _resource("static/js/src/jquery.tablesorter.min.js")) fragment.add_javascript(_resource("static/js/src/sga_team_view.js")) fragment.initialize_js('SgaTeamView') 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 unicode(self.scope_ids.usage_id) @reify def block_course_id(self): """ Return the course_id of the block. """ return unicode(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 """ 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 = '' 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: return Response( app_iter=file_contents_iter(path), content_type=mime_type, content_disposition="attachment; filename*=UTF-8''" + urllib.quote(filename.encode('utf-8'))) 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 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. """ 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 get_real_user(self): """returns session user""" 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() def team_grading_data(self): """ Return team member assignment information for display on the grading screen. """ members = self.get_teams_members() if not members: return {"assignments": list()} course_data = self.staff_grading_data() assignments = course_data["assignments"] assignments = self.filter_assigments_by_team_members( assignments, members) course_data["assignments"] = list(assignments) return course_data def get_teams_members(self): runtime = self.xmodule_runtime user = runtime.service(self, 'user').get_current_user() course_id = runtime.course_id username = user.opt_attrs['edx-platform.username'] xblock_settings = self.get_xblock_settings() try: user = xblock_settings["username"] password = xblock_settings["password"] client_id = xblock_settings["client_id"] client_secret = xblock_settings["client_secret"] except KeyError: raise server_url = settings.LMS_ROOT_URL api = ApiTeams(user, password, client_id, client_secret, server_url) team = api.get_user_team(course_id, username) if team: team = team[0] team_id = team["id"] members = api.get_members(team_id) if members: return members def filter_assigments_by_team_members(self, assignments, members): """This method compares the team's users with an assigments' list""" for member in members: user = get_user_by_username_or_email(member["user"]["username"]) for assignment in assignments: if user == user_by_anonymous_id(assignment["student_id"]): assignment["profile_image_url"] = self._user_image_url( user) yield assignment def is_team_member(self, student_id): """This methods verifies if the user is a team member """ team_data = self.team_grading_data() assignments = team_data["assignments"] for assignment in assignments: if assignment["student_id"] == student_id: return True return False def _user_image_url(self, user): """Returns an image url for the current user""" from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user # pylint: disable=relative-import profile_image_url = get_profile_image_urls_for_user(user)["full"] if profile_image_url.startswith("http"): return profile_image_url base_url = settings.LMS_ROOT_URL image_url = "{}{}".format(base_url, profile_image_url) return image_url
class PeerInstructionXBlock(XBlock, MissingDataFetcherMixin, PublishEventMixin): """ Peer Instruction XBlock Notes: storing index vs option text: when storing index, it is immune to the option text changes. But when the order changes, the results will be skewed. When storing option text, it is impossible (at least for now) to update the existing students responses when option text changed. a warning may be shown to the instructor that they may only do the minor changes to the question options and may not change the order of the options, add or delete options """ event_namespace = 'ubc.peer_instruction' # the display name that used on the interface display_name = String(default=_("Peer Instruction Question")) question_text = Dict( default={ 'text': _('<p>Where does most of the mass in a fully grown tree originate?</p>' ), 'image_url': '', 'image_position': 'below', 'image_show_fields': 0, 'image_alt': '' }, scope=Scope.content, help= _("The question the students see. This question appears above the possible answers which you set below. " "You can use text, an image or a combination of both. If you wish to add an image to your question, press " "the 'Add Image' button.")) options = List( default=[{ 'text': _('Air'), 'image_url': '', 'image_position': 'below', 'image_show_fields': 0, 'image_alt': '' }, { 'text': _('Soil'), 'image_url': '', 'image_position': 'below', 'image_show_fields': 0, 'image_alt': '' }, { 'text': _('Water'), 'image_url': '', 'image_position': 'below', 'image_show_fields': 0, 'image_alt': '' }], scope=Scope.content, help=_("The possible options from which the student may select"), ) rationale_size = Dict( default={ 'min': 1, 'max': MAX_RATIONALE_SIZE }, scope=Scope.content, help= _("The minimum and maximum number of characters a student is allowed for their rationale." ), ) correct_answer = Integer( default=0, scope=Scope.content, help=_("The correct option for the question"), ) correct_rationale = Dict( default={'text': _("Photosynthesis")}, scope=Scope.content, help=_("The feedback for student for the correct answer"), ) stats = Dict( default={ 'original': {}, 'revised': {} }, scope=Scope.user_state_summary, help=_("Overall stats for the instructor"), ) seeds = List( default=[{ 'answer': 0, 'rationale': _('Tree gets carbon from air.') }, { 'answer': 1, 'rationale': _('Tree gets minerals from soil.') }, { 'answer': 2, 'rationale': _('Tree drinks water.') }], scope=Scope.content, help= _("Instructor configured examples to give to students during the revise stage." ), ) # sys_selected_answers dict format: # { # option1_index: { # 'student_id1': { can store algorithm specific info here }, # 'student_id2': { can store algorithm specific info here }, # ... # } # option2_index: ... # } sys_selected_answers = Dict( default={}, scope=Scope.user_state_summary, help= _("System selected answers to give to students during the revise stage." ), ) other_answers_shown = Dict( default={}, scope=Scope.user_state, help= _("Stores the specific answers of other students shown, for a given student." ), ) algo = Dict( default={ 'name': 'simple', 'num_responses': '#' }, scope=Scope.content, help= _("The algorithm for selecting which answers to be presented to students" ), ) # Declare that we are not part of the grading System. Disabled for now as for the concern about the loading # speed of the progress page. has_score = True start = DateTime( default=None, scope=Scope.settings, help= _("ISO-8601 formatted string representing the start date of this assignment. We ignore this." )) due = DateTime( default=None, scope=Scope.settings, help= _("ISO-8601 formatted string representing the due date of this assignment. We ignore this." )) # required field for LMS progress page weight = Float( default=1, 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) def has_dynamic_children(self): """ Do we dynamically determine our children? No, we don't have any. """ return False def max_score(self): """ The maximum raw score of our problem. """ return 1 def studio_view(self, context=None): """ view function for studio edit """ html = self.resource_string("static/html/ubcpi_edit.html") frag = Fragment(html) frag.add_javascript( self.resource_string("static/js/src/ubcpi_edit.js")) frag.initialize_js( 'PIEdit', { 'display_name': self.ugettext(self.display_name), 'weight': self.weight, 'correct_answer': self.correct_answer, 'correct_rationale': self.correct_rationale, 'rationale_size': self.rationale_size, 'question_text': self.question_text, 'options': self.options, 'algo': self.algo, 'algos': { 'simple': self.ugettext( 'System will select one of each option to present to the students.' ), 'random': self.ugettext( 'Completely random selection from the response pool.') }, 'image_position_locations': { 'above': self.ugettext('Appears above'), 'below': self.ugettext('Appears below') }, 'seeds': self.seeds, 'lang': translation.get_language(), }) return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Submit handler for studio edit Args: data (dict): data submitted from the form suffix (str): not sure Returns: dict: result of the submission """ self.display_name = data['display_name'] self.weight = data['weight'] self.question_text = data['question_text'] self.rationale_size = data['rationale_size'] self.options = data['options'] self.correct_answer = data['correct_answer'] self.correct_rationale = data['correct_rationale'] self.algo = data['algo'] self.seeds = data['seeds'] return {'success': 'true'} @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.handler def get_asset(self, request, suffix=''): """ Get static partial assets from this XBlock As there is no way to directly access the static assets within the XBlock, we use a handler to expose assets by name. Only html is needed for now. Args: request (Request): HTTP request suffix (str): not sure Returns: Response: HTTP response with the content of the asset """ filename = request.params.get('f') return Response(self.resource_string('static/js/partials/' + filename), content_type='text/html') @classmethod def get_base_url_path_for_course_assets(cls, course_key): """ Slightly modified version of StaticContent.get_base_url_path_for_course_assets. Code is copied as we don't want to introduce the dependency of edx-platform so that we can develop in workbench Args: course_key (str): CourseKey Returns: str: course asset base URL string """ if course_key is None: return None # assert isinstance(course_key, CourseKey) placeholder_id = uuid.uuid4().hex # create a dummy asset location with a fake but unique name. strip off the name, and return it url_path = cls.serialize_asset_key_with_slash( course_key.make_asset_key('asset', placeholder_id).for_branch(None)) return url_path.replace(placeholder_id, '') @staticmethod def serialize_asset_key_with_slash(asset_key): """ Legacy code expects the serialized asset key to start w/ a slash; so, do that in one place Args: asset_key (str): Asset key to generate URL """ url = unicode(asset_key) if not url.startswith('/'): url = '/' + url return url def get_asset_url(self, static_url): """ Returns the asset url for imported files (eg. images) that are uploaded in Files & Uploads Args: static_url(str): The static url for the file Returns: str: The URL for the file """ # if static_url is not a "asset url", we will use it as it is if not static_url.startswith('/static/'): return static_url if hasattr(self, "xmodule_runtime"): file_name = os.path.split(static_url)[-1] return self.get_base_url_path_for_course_assets( self.course_id) + file_name else: return static_url def student_view(self, context=None): """ The primary view of the PeerInstructionXBlock, shown to students when viewing courses. """ # convert key into integers as json.dump and json.load convert integer dictionary key into string self.sys_selected_answers = { int(k): v for k, v in self.sys_selected_answers.items() } # generate a random seed for student student_item = self.get_student_item_dict() random.seed(student_item['student_id']) answers = self.get_answers_for_student() html = "" html += self.resource_string("static/html/ubcpi.html") frag = Fragment(html) frag.add_css(self.resource_string("static/css/ubcpi.css")) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/angularjs/1.3.13/angular.js") frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/angularjs/1.3.13/angular-messages.js" ) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/angularjs/1.3.13/angular-sanitize.js" ) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/angularjs/1.3.13/angular-cookies.js" ) frag.add_javascript_url( "//cdnjs.cloudflare.com/ajax/libs/angular-gettext/2.3.8/angular-gettext.min.js" ) frag.add_javascript_url( "//cdnjs.cloudflare.com/ajax/libs/d3/3.3.13/d3.min.js") frag.add_javascript(self.resource_string("static/js/src/d3-pibar.js")) frag.add_javascript(self.resource_string("static/js/src/ubcpi.js")) frag.add_javascript( self.resource_string( "static/js/src/ubcpi-answer-result-directive.js")) frag.add_javascript( self.resource_string("static/js/src/ubcpi-barchart-directive.js")) frag.add_javascript( self.resource_string("static/js/src/translations.js")) # convert image URLs question = deepcopy(self.question_text) question.update( {'image_url': self.get_asset_url(question.get('image_url'))}) options = deepcopy(self.options) for option in options: if option.get('image_url'): option.update( {'image_url': self.get_asset_url(option.get('image_url'))}) js_vals = { 'answer_original': answers.get_vote(0), 'rationale_original': answers.get_rationale(0), 'answer_revised': answers.get_vote(1), 'rationale_revised': answers.get_rationale(1), 'display_name': self.ugettext(self.display_name), 'question_text': question, 'weight': self.weight, 'options': options, 'rationale_size': self.rationale_size, 'user_role': self.get_user_role(), 'all_status': { 'NEW': STATUS_NEW, 'ANSWERED': STATUS_ANSWERED, 'REVISED': STATUS_REVISED }, 'lang': translation.get_language(), } if answers.has_revision(0) and not answers.has_revision(1): js_vals['other_answers'] = self.other_answers_shown # reveal the correct answer in the end if answers.has_revision(1): js_vals['correct_answer'] = self.correct_answer js_vals['correct_rationale'] = self.correct_rationale # Pass the answer to out Javascript frag.initialize_js('PeerInstructionXBlock', js_vals) self.publish_event_from_dict(self.event_namespace + '.accessed', {}) return frag def record_response(self, answer, rationale, status): """ Store response from student to the backend Args: answer (int): the option index that student responded rationale (str): the rationale text status (int): the progress status for this student. Possible values are: STATUS_NEW, STATUS_ANSWERED, STATUS_REVISED Raises: PermissionDenied: if we got an invalid status """ answers = self.get_answers_for_student() stats = self.get_current_stats() truncated_rationale, was_truncated = truncate_rationale(rationale) corr_ans_text = '' if self.correct_answer == len( self.options): # handle scenario with no correct answer corr_ans_text = 'n/a' else: corr_ans_text = self.options[self.correct_answer].get('text'), event_dict = { 'answer': answer, 'answer_text': self.options[answer].get('text'), 'rationale': truncated_rationale, 'correct_answer': self.correct_answer, 'correct_answer_text': corr_ans_text, 'correct_rationale': self.correct_rationale, 'truncated': was_truncated } if not answers.has_revision(0) and status == STATUS_NEW: student_item = self.get_student_item_dict() sas_api.add_answer_for_student(student_item, answer, rationale) num_resp = stats['original'].setdefault(answer, 0) stats['original'][answer] = num_resp + 1 offer_answer(self.sys_selected_answers, answer, rationale, student_item['student_id'], self.algo, self.options) self.other_answers_shown = get_other_answers( self.sys_selected_answers, self.seeds, self.get_student_item_dict, self.algo, self.options) event_dict['other_student_responses'] = self.other_answers_shown self.publish_event_from_dict( self.event_namespace + '.original_submitted', event_dict) return event_dict['other_student_responses'] elif answers.has_revision(0) and not answers.has_revision( 1) and status == STATUS_ANSWERED: sas_api.add_answer_for_student(self.get_student_item_dict(), answer, rationale) num_resp = stats['revised'].setdefault(answer, 0) stats['revised'][answer] = num_resp + 1 # Fetch the grade grade = self.get_grade() # Send the grade self.runtime.publish(self, 'grade', { 'value': grade, 'max_value': 1 }) self.publish_event_from_dict( self.event_namespace + '.revised_submitted', event_dict) else: raise PermissionDenied def get_grade(self): """ Return the grade Only returns 1 for now as a completion grade. """ return 1 def get_current_stats(self): """ Get the progress status for current user. This function also converts option index into integers """ # convert key into integers as json.dump and json.load convert integer dictionary key into string self.stats = { 'original': {int(k): v for k, v in self.stats['original'].iteritems()}, 'revised': {int(k): v for k, v in self.stats['revised'].iteritems()} } return self.stats @XBlock.json_handler def get_stats(self, data, suffix=''): """ Get the progress status for current user Args: data (dict): no input required suffix (str): not sure Return: dict: current progress status """ return self.get_current_stats() @XBlock.json_handler def submit_answer(self, data, suffix=''): """ Answer submission handler to process the student answers """ # convert key into integers as json.dump and json.load convert integer dictionary key into string self.sys_selected_answers = { int(k): v for k, v in self.sys_selected_answers.items() } return self.get_persisted_data( self.record_response(data['q'], data['rationale'], data['status'])) def get_persisted_data(self, other_answers): """ Formats a usable dict based on what data the user has persisted Adds the other answers and correct answer/rationale when needed """ answers = self.get_answers_for_student() ret = { "answer_original": answers.get_vote(0), "rationale_original": answers.get_rationale(0), "answer_revised": answers.get_vote(1), "rationale_revised": answers.get_rationale(1), } if answers.has_revision(0) and not answers.has_revision(1): ret['other_answers'] = other_answers # reveal the correct answer in the end if answers.has_revision(1): ret['correct_answer'] = self.correct_answer ret['correct_rationale'] = self.correct_rationale return ret @XBlock.json_handler def get_data(self, data, suffix=''): """ Retrieve persisted date from backend for current user """ return self.get_persisted_data(self.other_answers_shown) def get_answers_for_student(self): """ Retrieve answers from backend for current user """ return sas_api.get_answers_for_student(self.get_student_item_dict()) @XBlock.json_handler def validate_form(self, data, suffix=''): """ Validate edit form from studio. This will check if all the parameters set up for peer instruction question satisfy all the constrains defined by the algorithm. E.g. we need at least one seed for each option for simple algorithm. Args: data (dict): form data suffix (str): not sure Returns: dict: {success: true} if there is no problem Raises: JsonHandlerError: with 400 error code, if there is any problem. This is necessary for angular async form validation to be able to tell if the async validation success or failed """ msg = validate_seeded_answers(data['seeds'], data['options'], data['algo']) options_msg = validate_options(data) if msg is None and options_msg is None: return {'success': 'true'} else: msg = msg if msg else {} options_msg = options_msg if options_msg else {} msg.update(options_msg) raise JsonHandlerError(400, msg) @classmethod def workbench_scenarios(cls): # pragma: no cover """A canned scenario for display in the workbench.""" return [ ("UBC Peer Instruction: Basic", cls.resource_string('static/xml/basic_scenario.xml')), ] @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """ Instantiate XBlock object from runtime XML definition. Inherited from XBlock core. """ config = parse_from_xml(node) block = runtime.construct_xblock_from_class(cls, keys) # TODO: more validation for key, value in config.iteritems(): setattr(block, key, value) return block def add_xml_to_node(self, node): """ Serialize the XBlock to XML for exporting. """ serialize_to_xml(node, self)
class SecondMixin(XBlockMixin): """Test class for mixin ordering.""" number = 2 field = Integer(default=2)
class TestIntegerXblock(XBlock): counter = Integer(scope=Scope.content)
class OpenAssessmentBlock( MessageMixin, SubmissionMixin, PeerAssessmentMixin, SelfAssessmentMixin, StudioMixin, GradeMixin, LeaderboardMixin, StaffAreaMixin, WorkflowMixin, StudentTrainingMixin, LmsCompatibilityMixin, XBlock, ): """Displays a prompt and provides an area where students can compose a response.""" submission_start = String( default=DEFAULT_START, scope=Scope.settings, help="ISO-8601 formatted string representing the submission start date." ) submission_due = String( default=DEFAULT_DUE, scope=Scope.settings, help="ISO-8601 formatted string representing the submission due date.") allow_file_upload = Boolean( default=None, scope=Scope.content, help="Do not use. For backwards compatibility only.") file_upload_type_raw = String( default=None, scope=Scope.content, help= "File upload to be included with submission (can be 'image', 'pdf-and-image', or 'custom')." ) white_listed_file_types = List( default=[], scope=Scope.content, help="Custom list of file types allowed with submission.") allow_latex = Boolean(default=False, scope=Scope.settings, help="Latex rendering allowed with submission.") title = String(default="", scope=Scope.content, help="A title to display to a student (plain text).") leaderboard_show = Integer( default=0, scope=Scope.content, help="The number of leaderboard results to display (0 if none)") prompt = String(default=DEFAULT_PROMPT, scope=Scope.content, help="The prompts to display to a student.") rubric_criteria = List( default=DEFAULT_RUBRIC_CRITERIA, scope=Scope.content, help="The different parts of grading for students giving feedback.") rubric_feedback_prompt = String( default=DEFAULT_RUBRIC_FEEDBACK_PROMPT, scope=Scope.content, help="The rubric feedback prompt displayed to the student") rubric_feedback_default_text = String( default=DEFAULT_RUBRIC_FEEDBACK_TEXT, scope=Scope.content, help="The default rubric feedback text displayed to the student") rubric_assessments = List( default=DEFAULT_ASSESSMENT_MODULES, scope=Scope.content, help= "The requested set of assessments and the order in which to apply them." ) course_id = String( default=u"TestCourse", scope=Scope.content, help= "The course_id associated with this prompt (until we can get it from runtime)." ) submission_uuid = String( default=None, scope=Scope.user_state, help="The student's submission that others will be assessing.") has_saved = Boolean( default=False, scope=Scope.user_state, help="Indicates whether the user has saved a response.") saved_response = String( default=u"", scope=Scope.user_state, help="Saved response submission for the current user.") no_peers = Boolean( default=False, scope=Scope.user_state, help="Indicates whether or not there are peers to grade.") @property def course_id(self): return self._serialize_opaque_key(self.xmodule_runtime.course_id) # pylint:disable=E1101 @property def file_upload_type(self): """ Backward compatibility for existing block before the change from allow_file_upload to file_upload_type_raw. This property will use new file_upload_type_raw field when available, otherwise will fall back to allow_file_upload field for old blocks. """ if self.file_upload_type_raw is not None: return self.file_upload_type_raw if self.allow_file_upload: return 'image' else: return None @file_upload_type.setter def file_upload_type(self, value): """ Setter for file_upload_type_raw """ self.file_upload_type_raw = value @property def white_listed_file_types_string(self): """ Join the white listed file types into comma delimited string """ if self.white_listed_file_types: return ','.join(self.white_listed_file_types) else: return '' @white_listed_file_types_string.setter def white_listed_file_types_string(self, value): """ Convert comma delimited white list string into list with some clean up """ self.white_listed_file_types = [ file_type.strip().strip('.').lower() for file_type in value.split(',') ] if value else None def get_anonymous_user_id(self, username, course_id): """ Get the anonymous user id from Xblock user service. Args: username(str): user's name entered by staff to get info. course_id(str): course id. Returns: A unique id for (user, course) pair """ return self.runtime.service(self, 'user').get_anonymous_user_id( username, course_id) def get_student_item_dict(self, anonymous_user_id=None): """Create a student_item_dict from our surrounding context. See also: submissions.api for details. Args: anonymous_user_id(str): A unique anonymous_user_id for (user, course) pair. Returns: (dict): The student item associated with this XBlock instance. This includes the student id, item id, and course id. """ item_id = self._serialize_opaque_key(self.scope_ids.usage_id) # This is not the real way course_ids should work, but this is a # temporary expediency for LMS integration if hasattr(self, "xmodule_runtime"): course_id = self.course_id # pylint:disable=E1101 if anonymous_user_id: student_id = anonymous_user_id else: student_id = self.xmodule_runtime.anonymous_student_id # pylint:disable=E1101 else: course_id = "edX/Enchantment_101/April_1" if self.scope_ids.user_id is None: student_id = None else: student_id = unicode(self.scope_ids.user_id) student_item_dict = dict(student_id=student_id, item_id=item_id, course_id=course_id, item_type='openassessment') return student_item_dict def add_javascript_files(self, fragment, item): """ Add all the JavaScript files from a directory to the specified fragment """ if pkg_resources.resource_isdir(__name__, item): for child_item in pkg_resources.resource_listdir(__name__, item): path = os.path.join(item, child_item) if not pkg_resources.resource_isdir(__name__, path): fragment.add_javascript_url( self.runtime.local_resource_url(self, path)) else: fragment.add_javascript_url( self.runtime.local_resource_url(self, item)) def student_view(self, context=None): """The main view of OpenAssessmentBlock, displayed when viewing courses. The main view which displays the general layout for Open Ended Assessment Questions. The contents of the XBlock are determined dynamically based on the assessment workflow configured by the author. Args: context: Not used for this view. Returns: (Fragment): The HTML Fragment for this XBlock, which determines the general frame of the Open Ended Assessment Question. """ # On page load, update the workflow status. # We need to do this here because peers may have graded us, in which # case we may have a score available. try: self.update_workflow_status() except AssessmentWorkflowError: # Log the exception, but continue loading the page logger.exception( 'An error occurred while updating the workflow on page load.') ui_models = self._create_ui_models() # All data we intend to pass to the front end. context_dict = { "title": self.title, "prompts": self.prompts, "rubric_assessments": ui_models, "show_staff_area": self.is_course_staff and not self.in_studio_preview, } template = get_template("openassessmentblock/oa_base.html") context = Context(context_dict) fragment = Fragment(template.render(context)) i18n_service = self.runtime.service(self, 'i18n') if hasattr(i18n_service, 'get_language_bidi') and i18n_service.get_language_bidi(): css_url = "static/css/openassessment-rtl.css" else: css_url = "static/css/openassessment-ltr.css" if settings.DEBUG: fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) self.add_javascript_files(fragment, "static/js/src/oa_shared.js") self.add_javascript_files(fragment, "static/js/src/oa_server.js") self.add_javascript_files(fragment, "static/js/src/lms") else: # TODO: load CSS and JavaScript as URLs once they can be served by the CDN fragment.add_css(load(css_url)) fragment.add_javascript( load("static/js/openassessment-lms.min.js")) js_context_dict = { "ALLOWED_IMAGE_MIME_TYPES": self.ALLOWED_IMAGE_MIME_TYPES, "ALLOWED_FILE_MIME_TYPES": self.ALLOWED_FILE_MIME_TYPES, "FILE_EXT_BLACK_LIST": self.FILE_EXT_BLACK_LIST, "FILE_TYPE_WHITE_LIST": self.white_listed_file_types, } fragment.initialize_js('OpenAssessmentBlock', js_context_dict) return fragment @property def is_admin(self): """ Check whether the user has global staff permissions. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_admin', False) else: return False @property def is_course_staff(self): """ Check whether the user has course staff permissions for this XBlock. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_staff', False) else: return False @property def is_beta_tester(self): """ Check whether the user is a beta tester. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_beta_tester', False) else: return False @property def in_studio_preview(self): """ Check whether we are in Studio preview mode. Returns: bool """ # When we're running in Studio Preview mode, the XBlock won't provide us with a user ID. # (Note that `self.xmodule_runtime` will still provide an anonymous # student ID, so we can't rely on that) return self.scope_ids.user_id is None def _create_ui_models(self): """Combine UI attributes and XBlock configuration into a UI model. This method takes all configuration for this XBlock instance and appends UI attributes to create a UI Model for rendering all assessment modules. This allows a clean separation of static UI attributes from persistent XBlock configuration. """ ui_models = [UI_MODELS["submission"]] for assessment in self.valid_assessments: ui_model = UI_MODELS.get(assessment["name"]) if ui_model: ui_models.append(dict(assessment, **ui_model)) ui_models.append(UI_MODELS["grade"]) if self.leaderboard_show > 0: ui_models.append(UI_MODELS["leaderboard"]) return ui_models @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench. These scenarios are only intended to be used for Workbench XBlock Development. """ return [ ("OpenAssessmentBlock File Upload: Images", load('static/xml/file_upload_image_only.xml')), ("OpenAssessmentBlock File Upload: PDF and Images", load('static/xml/file_upload_pdf_and_image.xml')), ("OpenAssessmentBlock File Upload: Custom File Types", load('static/xml/file_upload_custom.xml')), ("OpenAssessmentBlock File Upload: allow_file_upload compatibility", load('static/xml/file_upload_compat.xml')), ("OpenAssessmentBlock Unicode", load('static/xml/unicode.xml')), ("OpenAssessmentBlock Example Based Rubric", load('static/xml/example_based_example.xml')), ("OpenAssessmentBlock Poverty Rubric", load('static/xml/poverty_rubric_example.xml')), ("OpenAssessmentBlock Leaderboard", load('static/xml/leaderboard.xml')), ("OpenAssessmentBlock Leaderboard with Custom File Type", load('static/xml/leaderboard_custom.xml')), ("OpenAssessmentBlock (Peer Only) Rubric", load('static/xml/poverty_peer_only_example.xml')), ("OpenAssessmentBlock (Self Only) Rubric", load('static/xml/poverty_self_only_example.xml')), ("OpenAssessmentBlock Censorship Rubric", load('static/xml/censorship_rubric_example.xml')), ("OpenAssessmentBlock Promptless Rubric", load('static/xml/promptless_rubric_example.xml')), ] @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """Instantiate XBlock object from runtime XML definition. Inherited by XBlock core. """ config = parse_from_xml(node) block = runtime.construct_xblock_from_class(cls, keys) xblock_validator = validator(block, block._, strict_post_release=False) xblock_validator(create_rubric_dict(config['prompts'], config['rubric_criteria']), config['rubric_assessments'], submission_start=config['submission_start'], submission_due=config['submission_due'], leaderboard_show=config['leaderboard_show']) block.rubric_criteria = config['rubric_criteria'] block.rubric_feedback_prompt = config['rubric_feedback_prompt'] block.rubric_feedback_default_text = config[ 'rubric_feedback_default_text'] block.rubric_assessments = config['rubric_assessments'] block.submission_start = config['submission_start'] block.submission_due = config['submission_due'] block.title = config['title'] block.prompts = config['prompts'] block.allow_file_upload = config['allow_file_upload'] block.file_upload_type = config['file_upload_type'] block.white_listed_file_types_string = config[ 'white_listed_file_types'] block.allow_latex = config['allow_latex'] block.leaderboard_show = config['leaderboard_show'] return block @property def _(self): i18nService = self.runtime.service(self, 'i18n') return i18nService.ugettext @property def prompts(self): """ Return the prompts. Initially a block had a single prompt which was saved as a simple string in the prompt field. Now prompts are saved as a serialized list of dicts in the same field. If prompt field contains valid json, parse and return it. Otherwise, assume it is a simple string prompt and return it in a list of dict. Returns: list of dict """ return create_prompts_list(self.prompt) @prompts.setter def prompts(self, value): """ Serialize the prompts and save to prompt field. Args: value (list of dict): The prompts to set. """ if value is None: self.prompt = None elif len(value) == 1: # For backwards compatibility. To be removed after all code # is migrated to use prompts property instead of prompt field. self.prompt = value[0]['description'] else: self.prompt = json.dumps(value) @property def valid_assessments(self): """ Return a list of assessment dictionaries that we recognize. This allows us to gracefully handle situations in which unrecognized assessment types are stored in the XBlock field (e.g. because we roll back code after releasing a feature). Returns: list """ _valid_assessments = [ asmnt for asmnt in self.rubric_assessments if asmnt.get('name') in VALID_ASSESSMENT_TYPES ] return update_assessments_format(copy.deepcopy(_valid_assessments)) @property def assessment_steps(self): return [asmnt['name'] for asmnt in self.valid_assessments] @lazy def rubric_criteria_with_labels(self): """ Backwards compatibility: We used to treat "name" as both a user-facing label and a unique identifier for criteria and options. Now we treat "name" as a unique identifier, and we've added an additional "label" field that we display to the user. If criteria/options in the problem definition do NOT have a "label" field (because they were created before this change), we create a new label that has the same value as "name". The result of this call is cached, so it should NOT be used in a runtime that can modify the XBlock settings (in the LMS, settings are read-only). Returns: list of criteria dictionaries """ criteria = copy.deepcopy(self.rubric_criteria) for criterion in criteria: if 'label' not in criterion: criterion['label'] = criterion['name'] for option in criterion['options']: if 'label' not in option: option['label'] = option['name'] return criteria def render_assessment(self, path, context_dict=None): """Render an Assessment Module's HTML Given the name of an assessment module, find it in the list of configured modules, and ask for its rendered HTML. Args: path (str): The path to the template used to render this HTML section. context_dict (dict): A dictionary of context variables used to populate this HTML section. Returns: (Response): A Response Object with the generated HTML fragment. This is intended for AJAX calls to load dynamically into a larger document. """ if not context_dict: context_dict = {} template = get_template(path) context = Context(context_dict) return Response(template.render(context), content_type='application/html', charset='UTF-8') def add_xml_to_node(self, node): """ Serialize the XBlock to XML for exporting. """ serialize_content_to_xml(self, node) def render_error(self, error_msg): """ Render an error message. Args: error_msg (unicode): The error message to display. Returns: Response: A response object with an HTML body. """ context = Context({'error_msg': error_msg}) template = get_template('openassessmentblock/oa_error.html') return Response(template.render(context), content_type='application/html', charset='UTF-8') def is_closed(self, step=None, course_staff=None): """ Checks if the question is closed. Determines if the start date is in the future or the end date has passed. Optionally limited to a particular step in the workflow. Start/due dates do NOT apply to course staff, since course staff may need to get to the peer grading step AFTER the submission deadline has passed. This may not be necessary when we implement a grading interface specifically for course staff. Keyword Arguments: step (str): The step in the workflow to check. Options are: None: check whether the problem as a whole is open. "submission": check whether the submission section is open. "peer-assessment": check whether the peer-assessment section is open. "self-assessment": check whether the self-assessment section is open. course_staff (bool): Whether to treat the user as course staff (disable start/due dates). If not specified, default to the current user's status. Returns: tuple of the form (is_closed, reason, start_date, due_date), where is_closed (bool): indicates whether the step is closed. reason (str or None): specifies the reason the step is closed ("start" or "due") start_date (datetime): is the start date of the step/problem. due_date (datetime): is the due date of the step/problem. Examples: >>> is_closed() False, None, datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) >>> is_closed(step="submission") True, "due", datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) >>> is_closed(step="self-assessment") True, "start", datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) """ submission_range = (self.submission_start, self.submission_due) assessment_ranges = [(asmnt.get('start'), asmnt.get('due')) for asmnt in self.valid_assessments] # Resolve unspecified dates and date strings to datetimes start, due, date_ranges = resolve_dates( self.start, self.due, [submission_range] + assessment_ranges, self._) open_range = (start, due) assessment_steps = self.assessment_steps if step == 'submission': open_range = date_ranges[0] elif step in assessment_steps: step_index = assessment_steps.index(step) open_range = date_ranges[1 + step_index] # Course staff always have access to the problem if course_staff is None: course_staff = self.is_course_staff if course_staff: return False, None, DISTANT_PAST, DISTANT_FUTURE if self.is_beta_tester: beta_start = self._adjust_start_date_for_beta_testers( open_range[0]) open_range = (beta_start, open_range[1]) # Check if we are in the open date range now = dt.datetime.utcnow().replace(tzinfo=pytz.utc) if now < open_range[0]: return True, "start", open_range[0], open_range[1] elif now >= open_range[1]: return True, "due", open_range[0], open_range[1] else: return False, None, open_range[0], open_range[1] def get_waiting_details(self, status_details): """ Returns the specific waiting status based on the given status_details. This status can currently be peer, example-based, or both. This is determined by checking that status details to see if all assessment modules have been graded. Args: status_details (dict): A dictionary containing the details of each assessment module status. This will contain keys such as "peer" and "ai", referring to dictionaries, which in turn will have the key "graded". If this key has a value set, these assessment modules have been graded. Returns: A string of "peer", "exampled-based", or "all" to indicate which assessment modules in the workflow are waiting on assessments. Returns None if no module is waiting on an assessment. Examples: >>> now = dt.datetime.utcnow().replace(tzinfo=pytz.utc) >>> status_details = { >>> 'peer': { >>> 'completed': None, >>> 'graded': now >>> }, >>> 'ai': { >>> 'completed': now, >>> 'graded': None >>> } >>> } >>> self.get_waiting_details(status_details) "peer" """ waiting = None peer_waiting = "peer" in status_details and not status_details["peer"][ "graded"] ai_waiting = "ai" in status_details and not status_details["ai"][ "graded"] if peer_waiting and ai_waiting: waiting = "all" elif peer_waiting: waiting = "peer" elif ai_waiting: waiting = "example-based" return waiting def is_released(self, step=None): """ Check if a question has been released. Keyword Arguments: step (str): The step in the workflow to check. None: check whether the problem as a whole is open. "submission": check whether the submission section is open. "peer-assessment": check whether the peer-assessment section is open. "self-assessment": check whether the self-assessment section is open. Returns: bool """ # By default, assume that we're published, in case the runtime doesn't support publish date. if hasattr(self.runtime, 'modulestore'): is_published = self.runtime.modulestore.has_published_version(self) else: is_published = True is_closed, reason, __, __ = self.is_closed(step=step) return is_published and (not is_closed or reason == 'due') def get_assessment_module(self, mixin_name): """ Get a configured assessment module by name. Args: mixin_name (str): The name of the mixin (e.g. "self-assessment" or "peer-assessment") Returns: dict Example: >>> self.get_assessment_module('peer-assessment') { "name": "peer-assessment", "start": None, "due": None, "must_grade": 5, "must_be_graded_by": 3, } """ for assessment in self.valid_assessments: if assessment["name"] == mixin_name: return assessment def publish_assessment_event(self, event_name, assessment): """ Emit an analytics event for the peer assessment. Args: event_name (str): An identifier for this event type. assessment (dict): The serialized assessment model. Returns: None """ parts_list = [] for part in assessment["parts"]: # Some assessment parts do not include point values, # only written feedback. In this case, the assessment # part won't have an associated option. option_dict = None if part["option"] is not None: option_dict = { "name": part["option"]["name"], "points": part["option"]["points"], } # All assessment parts are associated with criteria criterion_dict = { "name": part["criterion"]["name"], "points_possible": part["criterion"]["points_possible"] } parts_list.append({ "option": option_dict, "criterion": criterion_dict, "feedback": part["feedback"] }) self.runtime.publish( self, event_name, { "feedback": assessment["feedback"], "rubric": { "content_hash": assessment["rubric"]["content_hash"], }, "scorer_id": assessment["scorer_id"], "score_type": assessment["score_type"], "scored_at": assessment["scored_at"], "submission_uuid": assessment["submission_uuid"], "parts": parts_list }) def _serialize_opaque_key(self, key): """ Gracefully handle opaque keys, both before and after the transition. https://github.com/edx/edx-platform/wiki/Opaque-Keys Currently uses `to_deprecated_string()` to ensure that new keys are backwards-compatible with keys we store in ORA2 database models. Args: key (unicode or OpaqueKey subclass): The key to serialize. Returns: unicode """ if hasattr(key, 'to_deprecated_string'): return key.to_deprecated_string() else: return unicode(key) def get_username(self, anonymous_user_id): if hasattr(self, "xmodule_runtime"): return self.xmodule_runtime.get_real_user( anonymous_user_id).username def _adjust_start_date_for_beta_testers(self, start): if hasattr(self, "xmodule_runtime"): days_early_for_beta = getattr(self.xmodule_runtime, 'days_early_for_beta', 0) if days_early_for_beta is not None: delta = dt.timedelta(days_early_for_beta) effective = start - delta return effective return start
class PollBase(XBlock, ResourceMixin, PublishEventMixin): """ Base class for Poll-like XBlocks. """ event_namespace = 'xblock.pollbase' private_results = Boolean( default=False, help="Whether or not to display results to the user.") max_submissions = Integer( default=1, help="The maximum number of times a user may send a submission.") submissions_count = Integer( default=0, help="Number of times the user has sent a submission.", scope=Scope.user_state) feedback = String(default='', help="Text to display after the user votes.") def send_vote_event(self, choice_data): # Let the LMS know the user has answered the poll. self.runtime.publish(self, 'progress', {}) self.runtime.publish(self, 'grade', { 'value': 1, 'max_value': 1, }) # The SDK doesn't set url_name. event_dict = {'url_name': getattr(self, 'url_name', '')} event_dict.update(choice_data) self.publish_event_from_dict( self.event_namespace + '.submitted', event_dict, ) @staticmethod def any_image(field): """ Find out if any answer has an image, since it affects layout. """ return any(value['img'] for value in dict(field).values()) @staticmethod def markdown_items(items): """ Convert all items' labels into markdown. """ return [[ key, { 'label': markdown(value['label']), 'img': value['img'] } ] for key, value in items] @staticmethod def gather_items(data, result, noun, field, image=True): """ Gathers a set of label-img pairs from a data dict and puts them in order. """ items = [] if field not in data or not isinstance(data[field], list): source_items = [] result['success'] = False result['errors'].append( "'{0}' is not present, or not a JSON array.".format(field)) else: source_items = data[field] # Make sure all components are present and clean them. for item in source_items: if not isinstance(item, dict): result['success'] = False result['errors'].append( "{0} {1} not a javascript object!".format(noun, item)) continue key = item.get('key', '').strip() if not key: result['success'] = False result['errors'].append("{0} {1} contains no key.".format( noun, item)) image_link = item.get('img', '').strip() label = item.get('label', '').strip() if not label: if image and not image_link: result['success'] = False result['errors'].append( "{0} has no text or img. Please make sure all {1}s " "have one or the other, or both.".format( noun, noun.lower())) elif not image: result['success'] = False # If there's a bug in the code or the user just forgot to relabel a question, # votes could be accidentally lost if we assume the omission was an # intended deletion. result['errors'].append( "{0} was added with no label. " "All {1}s must have labels. Please check the form. " "Check the form and explicitly delete {1}s " "if not needed.".format(noun, noun.lower())) if image: # Labels might have prefixed space for markdown, though it's unlikely. items.append((key, { 'label': label, 'img': image_link.strip() })) else: items.append([key, label]) if not items: result['errors'].append( "You must include at least one {0}.".format(noun.lower())) result['success'] = False return items def can_vote(self): """ Checks to see if the user is permitted to vote. This may not be the case if they used up their max_submissions. """ if self.max_submissions == 0: return True if self.max_submissions > self.submissions_count: return True return False def can_view_private_results(self): """ Checks to see if the user has permissions to view private results. This only works inside the LMS. """ if HAS_EDX_ACCESS and hasattr(self.runtime, 'user') and hasattr( self.runtime, 'course_id'): # Course staff users have permission to view results. if has_access(self.runtime.user, 'staff', self, self.runtime.course_id): return True else: # Check if user is member of a group that is explicitly granted # permission to view the results through django configuration. group_names = getattr(settings, 'XBLOCK_POLL_EXTRA_VIEW_GROUPS', []) if group_names: group_ids = self.runtime.user.groups.values_list('id', flat=True) return GroupProfile.objects.filter( group_id__in=group_ids, name__in=group_names).exists() else: return False @staticmethod def get_max_submissions(data, result, private_results): """ Gets the value of 'max_submissions' from studio submitted AJAX data, and checks for conflicts with private_results, which may not be False when max_submissions is not 1, since that would mean the student could change their answer based on other students' answers. """ try: max_submissions = int(data['max_submissions']) except (ValueError, KeyError): max_submissions = 1 result['success'] = False result['errors'].append( 'Maximum Submissions missing or not an integer.') # Better to send an error than to confuse the user by thinking this would work. if (max_submissions != 1) and not private_results: result['success'] = False result['errors'].append( "Private results may not be False when Maximum Submissions is not 1." ) return max_submissions
class ThumbsBlockBase(object): """ An XBlock with thumbs-up/thumbs-down voting. Vote totals are stored for all students to see. Each student is recorded as has-voted or not. This demonstrates multiple data scopes and ajax handlers. """ upvotes = Integer(help="Number of up votes", default=0, scope=Scope.user_state_summary) downvotes = Integer(help="Number of down votes", default=0, scope=Scope.user_state_summary) voted = Boolean(help="Has this student voted?", default=False, scope=Scope.user_state) def student_view(self, context=None): # pylint: disable=W0613 """ Create a fragment used to display the XBlock to a student. `context` is a dictionary used to configure the display (unused) Returns a `Fragment` object specifying the HTML, CSS, and JavaScript to display. """ # Load the HTML fragment from within the package and fill in the template html_str = pkg_resources.resource_string(__name__, "static/html/thumbs.html") frag = Fragment(unicode(html_str).format(self=self)) # Load the CSS and JavaScript fragments from within the package css_str = pkg_resources.resource_string(__name__, "static/css/thumbs.css") frag.add_css(unicode(css_str)) js_str = pkg_resources.resource_string(__name__, "static/js/src/thumbs.js") frag.add_javascript(unicode(js_str)) frag.initialize_js('ThumbsBlock') return frag problem_view = student_view @XBlock.json_handler def vote(self, data, suffix=''): # pylint: disable=unused-argument """ Update the vote count in response to a user action. """ # Here is where we would prevent a student from voting twice, but then # we couldn't click more than once in the demo! # # if self.voted: # log.error("cheater!") # return if data['voteType'] not in ('up', 'down'): log.error('error!') return if data['voteType'] == 'up': self.upvotes += 1 else: self.downvotes += 1 self.voted = True return {'up': self.upvotes, 'down': self.downvotes} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [("three thumbs at once", """\ <vertical_demo> <thumbs/> <thumbs/> <thumbs/> </vertical_demo> """)]
class FlowCheckPointXblock(StudioEditableXBlockMixin, XBlock): """ FlowCheckPointXblock allows to take different learning paths based on a certain condition status """ display_name = String(display_name="Display Name", scope=Scope.settings, default="Flow Control") action = String(display_name="Action", help="Select the action to be performed " "when the condition is met", scope=Scope.content, default="display_message", values_provider=_actions_generator) condition = String(display_name="Flow control condition", help="Select a conditon to evaluate", scope=Scope.content, default='single_problem', values_provider=_conditions_generator) operator = String(display_name="Comparison type", help="Select an operator for the condition", scope=Scope.content, default='eq', values_provider=_operators_generator) ref_value = Integer(help="Enter the value to be used in " "the comparison. (From 0 to 100)", default=0, scope=Scope.content, display_name="Score percentage") tab_to = Integer(help="Number of unit tab to redirect to. (1, 2, 3...)", default=1, scope=Scope.content, display_name="Tab to redirect to") target_url = String(help="URL to redirect to, supports relative " "or absolute urls", scope=Scope.content, display_name="URL to redirect to") target_id = String(help="Unit identifier to redirect to (Location id)", scope=Scope.content, display_name="Unit identifier to redirect to") message = String(help="Message for the learners to view " "when the condition is met", scope=Scope.content, default='', display_name="Message", multiline_editor='html') problem_id = String(help="Problem id to use for the condition. (Not the " "complete problem locator. Only the 32 characters " "alfanumeric id. " "Example: 618c5933b8b544e4a4cc103d3e508378)", scope=Scope.content, display_name="Problem id") list_of_problems = String(help="List of problems ids separated by commas " "or line breaks. (Not the complete problem " "locators. Only the 32 characters alfanumeric " "ids. Example: 618c5933b8b544e4a4cc103d3e508378" ", 905333bd98384911bcec2a94bc30155f). " "The simple average score for all problems will " "be used.", scope=Scope.content, display_name="List of problems", multiline_editor=True, resettable_editor=False) editable_fields = ('condition', 'problem_id', 'list_of_problems', 'operator', 'ref_value', 'action', 'tab_to', 'target_url', 'target_id', 'message') def validate_field_data(self, validation, data): """ Validate this block's field data """ if data.tab_to <= 0: validation.add( ValidationMessage( ValidationMessage.ERROR, u"Tab to redirect to must be greater than zero")) if data.ref_value < 0 or data.ref_value > 100: validation.add( ValidationMessage( ValidationMessage.ERROR, u"Score percentage field must " u"be an integer number between 0 and 100")) def get_location_string(self, locator, is_draft=False): """ Returns the location string for one problem, given its id """ # pylint: disable=no-member course_prefix = 'course' resource = 'problem' course_url = self.course_id.to_deprecated_string() if is_draft: course_url = course_url.split(self.course_id.run)[0] prefix = 'i4x://' location_string = '{prefix}{couse_str}{type_id}/{locator}'.format( prefix=prefix, couse_str=course_url, type_id=resource, locator=locator) else: course_url = course_url.replace(course_prefix, '', 1) location_string = '{prefix}{couse_str}+{type}@{type_id}+{prefix}@{locator}'.format( prefix=self.course_id.BLOCK_PREFIX, couse_str=course_url, type=self.course_id.BLOCK_TYPE_PREFIX, type_id=resource, locator=locator) return location_string def get_condition_status(self): """ Returns the current condition status """ condition_reached = False problems = [] if self.problem_id and self.condition == 'single_problem': # now split problem id by spaces or commas problems = re.split('\s*,*|\s*,\s*', self.problem_id) problems = filter(None, problems) problems = problems[:1] if self.list_of_problems and self.condition == 'average_problems': # now split list of problems id by spaces or commas problems = re.split('\s*,*|\s*,\s*', self.list_of_problems) problems = filter(None, problems) if problems: condition_reached = self.condition_on_problem_list(problems) return condition_reached def student_view(self, context=None): # pylint: disable=unused-argument """ Returns a fragment for student view """ fragment = Fragment(u"<!-- This is the FlowCheckPointXblock -->") fragment.add_javascript(load("static/js/injection.js")) # helper variables # pylint: disable=no-member in_studio_runtime = hasattr(self.xmodule_runtime, 'is_author_mode') index_base = 1 default_tab = 'tab_{}'.format(self.tab_to - index_base) fragment.initialize_js('FlowControlGoto', json_args={ "display_name": self.display_name, "default": default_tab, "default_tab_id": self.tab_to, "action": self.action, "target_url": self.target_url, "target_id": self.target_id, "message": self.message, "in_studio_runtime": in_studio_runtime }) return fragment @XBlock.json_handler def condition_status_handler(self, data, suffix=''): # pylint: disable=unused-argument """ Returns the actual condition state """ return {'success': True, 'status': self.get_condition_status()} def author_view(self, context=None): # pylint: disable=unused-argument, no-self-use """ Returns author view fragment on Studio """ # creating xblock fragment # TO-DO display for studio with setting resume fragment = Fragment(u"<!-- This is the studio -->") fragment.add_javascript(load("static/js/injection.js")) fragment.initialize_js('StudioFlowControl') return fragment def studio_view(self, context=None): """ Returns studio view fragment """ fragment = super(FlowCheckPointXblock, self).studio_view(context=context) # We could also move this function to a different file fragment.add_javascript(load("static/js/injection.js")) fragment.initialize_js('EditFlowControl') return fragment def compare_scores(self, correct, total): """ Returns the result of comparison using custom operator """ result = False if total: # getting percentage score for that section percentage = (correct / total) * 100 if self.operator == 'eq': result = percentage == self.ref_value if self.operator == 'noeq': result = percentage != self.ref_value if self.operator == 'lte': result = percentage <= self.ref_value if self.operator == 'gte': result = percentage >= self.ref_value if self.operator == 'lt': result = percentage < self.ref_value if self.operator == 'gt': result = percentage > self.ref_value return result def are_all_not_null(self, problems_to_answer): """ Returns true when all problems have been answered """ result = False all_problems_were_answered = n_all(problems_to_answer) if problems_to_answer and all_problems_were_answered: result = True return result def has_null(self, problems_to_answer): """ Returns true when at least one problem have not been answered """ result = False all_problems_were_answered = n_all(problems_to_answer) if not problems_to_answer or not all_problems_were_answered: result = True return result def are_all_null(self, problems_to_answer): """ Returns true when all problems have not been answered """ for element in problems_to_answer: if element is not None: return False return True SPECIAL_COMPARISON_DISPATCHER = { 'all_not_null': are_all_not_null, 'all_null': are_all_null, 'has_null': has_null } def condition_on_problem_list(self, problems): """ Returns the score for a list of problems """ # pylint: disable=no-member user_id = self.xmodule_runtime.user_id scores_client = ScoresClient(self.course_id, user_id) correct_neutral = {'correct': 0.0} total_neutral = {'total': 0.0} total = 0 correct = 0 def _get_usage_key(problem): loc = self.get_location_string(problem) try: uk = UsageKey.from_string(loc) except InvalidKeyError: uk = _get_draft_usage_key(problem) return uk def _get_draft_usage_key(problem): loc = self.get_location_string(problem, True) try: uk = UsageKey.from_string(loc) uk = uk.map_into_course(self.course_id) except InvalidKeyError: uk = None return uk def _to_reducible(score): correct_default = 0.0 total_default = 1.0 if not score.total: return {'correct': correct_default, 'total': total_default} else: return {'correct': score.correct, 'total': score.total} def _calculate_correct(first_score, second_score): correct = first_score['correct'] + second_score['correct'] return {'correct': correct} def _calculate_total(first_score, second_score): total = first_score['total'] + second_score['total'] return {'total': total} usages_keys = map(_get_usage_key, problems) scores_client.fetch_scores(usages_keys) scores = map(scores_client.get, usages_keys) scores = filter(None, scores) problems_to_answer = [score.total for score in scores] if self.operator in self.SPECIAL_COMPARISON_DISPATCHER.keys(): evaluation = self.SPECIAL_COMPARISON_DISPATCHER[self.operator]( self, problems_to_answer) return evaluation reducible_scores = map(_to_reducible, scores) correct = reduce(_calculate_correct, reducible_scores, correct_neutral) total = reduce(_calculate_total, reducible_scores, total_neutral) return self.compare_scores(correct['correct'], total['total'])
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_frag_resources(frag) named_child_frags.append((child.name, frag)) result.add_css(""" .problem { border: solid 1px #888; padding: 3px; } """) result.add_content( self.runtime.render_template("problem.html", named_children=named_child_frags)) result.add_javascript(""" 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( "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 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 Goss92XBlock(ScorableXBlockMixin, XBlock): """ XBlock checks if a certain URL returns what is expected """ # Fields are defined on the class. You can access them in your code as # self.<fieldname>. #package = __package__ always_recalculate_grades = True score2 = Integer( default=0, scope=Scope.user_state, help="An indicator of success", ) 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 has_submitted_answer(self): """ Returns True if the user has made a submission. """ return self.fields['score2'].is_set_on(self) def max_score(self): # pylint: disable=no-self-use """ Return the problem's max score Required by the grading system in the LMS. """ return 1 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() #score.raw_earned = 1/2 self.score2 = 1 #score.raw_earned def get_score(self): """ Return the problem's current score as raw values. """ return Score(1, self.max_score()) 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(1, self.max_score()) # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view, shown to students when viewing courses. """ user_service = self.runtime.service(self, 'user') xb_user = user_service.get_current_user() CURRENT = xb_user.opt_attrs.get('edx-platform.username') html = self.resource_string("static/html/gossxblock.html") frag = Fragment(html.format(self=self)) res0 = textwrap.dedent(""" <p id='goss_hidden'><span id="gosscurrent">{}</span></p> """).format(CURRENT) frag.add_content(SafeText(res0)) HTMLURL = 'https://node-server.online/r/assets/x92.html' if sys.version_info.major >= 3: response = urlopen(HTMLURL) encoding = response.info().get_content_charset('utf-8') html_data = response.read().decode(encoding) else: html_data = urlopen(HTMLURL).read() res = textwrap.dedent(html_data) frag.add_content(SafeText(res)) frag.add_css(self.resource_string("static/css/gossxblock.css")) frag.add_javascript( self.resource_string("static/js/src/goss92xblock.js")) frag.initialize_js('Goss92XBlock') return frag @XBlock.json_handler def set_score2(self, data, suffix=''): """ An example handler, which increments the data. """ # indicator is now 100... if data['key'] == 'hundred': self.score2 = 1 else: self.score2 = 0 self._publish_grade(Score(self.score2, self.max_score())) return {"score": self.score2} # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("goss92XBlock", """<problem/> """), ("Multiple goss92XBlock", """<vertical_demo> <goss92xblock/> <goss92xblock/> <goss92xblock/> </vertical_demo> """), ]
class FirstMixin(XBlockMixin): """Test class for mixin ordering.""" number = 1 field = Integer(default=1)
class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): """ This XBlock provides an LTI consumer interface for integrating third-party tools using the LTI specification. Except usual Xmodule structure it proceeds with OAuth signing. How it works:: 1. Get credentials from course settings. 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: user_id oauth_callback lis_outcome_service_url lis_result_sourcedid launch_presentation_return_url lti_message_type lti_version roles *+ all custom parameters* These parameters should be encoded and signed by *OAuth1* together with `launch_url` and *POST* request type. 3. Signing proceeds with client key/secret pair obtained from course settings. That pair should be obtained from LTI provider and set into course settings by course author. After that signature and other OAuth data are generated. OAuth data which is generated after signing is usual:: oauth_callback oauth_nonce oauth_consumer_key oauth_signature_method oauth_timestamp oauth_version 4. All that data is passed to form and sent to LTI provider server by browser via autosubmit via JavaScript. Form example:: <form action="${launch_url}" name="ltiLaunchForm-${element_id}" class="ltiLaunchForm" method="post" target="ltiLaunchFrame-${element_id}" encType="application/x-www-form-urlencoded" > <input name="launch_presentation_return_url" value="" /> <input name="lis_outcome_service_url" value="" /> <input name="lis_result_sourcedid" value="" /> <input name="lti_message_type" value="basic-lti-launch-request" /> <input name="lti_version" value="LTI-1p0" /> <input name="oauth_callback" value="about:blank" /> <input name="oauth_consumer_key" value="${oauth_consumer_key}" /> <input name="oauth_nonce" value="${oauth_nonce}" /> <input name="oauth_signature_method" value="HMAC-SHA1" /> <input name="oauth_timestamp" value="${oauth_timestamp}" /> <input name="oauth_version" value="1.0" /> <input name="user_id" value="${user_id}" /> <input name="role" value="student" /> <input name="oauth_signature" value="${oauth_signature}" /> <input name="tool_consumer_info_product_family_code" value="openedx" /> <input name="tool_consumer_instance_guid" value="${tool_consumer_instance_guid}" /> <input name="custom_1" value="${custom_param_1_value}" /> <input name="custom_2" value="${custom_param_2_value}" /> <input name="custom_..." value="${custom_param_..._value}" /> <input type="submit" value="Press to Launch" /> </form> 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. If signatures are correct, LTI provider redirects iframe source to LTI tool web page, and LTI tool is rendered to iframe inside course. Otherwise error message from LTI provider is generated. """ display_name = String( display_name=_("Display Name"), help=_( "Enter the name that students see for this component. " "Analytics reports may also use the display name to identify this component." ), scope=Scope.settings, default=_("LTI Consumer"), ) description = String( display_name=_("LTI Application Information"), help=_( "Enter a description of the third party application. " "If requesting username and/or email, use this text box to inform users " "why their username and/or email will be forwarded to a third party application." ), default="", scope=Scope.settings ) lti_id = String( display_name=_("LTI ID"), help=_( "Enter the LTI ID for the external LTI provider. " "This value must be the same LTI ID that you entered in the " "LTI Passports setting on the Advanced Settings page." "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." ).format( docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>" ), default='', scope=Scope.settings ) launch_url = String( display_name=_("LTI URL"), help=_( "Enter the URL of the external tool that this component launches. " "This setting is only used when Hide External Tool is set to False." "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." ).format( docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>" ), default='', scope=Scope.settings ) custom_parameters = List( display_name=_("Custom Parameters"), help=_( "Add the key/value pair for any custom parameters, such as the page your e-book should open to or " "the background color for this component. Ex. [\"page=1\", \"color=white\"]" "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." ).format( docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>" ), scope=Scope.settings ) launch_target = String( display_name=_("LTI Launch Target"), help=_( "Select Inline if you want the LTI content to open in an IFrame in the current page. " "Select Modal if you want the LTI content to open in a modal window in the current page. " "Select New Window if you want the LTI content to open in a new browser window. " "This setting is only used when Hide External Tool is set to False." ), default=LaunchTarget.IFRAME.value, scope=Scope.settings, values=[ {"display_name": LaunchTarget.IFRAME.display_name, "value": LaunchTarget.IFRAME.value}, {"display_name": LaunchTarget.MODAL.display_name, "value": LaunchTarget.MODAL.value}, {"display_name": LaunchTarget.NEW_WINDOW.display_name, "value": LaunchTarget.NEW_WINDOW.value}, ], ) button_text = String( display_name=_("Button Text"), help=_( "Enter the text on the button used to launch the third party application. " "This setting is only used when Hide External Tool is set to False and " "LTI Launch Target is set to Modal or New Window." ), default="", scope=Scope.settings ) inline_height = Integer( display_name=_("Inline Height"), help=_( "Enter the desired pixel height of the iframe which will contain the LTI tool. " "This setting is only used when Hide External Tool is set to False and " "LTI Launch Target is set to Inline." ), default=800, scope=Scope.settings ) modal_height = Integer( display_name=_("Modal Height"), help=_( "Enter the desired viewport percentage height of the modal overlay which will contain the LTI tool. " "This setting is only used when Hide External Tool is set to False and " "LTI Launch Target is set to Modal." ), default=80, scope=Scope.settings ) modal_width = Integer( display_name=_("Modal Width"), help=_( "Enter the desired viewport percentage width of the modal overlay which will contain the LTI tool. " "This setting is only used when Hide External Tool is set to False and " "LTI Launch Target is set to Modal." ), default=80, scope=Scope.settings ) has_score = Boolean( display_name=_("Scored"), help=_("Select True if this component will receive a numerical score from the external LTI system."), default=False, scope=Scope.settings ) weight = Float( display_name="Weight", help=_( "Enter the number of points possible for this component. " "The default value is 1.0. " "This setting is only used when Scored is set to True." ), default=1.0, scope=Scope.settings, values={"min": 0}, ) module_score = Float( help=_("The score kept in the xblock KVS -- duplicate of the published score in django DB"), default=None, scope=Scope.user_state ) score_comment = String( help=_("Comment as returned from grader, LTI2.0 spec"), default="", scope=Scope.user_state ) hide_launch = Boolean( display_name=_("Hide External Tool"), help=_( "Select True if you want to use this component as a placeholder for syncing with an external grading " "system rather than launch an external tool. " "This setting hides the Launch button and any IFrames for this component." ), default=False, scope=Scope.settings ) accept_grades_past_due = Boolean( display_name=_("Accept grades past deadline"), help=_("Select True to allow third party systems to post grades past the deadline."), default=True, scope=Scope.settings ) # Users will be presented with a message indicating that their e-mail/username would be sent to a third # party application. When "Open in New Page" is not selected, the tool automatically appears without any # user action. ask_to_send_username = Boolean( display_name=_("Request user's username"), # Translators: This is used to request the user's username for a third party service. help=_("Select True to request the user's username."), default=False, scope=Scope.settings ) ask_to_send_email = Boolean( display_name=_("Request user's email"), # Translators: This is used to request the user's email for a third party service. help=_("Select True to request the user's email address."), default=False, scope=Scope.settings ) # StudioEditableXBlockMixin configuration of fields editable in Studio editable_fields = ( 'display_name', 'description', 'lti_id', 'launch_url', 'custom_parameters', 'launch_target', 'button_text', 'inline_height', 'modal_height', 'modal_width', 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due', 'ask_to_send_username', 'ask_to_send_email' ) def validate_field_data(self, validation, data): if not isinstance(data.custom_parameters, list): _ = self.runtime.service(self, "i18n").ugettext validation.add(ValidationMessage(ValidationMessage.ERROR, unicode(_("Custom Parameters must be a list")))) @property def descriptor(self): """ Returns this XBlock object. This is for backwards compatibility with the XModule API. Some LMS code still assumes a descriptor attribute on the XBlock object. See courseware.module_render.rebind_noauth_module_to_user. """ return self @property def context_id(self): """ Return context_id. context_id is an opaque identifier that uniquely identifies the context (e.g., a course) that contains the link being launched. """ return unicode(self.course_id) # pylint: disable=no-member @property def role(self): """ Get system user role and convert it to LTI role. """ return ROLE_MAP.get(self.runtime.get_user_role(), u'Student') @property def course(self): """ Return course by course id. """ return self.runtime.descriptor_runtime.modulestore.get_course(self.course_id) # pylint: disable=no-member @property def lti_provider_key_secret(self): """ Obtains client_key and client_secret credentials from current course. """ for lti_passport in self.course.lti_passports: try: lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] except ValueError: msg = self.ugettext('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').\ format(lti_passport='{0!r}'.format(lti_passport)) raise LtiError(msg) if lti_id == self.lti_id.strip(): return key, secret return '', '' @property def user_id(self): """ Returns the opaque anonymous_student_id for the current user. """ user_id = self.runtime.anonymous_student_id if user_id is None: raise LtiError(self.ugettext("Could not get user id for current request")) return unicode(urllib.quote(user_id)) @property def resource_link_id(self): """ This is an opaque unique identifier that the LTI Tool Consumer guarantees will be unique within the Tool Consumer for every placement of the link. If the tool / activity is placed multiple times in the same context, each of those placements will be distinct. This value will also change if the item is exported from one system or context and imported into another system or context. resource_link_id is a required LTI launch parameter. Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df' Hostname, edx.org, makes resource_link_id change on import to another system. Last part of location, location.name - 31de800015cf4afb973356dbe81496df, is random hash, updated by course_id, this makes resource_link_id unique inside single course. First part of location is tag-org-course-category, i4x-2-3-lti. Location.name itself does not change on import to another course, but org and course_id change. So together with org and course_id in a form of i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: makes resource_link_id to be unique among courses inside same system. """ return unicode(urllib.quote( "{}-{}".format(self.runtime.hostname, self.location.html_id()) # pylint: disable=no-member )) @property def lis_result_sourcedid(self): """ This field contains an identifier that indicates the LIS Result Identifier (if any) associated with this launch. This field identifies a unique row and column within the TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. This value may change for a particular resource_link_id / user_id from one launch to the next. The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. This field is generally optional, but is required for grading. """ return "{context}:{resource_link}:{user_id}".format( context=urllib.quote(self.context_id), resource_link=self.resource_link_id, user_id=self.user_id ) @property def outcome_service_url(self): """ Return URL for storing grades. To test LTI on sandbox we must use http scheme. While testing locally and on Jenkins, mock_lti_server use http.referer to obtain scheme, so it is ok to have http(s) anyway. The scheme logic is handled in lms/lib/xblock/runtime.py """ return self.runtime.handler_url(self, "outcome_service_handler", thirdparty=True).rstrip('/?') @property def tool_consumer_info_product_family_code(self): """ Return a code that identifies openedx as a tool consumer. In order to better assist tools in using extensions and also making their user interface fit into the TC's user interface that they are being called from, each TC is encouraged to include the this parameter. """ return "openedx" @property def tool_consumer_instance_guid(self): """ Return a code that identifies the instance openedx as a tool consumer. This is a unique identifier for the TC. A common practice is to use the DNS of the organization or the DNS of the TC instance. If the organization has multiple TC instances, then the best practice is to prefix the domain name with a locally unique identifier for the TC instance. In the single-tenancy case, the tool consumer data can be often be derived from the oauth_consumer_key. In a multi-tenancy case this can be used to differentiate between the multiple tenants within a single installation of a Tool Consumer. This parameter is strongly recommended in systems capable of multi-tenancy. """ return "{context}".format( context=urllib.quote(self.context_id) ) @property def prefixed_custom_parameters(self): """ Apply prefix to configured custom LTI parameters LTI provides a list of default parameters that might be passed as part of the POST data. These parameters should not be prefixed. Likewise, The creator of an LTI link can add custom key/value parameters to a launch which are to be included with the launch of the LTI link. In this case, we will automatically add `custom_` prefix before this parameters. See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520 """ # parsing custom parameters to dict custom_parameters = {} if isinstance(self.custom_parameters, list): for custom_parameter in self.custom_parameters: try: param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)] except ValueError: _ = self.runtime.service(self, "i18n").ugettext # pylint: disable=line-too-long msg = self.ugettext('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').\ format(custom_parameter="{0!r}".format(custom_parameter)) raise LtiError(msg) # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above. if param_name not in LTI_PARAMETERS: param_name = 'custom_' + param_name custom_parameters[unicode(param_name)] = unicode(param_value) return custom_parameters @property def is_past_due(self): """ Is it now past this problem's due date, including grace period? """ due_date = self.due # pylint: disable=no-member if self.graceperiod is not None and due_date: # pylint: disable=no-member close_date = due_date + self.graceperiod # pylint: disable=no-member else: close_date = due_date return close_date is not None and timezone.now() > close_date def student_view(self, context): """ XBlock student view of this component. Makes a request to `lti_launch_handler` either in an iframe or in a new window depending on the configuration of the instance of this XBlock Arguments: context (dict): XBlock context Returns: xblock.fragment.Fragment: XBlock HTML fragment """ fragment = Fragment() loader = ResourceLoader(__name__) context.update(self._get_context_for_template()) fragment.add_content(loader.render_mako_template('/templates/html/student.html', context)) fragment.add_css(loader.load_unicode('static/css/student.css')) fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js')) fragment.initialize_js('LtiConsumerXBlock') return fragment @XBlock.handler def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argument """ XBlock handler for launching the LTI provider. Displays a form which is submitted via Javascript to send the LTI launch POST request to the LTI provider. Arguments: request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request suffix (unicode): Request path after "lti_launch_handler/" Returns: webob.response: HTML LTI launch form """ lti_consumer = LtiConsumer(self) lti_parameters = lti_consumer.get_signed_lti_parameters() loader = ResourceLoader(__name__) context = self._get_context_for_template() context.update({'lti_parameters': lti_parameters}) template = loader.render_mako_template('/templates/html/lti_launch.html', context) return Response(template, content_type='text/html') @XBlock.handler def outcome_service_handler(self, request, suffix=''): # pylint: disable=unused-argument """ XBlock handler for LTI Outcome Service requests. Instantiates an `OutcomeService` instance to handle requests made by LTI providers to update a user's grade for this component. For details about the LTI Outcome Service see: https://www.imsglobal.org/specs/ltiomv1p0 Arguments: request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request suffix (unicode): Request path after "outcome_service_handler/" Returns: webob.response: XML Outcome Service response """ outcome_service = OutcomeService(self) return Response(outcome_service.handle_request(request), content_type="application/xml") @XBlock.handler def result_service_handler(self, request, suffix=''): """ Handler function for LTI 2.0 JSON/REST result service. See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html An example JSON object: { "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", "@type" : "Result", "resultScore" : 0.83, "comment" : "This is exceptional work." } For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json". We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/<anon_id> so suffix is of the form "user/<anon_id>" Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html (Note: this prevents good debug messages for the client, so we might want to change this, or the spec) Arguments: request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/<anon_id>" Returns: webob.response: response to this request. See above for details. """ lti_consumer = LtiConsumer(self) if self.runtime.debug: lti_provider_key, lti_provider_secret = self.lti_provider_key_secret log_authorization_header(request, lti_provider_key, lti_provider_secret) if not self.accept_grades_past_due and self.is_past_due: return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body try: anon_id = parse_handler_suffix(suffix) except LtiError: return Response(status=404) # 404 because a part of the URL (denoting the anon user id) is invalid try: lti_consumer.verify_result_headers(request, verify_content_type=True) except LtiError: return Response(status=401) # Unauthorized in this case. 401 is right user = self.runtime.get_real_user(anon_id) if not user: # that means we can't save to database, as we do not have real user id. msg = _("[LTI]: Real user not found against anon_id: {}").format(anon_id) log.info(msg) return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body try: # Call the appropriate LtiConsumer method args = [] if request.method == 'PUT': # Request body should be passed as an argument # to result handler method on PUT args.append(request.body) response_body = getattr(lti_consumer, "{}_result".format(request.method.lower()))(user, *args) except (AttributeError, LtiError): return Response(status=404) return Response( json.dumps(response_body), content_type=LtiConsumer.CONTENT_TYPE_RESULT_JSON, ) 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 if self.has_score else None def clear_user_module_score(self, user): """ Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule Arguments: user (django.contrib.auth.models.User): Actual user whose module state is to be cleared Returns: nothing """ self.set_user_module_score(user, None, None) def set_user_module_score(self, user, score, max_score, comment=u''): """ Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule Arguments: user (django.contrib.auth.models.User): Actual user whose module state is to be set score (float): user's numeric score to set. Must be in the range [0.0, 1.0] max_score (float): max score that could have been achieved on this module comment (unicode): comments provided by the grader as feedback to the student Returns: nothing """ if score is not None and max_score is not None: scaled_score = score * max_score else: scaled_score = None self.runtime.rebind_noauth_module_to_user(self, user) # have to publish for the progress page... self.runtime.publish( self, 'grade', { 'value': scaled_score, 'max_value': max_score, 'user_id': user.id, }, ) self.module_score = scaled_score self.score_comment = comment def _get_context_for_template(self): """ Returns the context dict for LTI templates. Arguments: None Returns: dict: Context variables for templates """ # use bleach defaults. see https://github.com/jsocol/bleach/blob/master/bleach/__init__.py # ALLOWED_TAGS are # ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul'] # # ALLOWED_ATTRIBUTES are # 'a': ['href', 'title'], # 'abbr': ['title'], # 'acronym': ['title'], # # This lets all plaintext through. sanitized_comment = bleach.clean(self.score_comment) return { 'launch_url': self.launch_url.strip(), 'element_id': self.location.html_id(), # pylint: disable=no-member 'element_class': self.category, # pylint: disable=no-member 'launch_target': self.launch_target, 'display_name': self.ugettext(self.display_name), 'form_url': self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?'), 'hide_launch': self.hide_launch, 'has_score': self.has_score, 'weight': self.weight, 'module_score': self.module_score, 'comment': self.ugettext(sanitized_comment), 'description': self.description, 'ask_to_send_username': self.ask_to_send_username, 'ask_to_send_email': self.ask_to_send_email, 'button_text': self.ugettext(self.button_text), 'inline_height': self.inline_height, 'modal_vertical_offset': self._get_modal_position_offset(self.modal_height), 'modal_horizontal_offset': self._get_modal_position_offset(self.modal_width), 'modal_width': self.modal_width, 'accept_grades_past_due': self.accept_grades_past_due, } def _get_modal_position_offset(self, viewport_percentage): """ Returns the css position offset to apply to the modal window element when launch_target is modal. This enables us to position the modal window as a percentage of the viewport dimensions. Arguments: viewport_percentage (int): The percentage of the viewport that the modal should occupy Returns: float: The css position offset to apply to the modal window """ return (100 - viewport_percentage) / 2
class ThirdMixin(XBlockMixin): """Test class for mixin ordering.""" field = Integer(default=3)
class TakeOneXBlock(XBlock): """ TO-DO: document what your XBlock does. """ # Fields are defined on the class. You can access them in your code as # self.<fieldname>. # TO-DO: delete count, and define your own fields. display_name = String(default="Take ONE", scope=Scope.content, help="edX display name for this block") count = Integer( default=0, scope=Scope.user_state, help="A simple counter, to show something happening", ) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view of the TakeOneXBlock, shown to students when viewing courses. """ html = self.resource_string("static/html/takeone.html") frag = Fragment(html.format(self=self)) frag.add_css(self.resource_string("static/css/takeone.css")) frag.add_javascript(self.resource_string("static/js/src/takeone.js")) frag.initialize_js('TakeOneXBlock') return frag # TO-DO: change this handler to perform your own actions. You may need more # than one handler, or you may not need any handlers at all. @XBlock.json_handler def increment_count(self, data, suffix=''): """ An example handler, which increments the data. """ # Just to show data coming in... assert data['hello'] == 'world' self.count += 1 return {"count": self.count} # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("TakeOneXBlock", """<takeone/> """), ("TakeOne & TakeTwo", """<vertical_demo> <takeone/> <taketwo/> </vertical_demo> """), ]
from xblock.core import XBlock from xblock.fields import Integer, Scope from xblock.fragment import Fragment class {{cookiecutter.class_name}}(XBlock): """ TO-DO: document what your XBlock does. """ # Fields are defined on the class. You can access them in your code as # self.<fieldname>. # TO-DO: delete count, and define your own fields. count = Integer( default=0, scope=Scope.user_state, help="A simple counter, to show something happening", ) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view of the {{cookiecutter.class_name}}, shown to students when viewing courses. """ html = self.resource_string("static/html/{{cookiecutter.short_name|lower}}.html") frag = Fragment(html.format(self=self))
class PureXBlock(XBlock): """Class for testing pure XBlocks.""" has_children = True field1 = String(default="something", scope=Scope.user_state) field2 = Integer(scope=Scope.user_state)
class IF_P_OR_Q_THEN_R_XBLOCKCLASS(XBlock): """ A XBlock providing CTAT tutors. """ ### xBlock tag variables width = Integer(help="Width of the StatTutor frame.", default=690, scope=Scope.content) height = Integer(help="Height of the StatTutor frame.", default=550, scope=Scope.content) ### Grading variables has_score = Boolean(default=True, scope=Scope.content) icon_class = String(default="problem", scope=Scope.content) score = Integer(help="Current count of correctly completed student steps", scope=Scope.user_state, default=0) max_problem_steps = Integer(help="Total number of steps", scope=Scope.user_state, default=1) def max_score(self): """ The maximum raw score of the problem. """ return 1 #self.max_problem_steps attempted = Boolean(help="True if at least one step has been completed", scope=Scope.user_state, default=False) completed = Boolean( help="True if all of the required steps are correctly completed", scope=Scope.user_state, default=False) 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) # weight needs to be set to something ### Basic interface variables src = String(help="The source html file for CTAT interface.", default="public/if_p_or_q_then_r.html", scope=Scope.settings) brd = String(help="The behavior graph.", default="public/problem_files/if_p_or_q_then_r.brd", scope=Scope.settings) ### CTATConfiguration variables log_name = String(help="Problem name to log", default="CTATEdXProblem", scope=Scope.settings) log_dataset = String(help="Dataset name to log", default="edxdataset", scope=Scope.settings) log_level1 = String(help="Level name to log", default="unit1", scope=Scope.settings) log_type1 = String(help="Level type to log", default="unit", scope=Scope.settings) log_level2 = String(help="Level name to log", default="unit2", scope=Scope.settings) log_type2 = String(help="Level type to log", default="unit", scope=Scope.settings) log_url = String(help="URL of the logging service", default="http://pslc-qa.andrew.cmu.edu/log/server", scope=Scope.settings) logtype = String(help="How should data be logged", default="clienttologserver", scope=Scope.settings) log_diskdir = String( help="Directory for log files relative to the tutoring service", default=".", scope=Scope.settings) log_port = String(help="Port used by the tutoring service", default="8080", scope=Scope.settings) log_remoteurl = String( help="Location of the tutoring service (localhost or domain name)", default="localhost", scope=Scope.settings) ctat_connection = String(help="", default="javascript", scope=Scope.settings) ### user information saveandrestore = String(help="Internal data blob used by the tracer", default="", scope=Scope.user_state) skillstring = String(help="Internal data blob used by the tracer", default="", scope=Scope.user_info) def logdebug(self, aMessage): global dbgopen, tmp_file if (dbgopen == False): tmp_file = open("/tmp/edx-tmp-log-ctat.txt", "a", 0) dbgopen = True tmp_file.write(aMessage + "\n") def resource_string(self, path): """ Read in the contents of a resource file. """ data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def strip_local(self, url): """ Returns the given url with //localhost:port removed. """ return re.sub('//localhost(:\d*)?', '', url) def get_local_resource_url(self, url): """ Wrapper for self.runtime.local_resource_url. """ return self.strip_local(self.runtime.local_resource_url(self, url)) # ------------------------------------------------------------------- # TO-DO: change this view to display your data your own way. # ------------------------------------------------------------------- def student_view(self, context=None): """ Create a Fragment used to display a CTAT StatTutor xBlock to a student. Returns a Fragment object containing the HTML to display """ # read in template html html = self.resource_string("static/html/ctatxblock.html") frag = Fragment( html.format(tutor_html=self.get_local_resource_url(self.src))) config = self.resource_string("static/js/CTATConfig.js") frag.add_javascript( config.format( self=self, tutor_html=self.get_local_resource_url(self.src), question_file=self.get_local_resource_url(self.brd), student_id=self.runtime.anonymous_student_id if hasattr( self.runtime, 'anonymous_student_id') else 'bogus-sdk-id', guid=str(uuid.uuid4()))) frag.add_javascript( self.resource_string("static/js/Initialize_CTATXBlock.js")) frag.initialize_js('Initialize_CTATXBlock') return frag @XBlock.json_handler def ctat_grade(self, data, suffix=''): #self.logdebug ("ctat_grade ()") #print('ctat_grade:',data,suffix) self.attempted = True self.score = int(data.get('value')) self.max_problem_steps = int(data.get('max_value')) self.completed = self.score >= self.max_problem_steps scaled = float(self.score) / float(self.max_problem_steps) # trying with max of 1. event_data = {'value': scaled, 'max_value': 1} self.runtime.publish(self, 'grade', event_data) return { 'result': 'success', 'finished': self.completed, 'score': scaled } # ------------------------------------------------------------------- # TO-DO: change this view to display your data your own way. # ------------------------------------------------------------------- def studio_view(self, context=None): html = self.resource_string("static/html/ctatstudio.html") frag = Fragment(html.format(self=self)) js = self.resource_string("static/js/ctatstudio.js") frag.add_javascript(unicode(js)) frag.initialize_js('CTATXBlockStudio') return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Called when submitting the form in Studio. """ self.src = data.get('src') self.brd = data.get('brd') self.width = data.get('width') self.height = data.get('height') return {'result': 'success'} @XBlock.json_handler def ctat_save_problem_state(self, data, suffix=''): """Called from CTATLMS.saveProblemState.""" if data.get('state') is not None: self.saveandrestore = data.get('state') return {'result': 'success'} return {'result': 'failure'} @XBlock.json_handler def ctat_get_problem_state(self, data, suffix=''): return {'result': 'success', 'state': self.saveandrestore} @XBlock.json_handler def ctat_set_variable(self, data, suffix=''): self.logdebug("ctat_set_variable ()") for key in data: #value = base64.b64decode(data[key]) value = data[key] self.logdebug("Setting ({}) to ({})".format(key, value)) if (key == "href"): self.href = value elif (key == "ctatmodule"): self.ctatmodule = value elif (key == "problem"): self.problem = value elif (key == "dataset"): self.dataset = value elif (key == "level1"): self.level1 = value elif (key == "type1"): self.type1 = value elif (key == "level2"): self.level2 = value elif (key == "type2"): self.type2 = value elif (key == "logurl"): self.logurl = value elif (key == "logtype"): self.logtype = value elif (key == "diskdir"): self.diskdir = value elif (key == "port"): self.port = value elif (key == "remoteurl"): self.remoteurl = value elif (key == "connection"): self.connection = value #elif (key=="src"): # self.src = value elif (key == "saveandrestore"): self.logdebug("Received saveandrestore request") self.saveandrestore = value #elif (key=="skillstring"): # self.skillstring = value return {'result': 'success'} # ------------------------------------------------------------------- # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. # ------------------------------------------------------------------- @staticmethod def workbench_scenarios(): return [ ("IF_P_OR_Q_THEN_R_XBLOCKCLASS", """<vertical_demo> <if_p_or_q_then_r_xblock width="" height=""/> </vertical_demo> """), ]
class PollBase(XBlock, ResourceMixin, PublishEventMixin): """ Base class for Poll-like XBlocks. """ event_namespace = 'xblock.pollbase' private_results = Boolean( default=False, help=_("Whether or not to display results to the user.")) max_submissions = Integer( default=1, help=_("The maximum number of times a user may send a submission.")) submissions_count = Integer( default=0, help=_("Number of times the user has sent a submission."), scope=Scope.user_state) feedback = String(default='', help=_("Text to display after the user votes.")) def send_vote_event(self, choice_data): # Let the LMS know the user has answered the poll. self.runtime.publish(self, 'progress', {}) # The SDK doesn't set url_name. event_dict = {'url_name': getattr(self, 'url_name', '')} event_dict.update(choice_data) self.publish_event_from_dict( self.event_namespace + '.submitted', event_dict, ) @staticmethod def any_image(field): """ Find out if any answer has an image, since it affects layout. """ return any(value['img'] for key, value in field) @staticmethod def markdown_items(items): """ Convert all items' labels into markdown. """ return [(key, { 'label': markdown(value['label']), 'img': value['img'], 'img_alt': value.get('img_alt') }) for key, value in items] 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) def img_alt_mandatory(self): """ Determine whether alt attributes for images are configured to be mandatory. Defaults to True. """ settings_service = self.runtime.service(self, "settings") if not settings_service: return True xblock_settings = settings_service.get_settings_bucket(self) return xblock_settings.get('IMG_ALT_MANDATORY', True) def gather_items(self, data, result, noun, field, image=True): """ Gathers a set of label-img pairs from a data dict and puts them in order. """ items = [] if field not in data or not isinstance(data[field], list): source_items = [] result['success'] = False error_message = self.ugettext( # Translators: {field} is either "answers" or "questions". "'{field}' is not present, or not a JSON array.").format( field=field) result['errors'].append(error_message) else: source_items = data[field] # Make sure all components are present and clean them. for item in source_items: if not isinstance(item, dict): result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". {item} identifies the answer or question. "{noun} {item} not a javascript object!").format(noun=noun, item=item) result['errors'].append(error_message) continue key = item.get('key', '').strip() if not key: result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". {item} identifies the answer or question. "{noun} {item} contains no key.").format(noun=noun, item=item) result['errors'].append(error_message) image_link = item.get('img', '').strip() image_alt = item.get('img_alt', '').strip() label = item.get('label', '').strip() if not label: if image and not image_link: result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". # {noun_lower} is the lowercase version of {noun}. "{noun} has no text or img. Please make sure all {noun_lower}s have one or the other, or both." ).format(noun=noun, noun_lower=noun.lower()) result['errors'].append(error_message) elif not image: result['success'] = False # If there's a bug in the code or the user just forgot to relabel a question, # votes could be accidentally lost if we assume the omission was an # intended deletion. error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". # {noun_lower} is the lowercase version of {noun}. "{noun} was added with no label. All {noun_lower}s must have labels. Please check the form. " "Check the form and explicitly delete {noun_lower}s if not needed." ).format(noun=noun, noun_lower=noun.lower()) result['errors'].append(error_message) if image_link and not image_alt and self.img_alt_mandatory(): result['success'] = False result['errors'].append( self.ugettext( "All images must have an alternative text describing the image in a way " "that would allow someone to answer the poll if the image did not load." )) if image: items.append((key, { 'label': label, 'img': image_link, 'img_alt': image_alt })) else: items.append([key, label]) if not items: error_message = self.ugettext( # Translators: "{noun_lower} is either "answer" or "question". "You must include at least one {noun_lower}.").format( noun_lower=noun.lower()) result['errors'].append(error_message) result['success'] = False return items def can_vote(self): """ Checks to see if the user is permitted to vote. This may not be the case if they used up their max_submissions. """ return self.max_submissions == 0 or self.submissions_count < self.max_submissions def can_view_private_results(self): """ Checks to see if the user has permissions to view private results. This only works inside the LMS. """ if not hasattr(self.runtime, 'user_is_staff'): return False # Course staff users have permission to view results. if self.runtime.user_is_staff: return True # Check if user is member of a group that is explicitly granted # permission to view the results through django configuration. if not HAS_GROUP_PROFILE: return False group_names = getattr(settings, 'XBLOCK_POLL_EXTRA_VIEW_GROUPS', []) if not group_names: return False user = self.runtime.get_real_user(self.runtime.anonymous_student_id) group_ids = user.groups.values_list('id', flat=True) return GroupProfile.objects.filter(group_id__in=group_ids, name__in=group_names).exists() @staticmethod def get_max_submissions(ugettext, data, result, private_results): """ Gets the value of 'max_submissions' from studio submitted AJAX data, and checks for conflicts with private_results, which may not be False when max_submissions is not 1, since that would mean the student could change their answer based on other students' answers. """ try: max_submissions = int(data['max_submissions']) except (ValueError, KeyError): max_submissions = 1 result['success'] = False result['errors'].append( ugettext('Maximum Submissions missing or not an integer.')) # Better to send an error than to confuse the user by thinking this would work. if (max_submissions != 1) and not private_results: result['success'] = False result['errors'].append( ugettext( "Private results may not be False when Maximum Submissions is not 1." )) return max_submissions @classmethod def static_replace_json_handler(cls, func): """A JSON handler that replace all static pseudo-URLs by the actual paths. The object returned by func is JSON-serialised, and the resulting string is passed to replace_static_urls() to perform regex-based URL replacing. We would prefer to explicitly call an API function on single image URLs, but such a function is not exposed by the LMS API, so we have to fall back to this slightly hacky implementation. """ @cls.json_handler @functools.wraps(func) def wrapper(self, request_json, suffix=''): response = json.dumps(func(self, request_json, suffix)) response = replace_static_urls(response, course_id=self.runtime.course_id) return Response(response, content_type='application/json') if HAS_STATIC_REPLACE: # Only use URL translation if it is available return wrapper # Otherwise fall back to a standard JSON handler return cls.json_handler(func)
class CourseFields(object): lti_passports = List( display_name=_("LTI Passports"), help= _("Enter the passports for course LTI tools in the following format: \"id:client_key:client_secret\"." ), scope=Scope.settings) textbooks = TextbookList( help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date( help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", default=DEFAULT_START_DATE, scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String( display_name=_("Course Advertised Start Date"), help= _("Enter the date you want to advertise as the course start date, if this date is different from the set start date. To advertise the set start date, enter null." ), 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( display_name=_("Show Calculator"), help= _("Enter true or false. When true, students can see the calculator in the course." ), default=False, scope=Scope.settings) display_name = String(help=_( "Enter the name of the course as it should appear in the edX.org course list." ), default="Empty", display_name=_("Course Display Name"), scope=Scope.settings) course_edit_method = String( display_name=_("Course Editor"), help= _("Enter the method by which this course is edited (\"XML\" or \"Studio\")." ), default="Studio", scope=Scope.settings, deprecated= True # Deprecated because someone would not edit this value within Studio. ) show_chat = Boolean( display_name=_("Show Chat Widget"), help= _("Enter true or false. When true, students can see the chat widget in the 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( display_name=_("Course Survey URL"), help= _("Enter the URL for the end-of-course survey. If your course does not have a survey, enter null." ), scope=Scope.settings) discussion_blackouts = List( display_name=_("Discussion Blackout Dates"), help= _("Enter pairs of dates between which students cannot post to discussion forums, formatted as \"YYYY-MM-DD-YYYY-MM-DD\". To specify times as well as dates, format the pairs as \"YYYY-MM-DDTHH:MM-YYYY-MM-DDTHH:MM\" (be sure to include the \"T\" between the date and time)." ), scope=Scope.settings) discussion_topics = Dict( display_name=_("Discussion Topic Mapping"), help= _("Enter discussion categories in the following format: \"CategoryName\": {\"id\": \"i4x-InstitutionName-CourseNumber-course-CourseRun\"}. For example, one discussion category may be \"Lydian Mode\": {\"id\": \"i4x-UniversityX-MUS101-course-2014_T1\"}." ), scope=Scope.settings) discussion_sort_alpha = Boolean( display_name=_("Discussion Sorting Alphabetical"), scope=Scope.settings, default=False, help= _("Enter true or false. If true, discussion categories and subcategories are sorted alphabetically. If false, they are sorted chronologically." )) announcement = Date(display_name=_("Course Announcement Date"), help=_("Enter the date to announce your course."), scope=Scope.settings) cohort_config = Dict(display_name=_("Cohort Configuration"), help=_("Cohorts are not currently supported by edX."), scope=Scope.settings) is_new = Boolean( display_name=_("Course Is New"), help= _("Enter true or false. If true, the course appears in the list of new courses on edx.org, and a New! badge temporarily appears next to the course image." ), scope=Scope.settings) no_grade = Boolean( display_name=_("Course Not Graded"), help=_("Enter true or false. If true, the course will not be graded."), default=False, scope=Scope.settings) disable_progress_graph = Boolean( display_name=_("Disable Progress Graph"), help= _("Enter true or false. If true, students cannot view the progress graph." ), default=False, scope=Scope.settings) pdf_textbooks = List( display_name=_("PDF Textbooks"), help=_("List of dictionaries containing pdf_textbook configuration"), scope=Scope.settings) html_textbooks = List( display_name=_("HTML Textbooks"), help= _("For HTML textbooks that appear as separate tabs in the courseware, enter the name of the tab (usually the name of the book) as well as the URLs and titles of all the chapters in the book." ), scope=Scope.settings) remote_gradebook = Dict( display_name=_("Remote Gradebook"), help= _("Enter the remote gradebook mapping. Only use this setting when REMOTE_GRADEBOOK_URL has been specified." ), scope=Scope.settings) allow_anonymous = Boolean( display_name=_("Allow Anonymous Discussion Posts"), help= _("Enter true or false. If true, students can create discussion posts that are anonymous to all users." ), scope=Scope.settings, default=True) allow_anonymous_to_peers = Boolean( display_name=_("Allow Anonymous Discussion Posts to Peers"), help= _("Enter true or false. If true, students can create discussion posts that are anonymous to other students. This setting does not make posts anonymous to course staff." ), scope=Scope.settings, default=False) advanced_modules = List( display_name=_("Advanced Module List"), help=_( "Enter the names of the advanced components to use 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( display_name=_("Course Info Sidebar Name"), help= _("Enter the heading that you want students to see above your course handouts on the Course Info page. Your course handouts appear in the right panel of the page." ), 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( display_name=_("Due Date Display Format"), help= _("Enter the format due dates are displayed in. Due dates must be in MM-DD-YYYY, DD-MM-YYYY, YYYY-MM-DD, or YYYY-DD-MM format." ), scope=Scope.settings, default=None) enrollment_domain = String( display_name=_("External Login Domain"), help=_( "Enter the external login method students can use for the course." ), scope=Scope.settings) certificates_show_before_end = Boolean( display_name=_("Certificates Downloadable Before End"), help= _("Enter true or false. If true, students can download certificates before the course ends, if they've met certificate requirements." ), scope=Scope.settings, default=False, deprecated=True) certificates_display_behavior = String( display_name=_("Certificates Display Behavior"), help= _("Has three possible states: 'end', 'early_with_info', 'early_no_info'. 'end' is the default behavior, where certificates will only appear after a course has ended. 'early_with_info' will display all certificate information before a course has ended. 'early_no_info' will hide all certificate information unless a student has earned a certificate." ), scope=Scope.settings, default="end") course_image = String( display_name=_("Course About Page Image"), help= _("Edit the name of the course image file. You must upload this file on the Files & Uploads page. You can also set the course image on the Settings & Details page." ), scope=Scope.settings, # Ensure that courses imported from XML keep their image default="images_course_image.jpg") ## Course level Certificate Name overrides. cert_name_short = String(help=_( "Between quotation marks, enter the short name of the course to use on the certificate that students receive when they complete the course." ), display_name=_("Certificate Name (Short)"), scope=Scope.settings, default="") cert_name_long = String(help=_( "Between quotation marks, enter the long name of the course to use on the certificate that students receive when they complete the course." ), display_name=_("Certificate Name (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( display_name=_("CSS Class for Course Reruns"), help= _("Allows courses to share the same css class across runs even if they have different numbers." ), scope=Scope.settings, default="", deprecated=True) # TODO: This is a quick kludge to allow CS50 (and other courses) to # specify their own discussion forums as external links by specifying a # "discussion_link" in their policy JSON file. This should later get # folded in with Syllabus, Course Info, and additional Custom tabs in a # more sensible framework later. discussion_link = String( display_name=_("Discussion Forum External Link"), help= _("Allows specification of an external link to replace discussion forums." ), scope=Scope.settings, deprecated=True) # TODO: same as above, intended to let internal CS50 hide the progress tab # until we get grade integration set up. # Explicit comparison to True because we always want to return a bool. hide_progress_tab = Boolean(display_name=_("Hide Progress Tab"), help=_("Allows hiding of the progress tab."), scope=Scope.settings, deprecated=True) display_organization = String( display_name=_("Course Organization Display String"), help= _("Enter the course organization that you want to appear in the courseware. This setting overrides the organization that you entered when you created the course. To use the organization that you entered when you created the course, enter null." ), scope=Scope.settings) display_coursenumber = String( display_name=_("Course Number Display String"), help= _("Enter the course number that you want to appear in the courseware. This setting overrides the course number that you entered when you created the course. To use the course number that you entered when you created the course, enter null." ), scope=Scope.settings) max_student_enrollments_allowed = Integer( display_name=_("Course Maximum Student Enrollment"), help= _("Enter the maximum number of students that can enroll in the course. To allow an unlimited number of students, enter null." ), scope=Scope.settings) allow_public_wiki_access = Boolean( display_name=_("Allow Public Wiki Access"), help= _("Enter true or false. If true, edX users can view the course wiki even if they're not enrolled in the course." ), default=False, scope=Scope.settings) invitation_only = Boolean( display_name=_("Invitation Only"), help= "Whether to restrict enrollment to invitation by the course staff.", default=False, scope=Scope.settings)
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_django_template( CALENDAR_EDIT_TEMPLATE, { 'self': self, 'defaultName': default_name, 'defaultID': self.fields['calendar_id']._default # pylint: disable=protected-access,unsubscriptable-object }, i18n_service=self.runtime.service(self, "i18n"), )) 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>")]
class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNestedXBlocksMixin, I18NService): """ An XBlock providing mentoring capabilities with explicit steps """ USER_STATE_FIELDS = ['num_attempts'] # Content extended_feedback = Boolean( display_name=_("Extended feedback"), help=_("Show extended feedback when all attempts are used up?"), default=False, Scope=Scope.content ) # Settings display_name = String( display_name=_("Title (display name)"), help=_("Title to display"), default=_("Step Builder"), scope=Scope.settings ) # User state active_step = Integer( # Keep track of the student progress. default=0, scope=Scope.user_state, enforce_type=True ) editable_fields = ('display_name', 'max_attempts', 'extended_feedback', 'weight') def build_user_state_data(self, context=None): user_state_data = super(MentoringWithExplicitStepsBlock, self).build_user_state_data() user_state_data['active_step'] = self.active_step_safe user_state_data['score_summary'] = self.get_score_summary() return user_state_data @lazy def question_ids(self): """ Get the usage_ids of all of this XBlock's children that are "Questions". """ return list(chain.from_iterable(self.runtime.get_block(step_id).step_ids for step_id in self.step_ids)) @lazy def questions(self): """ Get all questions associated with this block. """ return [self.runtime.get_block(question_id) for question_id in self.question_ids] @property def active_step_safe(self): """ Get self.active_step and double-check that it is a valid value. The stored value could be invalid if this block has been edited and new steps were added/deleted. """ active_step = self.active_step if 0 <= active_step < len(self.step_ids): return active_step if active_step == -1 and self.has_review_step: return active_step # -1 indicates the review step return 0 def get_active_step(self): """ Get the active step as an instantiated XBlock """ block = self.runtime.get_block(self.step_ids[self.active_step_safe]) if block is None: log.error("Unable to load step builder step child %s", self.step_ids[self.active_step_safe]) return block @lazy def step_ids(self): """ Get the usage_ids of all of this XBlock's children that are steps. """ from .step import MentoringStepBlock # Import here to avoid circular dependency return [ _normalize_id(child_id) for child_id in self.children if child_isinstance(self, child_id, MentoringStepBlock) ] @lazy def steps(self): """ Get the step children of this block. """ return [self.runtime.get_block(step_id) for step_id in self.step_ids] def get_question_number(self, question_name): question_names = [q.name for q in self.questions] return question_names.index(question_name) + 1 def answer_mapper(self, answer_status): steps = self.steps answer_map = [] for step in steps: for answer in step.student_results: if answer[1]['status'] == answer_status: answer_map.append({ 'id': answer[0], 'details': answer[1], 'step': step.step_number, 'number': self.get_question_number(answer[0]), }) return answer_map @property def has_review_step(self): return any(child_isinstance(self, child_id, ReviewStepBlock) for child_id in self.children) @property def review_step(self): """ Get the Review Step XBlock child, if any. Otherwise returns None """ for step_id in self.children: if child_isinstance(self, step_id, ReviewStepBlock): return self.runtime.get_block(step_id) @property def score(self): questions = self.questions total_child_weight = sum(float(question.weight) for question in questions) if total_child_weight == 0: return Score(0, 0, [], [], []) steps = self.steps questions_map = {question.name: question for question in questions} points_earned = 0 for step in steps: for question_name, question_results in step.student_results: question = questions_map.get(question_name) if question: # Under what conditions would this evaluate to False? points_earned += question_results['score'] * question.weight score = Decimal(points_earned) / Decimal(total_child_weight) correct = self.answer_mapper(CORRECT) incorrect = self.answer_mapper(INCORRECT) partially_correct = self.answer_mapper(PARTIAL) return Score( float(score), int(Decimal(score * 100).quantize(Decimal('1.'), rounding=ROUND_HALF_UP)), correct, incorrect, partially_correct ) @property def complete(self): return not self.score.incorrect and not self.score.partially_correct @property def review_tips(self): """ Get review tips, shown for wrong answers. """ if self.max_attempts > 0 and self.num_attempts >= self.max_attempts: # Review tips are only shown if the student is allowed to try again. return [] review_tips = [] status_cache = dict() steps = self.steps for step in steps: status_cache.update(dict(step.student_results)) for question in self.questions: result = status_cache.get(question.name) if result and result.get('status') != 'correct': # The student got this wrong. Check if there is a review tip to show. tip_html = question.get_review_tip() if tip_html: if getattr(self.runtime, 'replace_jump_to_id_urls', None) is not None: tip_html = self.runtime.replace_jump_to_id_urls(tip_html) review_tips.append(tip_html) return review_tips def show_extended_feedback(self): return self.extended_feedback and self.max_attempts_reached @XBlock.supports("multi_device") # Mark as mobile-friendly def student_view(self, context): fragment = Fragment() children_contents = [] context = context or {} context['hide_prev_answer'] = True # For Step Builder, we don't show the users' old answers when they try again context['score_summary'] = self.get_score_summary() for child_id in self.children: child = self.runtime.get_block(child_id) if child is None: # child should not be None but it can happen due to bugs or permission issues child_content = u"<p>[{}]</p>".format(self._(u"Error: Unable to load child component.")) else: child_fragment = self._render_child_fragment(child, context, view='mentoring_view') fragment.add_frag_resources(child_fragment) child_content = child_fragment.content children_contents.append(child_content) fragment.add_content(loader.render_django_template('templates/html/mentoring_with_steps.html', { 'self': self, 'title': self.display_name, 'show_title': self.show_title, 'children_contents': children_contents, }, i18n_service=self.i18n_service)) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/step_util.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_with_steps.js')) fragment.initialize_js('MentoringWithStepsBlock', { 'show_extended_feedback': self.show_extended_feedback(), }) return fragment @property def allowed_nested_blocks(self): """ Returns a list of allowed nested XBlocks. Each item can be either * An XBlock class * A NestedXBlockSpec If XBlock class is used it is assumed that this XBlock is enabled and allows multiple instances. NestedXBlockSpec allows explicitly setting disabled/enabled state, disabled reason (if any) and single/multiple instances """ # Import here to avoid circular dependency from .step import MentoringStepBlock return [ MentoringStepBlock, NestedXBlockSpec(ReviewStepBlock, single_instance=True), ] @XBlock.json_handler def submit(self, data, suffix=None): """ Called when the user has submitted the answer[s] for the current step. """ # First verify that active_step is correct: if data.get("active_step") != self.active_step_safe: raise JsonHandlerError(400, "Invalid Step. Refresh the page and try again.") # The step child will process the data: step_block = self.get_active_step() if not step_block: raise JsonHandlerError(500, "Unable to load the current step block.") response_data = step_block.submit(data) # Update the active step: new_value = self.active_step_safe + 1 if new_value < len(self.step_ids): self.active_step = new_value elif new_value == len(self.step_ids): # The user just completed the final step. # Update the number of attempts: self.num_attempts += 1 # Do we need to render a review (summary of the user's score): if self.has_review_step: self.active_step = -1 response_data['review_html'] = self.runtime.render(self.review_step, "mentoring_view", { 'score_summary': self.get_score_summary(), }).content response_data['num_attempts'] = self.num_attempts # And publish the score: score = self.score grade_data = { 'value': score.raw, 'max_value': self.max_score(), } self.runtime.publish(self, 'grade', grade_data) response_data['active_step'] = self.active_step return response_data def get_score_summary(self): if self.num_attempts == 0: return {} score = self.score return { 'score': score.percentage, 'correct_answers': len(score.correct), 'incorrect_answers': len(score.incorrect), 'partially_correct_answers': len(score.partially_correct), 'correct': score.correct, 'incorrect': score.incorrect, 'partial': score.partially_correct, 'complete': self.complete, 'max_attempts_reached': self.max_attempts_reached, 'show_extended_review': self.show_extended_feedback(), 'review_tips': self.review_tips, } @XBlock.json_handler def get_num_attempts(self, data, suffix): return { 'num_attempts': self.num_attempts } @XBlock.json_handler def try_again(self, data, suffix=''): self.active_step = 0 step_blocks = [self.runtime.get_block(child_id) for child_id in self.step_ids] for step in step_blocks: step.reset() return { 'active_step': self.active_step } def author_preview_view(self, context): return self.student_view(context) def author_edit_view(self, context): """ Add some HTML to the author view that allows authors to add child blocks. """ fragment = super(MentoringWithExplicitStepsBlock, self).author_edit_view(context) fragment.add_content(loader.render_django_template('templates/html/mentoring_url_name.html', { "url_name": self.url_name })) 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/problem-builder-edit.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-tinymce-content.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/container_edit.js')) fragment.initialize_js('ProblemBuilderContainerEdit') return fragment def student_view_data(self, context=None): components = [] for child_id in self.children: child = self.runtime.get_block(child_id) if hasattr(child, 'student_view_data'): components.append(child.student_view_data(context)) return { 'title': self.display_name, 'block_id': six.text_type(self.scope_ids.usage_id), 'display_name': self.display_name, 'show_title': self.show_title, 'weight': self.weight, 'extended_feedback': self.extended_feedback, 'max_attempts': self.max_attempts, 'components': components, }
def test_values_dict(): # Test that the format expected for integers is allowed test_field = Integer(values={"min": 1, "max": 100}) assert_equals({"min": 1, "max": 100}, test_field.values)
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 TestIntegerXblock(XBlock): """ XBlock with an integer field, for testing. """ __test__ = False counter = Integer(scope=Scope.content)
class FieldTester(XBlock): """Test XBlock for field access testing""" field_a = Integer(scope=Scope.settings) field_b = Integer(scope=Scope.content, default=10) field_c = Integer(scope=Scope.user_state, default=42)
class ProctoringFields(object): """ Fields that are specific to Proctored or Timed Exams """ is_time_limited = Boolean( display_name=_("Is Time Limited"), help=_( "This setting indicates whether students have a limited time" " to view or interact with this courseware component." ), default=False, scope=Scope.settings, ) default_time_limit_minutes = Integer( display_name=_("Time Limit in Minutes"), help=_( "The number of minutes available to students for viewing or interacting with this courseware component." ), default=None, scope=Scope.settings, ) is_proctored_enabled = Boolean( display_name=_("Is Proctoring Enabled"), help=_( "This setting indicates whether this exam is a proctored exam." ), default=False, scope=Scope.settings, ) exam_review_rules = String( display_name=_("Software Secure Review Rules"), help=_( "This setting indicates what rules the proctoring team should follow when viewing the videos." ), default='', scope=Scope.settings, ) is_practice_exam = Boolean( display_name=_("Is Practice Exam"), help=_( "This setting indicates whether this exam is for testing purposes only. Practice exams are not verified." ), default=False, scope=Scope.settings, ) is_onboarding_exam = Boolean( display_name=_("Is Onboarding Exam"), help=_( "This setting indicates whether this exam is an onboarding exam." ), default=False, scope=Scope.settings, ) def _get_course(self): """ Return course by course id. """ return self.descriptor.runtime.modulestore.get_course(self.course_id) # pylint: disable=no-member @property def is_timed_exam(self): """ Alias the permutation of above fields that corresponds to un-proctored timed exams to the more clearly-named is_timed_exam """ return not self.is_proctored_enabled and not self.is_practice_exam and self.is_time_limited @property def is_proctored_exam(self): """ Alias the is_proctored_enabled field to the more legible is_proctored_exam """ return self.is_proctored_enabled @property def allow_proctoring_opt_out(self): """ Returns true if the learner should be given the option to choose between taking a proctored exam, or opting out to take the exam without proctoring. """ return self._get_course().allow_proctoring_opt_out @is_proctored_exam.setter def is_proctored_exam(self, value): """ Alias the is_proctored_enabled field to the more legible is_proctored_exam """ self.is_proctored_enabled = value