class LmsCompatibilityMixin(object): """ Extra fields and methods used by LMS/Studio. """ # Studio the default value for this field to show this XBlock # in the list of "Advanced Components" display_name = String( default="SQL Injection capture-the-flag", scope=Scope.settings, help="Display name" ) start = DateTime( default=None, scope=Scope.settings, help="ISO-8601 formatted string representing the start date of this assignment." ) due = DateTime( default=None, scope=Scope.settings, help="ISO-8601 formatted string representing the due date of this assignment." ) weight = Float( display_name="Problem Weight", help="Defines the number of points this problem is worth.", values={"min": 0, "step": .1}, default=1.0, scope=Scope.settings ) def has_dynamic_children(self): """Do we dynamically determine our children? No, we don't have any. The LMS wants to know this to see if it has to instantiate our module and query it to find the children, or whether it can just trust what's in the static (cheaper) children listing. """ return False @property def has_score(self): """Are we a scored type (read: a problem). Yes. For LMS Progress page/grades download purposes, we're always going to have a score, even if it's just 0 at the start. """ return True def max_score(self): """The maximum raw score of our problem. """ return self.weight
class EditableXBlock(StudioEditableXBlockMixin, XBlock): """ A basic Studio-editable XBlock (for use in tests) """ CATEGORY = "editable" STUDIO_LABEL = "Editable Block" color = String(default="red") count = Integer(default=42) comment = String(default="") date = DateTime(default=datetime.datetime(2014, 5, 14, tzinfo=pytz.UTC)) editable_fields = ('color', 'count', 'comment', 'date') def student_view(self, context): return Fragment() def validate_field_data(self, validation, data): """ A validation method to check that 'count' is positive and prevent swearing in the 'comment' field. """ if data.count < 0: validation.add( ValidationMessage(ValidationMessage.ERROR, u"Count cannot be negative")) if "damn" in data.comment.lower(): validation.add( ValidationMessage(ValidationMessage.ERROR, u"No swearing allowed"))
class UserStateFieldsMixin: answer_1 = String(name="state1", scope=Scope.user_state) answer_2 = Boolean(name="state2", scope=Scope.user_state) preference_1 = String(name="pref1", scope=Scope.preferences) preference_2 = Integer(name="pref2", scope=Scope.preferences) user_info_1 = String(name="info1", scope=Scope.user_info) user_info_2 = DateTime(name="info2", scope=Scope.user_info)
class ShowAnswerXBlock(ShowAnswerXBlockMixin, XBlock): """ A basic ShowAnswer XBlock implementation (for use in tests) """ CATEGORY = 'showanswer' STUDIO_LABEL = 'Show Answers' color = String(default="red") count = Integer(default=42) comment = String(default="") date = DateTime(default=datetime.datetime(2014, 5, 14, tzinfo=pytz.UTC)) editable_fields = ('color', 'count', 'comment', 'date') def student_view(self, context): return Fragment()
class DoneXBlock(XBlock): """ Show a toggle which lets students mark things as done. """ # Fields are defined on the class. You can access them in your code as # self.<fieldname>. done = Boolean(scope=Scope.user_state, help="Is the student done?", default=False) align = String(scope=Scope.settings, help="Align left/right/center", default="left") has_score = True def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") @XBlock.json_handler def toggle_button(self, data, suffix=''): self.done = data['done'] if data['done']: grade = 1 else: grade = 0 self.runtime.publish(self, 'grade', {'value': grade, 'max_value': 1}) return {} def student_view(self, context=None): """ The primary view of the DoneXBlock, shown to students when viewing courses. """ html = self.resource_string("static/html/done.html") frag = Fragment(html) #.format(uid=self.scope_ids.usage_id)) frag.add_css_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/themes/smoothness/jquery-ui.css" ) #frag.add_javascript_url("//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js") frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/jquery-ui.min.js") frag.add_css(self.resource_string("static/css/done.css")) grow_left = 1 grow_right = 1 if self.align.lower() == "left": grow_left = 0 if self.align.lower() == "right": grow_right = 0 frag.add_css( ".done_left_spacer {{ flex-grow:{l}; }} .done_right_spacer {{ flex-grow:{r}; }}" .format(r=grow_right, l=grow_left)) frag.add_javascript(self.resource_string("static/js/src/done.js")) frag.initialize_js("DoneXBlock", {'state': self.done}) return frag # 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 [ ("DoneXBlock", """<vertical_demo> <done align="left"> </done> <done align="left"> </done> </vertical_demo> """), ] ## Everything below is stolen from https://github.com/edx/edx-ora2/blob/master/apps/openassessment/xblock/lms_mixin.py ## It's needed to keep the LMS+Studio happy. ## It should be included as a mixin. display_name = String(default="Completion", scope=Scope.settings, help="Display name") start = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the start date of this assignment. 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." ) 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) 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
class TestXBlockWithDateTime(XBlock): """ XBlock for DateTime fields export """ etree_node_tag = 'test_xblock_with_datetime' datetime = DateTime(default=None)
class GroupActivityXBlock(CommonMixinCollection, DashboardXBlockMixin, XBlockWithPreviewMixin, ActivityNotificationsMixin, XBlock): """ XBlock providing a group activity project for a group of students to collaborate upon """ display_name = String( display_name=_(u"Display Name"), help= _(u"This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default=_(u"Group Project Activity")) weight = Float( display_name=_(u"Weight"), help= _(u"This is the maximum score that the user receives when he/she successfully completes the problem." ), scope=Scope.settings, default=100.0) group_reviews_required_count = Integer( display_name=_(u"Reviews Required Minimum"), help=_( u"The minimum number of group-reviews that should be applied to a set of submissions " u"(set to 0 to be 'TA Graded')"), scope=Scope.settings, default=3) user_review_count = Integer( display_name=_(u"User Reviews Required Minimum"), help= _(u"The minimum number of other-group reviews that an individual user should perform" ), scope=Scope.settings, default=1) due_date = DateTime(display_name=_(u"Due date"), help=_(u"Activity due date"), has_score=Scope.settings, default=None) CATEGORY = "gp-v2-activity" STUDIO_LABEL = _(u"Group Project Activity") editable_fields = ("display_name", "weight", "group_reviews_required_count", "user_review_count", "due_date") has_score = True has_children = True template_location = 'activity' DASHBOARD_DETAILS_URL_KEY = 'dashboard_details_url' DEFAULT_DASHBOARD_DETAILS_URL_TPL = "/dashboard_details_view?activate_block_id={activity_id}" TA_REVIEW_URL_KEY = 'ta_review_url' DEFAULT_TA_REVIEW_URL_TPL = "ta_grading=true&activate_block_id={activate_block_id}&group_id={group_id}" @property def id(self): return self.scope_ids.usage_id def max_score(self): """ Used for grading purposes: * As max grade for submitting grade event. See :method:`assign_grade_to_group` * As theoretical max score for grade calculation when grade is not yet available :rtype: Float """ return self.weight @property def project(self): return self.get_parent() @property def content_id(self): return get_block_content_id(self) @property def is_ta_graded(self): return self.group_reviews_required_count == 0 @property def allowed_nested_blocks(self): # pylint: disable=no-self-use return [ BasicStage, CompletionStage, SubmissionStage, TeamEvaluationStage, PeerReviewStage, EvaluationDisplayStage, GradeDisplayStage ] @property def stages(self): return self._children @property def available_stages(self): for stage in self.stages: if stage.available_to_current_user: yield stage @property def default_stage(self): def_stage = get_default_stage(self.available_stages) if def_stage: return def_stage else: return self.stages[0] if self.stages else None @property def questions(self): return list(self._chain_questions(self.stages, 'questions')) @property def grade_questions(self): return list(self._chain_questions(self.stages, 'grade_questions')) @property def team_evaluation_questions(self): stages = self.get_children_by_category(TeamEvaluationStage.CATEGORY) return list(self._chain_questions(stages, 'questions')) @property def peer_review_questions(self): stages = self.get_children_by_category(PeerReviewStage.CATEGORY) return list(self._chain_questions(stages, 'questions')) def dashboard_details_url(self): """ Gets dashboard details view URL for current activity. If settings service is not available or does not provide URL template, default template is used. """ template = self._get_setting(self.DASHBOARD_DETAILS_URL_KEY, self.DEFAULT_DASHBOARD_DETAILS_URL_TPL) return template.format(program_id=self.user_preferences.get( self.DASHBOARD_PROGRAM_ID_KEY, None), course_id=self.course_id, project_id=self.project.scope_ids.usage_id, activity_id=self.id) def get_ta_review_link(self, group_id, target_block_id=None): target_block_id = target_block_id if target_block_id else self.id template = self._get_setting(self.TA_REVIEW_URL_KEY, self.DEFAULT_TA_REVIEW_URL_TPL) return template.format(course_id=self.course_id, group_id=group_id, activate_block_id=target_block_id) @staticmethod def _chain_questions(stages, question_type): return itertools.chain.from_iterable( getattr(stage, question_type, ()) for stage in stages) def get_stage_to_display(self, target_block_id): try: if target_block_id: stage = self.runtime.get_block(target_block_id) if self.get_child_category( stage ) in STAGE_TYPES and stage.available_to_current_user: return stage except (InvalidKeyError, KeyError, NoSuchUsage) as exc: log.exception(exc) return self.default_stage @groupwork_protected_view def student_view(self, context): """ Player view, displayed to the student """ fragment = Fragment() current_stage_id = context.get( Constants.CURRENT_STAGE_ID_PARAMETER_NAME, None) target_stage = self.get_stage_to_display(current_stage_id) if not target_stage: fragment.add_content(messages.NO_STAGES) else: stage_fragment = target_stage.render('student_view', context) fragment.add_frag_resources(stage_fragment) render_context = { 'activity': self, 'stage_content': stage_fragment.content, } render_context.update(context) fragment.add_content( self.render_template('student_view', render_context)) return fragment @groupwork_protected_view def navigation_view(self, context): fragment = Fragment() children_context = {} children_context.update(context) stage_fragments = self._render_children('navigation_view', children_context, self.available_stages) stage_contents = [frag.content for frag in stage_fragments] fragment.add_frags_resources(stage_fragments) render_context = {'activity': self, 'stage_contents': stage_contents} fragment.add_content( self.render_template('navigation_view', render_context)) return fragment @groupwork_protected_view def resources_view(self, context): fragment = Fragment() resources = [ resource for stage in self.stages for resource in stage.resources ] has_resources = bool(resources) resource_fragments = self._render_children('resources_view', context, resources) resource_contents = [frag.content for frag in resource_fragments] fragment.add_frags_resources(resource_fragments) render_context = { 'activity': self, 'resource_contents': resource_contents, 'has_resources': has_resources } fragment.add_content( self.render_template('resources_view', render_context)) return fragment @groupwork_protected_view def submissions_view(self, context): fragment = Fragment() submissions = [ submission for stage in self.stages if isinstance(stage, SubmissionStage) for submission in stage.submissions ] has_submissions = bool(submissions) submission_fragments = self._render_children('submissions_view', context, submissions) submission_contents = [frag.content for frag in submission_fragments] fragment.add_frags_resources(submission_fragments) render_context = { 'activity': self, 'submission_contents': submission_contents, 'has_submissions': has_submissions } fragment.add_content( self.render_template('submissions_view', render_context)) return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_view(self, context): fragment = Fragment() children_context = context.copy() stage_fragments = self._render_children('dashboard_view', children_context, self.stages) stage_contents = [frag.content for frag in stage_fragments] fragment.add_frags_resources(stage_fragments) render_context = {'activity': self, 'stage_contents': stage_contents} fragment.add_content( self.render_template('dashboard_view', render_context)) return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_detail_view(self, context): fragment = Fragment() children_context = context.copy() target_workgroups = context.get(Constants.TARGET_WORKGROUPS) target_users = context.get(Constants.TARGET_STUDENTS) filtered_users = children_context[Constants.FILTERED_STUDENTS] stages = [] stage_stats = {} for stage in self.stages: if not stage.is_graded_stage: continue stage_fragment = stage.render('dashboard_detail_view', children_context) stage_fragment.add_frag_resources(fragment) stages.append({"id": stage.id, 'content': stage_fragment.content}) stage_stats[stage.id] = self._get_stage_completion_details( stage, target_workgroups, target_users) groups_data = self._build_groups_data(target_workgroups, stage_stats, filtered_users) visible_groups = [ group for group in groups_data if group["group_visible"] ] render_context = { 'activity': self, 'StageState': StageState, 'stages': stages, 'stages_count': len(stages), 'groups': visible_groups, 'filtered_out_workgroups': len(groups_data) - len(visible_groups), 'stage_cell_width_percent': (100 - 30) / float(len(stages)), # 30% is reserved for first column 'assigned_to_groups_label': messages.ASSIGNED_TO_GROUPS_LABEL.format( group_count=len(groups_data)) } fragment.add_content( self.render_template('dashboard_detail_view', render_context)) return fragment def _render_user(self, user, stage_stats, filtered_students): """ :param group_project_v2.project_api.dtos.ReducedUserDetail user: :param dict[str, StageCompletionDetailsData] stage_stats: Stage completion statistics :param set[int] filtered_students: users filtered out from view :return: dict """ return { 'id': user.id, 'full_name': user.full_name, 'email': user.email, 'is_filtered_out': user.id in filtered_students, 'stage_states': { stage_id: stage_data.user_stats.get(user.id, StageState.UNKNOWN) for stage_id, stage_data in stage_stats.iteritems() }, 'groups_to_grade': { stage_id: [{ 'id': group.id, 'ta_grade_link': self.get_ta_review_link(group.id, stage_id) } for group in stage_data.groups_to_grade.get(user.id, [])] for stage_id, stage_data in stage_stats.iteritems() } } def _render_workgroup(self, workgroup, stage_stats, filtered_students): """ :param group_project_v2.project_api.dtos.WorkgroupDetails workgroup: :param dict[str, StageCompletionDetailsData] stage_stats: Stage completion statistics :param set[int] filtered_students: users filtered out from view :return: dict """ users = [ self._render_user(user, stage_stats, filtered_students) for user in workgroup.users ] users.sort(key=itemgetter('is_filtered_out')) group_visible = any((not user['is_filtered_out'] for user in users)) return { 'id': workgroup.id, 'ta_grade_link': self.get_ta_review_link(workgroup.id), 'group_visible': group_visible, 'stage_states': { stage_id: { 'internal_status': stage_data.internal_group_status.get( workgroup.id, StageState.UNKNOWN), 'external_status': stage_data.external_group_status.get( workgroup.id, StageState.NOT_AVAILABLE), 'external_status_label': stage_data.external_group_status_label.get( workgroup.id, ""), } for stage_id, stage_data in stage_stats.iteritems() }, 'users': users } def _build_groups_data(self, workgroups, stage_stats, filtered_users): """ Converts WorkgroupDetails into dict expected by dashboard_detail_view template. :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] workgroups: Workgroups :param dict[str, StageCompletionDetailsData] stage_stats: Stage statistics - group-wise and user-wise completion data and groups_to_review. :param set[int] filtered_users: users filtered out from view - depending on actual view (dashboard or dashboard details) such students are either completely excluded, or included but diplayed differently :rtype: list[dict] :returns: List of dictionaries with the following format: * id - Group ID * stage_states - dictionary stage_id -> StateState * users - dictionary with the following format: * id - User ID * full_name - User full name * email - user email * stage_states - dictionary stage_id -> StageState * groups_to_grade - dictionary stage_id -> list of groups to grade """ return [ self._render_workgroup(workgroup, stage_stats, filtered_users) for workgroup in workgroups ] @classmethod def _get_stage_completion_details(cls, stage, target_workgroups, target_students): """ Gets stage completion stats from individual stage :param group_project_v2.stage.BaseGroupActivityStage stage: Get stage stats from this stage :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :rtype: StageCompletionDetailsData :returns: Stage completion stats """ completed_users, partially_completed_users = stage.get_users_completion( target_workgroups, target_students) user_stats = {} groups_to_grade = {} for user in target_students: state = StageState.NOT_STARTED if user.id in completed_users: state = StageState.COMPLETED elif user.id in partially_completed_users: state = StageState.INCOMPLETE user_stats[user.id] = state if isinstance(stage, PeerReviewStage): groups_to_grade[user.id] = stage.get_review_subjects(user.id) external_group_status, external_group_status_label, internal_group_status = cls._get_group_statuses( stage, target_workgroups, user_stats) return StageCompletionDetailsData( internal_group_status=internal_group_status, external_group_status=external_group_status, external_group_status_label=external_group_status_label, user_stats=user_stats, groups_to_grade=groups_to_grade) @classmethod def _get_group_statuses(cls, stage, target_workgroups, user_stats): internal_group_status, external_group_status, external_group_status_label = {}, {}, {} for group in target_workgroups: user_completions = [ user_stats.get(user.id, StageState.UNKNOWN) for user in group.users ] student_review_state = StageState.NOT_STARTED if all(completion == StageState.COMPLETED for completion in user_completions): student_review_state = StageState.COMPLETED elif any(completion != StageState.NOT_STARTED for completion in user_completions): student_review_state = StageState.INCOMPLETE elif any(completion == StageState.UNKNOWN for completion in user_completions): student_review_state = StageState.UNKNOWN internal_group_status[group.id] = student_review_state external_status = stage.get_external_group_status(group) external_group_status[group.id] = external_status external_group_status_label[ group.id] = stage.get_external_status_label(external_status) return external_group_status, external_group_status_label, internal_group_status def mark_complete(self, user_id): self.runtime.publish(self, 'progress', {'user_id': user_id}) def validate_field_data(self, validation, data): super(GroupActivityXBlock, self).validate_field_data(validation, data) should_be_ints = ('weight', 'group_reviews_required_count', 'user_review_count') for field_name in should_be_ints: try: int(getattr(data, field_name)) except (TypeError, ValueError): message = _( u"{field_name} must be integer, {field_value} given" ).format(field_name=field_name, field_value=getattr(data, field_name)) validation.add( ValidationMessage(ValidationMessage.ERROR, message)) def calculate_and_send_grade(self, group_id): grade_value = self.calculate_grade(group_id) if grade_value: self.assign_grade_to_group(group_id, grade_value) workgroup = self.project_api.get_workgroup_by_id(group_id) for user in workgroup.users: self.mark_complete(user.id) def assign_grade_to_group(self, group_id, grade_value): """ Assigns grade to group, fires required events and notifications :param int group_id: Group ID :param float grade_value: Grade to assign :return: """ self.project_api.set_group_grade(group_id, self.course_id, self.content_id, grade_value, self.max_score()) # Emit analytics event... self.runtime.publish( self, "group_activity.final_grade", { "grade_value": grade_value, "group_id": group_id, "content_id": self.content_id, }) notifications_service = self.runtime.service(self, 'notifications') if notifications_service: self.fire_grades_posted_notification(group_id, notifications_service) def calculate_grade(self, group_id): # pylint:disable=too-many-locals,too-many-branches review_item_data = self.project_api.get_workgroup_review_items_for_group( group_id, self.content_id) review_item_map = { make_key(review_item['question'], self.real_user_id(review_item['reviewer'])): review_item['answer'] for review_item in review_item_data } all_reviewer_ids = set([ self.real_user_id(review_item['reviewer']) for review_item in review_item_data ]) group_reviewer_ids = [ user["id"] for user in self.project_api.get_workgroup_reviewers( group_id, self.content_id) ] admin_reviewer_ids = [ reviewer_id for reviewer_id in all_reviewer_ids if reviewer_id not in group_reviewer_ids ] def get_user_grade_value_list(user_id): user_grades = [] for question in self.grade_questions: user_value = review_item_map.get( make_key(question.question_id, user_id), None) if user_value is None: # if any are incomplete, we consider the whole set to be unusable return None else: user_grades.append(user_value) return user_grades admin_provided_grades = None if len(admin_reviewer_ids) > 0: admin_provided_grades = [] # Only include complete admin gradesets admin_reviewer_grades = [ arg for arg in [ get_user_grade_value_list(admin_id) for admin_id in admin_reviewer_ids ] if arg ] admin_grader_count = len(admin_reviewer_grades) if admin_grader_count > 1: for idx in range(len(self.grade_questions)): admin_provided_grades.append( mean([adm[idx] for adm in admin_reviewer_grades])) elif admin_grader_count > 0: # which actually means admin_grader_count == 1 admin_provided_grades = admin_reviewer_grades[0] user_grades = {} if len(group_reviewer_ids) > 0: for reviewer_id in group_reviewer_ids: this_reviewers_grades = get_user_grade_value_list(reviewer_id) if this_reviewers_grades is None: if admin_provided_grades: this_reviewers_grades = admin_provided_grades else: return None user_grades[reviewer_id] = this_reviewers_grades elif admin_provided_grades: group_reviewer_ids = [self.user_id] user_grades[self.user_id] = admin_provided_grades else: return None # Okay, if we've got here we have a complete set of marks to calculate the grade reviewer_grades = [ mean(user_grades[reviewer_id]) for reviewer_id in group_reviewer_ids if len(user_grades[reviewer_id]) > 0 ] group_grade = round( mean(reviewer_grades)) if len(reviewer_grades) > 0 else None return group_grade
def test_date_format_error(self): with self.assertRaises(ValueError): DateTime().from_json('invalid')
class StaffGradedAssignmentXBlock(XBlock): """ This block defines a Staff Graded Assignment. Students are shown a rubric and invited to upload a file which is then graded by staff. """ has_score = True icon_class = 'problem' STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB display_name = String( default='Staff Graded Assignment', scope=Scope.settings, help="This name appears in the horizontal navigation at the top of " "the page." ) weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={"min": 0, "step": .1}, scope=Scope.settings ) points = 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") 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 self.scope_ids.usage_id def student_submission_id(self, submission_id=None): # pylint: disable=no-member """ Returns dict required by the submissions app for creating and retrieving submissions for a particular student. """ if submission_id is None: submission_id = self.xmodule_runtime.anonymous_student_id assert submission_id != ( 'MOCK', "Forgot to call 'personalize' in test." ) return { "student_id": submission_id, "course_id": self.course_id, "item_id": self.block_id, "item_type": 'sga', # ??? } def get_submission(self, submission_id=None): """ Get student's most recent submission. """ submissions = submissions_api.get_submissions( self.student_submission_id(submission_id)) if submissions: # If I understand docs correctly, most recent submission should # be first return submissions[0] def get_score(self, submission_id=None): """ Return student's current score. """ score = submissions_api.get_score( self.student_submission_id(submission_id) ) if score: return score['points_earned'] @reify def score(self): """ Return score from submissions. """ return self.get_score() 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": getattr( settings, "STUDENT_FILEUPLOAD_MAX_SIZE", self.STUDENT_FILEUPLOAD_MAX_SIZE ) } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) fragment = Fragment() fragment.add_content( render_template( 'templates/staff_graded_assignment/show.html', context ) ) fragment.add_css(_resource("static/css/edx_sga.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.add_javascript(_resource("static/js/src/jquery.tablesorter.min.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment def 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 < _now() context['location'] = self.location context['category'] = type(self).__name__ context['fields'] = [ (name, field.read_from(self)) for name, field in self.fields.items()] def student_state(self): """ Returns a JSON serializable representation of student's state for rendering in client view. """ submission = self.get_submission() if submission: uploaded = {"filename": submission['answer']['filename']} else: uploaded = None if self.annotated_sha1: annotated = {"filename": self.annotated_filename} else: annotated = None score = self.score if score is not None: graded = {'score': score, 'comment': self.comment} else: graded = None return { "display_name": self.display_name, "uploaded": uploaded, "annotated": annotated, "graded": graded, "max_score": self.max_score(), "upload_allowed": self.upload_allowed(), } 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) 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( "Init for course:%s module:%s student:%s ", module.course_id, module.module_state_key, module.student.username ) state = json.loads(module.state) score = self.get_score(student.student_id) approved = score is not None if score is None: score = state.get('staff_score') needs_approval = score is not None else: needs_approval = False instructor = self.is_instructor() yield { 'module_id': module.id, 'student_id': student.student_id, 'submission_id': submission['uuid'], 'username': module.student.username, 'fullname': module.student.profile.name, 'filename': submission['answer']["filename"], 'timestamp': submission['created_at'].strftime( DateTime.DATETIME_FORMAT ), 'score': score, 'approved': approved, 'needs_approval': instructor and needs_approval, 'may_grade': instructor or not approved, 'annotated': state.get("annotated_filename"), 'comment': state.get("comment", ''), } return { 'assignments': list(get_student_data()), 'max_score': self.max_score(), 'display_name': self.display_name } def studio_view(self, context=None): """ Return fragment for editing block in studio. """ try: cls = type(self) def none_to_empty(data): """ Return empty string if data is None else return data. """ return data if data is not None else '' edit_fields = ( (field, none_to_empty(getattr(self, field.name)), validator) for field, validator in ( (cls.display_name, 'string'), (cls.points, 'number'), (cls.weight, 'number')) ) context = { 'fields': edit_fields } fragment = Fragment() fragment.add_content( render_template( 'templates/staff_graded_assignment/edit.html', context ) ) fragment.add_javascript(_resource("static/js/src/studio.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment except: # pragma: NO COVER log.error("Don't swallow my exceptions", exc_info=True) raise @XBlock.json_handler def save_sga(self, data, suffix=''): # 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 """ Save a students submission file. """ require(self.upload_allowed()) upload = request.params['assignment'] sha1 = _get_sha1(upload.file) answer = { "sha1": sha1, "filename": upload.file.name, "mimetype": mimetypes.guess_type(upload.file.name)[0], } student_id = self.student_submission_id() submissions_api.create_submission(student_id, answer) path = self._file_storage_path(sha1, upload.file.name) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) return Response(json_body=self.student_state()) @XBlock.handler def staff_upload_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Save annotated assignment from staff. """ require(self.is_course_staff()) upload = request.params['annotated'] module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['annotated_sha1'] = sha1 = _get_sha1(upload.file) state['annotated_filename'] = filename = upload.file.name state['annotated_mimetype'] = mimetypes.guess_type(upload.file.name)[0] state['annotated_timestamp'] = _now().strftime( DateTime.DATETIME_FORMAT ) path = self._file_storage_path(sha1, filename) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) module.state = json.dumps(state) module.save() log.info( "staff_upload_annotated for course:%s module:%s student:%s ", module.course_id, module.module_state_key, module.student.username ) return Response(json_body=self.staff_grading_data()) @XBlock.handler def download_assignment(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch student assignment from storage and return it. """ answer = self.get_submission()['answer'] path = self._file_storage_path(answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename']) @XBlock.handler def download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch assignment with staff annotations from storage and return it. """ path = self._file_storage_path( self.annotated_sha1, self.annotated_filename, ) return self.download( path, self.annotated_mimetype, self.annotated_filename ) @XBlock.handler def staff_download(self, request, suffix=''): # pylint: disable=unused-argument """ Return an assignment file requested by staff. """ require(self.is_course_staff()) submission = self.get_submission(request.params['student_id']) answer = submission['answer'] path = self._file_storage_path(answer['sha1'], answer['filename']) return self.download( path, answer['mimetype'], answer['filename'], require_staff=True ) @XBlock.handler def staff_download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Return annotated assignment file requested by staff. """ require(self.is_course_staff()) module = StudentModule.objects.get(pk=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 ) def download(self, path, mime_type, filename, require_staff=False): """ Return a file from storage and return in a Response. """ try: file_descriptor = default_storage.open(path) app_iter = iter(partial(file_descriptor.read, BLOCK_SIZE), '') return Response( app_iter=app_iter, content_type=mime_type, content_disposition="attachment; filename=" + 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 ) @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()) def validate_score_message(self, course_id, username): 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" } @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 = StudentModule.objects.get(pk=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, unicode(self.course_id), unicode(self.block_id)) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['staff_score'] = None state['comment'] = '' state['annotated_sha1'] = None state['annotated_filename'] = None state['annotated_mimetype'] = None state['annotated_timestamp'] = None module.state = json.dumps(state) module.save() 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()) 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) if due is not None: return _now() > due return False def upload_allowed(self): """ Return whether student is allowed to submit an assignment. """ return not self.past_due() and self.score is None def _file_storage_path(self, sha1, filename): # pylint: disable=no-member """ Get file path of storage. """ path = ( '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}' '/{sha1}{ext}'.format( loc=self.location, sha1=sha1, ext=os.path.splitext(filename)[1] ) ) return path
class BaseGroupActivityStage( CommonMixinCollection, DashboardXBlockMixin, XBlockWithPreviewMixin, StageNotificationsMixin, XBlockWithUrlNameDisplayMixin, AdminAccessControlXBlockMixin, XBlock, ): open_date = DateTime(display_name=_(u"Open Date"), help=_(u"Stage open date"), scope=Scope.settings) close_date = DateTime(display_name=_(u"Close Date"), help=_(u"Stage close date"), scope=Scope.settings) hide_stage_label = Boolean( display_name=_(u"Hide stage type label"), help=_(u"If true, hides stage type label in Project Navigator"), scope=Scope.settings, default=True) editable_fields = ('display_name', 'open_date', 'close_date', 'hide_stage_label') has_children = True has_score = False # TODO: Group project V1 are graded at activity level. Check if we need to follow that submissions_stage = False CATEGORY = None STAGE_WRAPPER_TEMPLATE = 'templates/html/stages/stage_wrapper.html' STAGE_CONTENT_TEMPLATE = 'templates/html/stages/default_view.html' NAVIGATION_LABEL = None STUDIO_LABEL = _(u"Stage") EXTERNAL_STATUSES_LABEL_MAPPING = {} DEFAULT_EXTERNAL_STATUS_LABEL = "" js_file = None js_init = None template_location = 'stages' @property def id(self): return self.scope_ids.usage_id @property def navigation_label(self): return self._(self.NAVIGATION_LABEL) @property def allowed_nested_blocks(self): # pylint: disable=no-self-use """ This property outputs an ordered dictionary of allowed nested XBlocks in form of block_category: block_caption. """ blocks = [HtmlXBlockShim, GroupProjectResourceXBlock] if GroupProjectVideoResourceXBlock.is_available(): blocks.append(GroupProjectVideoResourceXBlock) blocks.append(ProjectTeamXBlock) return blocks @lazy def activity(self): """ :rtype: group_project_v2.group_project.GroupActivityXBlock """ return self.get_parent() @property def allow_admin_grader_access(self): return False @property def content_id(self): return get_block_content_id(self) @property def activity_content_id(self): return self.activity.content_id @property def resources(self): return self.get_children_by_category( GroupProjectResourceXBlock.CATEGORY, GroupProjectVideoResourceXBlock.CATEGORY) @property def team_members(self): """ Returns teammates to review. May throw `class`: OutsiderDisallowedError """ if not self.is_group_member: return [] try: result = [] for team_member in self.workgroup.users: team_member_id = team_member.id if self.user_id == int(team_member_id): continue result.append(self.project_api.get_member_data(team_member_id)) return result except ApiError: return [] @property def formatted_open_date(self): return format_date(self.open_date) @property def formatted_close_date(self): return format_date(self.close_date) @property def is_open(self): return (self.open_date is None) or ( self.open_date <= datetime.utcnow().replace(tzinfo=pytz.UTC)) @property def is_closed(self): # If this stage is being loaded for the purposes of a TA grading, # then we never close the stage - in this way a TA can impose any # action necessary even if it has been closed to the group members if not self.is_group_member: return False if self.close_date is None: return False # A stage is closed at the end of the closing day. return self.close_date + timedelta( days=1) <= datetime.utcnow().replace(tzinfo=pytz.UTC) @property def completed(self): return self.get_stage_state() == StageState.COMPLETED @property def available_now(self): return self.is_open and not self.is_closed @property def url_name_caption(self): return self._(messages.STAGE_URL_NAME_TEMPLATE).format( stage_name=self._(self.STUDIO_LABEL)) @property def can_mark_complete(self): return self.available_now and self.is_group_member @property def is_graded_stage(self): # pylint: disable=no-self-use """ If a stage is graded it is shown as graded on the the main dashboard, this property also is used by default implementation of ``shown_on_detail_view``. :rtype: bool """ return False @property def shown_on_detail_view(self): """ If true details of this stage are shown on the dashboard detail view, by default it returns ``is_graded_stage``. :rtype: bool """ return self.is_graded_stage @property def dashboard_details_view_url(self): return self.activity.dashboard_details_url() def is_current_stage(self, context): target_stage_id = context.get( Constants.CURRENT_STAGE_ID_PARAMETER_NAME, None) if not target_stage_id: return False return target_stage_id == self.id def _view_render(self, context, view='student_view'): stage_fragment = self.get_stage_content_fragment(context, view) fragment = Fragment() fragment.add_fragment_resources(stage_fragment) render_context = { 'stage': self, 'stage_content': stage_fragment.content, "ta_graded": self.activity.group_reviews_required_count } fragment.add_content( loader.render_django_template( self.STAGE_WRAPPER_TEMPLATE, render_context, i18n_service=self.i18n_service, )) if stage_fragment.js_init_fn: fragment.initialize_js(stage_fragment.js_init_fn) return fragment @groupwork_protected_view def student_view(self, context): return self._view_render(context) @groupwork_protected_view def author_preview_view(self, context): # if we use student_view or author_view Studio will wrap it in HTML that we don't want in the preview fragment = self._view_render(context, "preview_view") url_name_fragment = self.get_url_name_fragment(self.url_name_caption) fragment.add_content(url_name_fragment.content) fragment.add_fragment_resources(url_name_fragment) return fragment @groupwork_protected_view def author_edit_view(self, context): fragment = super(BaseGroupActivityStage, self).author_edit_view(context) url_name_fragment = self.get_url_name_fragment(self.url_name_caption) fragment.add_content(url_name_fragment.content) fragment.add_fragment_resources(url_name_fragment) return fragment def render_children_fragments(self, context, view='student_view'): children_fragments = [] for child in self._children: child_fragment = self._render_child_fragment(child, context, view) children_fragments.append(child_fragment) return children_fragments def get_stage_content_fragment(self, context, view='student_view'): fragment = Fragment() children_fragments = self.render_children_fragments(context, view=view) render_context = { 'stage': self, 'children_contents': [frag.content for frag in children_fragments] } for frag in children_fragments: fragment.add_fragment_resources(frag) render_context.update(context) fragment.add_content( loader.render_django_template( self.STAGE_CONTENT_TEMPLATE, render_context, i18n_service=self.i18n_service, )) if self.js_file: add_resource(self, 'javascript', self.js_file, fragment) if self.js_init: fragment.initialize_js(self.js_init) return fragment def mark_complete(self, user_id=None): user_id = user_id if user_id is not None else self.user_id self.runtime.publish(self, 'progress', {'user_id': user_id}) def get_stage_state(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) def get_dashboard_stage_state(self, target_workgroups, target_students): """ :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :return (str, dict[str, float]): Stage state and detailed stage stats as returned by `get_stage_stats` """ state_stats = self.get_stage_stats(target_workgroups, target_students) if state_stats.get(StageState.COMPLETED, 0) == 1: stage_state = StageState.COMPLETED elif state_stats.get(StageState.INCOMPLETE, 0) > 0 or state_stats.get( StageState.COMPLETED, 0) > 0: stage_state = StageState.INCOMPLETE else: stage_state = StageState.NOT_STARTED return stage_state, state_stats def get_stage_stats(self, target_workgroups, target_students): # pylint: disable=no-self-use """ Calculates stage state stats for given workgroups and students :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :return dict[str, float]: Percentage of students completed, partially completed and not started the stage as floats in range[0..1] """ target_user_ids = set(user.id for user in target_students) if not target_user_ids: return { StageState.COMPLETED: None, StageState.INCOMPLETE: None, StageState.NOT_STARTED: None } target_user_count = float(len(target_user_ids)) completed_users_ids, partially_completed_users_ids = self.get_users_completion( target_workgroups, target_students) log_format_data = dict( stage=self.display_name, target_users=target_user_ids, completed=completed_users_ids, partially_completed=partially_completed_users_ids) log.info(STAGE_STATS_LOG_TPL, log_format_data) completed_ratio = len(completed_users_ids & target_user_ids) / target_user_count partially_completed_ratio = len(partially_completed_users_ids & target_user_ids) / target_user_count return { StageState.COMPLETED: completed_ratio, StageState.INCOMPLETE: partially_completed_ratio, StageState.NOT_STARTED: 1 - completed_ratio - partially_completed_ratio } def get_users_completion(self, target_workgroups, target_users): """ Returns sets of completed user ids and partially completed user ids :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users: :rtype: (set[int], set[int]) """ raise NotImplementedError(MUST_BE_OVERRIDDEN) def get_external_group_status(self, group): # pylint: disable=unused-argument, no-self-use """ Calculates external group status for the Stage. Meaning of external status varies by Stage - see actual implementations docstrings. :param group_project_v2.project_api.dtos.WorkgroupDetails group: workgroup :rtype: StageState """ return StageState.NOT_AVAILABLE def get_external_status_label(self, status): """ Gets human-friendly label for external status. Label vary by stage, so consult with actual implementaiton for details :param StageState status: external stage status :rtype: str """ return self.EXTERNAL_STATUSES_LABEL_MAPPING.get( status, self.DEFAULT_EXTERNAL_STATUS_LABEL) def navigation_view(self, context): """ Renders stage content for navigation view :param dict context: :rtype: Fragment """ fragment = Fragment() rendering_context = { 'stage': self, 'activity_id': self.activity.id, 'stage_state': self.get_stage_state(), 'block_link': get_link_to_block(self), 'is_current_stage': self.is_current_stage(context) } rendering_context.update(context) fragment.add_content( loader.render_django_template( "templates/html/stages/navigation_view.html", rendering_context, i18n_service=self.i18n_service, )) return fragment @classmethod def make_human_stats(cls, stats): """ Readies stats dictionary for presentation, by sorting it's contents, and converting ratios to percentages. """ return OrderedDict([ (StageState.get_human_name(stage), stats[stage] * 100 if stats[stage] is not None else None) for stage in (StageState.NOT_STARTED, StageState.INCOMPLETE, StageState.COMPLETED) ]) @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_view(self, context): """ Renders stage content for dashboard view. :param dict context: :rtype: Fragment """ fragment = Fragment() target_workgroups = context.get(Constants.TARGET_WORKGROUPS) target_students = context.get(Constants.TARGET_STUDENTS) filtered_students = context.get(Constants.FILTERED_STUDENTS) students_to_display = [ student for student in target_students if student.id not in filtered_students ] state, stats = self.get_dashboard_stage_state(target_workgroups, students_to_display) human_stats = self.make_human_stats(stats) render_context = { 'stage': self, 'stats': human_stats, 'stage_state': state, 'ta_graded': self.activity.is_ta_graded } render_context.update(context) fragment.add_content( self.render_template('dashboard_view', render_context)) return fragment @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_detail_view(self, context): """ Renders stage header for dashboard details view. :param dict context: :rtype: Fragment """ fragment = Fragment() render_context = { 'stage': self, 'ta_graded': self.activity.is_ta_graded, 'download_incomplete_emails_handler_url': self.get_incomplete_emails_handler_url() } fragment.add_content( self.render_template('dashboard_detail_view', render_context)) return fragment def get_incomplete_emails_handler_url(self): base_url = self.runtime.handler_url(self.activity.project, 'download_incomplete_list') query_params = {Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME: self.id} return base_url + '?' + urlencode(query_params) def get_new_stage_state_data(self): return { "activity_id": str(self.activity.id), "stage_id": str(self.id), "state": self.get_stage_state() }
class TCXBlock(XBlock): log = logging.getLogger(__name__) """ TO-DO: document what your XBlock does. """ # Fields are defined on the class. You can access them in your code as # self.<fieldname>. points= Integer( default=0, scope=Scope.user, help="Number of points", ) TournamentID= Integer( default=0, scope=Scope.user, help="Number of points", ) nquestions = Integer( default=0, scope=Scope.user_state, help="Number of questions", ) correct_answers = Integer( default=5, scope=Scope.user_state, help="Number of questions", ) tournamentID = Integer( default=0, scope=Scope.user_state, help="Number of questions", ) 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." ) visible = String( default="No", scope=Scope.settings, help="Indicates wether the tournament is visible or not", ) questions = List( scope=Scope.settings, help="List of questions for the assesment", ) active_tournaments = List( scope=Scope.settings, help="List of questions for the assesment", ) temp = List( scope=Scope.settings, help="List of questions for the assesment", ) finished_tournaments = List( scope=Scope.settings, help="List of questions for the assesment", ) active_tournaments = List( scope=Scope.settings, help="List of questions for the assesment", ) def days_between(d1, d2): a = dt.datetime(2013,12,30,23,59,59) b = dt.datetime(2013,12,31,23,59,59) return (b-a).total_seconds() 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 new_tournament(self, context=None): return frag def studio_view(self, context): """ The primary view of the TCXBlock course builder, shown to course builders when instantiating the xblock. """ html = self.resource_string("public/admin/html/mainMenu.html") frag2 = Fragment(html.format(self=self)) html2 = self.resource_string("public/html/New_Tournament.html") frag2.add_css(self.resource_string("public/admin/css/tournamentcreator.css")) img=self.runtime.local_resource_url(self, "public/admin/icons/listasd.png") frag2.add_content(html2) frag2.add_javascript(self.resource_string("public/admin/js/interface.js")) frag2.initialize_js('studio') return frag2 # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view of the TCXBlock, shown to students when viewing the xblock in their course. """ html = self.resource_string("public/html/tournamentcreator.html") frag = Fragment(html.format(self=self)) frag.add_css(self.resource_string("public/admin/css/tournamentcreator.css")) frag.add_css(self.resource_string("public/css/bootstrap.css")) frag.add_javascript(self.resource_string("public/js/src/tournamentcreator.js")) frag.initialize_js('TCXBlock') return frag """Course Builder Handlers """ @XBlock.json_handler def Load_Tournaments_List(self, data, suffix=''): """ Loads a list of all the tournaments available """ for x in xrange(len(self.active_tournaments)): if data["Name"]==self.active_tournaments[x]['TName']: for y in xrange(len(self.questions)): if self.active_tournaments[x]['TournamentID']==self.nquestions[y]['TournamentID']: temp.push(self.nquestions[y]) return 1 @XBlock.json_handler def Load_Inactive_Tournaments(self, data, suffix=''): """ Loads a list of all inactive tournaments. """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp @XBlock.json_handler def Publish_Unpublish_Tournament(self, data, suffix=''): """ Given a tournament id adds the tournament to the published or unpublished tournament list """ for x in xrange(len(self.active_tournaments)): if data["Name"]==self.active_tournaments[x]['TName']: for y in xrange(len(self.questions)): if self.active_tournaments[x]['TournamentID']==self.nquestions[y]['TournamentID']: temp.push(self.nquestions[y]) return 1 @XBlock.json_handler def Load_CourseBuilderTournament_Statistics(self, data, suffix=''): """ Loads the statistics of a tournament given by its id for the course builder """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp """Student Handlers """ @XBlock.json_handler def Load_Student_Statistics(self, data, suffix=''): """ Loads the statistics of a tournament given by its id for student """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp @XBlock.json_handler def Load_AllStudentsStatistics(self, data, suffix=''): """ Loads the statistics of all tournaments for a student """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp @XBlock.json_handler def Load_AllCourseBuildersStatistics(self, data, suffix=''): """ Loads the statistics of all tournaments for a course builder """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp @XBlock.json_handler def Save_Answer(self, data, suffix=''): """ Saves the answer for a certain student for a certain question """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp """Shared Handlers """ @XBlock.json_handler def Load_Active_Tournaments(self, data, suffix=''): """ Loads a list of all the published tournaments """ self.temp[:]=[] for x in xrange(len(self.active_tournaments)): self.temp.append(self.active_tournaments[x]) return self.temp @XBlock.json_handler def Load_Tournament(self, data, suffix=''): """ Given an id, loads the questions for the tournament """ for x in xrange(len(self.active_tournaments)): if data["Name"]==self.active_tournaments[x]['TName']: for y in xrange(len(self.questions)): if self.active_tournaments[x]['TournamentID']==self.nquestions[y]['TournamentID']: temp.push(self.nquestions[y]) return 1 @XBlock.json_handler def Save_Tournament(self, data, suffix=''): """ Given an id, saves the data for a tournament """ self.nquestions=data['NQuestions'] self.active_tournaments.append({ 'TournamentID': self.tournamentID, 'TName': data['TName'], 'NQuestions': data['NQuestions'] }) for x in xrange(self.nquestions): if data['question'][x]['Type']=="Multiple": self.questions.append({ 'TournamentID': self.tournamentID, 'QuestionN': data['question'][x]['QuestionN'], 'Type' : data['question'][x]['Type'], 'Question' : data['question'][x]['Question'], 'Answer1' : data['question'][x]['Answer1'], 'Answer2' : data['question'][x]['Answer2'], 'Answer3' : data['question'][x]['Answer3'], 'Answer4' : data['question'][x]['Answer4'], 'CAnswer' : data['question'][x]['CAnswer'], }) if data['question'][x]['Type']=="Image": self.questions.append({ 'TournamentID': self.tournamentID, 'QuestionN': data['question'][x]['QuestionN'], 'Type' : data['question'][x]['Type'], 'Question' : data['question'][x]['Question'], 'Tip' : data['question'][x]['Tip'], 'Answer' : data['question'][x]['Answer'], 'Url' : data['question'][x]['Url'], }) if data['question'][x]['Type']=="Video": self.questions.append({ 'TournamentID': self.tournamentID, 'QuestionN': data['question'][x]['QuestionN'], 'Type' : data['question'][x]['Type'], 'Question' : data['question'][x]['Question'], 'Tip' : data['question'][x]['Tip'], 'Answer' : data['question'][x]['Answer'], 'Url' : data['question'][x]['Url'], }) return 0 #for data['QuestionN']{ #} # 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 speed_mode(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 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 config_screen_four(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 [("TCXBlock", """ <tournamentcreator/> """), ]
class AntXBlockFields(object): # Отображаемое имя display_name = String( display_name="Display name", default='AcademicNT Assignment', help="This name appears in the horizontal navigation at the top of " "the page.", scope=Scope.settings) # Отображаемый текстовый комментарий content = String(display_name="Content", default='', help="Text.", scope=Scope.settings) # Максимальный результат за задание points = Float( default=0, scope=Scope.user_state, ) # Вес задания в рамках всего Grading'а 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 }, default=1, scope=Scope.settings) # Идентификатор курса в рамках СУО ANT ant_course_id = String(display_name="Course id", default='', help="Course id in ant.", scope=Scope.settings) # Идентификатор юнита лабораторной работы с рамках СУО ANT ant_unit_id = String(display_name="Course id", default='', help="Unit id in ant.", scope=Scope.settings) # Время, отведённое на выполнение работы в ANT ant_time_limit = Integer(display_name="Time limit", default=0, help="Time limit (in minutes)", scope=Scope.settings) # Количество попыток, отведённое на выполнение работы в ANT ant_attempts_limit = Integer( default=0, help="Submission id", scope=Scope.settings, ) # Статус выполнения задания, магическая строка: RUNNING, IDLE ant_status = String(scope=Scope.user_state, ) # Время последнего обновления лимитов (время, попытки), переданных от СУО limit_renewal = DateTime(display_name="Limits renewal", help="When limits have been renewed.", default=None, scope=Scope.settings) # Пользовательские баллы за лабораторную score = Float(display_name="Grade score", default=0, help="Total calculated score.", scope=Scope.user_state) # Количество затраченных на выполнение попыток attempts = Integer(display_name="Attempts used", default=0, help="Total used attempts.", scope=Scope.user_state) # Идентификатор текущего задания проверки, поставленного в очередь # (Возможно, больше не нужен) celery_task_id = String(display_name="Task id", default="", scope=Scope.user_state) # Результат последней проверки лабораторной, полученный от СУО ant_result = String(display_name="Latest ANT result", default="", help="Latest result retrieved by grade() method", scope=Scope.user_state) # Адрес для вытягивания информации о выполнении attempts_url = DefaultedDescriptor( base_class=String, display_name="Attempts API url", default=CONFIG.get('ATTEMPTS_URL'), help="Url api to get latest user attempts.", # scope=Scope(UserScope.NONE, BlockScope.TYPE) scope=Scope.content, ) # Адрес с лабораторной, открывается в попапе lab_url = DefaultedDescriptor( base_class=String, display_name="Lab API url", default=CONFIG.get('LAB_URL'), help="Lab url.", # scope=Scope(UserScope.NONE, BlockScope.TYPE) scope=Scope.content, )
class DoneWithAnswerXBlock(XBlock): """ Show a toggle which lets students mark things as done. """ description = String(scope=Scope.content, help=_("Problem description."), default=_("Default problem description")) done = Boolean(scope=Scope.user_state, help=_("Is the student done?"), default=False) feedback = String(scope=Scope.content, help=_("Feedback for student."), default=_("Default feedback")) button_name = String(scope=Scope.content, help=_("Button name."), default=_("Done")) has_score = True skip_flag = False display_name = String( default=_("Self-reflection question with feedback answer"), scope=Scope.settings, help=_("Display name")) def init_emulation(self): """ Emulation of init function, for translation purpose. """ if not self.skip_flag: i18n_ = self.runtime.service(self, "i18n").ugettext self.fields['display_name']._default = i18n_( self.fields['display_name']._default) self.skip_flag = True # pylint: disable=unused-argument @XBlock.json_handler def toggle_button(self, data, suffix=''): """ Ajax call when the button is clicked. Input is a JSON dictionary with one boolean field: `done`. This will save this in the XBlock field, and then issue an appropriate grade. """ if 'done' in data: self.done = data['done'] if data['done']: grade = 1 else: grade = 0 grade_event = {'value': grade, 'max_value': 1} self.runtime.publish(self, 'grade', grade_event) # This should move to self.runtime.publish, once that pipeline # is finished for XBlocks. self.runtime.publish(self, "edx.done.toggled", {'done': self.done}) return {'state': self.done} def student_view(self, context=None): # pylint: disable=unused-argument """ The primary view of the DoneXBlock, shown to students when viewing courses. """ html_resource = resource_string("static/html/done.html") html = html_resource.format(done=self.done, feedback=self.feedback, description=self.description, button_name=self.button_name, id=uuid.uuid1(0)) frag = Fragment(html) frag.add_css(resource_string("static/css/done.css")) frag.add_javascript(resource_string("static/js/src/done.js")) frag.initialize_js("DoneWithAnswerXBlock", { 'state': self.done, 'button_name': self.button_name }) return frag def studio_view(self, _context=None): # pylint: disable=unused-argument ''' Minimal view with no configuration options giving some help text. ''' self.init_emulation() ctx = { 'done': self.done, 'feedback': self.feedback, 'description': self.description, 'button_name': self.button_name, 'id': uuid.uuid1(0) } frag = Fragment() frag.add_content( loader.render_django_template( "static/html/studioview.html", context=ctx, i18n_service=self.runtime.service(self, "i18n"), )) frag.add_javascript(resource_string("static/js/src/studioview.js")) frag.initialize_js("DoneWithAnswerXBlockEdit") return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Called when submitting the form in Studio. """ self.description = data.get('description') self.feedback = data.get('feedback') self.button_name = data.get('button_name') return {'result': 'success'} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("DoneWithAnswerXBlock", """<vertical_demo> <donewithanswer description="Click Mark as complete" button_name="Mark as complete" feedback="Good job!"> </donewithanswer> <donewithanswer description="Think about Poland" button_name="Poland!" feedback="Well done!"> </donewithanswer> <donewithanswer description="Pres Alt+F4" button_name="Alt+F4" feedback="Great!"> </donewithanswer> <donewithanswer description="" feedback=""></donewithanswer> <donewithanswer></donewithanswer> </vertical_demo> """), ] # Everything below is stolen from # https://github.com/edx/edx-ora2/blob/master/apps/openassessment/ # xblock/lms_mixin.py # It's needed to keep the LMS+Studio happy. # It should be included as a mixin. 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.") 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) 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
class ShortAnswerXBlock(XBlock): """ This block defines a Short Answer. Students can submit a short answer and instructors can view, grade, add feedback and download a CSV of all submissions. """ has_score = True icons_class = 'problem' answer = String(display_name=_('Student\'s answer'), default='', scope=Scope.user_state, help=_('Text the student entered as answer.')) answered_at = DateTime( display_name=_('Answer submission time.'), default=None, scope=Scope.user_state, help=_('Time and date when the answer has been submitted.')) description = String( display_name=_('Description'), default= _('Submit your questions and observations in 1-2 short paragraphs below.' ), scope=Scope.settings, help=_('Description that appears above the text input area.')) display_name = String( display_name='Display name', default='Short Answer', scope=Scope.settings, help= 'This name appears in the horizontal navigation at the top of the page.' ) feedback = String( display_name=_('Instructor feedback'), default=_('Your answer was submitted successfully.'), scope=Scope.settings, help= _('Message that will be shown to the student once the student submits an answer.' )) 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 }, default=100, scope=Scope.settings) score = Integer(display_name=_('Student score'), default=0, scope=Scope.settings, help=_('Score given for this assignment.')) grades_published = Boolean( display_name='Display grade to students', scope=Scope.user_state_summary, default=False, help='Indicates if the grades will be displayed to students.') width = Integer(display_name=_('Input Area Width'), default=500, scope=Scope.settings, help='Defines the width of the input text area in pixels.') @property def module(self): """ Retrieve the student module for current user. """ module, _ = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=self.user, ) return module @property def passed_due(self): """ Return true if the due date has passed. """ now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) due = get_extended_due_date(self) if due is not None: return now > due return False @property def student_grade(self): """ Retrieve the manually added grade for the current user. """ return self.module.grade @property def user(self): """ Retrieve the user object from the user_id in xmodule_runtime. """ return User.objects.get(id=self.xmodule_runtime.user_id) def max_score(self): return self.weight or 100 def studio_view(self, context=None): """ View for the form when editing this block in Studio. """ cls = type(self) context['fields'] = ( (cls.display_name, getattr(self, 'display_name', ''), 'input', 'text'), (cls.description, getattr(self, 'description', ''), 'textarea', 'text'), (cls.feedback, getattr(self, 'feedback', ''), 'textarea', 'text'), (cls.weight, getattr(self, 'weight', ''), 'input', 'number'), (cls.width, getattr(self, 'width', ''), 'input', 'number'), ) frag = Fragment() frag.add_content( render_template('static/html/short_answer_edit.html', context)) frag.add_css(resource_string('static/css/short_answer_edit.css')) frag.add_javascript( resource_string('static/js/src/short_answer_edit.js')) frag.initialize_js('ShortAnswerStudioXBlock') return frag def student_view(self, context=None): """ The primary view of the ShortAnswerXBlock, shown to students when viewing courses. """ js_options = { 'gradesPublished': self.grades_published, 'weight': self.weight, 'passedDue': self.passed_due, 'score': self.student_grade, } context.update({ 'answer': self.answer, 'description': self.description, 'feedback': self.feedback, 'grades_published': self.grades_published, 'is_course_staff': getattr(self.xmodule_runtime, 'user_is_staff', False), 'max_score': self.max_score(), 'module_id': self.module. id, # Use the module id to generate different pop-up modals 'passed_due': self.passed_due, 'score': self.student_grade, 'width': self.width, }) frag = Fragment() frag.add_content( render_template('static/html/short_answer.html', context)) frag.add_css(resource_string('static/css/short_answer.css')) frag.add_javascript(resource_string('static/js/src/short_answer.js')) frag.initialize_js('ShortAnswerXBlock', js_options) return frag def update_weight(self): """Update all previous CSM's.""" StudentModule.objects.filter( course_id=self.course_id, module_state_key=self.location).update(max_grade=self.weight) def error_response(self, msg): return Response(status_code=400, body=json.dumps({'error': msg})) @XBlock.json_handler def student_submission(self, data, _): """ Handle the student's answer submission. """ if self.passed_due: return self.error_response('Submission due date has passed.') elif self.grades_published: return self.error_response('Grades already published.') self.answer = data.get('submission') self.answered_at = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) return Response(status_code=201) @XBlock.json_handler def submit_edit(self, data, _): """ Handle the Studio edit form request. """ for key in data: setattr(self, key, data[key]) self.update_weight() return Response(status_code=201) @XBlock.json_handler def submit_grade(self, data, _): """ Handle the grade submission request. """ score = data.get('score') module_id = data.get('module_id') if not (score and module_id): error_msg_tpl = 'Missing {params} parameter.' if not (score or module_id): missing_params = 'score and module_id' else: missing_params = 'score' if not score else 'module_id' return self.error_response( error_msg_tpl.format(params=missing_params)) if float(score) > self.weight: return self.error_response( 'Submitted score larger than the maximum allowed.') module = StudentModule.objects.get(pk=module_id) module.grade = float(score) module.max_grade = self.weight module.save() return Response(status_code=200, body=json.dumps({'new_score': module.grade})) # pylint: disable=no-self-use @XBlock.json_handler def remove_grade(self, data, _): """ Handle the grade removal request. """ module_id = data.get('module_id') if not module_id: return self.error_response('Missing module_id parameters.') module = StudentModule.objects.get(pk=module_id) module.grade = None module.save() return Response(status_code=200) @XBlock.handler def update_grades_published(self, request, suffix=''): self.grades_published = json.loads( request.params.get('grades_published')) return Response(status=200) def get_submissions_list(self): """ Return a list of all enrolled students and their answer submission information. """ enrollments = CourseEnrollment.objects.filter( course_id=self.course_id, is_active=True).exclude( Q(user__is_staff=True) | Q(user__is_superuser=True)) submissions_list = [] for enrollment in enrollments: student = enrollment.user module, _ = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=student, defaults={ 'max_grade': self.weight, 'module_type': self.category, 'state': '{}', 'grade': 0, }) state = json.loads(module.state) if module.state else {} state_answered_at = state.get('answered_at', '') # For answers not defaulted to zero. if module.grade is None: module.grade = 0 module.save() submissions_list.append({ 'answer': state.get('answer'), 'answered_at': str(state_answered_at), 'email': student.email, 'fullname': student.profile.name, 'max_score': self.max_score(), 'module_id': module.id, 'score': '{:.2f}'.format(module.grade), }) return submissions_list @XBlock.handler def answer_submissions(self, *args, **kwargs): # pylint: disable=unused-argument """ Return the submission information of the enrolled students. """ submissions_list = self.get_submissions_list() return Response(status_code=200, body=json.dumps(submissions_list)) def create_csv(self, local_timezone_offset): """ CSV file generator. Yields a CSV line in each iteration. """ submission_list = self.get_submissions_list() yield create_csv_row( ['Name', 'Email', 'Answer', 'Answered at', 'Score']) for entry in submission_list: answered_at = entry.get('answered_at', '') if answered_at and local_timezone_offset: answered_at = localize_datetime(answered_at, local_timezone_offset) yield create_csv_row([ entry.get('fullname'), entry.get('email'), entry.get('answer', ''), answered_at, entry.get('score') ]) @XBlock.handler def csv_download(self, request, *args, **kwargs): # pylint: disable=unused-argument """ Handles the CSV download request. """ local_datetime = request.params.get('local-datetime') local_timezone_offset = dateutil.parser.parse(local_datetime).tzinfo response = Response(content_type='text/csv') response.content_disposition = 'attachment; filename="short_answer_submissions.csv"' response.app_iter = self.create_csv(local_timezone_offset) return response
class ScormXBlock(XBlock): display_name = String( display_name=_("Display Name"), help=_("Display name for this module"), default="Scorm", scope=Scope.settings, ) scorm_file = String( display_name=_("Upload scorm file"), scope=Scope.settings, ) scorm_modified = DateTime( scope=Scope.settings, ) version_scorm = String( default="SCORM_12", scope=Scope.settings, ) # save completion_status for SCORM_2004 lesson_status = String( scope=Scope.user_state, default='not attempted' ) success_status = String( scope=Scope.user_state, default='unknown' ) lesson_location = String( scope=Scope.user_state, default='' ) suspend_data = String( scope=Scope.user_state, default='' ) data_scorm = Dict( scope=Scope.user_state, default={} ) lesson_score = Float( scope=Scope.user_state, default=0 ) weight = Float( display_name=_('Weight'), default=1.0, values={"min": 0, "step": .1}, help=_("Weight of this Scorm, by default keep 1"), scope=Scope.settings ) has_score = Boolean( display_name=_("Scored"), help=_("Select true if this component will receive a numerical score from the Scorm"), default=False, scope=Scope.settings ) icon_class = String( default="video", scope=Scope.settings, ) cmi_modified = DateTime( scope=Scope.user_state ) cmi_data = Dict( scope=Scope.user_state, default={}, ) has_author_view = True def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def get_fields_data(self, only_value=False, *fields): data = {} for k, v in self.fields.iteritems(): if k in fields: if not only_value: data[k] = v data["{}_value".format(k)] = getattr(self, k) if 'scorm_file' in data and self.scorm_file: request = get_current_request() scheme = 'https' if settings.HTTPS == 'on' else 'http' scorm_file_value = '{}://{}{}'.format(scheme, request.site.domain, self.scorm_file) data['scorm_file_value'] = scorm_file_value if 'scorm_modified_value' in data and data['scorm_modified_value']: data['scorm_modified_value'] = dt2str(data['scorm_modified_value']) if 'cmi_modified_value' in data and data['cmi_modified_value']: data['cmi_modified_value'] = dt2str(data['cmi_modified_value']) return data def studio_view(self, context=None): # context_html = self.get_context_studio() fields_data = self.get_fields_data(False, 'display_name', 'scorm_file', 'has_score', 'weight') template = self.render_template('static/html/studio.html', fields_data) frag = Fragment(template) frag.add_css(self.resource_string("static/css/scormxblock.css")) frag.add_javascript(self.resource_string("static/js/src/studio.js")) frag.initialize_js('ScormStudioXBlock') return frag def get_student_data(self): fields_data = self.get_fields_data(False, 'lesson_score', 'weight', 'has_score', 'success_status', 'scorm_file') return fields_data def student_view(self, context=None): template = self.render_template('static/html/scormxblock.html', self.get_student_data()) frag = Fragment(template) frag.add_css(self.resource_string("static/css/scormxblock.css")) frag.add_javascript(self.resource_string("static/js/src/scormxblock.js")) frag.initialize_js('ScormXBlock', json_args=self.get_fields_data(True, 'version_scorm', 'scorm_modified')) return frag @XBlock.handler def studio_submit(self, request, suffix=''): self.display_name = request.params['display_name'] self.has_score = request.params['has_score'] self.weight = request.params['weight'] self.icon_class = 'problem' if self.has_score == 'True' else 'video' if hasattr(request.params['file'], 'file'): file = request.params['file'].file zip_file = zipfile.ZipFile(file, 'r') path_to_file = os.path.join(settings.PROFILE_IMAGE_BACKEND['options']['location'], self.location.block_id) path_to_file = str(path_to_file) if os.path.exists(path_to_file): shutil.rmtree(path_to_file, ignore_errors=True) zip_file.extractall(path_to_file) self.set_scorm(path_to_file) return Response(json.dumps({'result': 'success'}), content_type='application/json') def author_view(self, context): html = self.resource_string("static/html/author_view.html") frag = Fragment(html) return frag # @XBlock.json_handler # def scorm_get_value(self, data, suffix=''): # name = data.get('name') # if name in ['cmi.core.lesson_status', 'cmi.completion_status']: # return {'value': self.lesson_status} # elif name == 'cmi.success_status': # return {'value': self.success_status} # elif name == 'cmi.core.lesson_location': # return {'value': self.lesson_location} # elif name == 'cmi.suspend_data': # return {'value': self.suspend_data} # else: # return {'value': self.data_scorm.get(name, '')} # @XBlock.json_handler # def commit(self, data, suffix=''): # context = {'result': 'success'} # for name, value in data.iteritems(): # if name in ['cmi.core.lesson_status', 'cmi.completion_status']: # self.lesson_status = value # if self.has_score and value in ['completed', 'failed', 'passed']: # context.update({"lesson_score": self.lesson_score}) # # elif name == 'cmi.success_status': # self.success_status = value # if self.has_score: # if self.success_status == 'unknown': # self.lesson_score = 0 # context.update({"lesson_score": self.lesson_score}) # # elif name in ['cmi.core.score.raw', 'cmi.score.raw'] and self.has_score: # score = float(data.get(name, 0)) # self.lesson_score = score / 100.0 # if self.lesson_score > self.weight: # logger.error("error score, user {}: {}".format( # self.get_user_id(), # self.data # )) # context.update({"lesson_score": self.lesson_score}) # # elif name == 'cmi.core.lesson_location': # self.lesson_location = str(value) or '' # # elif name == 'cmi.suspend_data': # self.suspend_data = value or '' # else: # self.data_scorm[name] = value or '' # # self.publish_grade() # context.update({"completion_status": self.get_completion_status()}) # return context def is_cmi_data_expired(self, package_date): expired = False need_update = True if self.scorm_modified: if package_date and str2dt(package_date) < self.scorm_modified: # when user still visit one scorm, but new package uploaded # so, no update, only update when user next time visit new uploaded scorm expired = True need_update = False elif self.cmi_modified and self.cmi_modified < self.scorm_modified: # normal case expired = True need_update = True return expired, need_update @XBlock.json_handler def scorm_get_value(self, data, suffix=''): name = data['name'] package_version = data.pop('package_version', '') if package_version == 'SCORM_12': default = SCORM_12_RUNTIME_DEFAULT.get(name, '') else: default = SCORM_2004_RUNTIME_DEFAULT.get(name, '') package_date = data.pop('package_date', '') if self.is_cmi_data_expired(package_date)[0]: value = default else: value = self.cmi_data.get(name, default) return {"value": value} @XBlock.json_handler def commit(self, data, suffix=''): package_date = data.pop('package_date', '') package_version = data.pop('package_version', '') expired, need_update = self.is_cmi_data_expired(package_date) if expired: self.cmi_data = {} if need_update: self.cmi_data.update(data) self.cmi_modified = timezone.now() if self.set_lesson(data, package_version): self.publish_grade() return self.get_fields_data(True, 'success_status', 'lesson_score') def _set_lesson_12(self, data): score_updated = False if 'cmi.core.score.raw' in data: if 'cmi.core.score.min' in data and 'cmi.core.score.max' in data: score = (float(data['cmi.core.score.raw']) - float(data['cmi.core.score.min']))/(float(data['cmi.core.score.max']) - float(data['cmi.core.score.min'])) self.lesson_score = score score_updated = True else: score = float(data['cmi.core.score.raw']) ''' if score < 0: score = float(0) if self.lesson_score != float(100): self.lesson_score = score ''' self.lesson_score = score score_updated = True if 'cmi.core.lesson_status' in data: self.cmi_data['cmi.core.lesson_status'] = data['cmi.core.lesson_status'] self.success_status = data['cmi.core.lesson_status'] return score_updated def _set_lesson_2004(self, data): score_updated = False if 'cmi.score.scaled' in data: self.lesson_score = float(data['cmi.score.scaled']) score_updated = True if 'cmi.success_status' in data: self.success_status = data['cmi.success_status'] return score_updated def set_lesson(self, data, version): """ all the score has been resize to [0, 1] """ if version == 'SCORM_12': return self._set_lesson_12(data) else: return self._set_lesson_2004(data) def publish_grade(self): self.runtime.publish( self, 'grade', { 'value': self.lesson_score, 'max_value': 1.0, }) def get_user_id(self): return getattr(self.runtime, 'user_id', None) def max_score(self): """ Return the maximum score possible. """ return self.weight if self.has_score else None def render_template(self, template_path, context): template_str = self.resource_string(template_path) template = Template(template_str) return template.render(Context(context)) def set_scorm(self, path_to_file): path_index_page = 'index.html' try: tree = ET.parse('{}/imsmanifest.xml'.format(path_to_file)) except IOError: pass else: namespace = '' for node in [node for _, node in ET.iterparse('{}/imsmanifest.xml'.format(path_to_file), events=['start-ns'])]: if node[0] == '': namespace = node[1] break root = tree.getroot() if namespace: resource = root.find('{{{0}}}resources/{{{0}}}resource'.format(namespace)) schemaversion = root.find('{{{0}}}metadata/{{{0}}}schemaversion'.format(namespace)) else: resource = root.find('resources/resource') schemaversion = root.find('metadata/schemaversion') if resource: path_index_page = resource.get('href') if (not schemaversion is None) and (re.match('^1.2$', schemaversion.text) is None): self.version_scorm = 'SCORM_2004' else: self.version_scorm = 'SCORM_12' self.scorm_modified = timezone.now() self.scorm_file = os.path.join(settings.PROFILE_IMAGE_BACKEND['options']['base_url'], '{}/{}'.format(self.location.block_id, path_index_page)) def get_completion_status(self): return self.success_status @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("ScormXBlock", """<vertical_demo> <scormxblock/> </vertical_demo> """), ]
class StaffGradedAssignmentXBlock(StudioEditableXBlockMixin, ShowAnswerXBlockMixin, XBlock): """ This block defines a Staff Graded Assignment. Students are shown a rubric and invited to upload a file which is then graded by staff. """ has_score = True icon_class = 'problem' STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB editable_fields = ('display_name', 'points', 'weight', 'showanswer', 'solution') display_name = String( display_name=_("Display Name"), default=_('Staff Graded Assignment'), scope=Scope.settings, help=_("This name appears in the horizontal navigation at the top of " "the page.")) weight = Float( display_name=_("Problem Weight"), help=_("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) points = Integer( display_name=_("Maximum score"), help=_("Maximum grade score given to assignment by staff."), default=100, scope=Scope.settings) staff_score = Integer( display_name=_("Score assigned by non-instructor staff"), help=_("Score will need to be approved by instructor before being " "published."), default=None, scope=Scope.settings) comment = String(display_name=_("Instructor comment"), default='', scope=Scope.user_state, help=_("Feedback given to student by instructor.")) annotated_sha1 = String( display_name=_("Annotated SHA1"), scope=Scope.user_state, default=None, help=_("sha1 of the annotated file uploaded by the instructor for " "this assignment.")) annotated_filename = String( display_name=_("Annotated file name"), scope=Scope.user_state, default=None, help=_("The name of the annotated file uploaded for this assignment.")) annotated_mimetype = String( display_name=_("Mime type of annotated file"), scope=Scope.user_state, default=None, help=_( "The mimetype of the annotated file uploaded for this assignment.") ) annotated_timestamp = DateTime( display_name=_("Timestamp"), scope=Scope.user_state, default=None, help=_("When the annotated file was uploaded")) @classmethod def student_upload_max_size(cls): """ returns max file size limit in system """ return getattr(settings, "STUDENT_FILEUPLOAD_MAX_SIZE", cls.STUDENT_FILEUPLOAD_MAX_SIZE) @classmethod def file_size_over_limit(cls, file_obj): """ checks if file size is under limit. """ file_obj.seek(0, os.SEEK_END) return file_obj.tell() > cls.student_upload_max_size() @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """ Override default serialization to handle <solution /> elements """ block = runtime.construct_xblock_from_class(cls, keys) for child in node: if child.tag == "solution": # convert child elements of <solution> into HTML for display block.solution = ''.join( etree.tostring(subchild) for subchild in child) # Attributes become fields. # Note that a solution attribute here will override any solution XML element for name, value in node.items(): # lxml has no iteritems cls._set_field_if_present(block, name, value, {}) return block def add_xml_to_node(self, node): """ Override default serialization to output solution field as a separate child element. """ super().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,raise-missing-from """ 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 """ Save a students submission file. """ require(self.upload_allowed()) user = self.get_real_user() require(user) upload = request.params['assignment'] sha1 = get_sha1(upload.file) if self.file_size_over_limit(upload.file): raise JsonHandlerError( 413, 'Unable to upload file. Max size limit is {size}'.format( size=self.student_upload_max_size())) # Uploading an assignment represents a change of state with this user in this block, # so we need to ensure that the user has a StudentModule record, which represents that state. self.get_or_create_student_module(user) answer = { "sha1": sha1, "filename": upload.file.name, "mimetype": mimetypes.guess_type(upload.file.name)[0], "finalized": False } student_item_dict = self.get_student_item_dict() submissions_api.create_submission(student_item_dict, answer) path = self.file_storage_path(sha1, upload.file.name) log.info("Saving file: %s at path: %s for user: %s", upload.file.name, path, user.username) if default_storage.exists(path): # save latest submission default_storage.delete(path) default_storage.save(path, File(upload.file)) return Response(json_body=self.student_state()) @XBlock.handler def finalize_uploaded_assignment(self, request, suffix=''): # pylint: disable=unused-argument """ Finalize a student's uploaded submission. This prevents further uploads for the given block, and makes the submission available to instructors for grading """ submission_data = self.get_submission() require(self.upload_allowed(submission_data=submission_data)) # Editing the Submission record directly since the API doesn't support it submission = Submission.objects.get(uuid=submission_data['uuid']) if not submission.answer.get('finalized'): submission.answer['finalized'] = True submission.submitted_at = django_now() submission.save() return Response(json_body=self.student_state()) @XBlock.handler def staff_upload_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Save annotated assignment from staff. """ require(self.is_course_staff()) upload = request.params['annotated'] sha1 = get_sha1(upload.file) if self.file_size_over_limit(upload.file): raise JsonHandlerError( 413, 'Unable to upload file. Max size limit is {size}'.format( size=self.student_upload_max_size())) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) state['annotated_sha1'] = sha1 state['annotated_filename'] = filename = upload.file.name state['annotated_mimetype'] = mimetypes.guess_type(upload.file.name)[0] state['annotated_timestamp'] = utcnow().strftime( DateTime.DATETIME_FORMAT) path = self.file_storage_path(sha1, filename) if not default_storage.exists(path): default_storage.save(path, File(upload.file)) module.state = json.dumps(state) module.save() log.info("staff_upload_annotated for course:%s module:%s student:%s ", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def download_assignment(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch student assignment from storage and return it. """ answer = self.get_submission()['answer'] path = self.file_storage_path(answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename']) @XBlock.handler def download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Fetch assignment with staff annotations from storage and return it. """ path = self.file_storage_path( self.annotated_sha1, self.annotated_filename, ) return self.download(path, self.annotated_mimetype, self.annotated_filename) @XBlock.handler def staff_download(self, request, suffix=''): # pylint: disable=unused-argument """ Return an assignment file requested by staff. """ require(self.is_course_staff()) submission = self.get_submission(request.params['student_id']) answer = submission['answer'] path = self.file_storage_path(answer['sha1'], answer['filename']) return self.download(path, answer['mimetype'], answer['filename'], require_staff=True) @XBlock.handler def staff_download_annotated(self, request, suffix=''): # pylint: disable=unused-argument """ Return annotated assignment file requested by staff. """ require(self.is_course_staff()) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) path = self.file_storage_path(state['annotated_sha1'], state['annotated_filename']) return self.download(path, state['annotated_mimetype'], state['annotated_filename'], require_staff=True) @XBlock.handler def get_staff_grading_data(self, request, suffix=''): # pylint: disable=unused-argument """ Return the html for the staff grading view """ require(self.is_course_staff()) return Response(json_body=self.staff_grading_data()) @XBlock.handler def enter_grade(self, request, suffix=''): # pylint: disable=unused-argument """ Persist a score for a student given by staff. """ require(self.is_course_staff()) score = request.params.get('grade', None) module = self.get_student_module(request.params['module_id']) if not score: return Response(json_body=self.validate_score_message( module.course_id, module.student.username)) state = json.loads(module.state) try: score = int(score) except ValueError: return Response(json_body=self.validate_score_message( module.course_id, module.student.username)) if self.is_instructor(): uuid = request.params['submission_id'] submissions_api.set_score(uuid, score, self.max_score()) else: state['staff_score'] = score state['comment'] = request.params.get('comment', '') module.state = json.dumps(state) module.save() log.info("enter_grade for course:%s module:%s student:%s", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def remove_grade(self, request, suffix=''): # pylint: disable=unused-argument """ Reset a students score request by staff. """ require(self.is_course_staff()) student_id = request.params['student_id'] submissions_api.reset_score(student_id, self.block_course_id, self.block_id) module = self.get_student_module(request.params['module_id']) state = json.loads(module.state) state['staff_score'] = None state['comment'] = '' state['annotated_sha1'] = None state['annotated_filename'] = None state['annotated_mimetype'] = None state['annotated_timestamp'] = None module.state = json.dumps(state) module.save() log.info("remove_grade for course:%s module:%s student:%s", module.course_id, module.module_state_key, module.student.username) return Response(json_body=self.staff_grading_data()) @XBlock.handler def prepare_download_submissions(self, request, suffix=''): # pylint: disable=unused-argument """ Runs a async task that collects submissions in background and zip them. """ # pylint: disable=no-member require(self.is_course_staff()) user = self.get_real_user() require(user) zip_file_ready = False location = str(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 # check if some one reset submission. If yes the recreate zip file assignment_count = len(assignments) if self.count_archive_files(user) != assignment_count: zip_file_ready = False if not zip_file_ready: log.info("Creating new zip file for block: %s for instructor: %s", location, user.username) zip_student_submissions.delay(self.block_course_id, self.block_id, location, user.username) return Response(json_body={"downloadable": zip_file_ready}) @XBlock.handler def download_submissions(self, request, suffix=''): # pylint: disable=unused-argument """ Api for downloading zip file which consist of all students submissions. """ # pylint: disable=no-member require(self.is_course_staff()) user = self.get_real_user() require(user) try: zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) zip_file_name = get_zip_file_name(user.username, self.block_course_id, self.block_id) return Response(app_iter=file_contents_iter(zip_file_path), content_type='application/zip', content_disposition="attachment; filename=" + zip_file_name) except OSError: return Response( "Sorry, submissions cannot be found. Press Collect ALL Submissions button or" " contact {} if you issue is consistent".format( settings.TECH_SUPPORT_EMAIL), status_code=404) @XBlock.handler def download_submissions_status(self, request, suffix=''): # pylint: disable=unused-argument """ returns True if zip file is available for download """ require(self.is_course_staff()) user = self.get_real_user() require(user) return Response( json_body={"zip_available": self.is_zip_file_available(user)}) def student_view(self, context=None): # pylint: disable=no-member """ The primary view of the StaffGradedAssignmentXBlock, shown to students when viewing courses. """ context = { "student_state": json.dumps(self.student_state()), "id": self.location.name.replace('.', '_'), "max_file_size": self.student_upload_max_size(), "support_email": settings.TECH_SUPPORT_EMAIL } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/show.html', context)) fragment.add_css(_resource("static/css/edx_sga.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.add_javascript( _resource("static/js/src/jquery.tablesorter.min.js")) fragment.initialize_js('StaffGradedAssignmentXBlock') return fragment def studio_view(self, context=None): """ Render a form for editing this XBlock """ # this method only exists to provide context=None for backwards compat return super().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 str(self.scope_ids.usage_id) @reify def block_course_id(self): """ Return the course_id of the block. """ return str(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] return None 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'] return None @reify def score(self): """ Return score from submissions. """ return self.get_score() def update_staff_debug_context(self, context): # pylint: disable=no-member """ Add context info for the Staff Debug interface. """ published = self.start context['is_released'] = published and published < utcnow() context['location'] = self.location context['category'] = type(self).__name__ context['fields'] = [(name, field.read_from(self)) for name, field in self.fields.items()] def get_student_module(self, module_id): """ Returns a StudentModule that matches the given id Args: module_id (int): The module id Returns: StudentModule: A StudentModule object """ return StudentModule.objects.get(pk=module_id) def get_or_create_student_module(self, user): """ Gets or creates a StudentModule for the given user for this block Returns: StudentModule: A StudentModule object """ # pylint: disable=no-member student_module, created = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=user, defaults={ 'state': '{}', 'module_type': self.category, }) if created: log.info("Created student module %s [course: %s] [student: %s]", student_module.module_state_key, student_module.course_id, student_module.student.username) return student_module def student_state(self): """ Returns a JSON serializable representation of student's state for rendering in client view. """ submission = self.get_submission() if submission: uploaded = {"filename": submission['answer']['filename']} else: uploaded = None if self.annotated_sha1: annotated = {"filename": force_text(self.annotated_filename)} else: annotated = None score = self.score if score is not None: graded = {'score': score, 'comment': force_text(self.comment)} else: graded = None if self.answer_available(): solution = self.runtime.replace_urls(force_text(self.solution)) else: solution = '' # pylint: disable=no-member return { "display_name": force_text(self.display_name), "uploaded": uploaded, "annotated": annotated, "graded": graded, "max_score": self.max_score(), "upload_allowed": self.upload_allowed(submission_data=submission), "solution": solution, "base_asset_url": StaticContent.get_base_url_path_for_course_assets( self.location.course_key), } def staff_grading_data(self): """ Return student assignment information for display on the grading screen. """ def get_student_data(): """ Returns a dict of student assignment information along with annotated file name, student id and module id, this information will be used on grading screen """ # Submissions doesn't have API for this, just use model directly. students = SubmissionsStudent.objects.filter( course_id=self.course_id, item_id=self.block_id) for student in students: submission = self.get_submission(student.student_id) if not submission: continue user = user_by_anonymous_id(student.student_id) student_module = self.get_or_create_student_module(user) state = json.loads(student_module.state) score = self.get_score(student.student_id) approved = score is not None if score is None: score = state.get('staff_score') needs_approval = score is not None else: needs_approval = False instructor = self.is_instructor() yield { 'module_id': student_module.id, 'student_id': student.student_id, 'submission_id': submission['uuid'], 'username': student_module.student.username, 'fullname': student_module.student.profile.name, 'filename': submission['answer']["filename"], 'timestamp': submission['created_at'].strftime( DateTime.DATETIME_FORMAT), 'score': score, 'approved': approved, 'needs_approval': instructor and needs_approval, 'may_grade': instructor or not approved, 'annotated': force_text(state.get("annotated_filename", '')), 'comment': force_text(state.get("comment", '')), 'finalized': is_finalized_submission(submission_data=submission) } return { 'assignments': list(get_student_data()), 'max_score': self.max_score(), 'display_name': force_text(self.display_name) } def get_sorted_submissions(self): """returns student recent assignments sorted on date""" assignments = [] submissions = submissions_api.get_all_submissions( self.course_id, self.block_id, ITEM_TYPE) for submission in submissions: if is_finalized_submission(submission_data=submission): assignments.append({ 'submission_id': submission['uuid'], 'filename': submission['answer']["filename"], 'timestamp': submission['submitted_at'] or submission['created_at'] }) assignments.sort(key=lambda assignment: assignment['timestamp'], reverse=True) return assignments def download(self, path, mime_type, filename, require_staff=False): """ Return a file from storage and return in a Response. """ try: content_disposition = "attachment; filename*=UTF-8''" content_disposition += six.moves.urllib.parse.quote( filename.encode('utf-8')) output = Response(app_iter=file_contents_iter(path), content_type=mime_type, content_disposition=content_disposition) return output except OSError: 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-function-docstring # pylint: disable=no-member log.error( "enter_grade: invalid grade submitted for course:%s module:%s student:%s", course_id, self.location, username) return {"error": "Please enter valid grade"} def is_course_staff(self): # pylint: disable=no-member """ Check if user is course staff. """ return getattr(self.xmodule_runtime, 'user_is_staff', False) def is_instructor(self): # pylint: disable=no-member """ Check if user role is instructor. """ return self.xmodule_runtime.get_user_role() == 'instructor' def show_staff_grading_interface(self): """ Return if current user is staff and not in studio. """ in_studio_preview = self.scope_ids.user_id is None return self.is_course_staff() and not in_studio_preview def past_due(self): """ Return whether due date has passed. """ due = get_extended_due_date(self) try: graceperiod = self.graceperiod except AttributeError: # graceperiod and due are defined in InheritanceMixin # It's used automatically in edX but the unit tests will need to mock it out graceperiod = None if graceperiod is not None and due: close_date = due + graceperiod else: close_date = due if close_date is not None: return utcnow() > close_date return False def upload_allowed(self, submission_data=None): """ Return whether student is allowed to upload an assignment. """ submission_data = submission_data if submission_data is not None else self.get_submission( ) return (not self.past_due() and self.score is None and not is_finalized_submission(submission_data)) def file_storage_path(self, file_hash, original_filename): # pylint: disable=no-member """ Helper method to get the path of an uploaded file """ return get_file_storage_path(self.location, file_hash, original_filename) def is_zip_file_available(self, user): """ returns True if zip file exists. """ # pylint: disable=no-member zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) return default_storage.exists(zip_file_path) def count_archive_files(self, user): """ returns number of files archive in zip. """ # pylint: disable=no-member zip_file_path = get_zip_file_path(user.username, self.block_course_id, self.block_id, self.location) with default_storage.open(zip_file_path, 'rb') as zip_file: with closing(ZipFile(zip_file)) as archive: return len(archive.infolist()) def get_real_user(self): """returns session user""" # pylint: disable=no-member return self.runtime.get_real_user( self.xmodule_runtime.anonymous_student_id) def correctness_available(self): """ For SGA is_correct just means the user submitted the problem, which we always know one way or the other """ return True def is_past_due(self): """ Is it now past this problem's due date? """ return self.past_due() def is_correct(self): """ For SGA we show the answer as soon as we know the user has given us their submission """ return self.has_attempted() def has_attempted(self): """ True if the student has already attempted this problem """ submission = self.get_submission() if not submission: return False return submission['answer']['finalized'] def can_attempt(self): """ True if the student can attempt the problem """ return not self.has_attempted() def runtime_user_is_staff(self): """ Is the logged in user a staff user? """ return self.is_course_staff()
class ScormXBlock(XBlock): has_score = True has_author_view = True has_custom_completion = True display_name = String(display_name=_("Display Name"), help=_("Display name for this module"), default="SCORM", scope=Scope.settings) description = String( display_name=_("Description"), help= _("Brief description of the SCORM modules will be displayed above the player. Can contain HTML." ), default="", scope=Scope.settings) scorm_file = String(display_name=_("Upload scorm file (.zip)"), help=_('Upload a new SCORM package.'), scope=Scope.settings) scorm_player = String( values=[{ "value": key, "display_name": DEFINED_PLAYERS[key]['name'] } for key in DEFINED_PLAYERS.keys()] + [ SCORM_PKG_INTERNAL, ], display_name=_("SCORM player"), help= _("SCORM player configured in Django settings, or index.html file contained in SCORM package" ), scope=Scope.settings) # this stores latest raw SCORM API data in JSON string raw_scorm_status = String(scope=Scope.user_state, default='{}') scorm_initialized = Boolean(scope=Scope.user_state, default=False) lesson_status = String(scope=Scope.user_state, default='not attempted') lesson_score = Float(scope=Scope.user_state, default=0) scorm_progress = Float(scope=Scope.user_state, default=0) weight = Integer( default=1, help= _('SCORM block\'s problem weight in the course, in points. If not graded, set to 0' ), scope=Scope.settings) auto_completion = Boolean( display_name=_("Enable completion upon viewing SCORM file"), default=False, scope=Scope.settings) is_next_module_locked = Boolean( display_name=_("Locking"), help= _('Enable requirement to complete SCORM content before moving to next module' ), default=False, scope=Scope.settings, ) display_type = String( display_name=_("Display Type"), values=["iframe", "popup"], default="iframe", help=_( "Open in a new popup window, or an iframe. This setting may be overridden by " "player-specific configuration."), scope=Scope.settings) popup_launch_type = String( display_name=_("Popup Launch Type"), values=["auto", "manual"], default="auto", help=_("Open in a new popup through button or automatically."), scope=Scope.settings) launch_button_text = String(display_name=_("Launch Button Text"), help=_("Display text for Launch Button"), default="Launch", scope=Scope.settings) display_width = Integer(display_name=_("Display Width (px)"), help=_('Width of iframe or popup window'), default=820, scope=Scope.settings) display_height = Integer(display_name=_("Display Height (px)"), help=_('Height of iframe or popup window'), default=450, scope=Scope.settings) encoding = String( display_name=_("SCORM Package text encoding"), default='cp850', help=_( "Character set used in SCORM package. Defaults to cp850 (or IBM850), " "for Latin-1: Western European languages)"), values=[{ "value": AVAIL_ENCODINGS[key], "display_name": key } for key in sorted(AVAIL_ENCODINGS.keys())], scope=Scope.settings) player_configuration = String( display_name=_("Player Configuration"), default='', help= _("JSON object string with overrides to be passed to selected SCORM player. " "These will be exposed as data attributes on the host iframe and sent in a window.postMessage " "to the iframe's content window. Attributes can be any. " "'Internal player' will always check this field for an 'initial_html' attribute " "to override index.html as the initial page."), scope=Scope.settings) scorm_file_name = String(display_name=_("Scorm File Name"), help=_("Scorm Package Uploaded File Name"), default="", scope=Scope.settings) file_uploaded_date = DateTime(default=None, scope=Scope.settings, help="Scorm File Last Uploaded Date") @property def student_id(self): if hasattr(self, "scope_ids"): return self.scope_ids.user_id else: return None @property def student_name(self): if hasattr(self, "xmodule_runtime"): user = self.xmodule_runtime._services['user'].get_current_user() try: return user.display_name except AttributeError: return user.full_name else: return None @property def course_id(self): if hasattr(self, "xmodule_runtime"): return self._serialize_opaque_key(self.xmodule_runtime.course_id) else: return None def _reverse_student_name(self, name): parts = name.split(' ', 1) parts.reverse() return ', '.join(parts) def _serialize_opaque_key(self, key): if hasattr(key, 'to_deprecated_string'): return key.to_deprecated_string() else: return six.text_type(key) 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 student_view(self, context=None, authoring=False): scheme = 'https' if settings.HTTPS == 'on' else 'http' lms_base = settings.ENV_TOKENS.get('LMS_BASE') if isinstance(context, QueryDict): context = context.dict() scorm_player_url = "" course_directory = self.scorm_file if self.scorm_player == 'SCORM_PKG_INTERNAL': # TODO: support initial filename other than index.html for internal players scorm_player_url = '{}://{}{}'.format(scheme, lms_base, self.scorm_file) elif self.scorm_player: player_config = DEFINED_PLAYERS[self.scorm_player] player = player_config['location'] if '://' in player: scorm_player_url = player else: scorm_player_url = '{}://{}{}'.format(scheme, lms_base, player) course_directory = '{}://{}{}'.format( scheme, lms_base, self.runtime.handler_url(self, "proxy_content")) html = self.resource_string("static/html/scormxblock.html") # don't call handlers if student_view is not called from within LMS # (not really a student) if not authoring: get_url = '{}://{}{}'.format( scheme, lms_base, self.runtime.handler_url(self, "get_raw_scorm_status")) set_url = '{}://{}{}'.format( scheme, lms_base, self.runtime.handler_url(self, "set_raw_scorm_status")) get_completion_url = '{}://{}{}'.format( scheme, lms_base, self.runtime.handler_url(self, "get_scorm_completion")) # PreviewModuleSystem (runtime Mixin from Studio) won't have a hostname else: # we don't want to get/set SCORM status from preview get_url = set_url = get_completion_url = '#' # if display type is popup, don't use the full window width for the host iframe iframe_width = self.display_type == 'popup' and DEFAULT_IFRAME_WIDTH or self.display_width iframe_height = self.display_type == 'popup' and DEFAULT_IFRAME_HEIGHT or self.display_height show_popup_manually = self.display_type == 'popup' and self.popup_launch_type == 'manual' lock_next_module = self.is_next_module_locked and self.scorm_progress < constants.MAX_PROGRESS_VALUE try: player_config = json.loads(self.player_configuration) except ValueError: player_config = {} frag = Fragment() frag.add_content( MakoTemplate(text=html.format( self=self, scorm_player_url=scorm_player_url, get_url=get_url, set_url=set_url, get_completion_url=get_completion_url, iframe_width=iframe_width, iframe_height=iframe_height, player_config=player_config, show_popup_manually=show_popup_manually, scorm_file=course_directory, is_next_module_locked=lock_next_module)).render_unicode()) frag.add_css(self.resource_string("static/css/scormxblock.css")) context['block_id'] = self.url_name js = self.resource_string("static/js/src/scormxblock.js") jsfrag = MakoTemplate(js).render_unicode(**context) frag.add_javascript(jsfrag) # TODO: this will only work to display staff debug info if 'scormxblock' is one of the # categories of blocks that are specified in lms/templates/staff_problem_info.html so this will # for now have to be overridden in theme or directly in edx-platform # TODO: is there another way to approach this? key's location.category isn't mutable to spoof 'problem', # like setting the name in the entry point to 'problem'. Doesn't seem like a good idea. Better to # have 'staff debuggable' categories configurable in settings or have an XBlock declare itself staff debuggable if SCORM_DISPLAY_STAFF_DEBUG_INFO and not authoring: # don't show for author preview from courseware.access import has_access from courseware.courses import get_course_by_id course = get_course_by_id(self.xmodule_runtime.course_id) dj_user = self.xmodule_runtime._services['user']._django_user has_instructor_access = bool( has_access(dj_user, 'instructor', course)) if has_instructor_access: disable_staff_debug_info = settings.FEATURES.get( 'DISPLAY_DEBUG_INFO_TO_STAFF', True) and False or True block = self view = 'student_view' frag = add_staff_markup(dj_user, has_instructor_access, disable_staff_debug_info, block, view, frag, context) frag.initialize_js('ScormXBlock_{0}'.format(context['block_id'])) return frag def author_view(self, context=None): return self.student_view(context, authoring=True) def studio_view(self, context=None): html = self.resource_string("static/html/studio.html") frag = Fragment() file_uploaded_date = get_default_time_display( self.file_uploaded_date) if self.file_uploaded_date else '' context = {'block': self, 'file_uploaded_date': file_uploaded_date} frag.add_content(MakoTemplate(text=html).render_unicode(**context)) frag.add_css(self.resource_string("static/css/scormxblock.css")) frag.add_javascript(self.resource_string("static/js/src/studio.js")) frag.add_javascript_url( self.runtime.local_resource_url(self, 'public/jquery.fileupload.js')) frag.initialize_js('ScormStudioXBlock') return frag @XBlock.handler def upload_status(self, request, suffix=''): """ Scorm package upload to storage status """ upload_percent = ScormPackageUploader.get_upload_percentage( self.location.block_id) logger.info('Upload percentage is: {}'.format(upload_percent)) return Response(json_body=json.dumps({"progress": upload_percent})) @XBlock.handler def file_upload_handler(self, request, suffix=''): """ Handler for scorm file upload """ response = {} scorm_uploader = ScormPackageUploader( request=request, xblock=self, scorm_storage_location=SCORM_STORAGE) try: state, data = scorm_uploader.upload() except Exception as e: logger.error('Scorm package upload error: {}'.format(e.message)) ScormPackageUploader.clear_percentage_cache(self.location.block_id) return Response(json_body=json.dumps({ 'status': 'error', 'message': e.message })) if state == UPLOAD_STATE.PROGRESS: response = {"files": [{"size": data}]} elif state == UPLOAD_STATE.COMPLETE and data: ScormPackageUploader.clear_percentage_cache(self.location.block_id) self.scorm_file = data response = {'status': 'OK'} return Response(json_body=json.dumps(response)) @XBlock.handler def studio_submit(self, request, suffix=''): self.display_name = request.params['display_name'] self.description = request.params['description'] self.weight = request.params['weight'] self.display_width = request.params['display_width'] self.display_height = request.params['display_height'] self.display_type = request.params['display_type'] self.launch_button_text = request.params['launch_button_text'] self.popup_launch_type = request.params['popup_launch_type'] self.scorm_player = request.params['scorm_player'] self.encoding = request.params['encoding'] self.auto_completion = request.params['auto_completion'] self.is_next_module_locked = request.params['is_next_module_locked'] if request.params['new_scorm_file_uploaded'] == 'true': self.scorm_file_name = request.params['scorm_file_name'] self.file_uploaded_date = datetime.utcnow().replace( tzinfo=pytz.utc) if request.params['player_configuration']: try: json.loads( request.params['player_configuration']) # just validation self.player_configuration = request.params[ 'player_configuration'] except ValueError as e: return Response(json_body=json.dumps({ 'result': 'failure', 'error': 'Invalid JSON in Player Configuration'.format(e) }), content_type='application/json') return Response(json_body=json.dumps({'result': 'success'}), content_type='application/json') # if player sends SCORM API JSON directly @XBlock.json_handler def scorm_get_value(self, data, suffix=''): name = data.get('name') if name == 'cmi.core.lesson_status': return {'value': self.lesson_status} return {'value': ''} # if player sends SCORM API JSON directly @XBlock.json_handler def scorm_set_value(self, data, suffix=''): context = {'result': 'success'} name = data.get('name') if name == 'cmi.core.lesson_status' and data.get( 'value') != 'completed': self.lesson_status = data.get('value') self._publish_grade() context.update({"lesson_score": self.lesson_score}) if name == 'cmi.core.score.raw': self._set_lesson_score(data.get('value', 0)) return context def _get_all_scos(self): return json.loads(self.raw_scorm_status).get('scos', None) def _status_serialize_key(self, key, val): """ update value in JSON serialized raw_scorm_status passing a string key and a deserialized object """ status = json.loads(self.raw_scorm_status) status[key] = val self.raw_scorm_status = json.dumps(status) def _scos_set_values(self, key, val, overwrite=False): """ set a value for a key on all scos return new full raw scorm data """ scos = self._get_all_scos() if scos: for sco in scos: if not scos[sco].get('key') or (scos[sco].get('key') and overwrite): scos[sco][key] = val self._status_serialize_key('scos', scos) def _init_scos(self): """ initialize all SCOs with proper credit and status values in case content package does not do this correctly """ # set all scos lesson status to 'not attempted' # set credit/no-credit on all scos credit = self.weight > 0 and 'credit' or 'no-credit' self._scos_set_values('cmi.core.credit', credit) self._scos_set_values('cmi.core.lesson_status', 'not attempted', True) self.scorm_initialized = True @XBlock.handler def get_raw_scorm_status(self, request, suffix=''): """ retrieve JSON SCORM API status as stored by SSLA player (or potentially others) """ # TODO: handle errors # TODO: this is specific to SSLA player at this point. evaluate for broader use case response = Response(json_body=self.raw_scorm_status, content_type='application/json', charset='UTF-8') if self.auto_completion: # Mark 100% progress upon launching the scorm content if auto_completion is true self._publish_progress(constants.MAX_PROGRESS_VALUE) return response @XBlock.handler def set_raw_scorm_status(self, request, suffix=''): """ store JSON SCORM API status from SSLA player (or potentially others) """ # TODO: this is specific to SSLA player at this point. evaluate for broader use case data = request.POST['data'] scorm_data = json.loads(data) new_status = scorm_data.get('status', 'not attempted') if not self.scorm_initialized: self._init_scos() old_scorm_data = json.loads(self.raw_scorm_status) self.raw_scorm_status = data self.lesson_status = new_status score = scorm_data.get('score', '') self._publish_grade(new_status, score) self.publish_progress(old_scorm_data, scorm_data) self.save() # TODO: handle errors return Response(json_body=json.dumps(self.raw_scorm_status), content_type='application/json', charset='UTF-8') @XBlock.handler def get_scorm_completion(self, request, suffix=''): completion = {'completion': self.scorm_progress or 0} return Response(json_body=json.dumps(completion), content_type='application/json') @XBlock.handler def proxy_content(self, request, suffix=''): storage = default_storage contents = '' content_type = 'application/octet-stream' path_to_file = os.path.join(SCORM_STORAGE, self.location.block_id, suffix) if storage.exists(path_to_file): f = storage.open(path_to_file, 'rb') contents = f.read() ext = os.path.splitext(path_to_file)[1] if ext in mimetypes.types_map: content_type = mimetypes.types_map[ext] else: return Response('Did not exist in storage: ' + path_to_file, status=404, content_type='text/html', charset='UTF-8') return Response(contents, content_type=content_type) def generate_report_data(self, user_state_iterator, limit_responses=None): """ Return a list of student responses to this block in a readable way. Arguments: user_state_iterator: iterator over UserStateClient objects. E.g. the result of user_state_client.iter_all_for_block(block_key) limit_responses (int|None): maximum number of responses to include. Set to None (default) to include all. Returns: each call returns a tuple like: ("username", { "Question": "What's your favorite color?" "Answer": "Red", "Submissions count": 1 }) """ count = 0 for user_state in user_state_iterator: for report in self._get_user_report(user_state.state): if limit_responses is not None and count >= limit_responses: # End the iterator here return count += 1 yield (user_state.username, report) def _get_user_report(self, user_state): interaction_prefix = "cmi.interactions." raw_status = json.loads(user_state['raw_scorm_status']) scos = raw_status.get('scos', {}) for sco in scos.values(): sco_data = sco.get('data') or {} interactions_count = sco_data.get(interaction_prefix + '_count', 0) for interaction_index in range(interactions_count): current_interaction_prefix = interaction_prefix + str( interaction_index) + "." report = { self.ugettext('Question'): sco_data.get(current_interaction_prefix + "description"), self.ugettext('Answer'): sco_data.get(current_interaction_prefix + "learner_response"), self.ugettext('Submissions count'): interactions_count } yield report def _get_value_from_sco(self, sco, key, default): """ return a set or default value from a key in a SCO treat blank string values as empty """ try: val = sco[key] except (KeyError, ValueError): return default finally: if str(val) == '': return default else: return val def _set_lesson_score(self, scos): """ roll up a total lesson score from an average of SCO scores """ # note SCORM 2004+ supports complex weighting of scores from multiple SCOs # see http://scorm.com/blog/2009/10/score-rollup-in-scorm-1-2-theres-no-silver-bullet/ # For now we will weight each SCO equally and take an average # TODO: handle more complex weighting when we support SCORM2004+ total_score = 0 for sco in scos.keys(): sco = scos[sco]['data'] total_score += int( self._get_value_from_sco(sco, 'cmi.core.score.raw', 0)) score_rollup = float(total_score) / float(len(list(scos.keys()))) self.lesson_score = score_rollup return score_rollup def _publish_grade(self, status, score): """ publish the grade in the LMS. """ # We must do this regardless of the lesson # status to avoid race condition issues where a grade of None might overwrite a # grade value for incomplete lesson statuses. # translate the internal score as a percentage of block's weight # we are assuming here the best practice for SCORM 1.2 of a max score of 100 # if we weren't dealing with KESDEE publisher's incorrect usage of cmi.core.score.max # we could compute based on a real max score # in practice, SCOs will almost certainly have a max of 100 # http://www.ostyn.com/blog/2006/09/scoring-in-scorm.html # TODO: handle variable max scores when we support SCORM2004+ or a better KESDEE workaround if score != '': self.runtime.publish( self, 'grade', { 'value': (float(score) / float(DEFAULT_SCO_MAX_SCORE)) * self.weight, 'max_value': self.weight, }) def publish_progress(self, old_scorm_data, current_scorm_data): """ Update progress % if cmi.progress_measure is emitted (i.e. it exists) Else check status and mark 100% completion if course is complete """ progress_measure = self.calculate_progress_measure(current_scorm_data) if progress_measure: # We do not want the elif to run if progress_measure exits but is invalid if self.is_progress_measure_valid(progress_measure, old_scorm_data): self._publish_progress(progress_measure) elif current_scorm_data.get('status', '') in constants.SCORM_COMPLETION_STATUS: self._publish_progress(constants.MAX_PROGRESS_VALUE) def _publish_progress(self, completion): """ Update completion by calling the completion API """ self.scorm_progress = completion self.runtime.publish(self, 'completion', {'completion': completion}) def calculate_progress_measure(self, scorm_data): """ Returns the averaged progress_measure of all scos in the current scorm content :return: progress_measure if found, else 0 """ progress_sum = 0 scos = scorm_data.get('scos', {}) for sco in scos.values(): sco_data = sco.get('data', {}) try: progress_sum += float( sco_data.get('cmi.progress_measure', '0.0')) except (ValueError, AttributeError): pass return progress_sum / len(scos) if len(scos) else 0 def is_progress_measure_valid(self, current_progress_measure, old_scorm_data): """ - Checks if the current progress (to be updated on the LMS) is greater than the previously stored progress - This is done to ensure that restarting a scorm course does not resets its progress on our LMS """ if old_scorm_data: # If old data exists for comparison old_progress_measure = self.calculate_progress_measure( old_scorm_data) if old_progress_measure: if current_progress_measure > old_progress_measure: return True return False else: # If nothing to compare, return valid return True @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("ScormXBlock", """<vertical_demo> <scormxblock/> </vertical_demo> """), ]
class DoneXBlock(XBlock): """ Show a toggle which lets students mark things as done. """ done = Boolean(scope=Scope.user_state, help="Is the student done?", default=False) align = String(scope=Scope.settings, help="Align left/right/center", default="left") has_score = True # pylint: disable=unused-argument @XBlock.json_handler def toggle_button(self, data, suffix=''): """ Ajax call when the button is clicked. Input is a JSON dictionary with one boolean field: `done`. This will save this in the XBlock field, and then issue an appropriate grade. """ if 'done' in data: self.done = data['done'] if data['done']: grade = 1 else: grade = 0 grade_event = {'value': grade, 'max_value': 1} self.runtime.publish(self, 'grade', grade_event) # This should move to self.runtime.publish, once that pipeline # is finished for XBlocks. self.runtime.publish(self, "edx.done.toggled", {'done': self.done}) return {'state': self.done} # Decorate the view in order to support multiple devices e.g. mobile # See: https://openedx.atlassian.net/wiki/display/MA/Course+Blocks+API # section 'View @supports(multi_device) decorator' @XBlock.supports('multi_device') def student_view(self, context=None): # pylint: disable=unused-argument """ The primary view of the DoneXBlock, shown to students when viewing courses. """ html_resource = resource_string("static/html/done.html") html = html_resource.format(done=self.done, id=uuid.uuid1(0)) (unchecked_png, checked_png) = (self.runtime.local_resource_url(self, x) for x in ('public/check-empty.png', 'public/check-full.png')) frag = Fragment(html) frag.add_css(resource_string("static/css/done.css")) frag.add_javascript(resource_string("static/js/src/done.js")) frag.initialize_js( "DoneXBlock", { 'state': self.done, 'unchecked': unchecked_png, 'checked': checked_png, 'align': self.align.lower() }) return frag def studio_view(self, _context=None): # pylint: disable=unused-argument ''' Minimal view with no configuration options giving some help text. ''' html = resource_string("static/html/studioview.html") frag = Fragment(html) return frag @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("DoneXBlock", """<vertical_demo> <done align="left"> </done> <done align="right"> </done> <done align="center"> </done> </vertical_demo> """), ] # Everything below is stolen from # https://github.com/edx/edx-ora2/blob/master/apps/openassessment/ # xblock/lms_mixin.py # It's needed to keep the LMS+Studio happy. # It should be included as a mixin. display_name = String(default="Completion", scope=Scope.settings, help="Display name") start = DateTime( default=None, scope=Scope.settings, help="ISO-8601 formatted string representing the start date " "of this assignment. 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.") 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) 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
class AnimationXBlock(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. animation = List( default=[], scope=Scope.settings, help="Animation", ) height = Integer(scope=Scope.settings, help="Height") textheight = Integer(scope=Scope.settings, help="Text Height") width = Integer(scope=Scope.settings, help="Width") position = Integer(scope=Scope.user_state, help="Current position", default=0) max_position = Integer(scope=Scope.user_state, help="Maximum position (for progress)", default=0) @XBlock.json_handler def update_position(self, data, suffix): if 'position' in data: self.position = data['position'] if 'max_position' in data: self.max_position = data['max_position'] grade = self.max_position / float(len(self.animation)) self.runtime.publish(self, 'grade', { 'value': grade, 'max_value': 1 }) return {"status": "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") # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view of the AnimationXBlock, shown to students when viewing courses. """ html = self.resource_string("static/html/animation.html") frag = Fragment( html.format(height=self.height, textheight=self.textheight, width=self.width, inner_width=self.width - 20, animation=json.dumps(self.animation), position=self.position, max_position=self.max_position)) # frag.add_javascript_url("//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js") frag.add_css_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/themes/smoothness/jquery-ui.css" ) frag.add_css( self.resource_string("static/css/jquery.ui.labeledslider.css")) frag.add_javascript_url( "//ajax.googleapis.com/ajax/libs/jqueryui/1.10.4/jquery-ui.min.js") frag.add_javascript( self.resource_string("static/js/src/jquery.ui.labeledslider.js")) frag.add_css(self.resource_string("static/css/animation.css")) frag.add_javascript(self.resource_string("static/js/src/animation.js")) frag.initialize_js('AnimationXBlock') return frag @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """ Parse the XML for an HTML block. The entire subtree under `node` is re-serialized, and set as the content of the XBlock. """ block = runtime.construct_xblock_from_class(cls, keys) animation = [] element = {"desc": ""} # Dummy; ignored for line in node.text.split('\n'): line = line.strip() if line.startswith("http"): element = {"src": line, "desc": ""} animation.append(element) else: element["desc"] = element["desc"] + " " + line block.animation = animation block.height = node.attrib["height"] block.textheight = node.attrib["textheight"] block.width = node.attrib["width"] return block # 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 [ ("AnimationXBlock", """<vertical_demo> <animation width="460" height="384" textheight="100"> http://upload.wikimedia.org/wikipedia/commons/e/e8/Pin_tumbler_no_key.svg Without a key in the lock, the driver pins (blue) are pushed downwards, preventing the plug (yellow) from rotating. http://upload.wikimedia.org/wikipedia/commons/5/54/Pin_tumbler_bad_key.svg When an incorrect key is inserted into the lock, the key pins (red) and driver pins (blue) do not align with the shear line; therefore, it does not allow the plug (yellow) to rotate. http://upload.wikimedia.org/wikipedia/commons/6/6e/Pin_tumbler_with_key.svg When the correct key is inserted, the gaps between the key pins (red) and driver pins (blue) align with the edge of the plug (yellow). http://upload.wikimedia.org/wikipedia/commons/e/e1/Pin_tumbler_unlocked.svg With the gaps between the pins aligned with the shear line, the plug (yellow) can rotate freely. </animation> </vertical_demo> """), ] ## Everything below is stolen from https://github.com/edx/edx-ora2/blob/master/apps/openassessment/xblock/lms_mixin.py ## It's needed to keep the LMS+Studio happy. ## It should be included as a mixin. ## ## The only LMS functionality we need and use is grading. Cale ## believes most of this is unnecessary, but I did not want to do ## a binary search for what is and is not necessary, since this is ## effectively a TODO. display_name = String(default="Completion", scope=Scope.settings, help="Display name") start = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the start date of this assignment. 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." ) 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) 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
class DoneXBlock(XBlock): """ Show a toggle which lets students mark things as done. """ done = Boolean( scope=Scope.user_state, help="Is the student done?", default=False ) helpful = Boolean( scope=Scope.user_state, help="Was the unit helpful?" ) display_type = String( scope=Scope.settings, help="Toggle student view.", default='helpful' ) align = String( scope=Scope.settings, help="Align left/right/center", default="left" ) has_score = True # pylint: disable=unused-argument @XBlock.json_handler def toggle_button(self, data, suffix=''): """ Ajax call when the button is clicked. Input is a JSON dictionary with one boolean field: `done`. This will save this in the XBlock field, and then issue an appropriate grade. """ if 'done' in data: self.done = data['done'] if data['done']: grade = 1 else: grade = 0 grade_event = {'value': grade, 'max_value': 1} self.runtime.publish(self, 'grade', grade_event) # This should move to self.runtime.publish, once that pipeline # is finished for XBlocks. self.runtime.publish(self, "edx.done.toggled", {'done': self.done}) if 'helpful' in data: self.helpful = data['helpful'] if data['helpful']: grade = 1 else: grade = 0 grade_event = {'value': grade, 'max_value': 1} self.runtime.publish(self, 'grade', grade_event) # This should move to self.runtime.publish, once that pipeline # is finished for XBlocks. self.runtime.publish(self, "edx.done.toggled", {'done': self.done, 'helpful': self.helpful}) return {'state': {'done': self.done, 'helpful': self.helpful}} @XBlock.handler def get_state(self, *args, **kwargs): return Response(body=json.dumps({ 'state': self.done, 'helpful': self.helpful, })) def student_view(self, context=None): # pylint: disable=unused-argument """ The primary view of the DoneXBlock, shown to students when viewing courses. """ context = { 'done': self.done, 'helpful': self.helpful, 'id': uuid.uuid1(0), 'display_type': self.display_type } html = self.render_template('static/html/done.html', context) (unchecked_png, checked_png) = ( self.runtime.local_resource_url(self, x) for x in ('public/check-empty.png', 'public/check-full.png') ) frag = Fragment(html) frag.add_css(resource_string("static/css/done.css")) frag.add_javascript(resource_string("static/js/src/done.js")) frag.initialize_js("DoneXBlock", {'state': self.done, 'unchecked': unchecked_png, 'checked': checked_png, 'align': self.align.lower()}) return frag def studio_view(self, _context=None): # pylint: disable=unused-argument ''' Minimal view with no configuration options giving some help text. ''' context = { 'display_type': self.display_type } html = self.render_template('static/html/studioview.html', context) frag = Fragment(html) js_str = pkg_resources.resource_string(__name__, "static/js/src/studio_edit.js") frag.add_javascript(unicode(js_str)) frag.initialize_js('StudioEdit') return frag @XBlock.handler def studio_submit(self, request, suffix=''): """ Called when submitting the form in Studio. """ self.display_type = request.POST['display_type'] return Response(json_body={ 'result': "success" }) @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("DoneXBlock", """<vertical_demo> <done align="left"> </done> <done align="right"> </done> <done align="center"> </done> </vertical_demo> """), ] def load_resource(self, resource_path): """ Gets the content of a resource """ resource_content = pkg_resources.resource_string(__name__, resource_path) return unicode(resource_content.decode('utf-8')) def render_template(self, template_path, context={}): """ Evaluate a template by resource path, applying the provided context """ template_str = self.load_resource(template_path) return Template(template_str).render(Context(context)) # Everything below is stolen from # https://github.com/edx/edx-ora2/blob/master/apps/openassessment/ # xblock/lms_mixin.py # It's needed to keep the LMS+Studio happy. # It should be included as a mixin. display_name = String( default="Completion", scope=Scope.settings, help="Display name" ) start = DateTime( default=None, scope=Scope.settings, help="ISO-8601 formatted string representing the start date " "of this assignment. 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." ) 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 ) 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
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 @XBlock.supports('multi_device') # Mark as mobile-friendly 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 RocketChatXBlock(XBlock, XBlockWithSettingsMixin, StudioEditableXBlockMixin): """ This class allows to embed a chat window inside a unit course and set the necessary variables to config the rocketChat enviroment """ display_name = String(display_name=_("Display Name"), scope=Scope.settings, default="Rocket Chat") email = String( default="", scope=Scope.user_state, help="Email in rocketChat", ) rocket_chat_role = String(default="user", scope=Scope.user_state, help="The defined role in rocketChat") default_channel = String( display_name=_("Specific Channel"), scope=Scope.content, help= _("This field allows to select the channel that would be accesible in the unit" ), values_provider=lambda self: self.get_groups(), ) ui_is_block = Boolean( default=False, scope=Scope.user_state, help="This is the flag for the initial channel", ) selected_view = String( display_name=_("Select Channel"), default=_("Main View"), scope=Scope.content, help= _("This field allows to select the channel that would be accesible in the unit" ), values_provider=lambda self: self.channels_enabled(), ) team_channel = String(default="", scope=Scope.user_state) emoji = String( display_name=_("Emoji to grade with"), default="", scope=Scope.settings, help=_("Select the emoji which you want to grade"), ) oldest = DateTime( display_name=_("Date From"), default=None, scope=Scope.settings, help= _("ISO-8601 formatted string representing the start date of this assignment." )) latest = DateTime( display_name=_("Date To"), default=None, scope=Scope.settings, help= _("ISO-8601 formatted string representing the due date of this assignment." )) target_reaction = Integer( display_name=_("Target Reaction Count"), default=5, scope=Scope.settings, help=_("Target value in order to achieve a defined grade.")) graded_activity = Boolean( display_name=_("Graded Activity"), default=False, scope=Scope.settings, ) weight = Float( display_name=_("Score"), help=_("Defines the number of points each problem is worth. "), values={ "min": 0, "step": .1 }, default=1, scope=Scope.settings) grade = Float(scope=Scope.user_state, default=0) count_messages = Integer(display_name=_("Last Messages"), default=1000, scope=Scope.settings, help=_("The amount of messages to retrieve")) has_score = graded_activity team_view = True VIEWS = ["Main View", "Team Discussion", "Specific Channel"] # Possible editable fields editable_fields = ('selected_view', 'default_channel', 'graded_activity', 'emoji', 'target_reaction', 'oldest', 'latest', 'weight', 'count_messages') def resource_string(self, path): """Handy helper for getting resources from our kit.""" # pylint: disable=no-self-use data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") @XBlock.supports('multi_device') # Mark as mobile-friendly def student_view(self, context=None): """ The primary view of the RocketChatXBlock, shown to students when viewing courses. """ self.api_rocket_chat = self._api_rocket_chat() # pylint: disable=attribute-defined-outside-init in_studio_runtime = hasattr(self.xmodule_runtime, 'is_author_mode') # pylint: disable=no-member if in_studio_runtime: return self.author_view(context) context = { "response": self.init(), "user_data": self.user_data, "ui_is_block": self.ui_is_block, "team_view": self.team_view, "public_url_service": self.server_data["public_url_service"], "key": hashlib.sha1("{}_{}".format( ROCKET_CHAT_DATA, self.user_data["username"])).hexdigest() } frag = Fragment( LOADER.render_template('static/html/rocketc.html', context)) frag.add_css(self.resource_string("static/css/rocketc.css")) frag.add_javascript(self.resource_string("static/js/src/rocketc.js")) frag.initialize_js('RocketChatXBlock') return frag def author_view(self, context=None): """ Returns author view fragment on Studio """ # pylint: disable=unused-argument self.api_rocket_chat.convert_to_private_channel("general") frag = Fragment(u"Studio Runtime RocketChatXBlock") frag.initialize_js('RocketChatXBlock') return frag def studio_view(self, context=None): """ Returns edit studio view fragment """ frag = super(RocketChatXBlock, self).studio_view(context) frag.add_content( LOADER.render_template('static/html/studio.html', context)) frag.add_css(self.resource_string("static/css/rocketc.css")) frag.add_javascript( self.resource_string("static/js/src/studio_view.js")) frag.initialize_js('StudioViewEdit') return frag # 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 [ ("RocketChatXBlock", """<rocketc/> """), ("Multiple RocketChatXBlock", """<vertical_demo> <rocketc/> <rocketc/> <rocketc/> </vertical_demo> """), ] def _api_rocket_chat(self): """ Creates an ApiRocketChat object """ try: user = self.xblock_settings["admin_user"] password = self.xblock_settings["admin_pass"] except KeyError: LOG.exception("The admin's settings has not been found") raise api = ApiRocketChat(user, password, self.server_data["private_url_service"]) LOG.info("Api rocketChat initialize: %s ", api) return api def _api_teams(self): """ Creates an ApiTeams object """ try: client_id = self.xblock_settings["client_id"] client_secret = self.xblock_settings["client_secret"] except KeyError: raise server_url = settings.LMS_ROOT_URL api = ApiTeams(client_id, client_secret, server_url) LOG.info("Api Teams initialize: %s ", api) return api @property def server_data(self): """ This method allows to get private and public url from xblock settings """ xblock_settings = self.xblock_settings server_data = {} server_data["private_url_service"] = xblock_settings[ "private_url_service"] server_data["public_url_service"] = xblock_settings[ "public_url_service"] return server_data @property def user_data(self): """ This method initializes the user's parameters """ runtime = self.xmodule_runtime # pylint: disable=no-member user = runtime.service(self, 'user').get_current_user() user_data = {} user_data["email"] = user.emails[0] user_data["role"] = runtime.get_user_role() user_data["course_id"] = runtime.course_id user_data["course"] = re.sub('[^A-Za-z0-9]+', '', runtime.course_id.to_deprecated_string()) # pylint: disable=protected-access user_data["username"] = user.opt_attrs['edx-platform.username'] user_data["anonymous_student_id"] = runtime.anonymous_student_id return user_data @property def xblock_settings(self): """ This method allows to get the xblock settings """ return self.get_xblock_settings() def channels_enabled(self): """ This method returns a list with the channel options """ if self._teams_is_enabled(): return self.VIEWS view = list(self.VIEWS) view.remove(self.VIEWS[1]) return view def get_groups(self): """ This method lists the existing groups """ api = self._api_teams() teams = api.get_course_teams(self.runtime.course_id) topics = [re.sub(r'\W+', '', team["topic_id"]) for team in teams] # the following instructions get all the groups # except the groups with the string of some topic in its name query = { 'name': { '$regex': '^(?!.*({topics}).*$)'.format( topics='|'.join(topics)) } } if topics else {} kwargs = {"query": json.dumps(query)} groups = self._api_rocket_chat().get_groups(**kwargs) # these instructions get all the groups with the customField "specificTeam" set query = {'customFields.specificTeam': {'$regex': '^.*'}} kwargs = {'query': json.dumps(query)} team_groups = self._api_rocket_chat().get_groups(**kwargs) # This instruction adds the string "(Team Group)" if the group is in team_groups # pylint: disable=line-too-long groups = [ '{}-{}'.format('(Team Group)', group) if group in team_groups else group for group in groups ] groups.append("") return sorted(groups) def init(self): """ This method initializes the user's variables and log in to rocketchat account """ user_data = self.user_data response = self.login(user_data) if response['success']: response = response['data'] user_id = response['userId'] auth_token = response['authToken'] response['default_group'] = self._join_user_to_groups( user_id, user_data, auth_token) self._update_user(user_id, user_data) if user_data[ "role"] == "instructor" and self.rocket_chat_role != "bot": self.api_rocket_chat.change_user_role(user_id, "bot") self._grading_discussions(response['default_group']) return response else: return response['errorType'] def login(self, user_data): """ This method allows to get the user's authToken and id or creates a user to login in RocketChat """ api = self.api_rocket_chat rocket_chat_user = api.search_rocket_chat_user(user_data["username"]) LOG.info("Login method: result search user: %s", rocket_chat_user["success"]) key = hashlib.sha1("{}_{}".format(ROCKET_CHAT_DATA, user_data["username"])).hexdigest() data = cache.get(key) if data: return data elif rocket_chat_user['success']: data = api.create_token(user_data["username"]) else: response = api.create_user(user_data["anonymous_student_id"], user_data["email"], user_data["username"]) LOG.info("Login method: result create user : %s", response) data = api.create_token(user_data["username"]) LOG.info("Login method: result create token: %s", data) cache.set(key, data, CACHE_TIMEOUT) return data def _add_user_to_course_group(self, group_name, user_id): """ This method add the user to the default course channel """ api = self.api_rocket_chat rocket_chat_group = api.search_rocket_chat_group(group_name) if rocket_chat_group['success']: api.add_user_to_group(user_id, rocket_chat_group['group']['_id']) else: rocket_chat_group = api.create_group(group_name, [self.user_data["username"]]) self.group = api.search_rocket_chat_group( # pylint: disable=attribute-defined-outside-init group_name) def _add_user_to_default_group(self, group_name, user_id): """ """ api = self.api_rocket_chat group_info = api.search_rocket_chat_group(group_name) if group_info["success"]: api.add_user_to_group(user_id, group_info['group']['_id']) return True return False def _add_user_to_team_group(self, user_id, username, course_id, auth_token): """ Add the user to team's group in rocketChat """ self.api_teams = self._api_teams() # pylint: disable=attribute-defined-outside-init team = self._get_team(username, course_id) if team is None: self._remove_user_from_group(self.team_channel, user_id, auth_token) return False topic_id = re.sub(r'\W+', '', team["topic_id"]) team_name = re.sub(r'\W+', '', team["name"]) group_name = "-".join(["Team", topic_id, team_name]) if self.team_channel != group_name: self._remove_user_from_group(self.team_channel, user_id, auth_token) self.team_channel = group_name response = self._add_user_to_group(user_id, group_name, username) LOG.info("Add to team group response: %s", response) return response["success"] def _get_team(self, username, course_id): """ This method gets the user's team """ team = self.api_teams.get_user_team(course_id, username) LOG.info("Get Team response: %s", team) if team: return team[0] return None def _join_user_to_groups(self, user_id, user_data, auth_token): """ This method add the user to the different channels """ default_channel = self.default_channel if self.selected_view == self.VIEWS[1] and self._teams_is_enabled(): self.team_view = self._add_user_to_team_group( user_id, user_data["username"], user_data["course_id"], auth_token) self.ui_is_block = self.team_view return self.team_channel elif self.selected_view == self.VIEWS[2] and default_channel: if default_channel.startswith("(Team Group)"): return self._join_user_to_specific_team_group( user_id, user_data, default_channel) self.ui_is_block = self._add_user_to_default_group( default_channel, user_id) return default_channel else: self.ui_is_block = False self._add_user_to_course_group(user_data["course"], user_id) return None def _add_user_to_group(self, user_id, group_name, username): group_info = self.api_rocket_chat.search_rocket_chat_group(group_name) if group_info["success"]: response = self.api_rocket_chat.add_user_to_group( user_id, group_info['group']['_id']) LOG.info("Add to team group response: %s", response) return response return self.api_rocket_chat.create_group(group_name, [username]) def _join_user_to_specific_team_group(self, user_id, user_data, default_channel): self.api_teams = self._api_teams() # pylint: disable=attribute-defined-outside-init team = self._get_team(user_data["username"], user_data["course_id"]) if team is None: self.team_view = False return None default_channel = self._create_team_group_name( team, default_channel.replace("(Team Group)-", "")) response = self._add_user_to_group(user_id, default_channel, user_data["username"]) self.ui_is_block = response["success"] return default_channel def _teams_is_enabled(self): """ This method verifies if teams are available """ from openedx_dependencies import modulestore # pylint: disable=relative-import try: course_id = self.runtime.course_id # pylint: disable=no-member except AttributeError: return False course = modulestore().get_course(course_id, depth=0) teams_configuration = course.teams_configuration LOG.info("Team is enabled result: %s", teams_configuration) if "topics" in teams_configuration and teams_configuration["topics"]: return True return False def _update_user(self, user_id, user_data): """ This method updates the email and photo's profile """ api = self.api_rocket_chat if user_data["email"] != self.email: self.email = api.update_user(user_id, user_data["email"]) api.set_avatar(user_data["username"], self._user_image_url()) def _user_image_url(self): """Returns an image url for the current user""" from openedx_dependencies import get_profile_image_urls_for_user # pylint: disable=relative-import current_user = User.objects.get(username=self.user_data["username"]) profile_image_url = get_profile_image_urls_for_user( current_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) LOG.info("User image url: %s ", image_url) return image_url @XBlock.json_handler def create_group(self, data, suffix=""): """ This method allows to create a group """ # pylint: disable=unused-argument self.api_rocket_chat = self._api_rocket_chat() # pylint: disable=attribute-defined-outside-init self.api_teams = self._api_teams() # pylint: disable=attribute-defined-outside-init group_name = data["groupName"] description = data["description"] topic = data["topic"] if group_name == "" or group_name is None: return {"success": False, "error": "Group Name is not valid"} group_name = re.sub(r'\W+', '', group_name) if data.get("asTeam", False): custom_fields = {"customFields": {"specificTeam": group_name}} group = self.api_rocket_chat.create_group(group_name, **custom_fields) if group["success"]: self.default_channel = "{}-{}".format('(Team Group)', group_name) else: try: course_id = self.xmodule_runtime.course_id # pylint: disable=no-member team = self._get_team(self.user_data["username"], course_id) members = list(self.get_team_members(team)) group = self.api_rocket_chat.create_group( self._create_team_group_name(team, group_name), members) except AttributeError: group = self.api_rocket_chat.create_group(group_name) if group["success"]: self.default_channel = group_name if "group" in group: group_id = group["group"]["_id"] self.api_rocket_chat.set_group_description(group_id, description) self.api_rocket_chat.set_group_topic(group_id, topic) LOG.info("Method Public Create Group: %s", group) return group @staticmethod def _create_team_group_name(team, group_name): team_topic = re.sub(r'\W+', '', team["topic_id"]) team_name = re.sub(r'\W+', '', team["name"]) return "-".join([team_topic, team_name, group_name]) def _remove_user_from_group(self, group_name, user_id, auth_token=None): """ This method removes a user form a team """ api = self.api_rocket_chat if group_name.startswith("Team-") and auth_token is not None: regex = group_name.replace('Team-', '', 1) query = {"name": {"$regex": regex}} kwargs = {"query": json.dumps(query)} api = self.api_rocket_chat groups = api.list_all_groups(user_id, auth_token, **kwargs) if groups["success"]: groups = groups["groups"] for group in groups: api.kick_user_from_group(user_id, group["_id"]) return True group = api.search_rocket_chat_group(group_name) if group["success"]: group = group["group"] response = api.kick_user_from_group(user_id, group["_id"]) return response.get('success', False) return False def get_team_members(self, team): """ This method allows to get the members of a team """ if team: team_id = team["id"] members = self.api_teams.get_members(team_id) if members: for member in members: yield member["user"]["username"] @XBlock.json_handler def leave_group(self, data, suffix=""): """ This method allows to leave a group """ # pylint: disable=unused-argument self.api_rocket_chat = self._api_rocket_chat() # pylint: disable=attribute-defined-outside-init username = self.user_data["username"] user = self.api_rocket_chat.search_rocket_chat_user(username) group_name = data["groupName"] if not user["success"]: return {"success": False, "error": "User is not valid"} if group_name == "" or group_name is None: return {"success": False, "error": "Group Name is not valid"} if group_name == self.team_channel or group_name == self.user_data[ "course"]: return { "success": False, "error": "You Can Not Leave a Main Group" } return self._remove_user_from_group(group_name, user["user"]["_id"]) @XBlock.json_handler def get_list_of_groups(self, data, suffix=""): """Returns a list with the group names""" # pylint: disable=unused-argument user_id = data.get("userId", None) auth_token = data.get("authToken", None) self.api_teams = self._api_teams() # pylint: disable=attribute-defined-outside-init if not user_id or not auth_token: LOG.warn("Invalid data for method get_list_of_groups: %s", data) return None course_id = self.xmodule_runtime.course_id # pylint: disable=no-member team = self._get_team(self.user_data["username"], course_id) topic = re.sub(r'\W+', '', team["topic_id"]) team_name = re.sub(r'\W+', '', team["name"]) regex = "-".join([topic, team_name]) query = {"name": {"$regex": regex}} kwargs = {"query": json.dumps(query)} groups = list(self._get_list_groups(user_id, auth_token, **kwargs)) return groups def _get_list_groups(self, user_id, auth_token, **kwargs): """ This method allows to get a list of group names """ api = self._api_rocket_chat() groups = api.list_all_groups(user_id, auth_token, **kwargs) if groups["success"]: groups = groups["groups"] for group in groups: yield group["name"] def _get_user_messages(self, group_name, latest="", oldest="", count=100): """ Gets the messages from a user's private group. """ api = self.api_rocket_chat rocket_chat_group = api.search_rocket_chat_group(group_name) if not rocket_chat_group['success']: return [] group_id = rocket_chat_group['group']['_id'] messages = api.get_groups_history(room_id=group_id, latest=latest, oldest=oldest, count=count) if not messages["success"]: return [] return [ message for message in messages["messages"] if message["u"]["username"] == self.user_data["username"] ] def _filter_by_reaction_and_user_role(self, messages, reaction): """ Returns generator with filtered messages by a given reaction """ for message in messages: if not reaction in message.get("reactions", {}): continue usernames = message["reactions"][reaction]["usernames"] for username in usernames: if self._validate_user_role(username): yield message break def _validate_user_role(self, username): """ Returns True if the user is privileged in teams discussions for this course. """ from openedx_dependencies import CourseStaffRole # pylint: disable=relative-import user = User.objects.get(username=username) if user.is_staff: return True if CourseStaffRole(self.user_data["course_id"]).has_user(user): return True return False def _grading_discussions(self, graded_group): """ This method allows to grade contributions to Rocket.Chat given a reaction. """ if not self.graded_activity or self.grade == self.weight: return messages = self._get_user_messages(graded_group, self.latest, self.oldest, self.count_messages) messages = list( self._filter_by_reaction_and_user_role(messages, self.emoji)) if len(messages) >= self.target_reaction: self.grade = self.weight self.runtime.publish(self, 'grade', { 'value': self.grade, 'max_value': self.weight }) def max_score(self): return self.weight @XBlock.handler def logout_user(self, request=None, suffix=None): """ This method allows to invalidate the user token """ # pylint: disable=unused-argument key = request.GET.get("beacon_rc") data = cache.get(key) if data: api = self._api_rocket_chat() user_data = data.get("data") login_token = user_data.get("authToken") user_id = user_data.get("userId") response = api.logout_user(user_id, login_token) try: response = response.json() if response.get("status") == "success": cache.delete(key) return Response(status=202) except AttributeError: return Response(status=503) return Response(status=404)
def test_none(self): self.assertJSONOrSetEquals(None, None) self.assertJSONOrSetEquals(None, '') self.assertEqual(DateTime().to_json(None), None)
class Essay(XBlock): has_score = True icon_class = 'problem' display_name = String( default='Essay', scope=Scope.settings, help=u'Имя будет показано над этим блоком и в полосе навигации.') points = Integer(display_name='Points count', values={ "min": 0, "step": 1 }, default=10, scope=Scope.settings) score = Integer(display_name="Grade score", default=None, values={ "min": 0, "step": 1 }, scope=Scope.user_state) comment = String( display_name="Instructor comment", default='', scope=Scope.user_state, ) essay_timestamp = DateTime(display_name="Timestamp", scope=Scope.user_state, default=None) def max_score(self): return self.points def student_view(self, context=None): if not self.score_published: self.runtime.publish(self, 'grade', { 'value': self.score, 'max_value': self.max_score(), }) self.score_published = True context = { "student_state": json.dumps(self.student_state()), "id": self.location.name.replace('.', '_') } fragment = Fragment() fragment.add_content( render_template('templates/Essay/show.html', context)) fragment.add_css(_resource("static/css/essay.css")) fragment.add_javascript(_resource("static/js/essay.js")) fragment.initialize_js('Essay') return fragment def student_state(self): if self.score is not None: graded = {'score': self.score, 'comment': self.comment} else: graded = None return { "graded": graded, "max_score": self.max_score(), } def staff_grading_data(self): def get_student_data(module): state = json.loads(module.state) return { 'module_id': module.id, 'username': module.student.username, 'fullname': module.student.profile.name, 'timestamp': state.get("essay_timestamp"), 'score': state.get("score"), 'comment': state.get("comment", ''), } query = StudentModule.objects.filter( course_id=self.xmodule_runtime.course_id, module_state_key=self.location) return { 'assignments': [get_student_data(module) for module in query], 'max_score': self.max_score(), } def studio_view(self, context=None): try: cls = type(self) def none_to_empty(x): return x if x is not None else '' edit_fields = ((field, none_to_empty(getattr(self, field.name)), validator) for field, validator in ((cls.display_name, 'string'), (cls.points, 'number'))) context = {'fields': edit_fields} fragment = Fragment() fragment.add_content( render_template('templates/Essay/edit.html', context)) fragment.add_javascript(_resource("static/js/studio.js")) fragment.initialize_js('Essay') return fragment except: # pragma: NO COVER log.error("Not Essay", exc_info=True) raise
def test_serialize_error(self): with self.assertRaises(TypeError): DateTime().to_json('not a datetime')
class StaffGradedAssignmentXBlock2(XBlock): """ This block defines a Staff Graded Assignment. Students are shown a rubric and invited to upload a file which is then graded by staff. """ has_score = True icon_class = 'problem' display_name = String( display_name=_("display_name"), default=_('K-MOOC Staff Graded Assignment'), scope=Scope.settings, help= _("This name appears in the horizontal navigation at the top of the page." )) pass_file = Boolean(display_name=_("pass file"), help=("pass file"), default=True, scope=Scope.settings) 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") def max_score(self): return self.points @reify def block_id(self): # cargo culted gibberish return self.scope_ids.usage_id def student_submission_id(self, id=None): """ Returns dict required by the submissions app for creating and retrieving submissions for a particular student. """ if id is None: id = self.xmodule_runtime.anonymous_student_id assert id != 'MOCK', "Forgot to call 'personalize' in test." return { "student_id": id, "course_id": self.course_id, "item_id": self.block_id, "item_type": 'sga', # ??? } def get_submission(self, id=None): """ Get student's most recent submission. """ try: submissions = submissions_api.get_submissions( self.student_submission_id(id)) except: submissions = None if submissions: # If I understand docs correctly, most recent submission should # be first return submissions[0] def get_score(self, id=None): """ Get student's current score. """ score = submissions_api.get_score(self.student_submission_id(id)) if score: return score['points_earned'] @reify def score(self): return self.get_score() def student_view(self, context=None): """ The primary view of the StaffGradedAssignmentXBlock2, shown to students when viewing courses. """ context = { "student_state": json.dumps(self.student_state()), "id": self.location.name.replace('.', '_'), } if self.show_staff_grading_interface(): context['is_course_staff'] = True self.update_staff_debug_context(context) fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/show.html', context)) fragment.add_css(_resource("static/css/edx_sga.css")) fragment.add_javascript(_resource("static/js/src/edx_sga.js")) fragment.initialize_js('StaffGradedAssignmentXBlock2') return fragment def update_staff_debug_context(self, context): published = self.start context['is_released'] = published and published < _now() context['location'] = self.location context['category'] = type(self).__name__ context['fields'] = [(name, field.read_from(self)) for name, field in self.fields.items()] def student_state(self): """ Returns a JSON serializable representation of student's state for rendering in client view. """ submission = self.get_submission() if submission: uploaded = {"filename": submission['answer']['filename']} else: uploaded = None if self.annotated_sha1: annotated = {"filename": self.annotated_filename} else: annotated = None score = self.score if score is not None: graded = {'score': score, 'comment': self.comment} else: graded = None return { "uploaded": uploaded, "annotated": annotated, "graded": graded, "max_score": self.max_score(), "upload_allowed": self.upload_allowed(), "pass_file": self.pass_file, "display_name": str(self.display_name), } def staff_grading_data(self): def get_student_data(): # Submissions doesn't have API for this, just use model directly students = SubmissionsStudent.objects.filter( course_id=self.course_id, item_id=self.block_id) for student in students: submission = self.get_submission(student.student_id) if not submission: continue user = user_by_anonymous_id(student.student_id) module, _ = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=user, defaults={ 'state': '{}', 'module_type': self.category, }) state = json.loads(module.state) score = self.get_score(student.student_id) approved = score is not None if score is None: score = state.get('staff_score') needs_approval = score is not None else: needs_approval = False instructor = self.is_instructor() yield { 'module_id': module.id, 'student_id': student.student_id, 'submission_id': submission['uuid'], 'username': module.student.username, 'fullname': module.student.profile.name, 'filename': submission['answer']["filename"], 'timestamp': submission['created_at'].strftime( DateTime.DATETIME_FORMAT), 'score': score, 'approved': approved, 'needs_approval': instructor and needs_approval, 'may_grade': instructor or not approved, 'annotated': state.get("annotated_filename"), 'comment': state.get("comment", ''), } return { 'assignments': list(get_student_data()), 'max_score': self.max_score(), } def studio_view(self, context=None): try: cls = type(self) def none_to_empty(x): return x if x is not None else '' edit_fields = ( (field, none_to_empty(getattr(self, field.name)), validator) for field, validator in ( (cls.display_name, 'string'), # (cls.pass_file, 'boolean'), (cls.points, 'number'), # (cls.weight, 'number') )) context = {'fields': edit_fields} fragment = Fragment() fragment.add_content( render_template('templates/staff_graded_assignment/edit.html', context)) fragment.add_javascript(_resource("static/js/src/studio.js")) fragment.initialize_js('StaffGradedAssignmentXBlock2') return fragment except: # pragma: NO COVER log.error("Don't swallow my exceptions", exc_info=True) raise @XBlock.json_handler def save_sga(self, data, suffix=''): self.display_name = data.get('display_name', self.display_name) self.weight = data.get('weight', self.weight) self.pass_file = data.get('pass_file', self.weight) # 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 @XBlock.handler def upload_assignment(self, request, suffix=''): require(self.upload_allowed()) answer = { "sha1": 'pass_file', "filename": '', "mimetype": '', } student_id = self.student_submission_id() submissions_api.create_submission(student_id, answer) return Response(json_body=self.student_state()) # require(self.upload_allowed()) # upload = request.params['assignment'] # sha1 = _get_sha1(upload.file) # answer = { # "sha1": sha1, # "filename": upload.file.name, # "mimetype": mimetypes.guess_type(upload.file.name)[0], # } # student_id = self.student_submission_id() # submissions_api.create_submission(student_id, answer) # path = self._file_storage_path(sha1, upload.file.name) # if not default_storage.exists(path): # default_storage.save(path, File(upload.file)) # return Response(json_body=self.student_state()) @XBlock.handler def staff_upload_annotated(self, request, suffix=''): require(self.is_course_staff()) upload = request.params['annotated'] module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['annotated_sha1'] = sha1 = _get_sha1(upload.file) state['annotated_filename'] = filename = upload.file.name state['annotated_mimetype'] = mimetypes.guess_type(upload.file.name)[0] state['annotated_timestamp'] = _now().strftime( DateTime.DATETIME_FORMAT) path = 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() return Response(json_body=self.staff_grading_data()) @XBlock.handler def download_assignment(self, request, suffix=''): 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=''): 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=''): 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']) @XBlock.handler def staff_download_annotated(self, request, suffix=''): require(self.is_course_staff()) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) path = self._file_storage_path(state['annotated_sha1'], state['annotated_filename']) return self.download(path, state['annotated_mimetype'], state['annotated_filename']) def download(self, path, mimetype, filename): BLOCK_SIZE = (1 << 10) * 8 # 8kb file = default_storage.open(path) app_iter = iter(partial(file.read, BLOCK_SIZE), '') return Response(app_iter=app_iter, content_type=mimetype, content_disposition="attachment; filename=" + filename) @XBlock.handler def get_staff_grading_data(self, request, suffix=''): require(self.is_course_staff()) return Response(json_body=self.staff_grading_data()) @XBlock.handler def enter_grade(self, request, suffix=''): require(self.is_course_staff()) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) score = int(request.params['grade']) if self.is_instructor(): uuid = request.params['submission_id'] submissions_api.set_score(uuid, score, self.max_score()) else: state['staff_score'] = score state['comment'] = request.params.get('comment', '') module.state = json.dumps(state) module.save() return Response(json_body=self.staff_grading_data()) @XBlock.handler def remove_grade(self, request, suffix=''): require(self.is_course_staff()) student_id = request.params['student_id'] submissions_api.reset_score(student_id, self.course_id, self.block_id) module = StudentModule.objects.get(pk=request.params['module_id']) state = json.loads(module.state) state['staff_score'] = None state['comment'] = '' state['annotated_sha1'] = None state['annotated_filename'] = None state['annotated_mimetype'] = None state['annotated_timestamp'] = None module.state = json.dumps(state) module.save() return Response(json_body=self.staff_grading_data()) def is_course_staff(self): return getattr(self.xmodule_runtime, 'user_is_staff', False) def is_instructor(self): return self.xmodule_runtime.get_user_role() == 'instructor' def show_staff_grading_interface(self): in_studio_preview = self.scope_ids.user_id is None return self.is_course_staff() and not in_studio_preview def past_due(self): due = get_extended_due_date(self) if due is not None: return _now() > due return False def upload_allowed(self): return not self.past_due() and self.score is None def _file_storage_path(self, sha1, filename): path = ('{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}' '/{sha1}{ext}'.format(loc=self.location, sha1=sha1, ext=os.path.splitext(filename)[1])) return path
class LmsCompatibilityMixin(object): """ Extra fields and methods used by LMS/Studio. """ # Studio the default value for this field to show this XBlock # in the list of "Advanced Components" display_name = String(default="Peer Assessment", scope=Scope.settings, help="Display name") start = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the start date of this assignment." ) due = DateTime( default=None, scope=Scope.settings, help= "ISO-8601 formatted string representing the due date of this assignment." ) weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) def has_dynamic_children(self): """Do we dynamically determine our children? No, we don't have any. The LMS wants to know this to see if it has to instantiate our module and query it to find the children, or whether it can just trust what's in the static (cheaper) children listing. """ return False @property def has_score(self): """Are we a scored type (read: a problem). Yes. For LMS Progress page/grades download purposes, we're always going to have a score, even if it's just 0 at the start. """ return True def max_score(self): """The maximum raw score of our problem. Called whenever the LMS knows that something is scorable, but finds no recorded raw score for it (i.e. the student hasn't done it). In that case, the LMS knows that the earned score is 0, but it doesn't know what to put in the denominator. So we supply it with the total number of points that it is possible for us to earn -- the sum of the highest pointed options from each criterion. Note that if we have already recorded a score in submissions, this method will never be called. So it's perfectly possible for us to have 10/10 on the progress page and a 12 returning from this method if our 10/10 score was earned in the past and the problem has changed since then. """ return sum( max(option["points"] for option in criterion["options"]) for criterion in self.rubric_criteria)
class GradedDiscussionXBlock(XBlock, StudioEditableXBlockMixin, XBlockWithSettingsMixin): """ GradedDiscussionXBlock Class """ has_score = True display_name = String( display_name=_("Display Name"), help=_("Component Name."), scope=Scope.settings, default=_("Graded Discussion") ) points = Float( display_name=_("Score"), help=_("Defines the number of points each problem is worth."), values={"min": 0, "step": .1}, default=100, scope=Scope.settings ) rubric = String( display_name=_("Rubric"), scope=Scope.settings, help=_("A list of criteria to grade the contribution."), default="", multiline_editor="html", resettable_editor=False ) grading_message = String( display_name=_("Pre-Grading Message"), scope=Scope.settings, help=_("The message to show to learners before their score has been graded."), default="This discussion is staff graded. Your score will appear here when grading is complete.", ) start_date = DateTime( display_name=_("Start Date"), default=None, scope=Scope.settings, help=_("Start date of this assignment.") ) end_date = DateTime( display_name=_("End Date"), default=None, scope=Scope.settings, help=_("Due date of this assignment.") ) discussion_topic = String( display_name=_("Discussion Topic"), default="All topics", scope=Scope.settings, help=_("Select the topic that you want to use for grading."), values_provider=lambda self: self.get_discussion_topics(), ) # Possible editable fields editable_fields = ( "display_name", "points", "rubric", "grading_message", "start_date", "end_date", "discussion_topic", ) @cached_property def api_discussion(self): """ Returns an instance of ApiDiscussion """ try: client_id = self.get_xblock_settings()["client_id"] client_secret = self.get_xblock_settings()["client_secret"] except KeyError: raise return ApiDiscussion(settings.LMS_ROOT_URL, unicode(self.course_id), client_id, client_secret, self.location) @cached_property def api_teams(self): """ Returns an instance of ApiTeams """ try: client_id = self.get_xblock_settings()["client_id"] client_secret = self.get_xblock_settings()["client_secret"] except KeyError: raise return ApiTeams(settings.LMS_ROOT_URL, client_id, client_secret, self.location) @XBlock.handler def enter_grade(self, request, suffix=''): """ """ require(self.is_course_staff()) user = get_user_by_username_or_email(request.params.get('user')) score = request.params.get('score') comment = request.params.get('comment') if not score: return Response( json_body={"error": "Enter a valid grade"}, status_code=400, ) try: score = int(score) except ValueError: return Response( json_body={"error": "Enter a valid grade"}, status_code=400, ) submission_id = self.get_submission_id(user) submission = submissions_api.create_submission(submission_id, {'comment': comment}) submissions_api.set_score(submission['uuid'], score, self.max_score()) self.get_or_create_student_module(user, score, comment) return Response(json_body={"success": "success"}) @cached_property def contributions(self): return self.api_discussion.get_contributions(self.topic_id) def get_comment(self): """ """ submissions = submissions_api.get_submissions(self.submission_id) if submissions: return submissions[0]['answer']['comment'] def get_discussion_topics(self): """ """ topics = self.api_discussion.get_topics_names() topics.append("All topics") return topics def get_or_create_student_module(self, user, score, comment): """ """ state = {"score": score, "comment": comment} student_module, created = StudentModule.objects.get_or_create( course_id=self.course_id, module_state_key=self.location, student=user, defaults={ 'state': json.dumps(state), } ) return student_module def get_score(self, user): """ Return student's current score. """ score = submissions_api.get_score(self.get_submission_id(user)) if score: return score['points_earned'] def get_student_list(self): """ """ users = CourseEnrollmentManager().users_enrolled_in(self.course_id) graded_students = self._get_graded_students() return [ dict( username=user.username, image_url=self._get_image_url(user), last_post=self._get_last_date_on_post(self._get_contributions(user.username)), cohort_id=get_cohort_id(user, self.course_id), team=self.api_teams.get_user_team(unicode(self.course_id), user.username), contributions=json.dumps(self._get_contributions(user.username)), ) for user in users if not user.is_staff and user not in graded_students ] def get_submission_id(self, user): """ """ return dict( item_id=unicode(self.location), item_type='graded_discussion', course_id=unicode(self.course_id), student_id=anonymous_id_for_user(user, self.course_id) ) def is_course_staff(self): """ Check if user is course staff. """ return getattr(self.xmodule_runtime, "user_is_staff", False) def max_score(self): """ Return the maximum score possible. """ return self.points @XBlock.json_handler def get_contributions(self, data, suffix=""): """ """ require(self.is_course_staff()) users = data.get("users") self._delete_cache(users) contributions = {user: json.dumps(self._get_contributions(user)) for user in users} return Response(json=contributions) def resource_string(self, path): """Handy helper for getting resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") @cached_property def score(self): """ """ user = get_user_by_username_or_email(self.username) return self.get_score(user) @cached_property def submission_id(self): """ """ user = get_user_by_username_or_email(self.username) return self.get_submission_id(user) # TO-DO: change this view to display your data your own way. def student_view(self, context=None): """ The primary view of the GradedDiscussionXBlock, shown to students when viewing courses. """ frag = Fragment(LOADER.render_django_template("static/html/graded_discussion.html", self._get_context())) frag.add_css_url("https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.css") frag.add_css(self.resource_string("static/css/graded_discussion.css")) frag.add_javascript_url("https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.js") frag.add_javascript(self.resource_string("static/js/src/graded_discussion.js")) frag.initialize_js('GradedDiscussionXBlock') return frag @cached_property def username(self): """ Returns the username for the currently user """ user = self.runtime.service(self, 'user').get_current_user() return user.opt_attrs['edx-platform.username'] @cached_property def topic_id(self): """ """ if self.discussion_topic == "All topics": return None return self.api_discussion.get_topic_id(self.discussion_topic) def validate_field_data(self, validation, data): """ This method validates the data from studio before it is saved """ start_date = data.start_date end_date = data.end_date if start_date and end_date and end_date <= start_date: validation.add(ValidationMessage(ValidationMessage.ERROR, u"The start date must be before the end date")) def _delete_cache(self, users): cache.delete(self.location) for user in users: key = "{}-{}".format(self.location, user) cache.delete(key) key = "{}-{}".format(self.location, "contributions") cache.delete(key) def _filter_by_date(self, contributions): """ """ if self.start_date and self.end_date: return [ contribution for contribution in contributions if parse(contribution["created_at"]) >= self.start_date and parse(contribution["created_at"]) <= self.end_date ] elif self.start_date: return [ contribution for contribution in contributions if parse(contribution["created_at"]) >= self.start_date ] elif self.end_date: return [ contribution for contribution in contributions if parse(contribution["created_at"]) <= self.end_date ] return contributions def _get_context(self): """ """ if self.is_course_staff(): return dict( user_is_staff=True, rubric=self.rubric, users=self.get_student_list(), reload_url=self.runtime.local_resource_url(self, 'public/img/reload-icon.png'), cohorts=get_cohort_names(get_course_by_id(self.course_id)), teams=self.api_teams.get_course_teams(unicode(self.course_id)), grading_message=self.grading_message, ) comment = self.get_comment() return dict( user_is_staff=False, grading_message=comment if comment and self.score else self.grading_message, score=self.score, max_score=self.points ) def _get_contributions(self, username): """ This returns the contributions for a given username """ key = "{}-{}".format(self.location, username) contributions = cache.get(key) if contributions: return contributions contributions = [contribution for contribution in self.contributions if contribution["author"] == username] contributions = self._filter_by_date(contributions) cache.set(key, contributions) return contributions def _get_graded_students(self): """ """ students = StudentItem.objects.filter( course_id=self.course_id, item_id=unicode(self.location) ) result = [] for student in students: user = user_by_anonymous_id(student.student_id) if self.get_score(user): result.append(user) return result def _get_image_url(self, user): """ """ 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 def _get_last_date_on_post(self, contributions): """ """ contributions.sort(key=lambda item: item["created_at"]) try: return contributions[0].get("created_at") except IndexError: return "" def _get_topic_id(self, name): """ """ return self.api_discussion.get_topic_id(name) # 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 [ ("GradedDiscussionXBlock", """<graded_discussion/> """), ("Multiple GradedDiscussionXBlock", """<vertical_demo> <graded_discussion/> <graded_discussion/> <graded_discussion/> </vertical_demo> """), ]
class VideoFields(object): """Fields for `VideoModule` and `VideoDescriptor`.""" display_name = String( help=_("The name students see. This name appears in the course ribbon and as a header for the video."), display_name=_("Component Display Name"), default="Video", scope=Scope.settings ) saved_video_position = RelativeTime( help=_("Current position in the video."), scope=Scope.user_state, default=datetime.timedelta(seconds=0) ) # TODO: This should be moved to Scope.content, but this will # require data migration to support the old video module. youtube_id_1_0 = String( help=_("Optional, for older browsers: the YouTube ID for the normal speed video."), display_name=_("YouTube ID"), scope=Scope.settings, default="3_yD_cEKoCk" ) youtube_id_0_75 = String( help=_("Optional, for older browsers: the YouTube ID for the .75x speed video."), display_name=_("YouTube ID for .75x speed"), scope=Scope.settings, default="" ) youtube_id_1_25 = String( help=_("Optional, for older browsers: the YouTube ID for the 1.25x speed video."), display_name=_("YouTube ID for 1.25x speed"), scope=Scope.settings, default="" ) youtube_id_1_5 = String( help=_("Optional, for older browsers: the YouTube ID for the 1.5x speed video."), display_name=_("YouTube ID for 1.5x speed"), scope=Scope.settings, default="" ) start_time = RelativeTime( # datetime.timedelta object help=_( "Time you want the video to start if you don't want the entire video to play. " "Not supported in the native mobile app: the full video file will play. " "Formatted as HH:MM:SS. The maximum value is 23:59:59." ), display_name=_("Video Start Time"), scope=Scope.settings, default=datetime.timedelta(seconds=0) ) end_time = RelativeTime( # datetime.timedelta object help=_( "Time you want the video to stop if you don't want the entire video to play. " "Not supported in the native mobile app: the full video file will play. " "Formatted as HH:MM:SS. The maximum value is 23:59:59." ), display_name=_("Video Stop Time"), scope=Scope.settings, default=datetime.timedelta(seconds=0) ) #front-end code of video player checks logical validity of (start_time, end_time) pair. # `source` is deprecated field and should not be used in future. # `download_video` is used instead. source = String( help=_("The external URL to download the video."), display_name=_("Download Video"), scope=Scope.settings, default="" ) download_video = Boolean( help=_("Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field."), # pylint: disable=line-too-long display_name=_("Video Download Allowed"), scope=Scope.settings, default=False ) html5_sources = List( help=_("The URL or URLs where you've posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. (For browser compatibility, we strongly recommend .mp4 and .webm format.) Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True."), # pylint: disable=line-too-long display_name=_("Video File URLs"), scope=Scope.settings, ) track = String( help=_("By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video."), # pylint: disable=line-too-long display_name=_("Downloadable Transcript URL"), scope=Scope.settings, default='' ) download_track = Boolean( help=_("Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field."), # pylint: disable=line-too-long display_name=_("Download Transcript Allowed"), scope=Scope.settings, default=False ) sub = String( help=_("The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting."), # pylint: disable=line-too-long display_name=_("Default Timed Transcript"), scope=Scope.settings, default="" ) show_captions = Boolean( help=_("Specify whether the transcripts appear with the video by default."), display_name=_("Show Transcript"), scope=Scope.settings, default=True ) # Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'} transcripts = Dict( help=_("Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language."), # pylint: disable=line-too-long display_name=_("Transcript Languages"), scope=Scope.settings, default={} ) transcript_language = String( help=_("Preferred language for transcript."), display_name=_("Preferred language for transcript"), scope=Scope.preferences, default="en" ) transcript_download_format = String( help=_("Transcript file format to download by user."), scope=Scope.preferences, values=[ # Translators: This is a type of file used for captioning in the video player. {"display_name": _("SubRip (.srt) file"), "value": "srt"}, {"display_name": _("Text (.txt) file"), "value": "txt"} ], default='srt', ) speed = Float( help=_("The last speed that the user specified for the video."), scope=Scope.user_state ) global_speed = Float( help=_("The default speed for the video."), scope=Scope.preferences, default=1.0 ) youtube_is_available = Boolean( help=_("Specify whether YouTube is available for the user."), scope=Scope.user_info, default=True ) handout = String( help=_("Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video."), # pylint: disable=line-too-long display_name=_("Upload Handout"), scope=Scope.settings, ) only_on_web = Boolean( help=_( "Specify whether access to this video is limited to browsers only, or if it can be " "accessed from other applications including mobile apps." ), display_name=_("Video Available on Web Only"), scope=Scope.settings, default=False ) edx_video_id = String( help=_("If you were assigned a Video ID by edX for the video to play in this component, enter the ID here. In this case, do not enter values in the Default Video URL, the Video File URLs, and the YouTube ID fields. If you were not assigned a Video ID, enter values in those other fields and ignore this field."), # pylint: disable=line-too-long display_name=_("Video ID"), scope=Scope.settings, default="", ) bumper_last_view_date = DateTime( display_name=_("Date of the last view of the bumper"), scope=Scope.preferences, ) bumper_do_not_show_again = Boolean( display_name=_("Do not show bumper again"), scope=Scope.preferences, default=False, )
class PollBase(XBlock, ResourceMixin, PublishEventMixin): """ Base class for Poll-like XBlocks. """ event_namespace = 'xblock.pollbase' private_results = Boolean(default=False, help=u"Показывать ли студенту результаты.") max_submissions = Integer( default=1, help= u"Максимальное допустимое количество отправок ответов одним студентом." ) submissions_count = Integer( default=0, help=u"Сколько раз студент отправил свои ответы.", scope=Scope.user_state) feedback = String( default='', help= u"Текст, показываемый студенту, после того, как он отправил свои ответы." ) due = DateTime( default=None, help= u"Время и дата окончания приема ответов - часовой пояс UTC, формат записи ISO-8601." ) has_score = True weight = 1.0 def max_score(self): return self.weight 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. """ try: if self.due and arrow.now() > arrow.get(self.due): return False except: pass 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): # Mihara: I'm not sure why, but this whole logic plain doesn't work. Where did they run this? # I'm falling back to what I know does work... if HAS_EDX_ACCESS: if self.xmodule_runtime.get_user_role() in ['staff', 'instructor']: return True else: # Check if user is member of a group that is explicitly granted # permission to view the results through django configuration. if HAS_API_MANAGER: 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() 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( u'Параметр "Максимальное Количество Отправок" отсутствует или не является целым числом.' ) # 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( u'Параметр "Скрыть результаты" не может быть False если "Максимальное Количество Отправок" не равно 1.' ) return max_submissions