class PollBase(XBlock, ResourceMixin, PublishEventMixin): """ Base class for Poll-like XBlocks. """ has_author_view = True event_namespace = 'xblock.pollbase' private_results = Boolean(default=False, help=_("Whether or not to display results to the user.")) max_submissions = Integer(default=1, help=_("The maximum number of times a user may send a submission.")) submissions_count = Integer( default=0, help=_("Number of times the user has sent a submission."), scope=Scope.user_state ) feedback = String(default='', help=_("Text to display after the user votes.")) def send_vote_event(self, choice_data): # Let the LMS know the user has answered the poll. self.runtime.publish(self, 'progress', {}) # The SDK doesn't set url_name. event_dict = {'url_name': getattr(self, 'url_name', '')} event_dict.update(choice_data) self.publish_event_from_dict( self.event_namespace + '.submitted', event_dict, ) @staticmethod def any_image(field): """ Find out if any answer has an image, since it affects layout. """ return any(value['img'] for key, value in field) @staticmethod def markdown_items(items): """ Convert all items' labels into markdown. """ return [(key, {'label': markdown(value['label']), 'img': value['img'], 'img_alt': value.get('img_alt')}) for key, value in items] def _get_block_id(self): """ Return unique ID of this block. Useful for HTML ID attributes. Works both in LMS/Studio and workbench runtimes: - In LMS/Studio, use the location.html_id method. - In the workbench, use the usage_id. """ if hasattr(self, 'location'): return self.location.html_id() # pylint: disable=no-member return unicode(self.scope_ids.usage_id) def img_alt_mandatory(self): """ Determine whether alt attributes for images are configured to be mandatory. Defaults to True. """ settings_service = self.runtime.service(self, "settings") if not settings_service: return True xblock_settings = settings_service.get_settings_bucket(self) return xblock_settings.get('IMG_ALT_MANDATORY', True) def gather_items(self, data, result, noun, field, image=True): """ Gathers a set of label-img pairs from a data dict and puts them in order. """ items = [] if field not in data or not isinstance(data[field], list): source_items = [] result['success'] = False error_message = self.ugettext( # Translators: {field} is either "answers" or "questions". "'{field}' is not present, or not a JSON array." ).format(field=field) result['errors'].append(error_message) else: source_items = data[field] # Make sure all components are present and clean them. for item in source_items: if not isinstance(item, dict): result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". {item} identifies the answer or question. "{noun} {item} not a javascript object!" ).format(noun=noun, item=item) result['errors'].append(error_message) continue key = item.get('key', '').strip() if not key: result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". {item} identifies the answer or question. "{noun} {item} contains no key." ).format(noun=noun, item=item) result['errors'].append(error_message) image_link = item.get('img', '').strip() image_alt = item.get('img_alt', '').strip() label = item.get('label', '').strip() if not label: if image and not image_link: result['success'] = False error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". # {noun_lower} is the lowercase version of {noun}. "{noun} has no text or img. Please make sure all {noun_lower}s have one or the other, or both." ).format(noun=noun, noun_lower=noun.lower()) result['errors'].append(error_message) elif not image: result['success'] = False # If there's a bug in the code or the user just forgot to relabel a question, # votes could be accidentally lost if we assume the omission was an # intended deletion. error_message = self.ugettext( # Translators: {noun} is either "Answer" or "Question". # {noun_lower} is the lowercase version of {noun}. "{noun} was added with no label. All {noun_lower}s must have labels. Please check the form. " "Check the form and explicitly delete {noun_lower}s if not needed." ).format(noun=noun, noun_lower=noun.lower()) result['errors'].append(error_message) if image_link and not image_alt and self.img_alt_mandatory(): result['success'] = False result['errors'].append( self.ugettext( "All images must have an alternative text describing the image in a way " "that would allow someone to answer the poll if the image did not load." ) ) if image: items.append((key, {'label': label, 'img': image_link, 'img_alt': image_alt})) else: items.append([key, label]) if not items: error_message = self.ugettext( # Translators: "{noun_lower} is either "answer" or "question". "You must include at least one {noun_lower}." ).format(noun_lower=noun.lower()) result['errors'].append(error_message) result['success'] = False return items def can_vote(self): """ Checks to see if the user is permitted to vote. This may not be the case if they used up their max_submissions. """ return self.max_submissions == 0 or self.submissions_count < self.max_submissions def can_view_private_results(self): """ Checks to see if the user has permissions to view private results. This only works inside the LMS. """ if not hasattr(self.runtime, 'user_is_staff'): return False # Course staff users have permission to view results. if self.runtime.user_is_staff: return True # Check if user is member of a group that is explicitly granted # permission to view the results through django configuration. if not HAS_GROUP_PROFILE: return False group_names = getattr(settings, 'XBLOCK_POLL_EXTRA_VIEW_GROUPS', []) if not group_names: return False user = self.runtime.get_real_user(self.runtime.anonymous_student_id) group_ids = user.groups.values_list('id', flat=True) return GroupProfile.objects.filter(group_id__in=group_ids, name__in=group_names).exists() @staticmethod def get_max_submissions(ugettext, data, result, private_results): """ Gets the value of 'max_submissions' from studio submitted AJAX data, and checks for conflicts with private_results, which may not be False when max_submissions is not 1, since that would mean the student could change their answer based on other students' answers. """ try: max_submissions = int(data['max_submissions']) except (ValueError, KeyError): max_submissions = 1 result['success'] = False result['errors'].append(ugettext('Maximum Submissions missing or not an integer.')) # Better to send an error than to confuse the user by thinking this would work. if (max_submissions != 1) and not private_results: result['success'] = False result['errors'].append(ugettext("Private results may not be False when Maximum Submissions is not 1.")) return max_submissions @classmethod def static_replace_json_handler(cls, func): """A JSON handler that replace all static pseudo-URLs by the actual paths. The object returned by func is JSON-serialised, and the resulting string is passed to replace_static_urls() to perform regex-based URL replacing. We would prefer to explicitly call an API function on single image URLs, but such a function is not exposed by the LMS API, so we have to fall back to this slightly hacky implementation. """ @cls.json_handler @functools.wraps(func) def wrapper(self, request_json, suffix=''): response = json.dumps(func(self, request_json, suffix)) response = replace_static_urls(response, course_id=self.runtime.course_id) return Response(response, content_type='application/json') if HAS_STATIC_REPLACE: # Only use URL translation if it is available return wrapper # Otherwise fall back to a standard JSON handler return cls.json_handler(func)
class TemplateBuilderContainerBlock(StudioContainerWithNestedXBlocksMixin, XBlock, StudioEditableXBlockMixin): display_name = String( display_name = _("Title"), help =_("This block is a container of problem template builder xblocks developed by GCS"), scope = Scope.settings, default = _("GCS Problem Builder") ) library_mode = Boolean( display_name=_("Library Mode"), default=False, help=_("If True, use this container xBlock as a library, i.e. it will randomly pick the number of components set at field 'Count' and render to studnets."), scope=Scope.settings, ) count = Integer( display_name = _("Count"), default = 1, help=_("Enter the number of components to display to each student."), scope = Scope.settings, ) random_samples = List( default=[], scope= Scope.user_state ) editable_fields = ('display_name', 'library_mode', 'count') @property def allowed_nested_blocks(self): ''' Define nested XBlock list ''' return [MathProblemTemplateBuilderXBlock] def validate_field_data(self, validation, data): """"" Ask this xblock to validate itself. XBlock subclass are expected to override this method. Any overiding method should call super() to collect validation results from its superclass, and then add any additional results as necesary. """"" super(TemplateBuilderContainerBlock, self).validate_field_data(validation, data) def add_error(msg): validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) def author_edit_view(self, context = None): frag = super(TemplateBuilderContainerBlock, self).author_edit_view(context) return frag def student_view(self, context): children_contents = [] fragment = Fragment() # print "Type of self.children: {}".format(type(self.children)) # print "self.children = {}".format(self.children) # # Process library mode # if not self.library_mode: # # Get all child components # self.random_samples = self.children # else: # # Randomly pick Count samples from the list of all child components to render to specific student on specific course. # # # # refer: https://stackoverflow.com/questions/15511349/select-50-items-from-list-at-random-to-write-to-file # # # Check Count vs Total child components # number_of_childs = len(self.children) # if self.count > number_of_childs: # self.count = number_of_childs # # Pick self.count samples from the childrent list # self.random_samples = random.sample(self.children, self.count) # print "Type of self.random_samples = {}".format(type(self.random_samples)) # print "self.random_samples = {}".format(self.random_samples) # for child_id in self.random_samples: for child_id in self.children: child = self.runtime.get_block(child_id) child_fragment = self._render_child_fragment(child, context, 'student_view') fragment.add_frag_resources(child_fragment) children_contents.append(child_fragment.content) render_context = { 'block': self, 'children_contents': children_contents } render_context.update(context) fragment.add_content(self.loader.render_template(self.CHILD_PREVIEW_TEMPLATE, render_context)) return fragment
class VideoFields(object): """Fields for `VideoModule` and `VideoDescriptor`.""" display_name = String(display_name="Display Name", help="Display name for this module.", default="Video", scope=Scope.settings) position = Integer(help="Current position in the video", scope=Scope.user_state, default=0) show_captions = Boolean( help="This controls whether or not captions are shown by default.", display_name="Show Transcript", scope=Scope.settings, default=True) # 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="This is the Youtube ID reference for the normal speed video.", display_name="Youtube ID", scope=Scope.settings, default="OEoXaMPEzfM") 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="Start time for the video (HH:MM:SS).", display_name="Start Time", scope=Scope.settings, default=datetime.timedelta(seconds=0)) end_time = RelativeTime( # datetime.timedelta object help="End time for the video (HH:MM:SS).", display_name="End 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 = String( help= "The external URL to download the video. This appears as a link beneath the video.", display_name="Download Video", scope=Scope.settings, default="") html5_sources = List( help= "A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.", display_name="Video Sources", scope=Scope.settings, ) track = String( help= "The external URL to download the timed transcript track. This appears as a link beneath the video.", display_name="Download Transcript", scope=Scope.settings, default="") sub = String( help="The name of the timed transcript track (for non-Youtube videos).", display_name="HTML5 Transcript", scope=Scope.settings, default="")
class VideoXBlock(SettingsMixin, TranscriptsMixin, PlaybackStateMixin, LocationMixin, StudioEditableXBlockMixin, ContentStoreMixin, WorkbenchMixin, XBlock): """ Main VideoXBlock class, responsible for saving video settings and rendering it for students. VideoXBlock only provide a storage facilities for fields data, but not decide what fields to show to user. `BaseVideoPlayer` and it's subclassess declare what fields are required for proper configuration of a video. See `BaseVideoPlayer.basic_fields` and `BaseVideoPlayer.advanced_fields`. """ icon_class = "video" display_name = String( default=_('Video'), display_name=_('Component Display Name'), help= _('The name students see. This name appears in the course ribbon and as a header for the video.' ), scope=Scope.content, ) href = String( default='', display_name=_('Video URL'), help= _('URL of the video page. E.g. https://example.wistia.com/medias/12345abcde' ), scope=Scope.content) download_video_allowed = Boolean( default=False, scope=Scope.content, display_name=_('Video Download Allowed'), help=_( "Allow students to download this video if they cannot use the edX video player." " A link to download the file appears below the video."), resettable_editor=False) download_video_url = String( default='', display_name=_('Video file URL'), help= _("The URL where you've posted non hosted versions of the video. URL must end in .mpeg, .mp4, .ogg, or" " .webm. (For browser compatibility, we strongly recommend .mp4 and .webm format.) To allow students to" " download these videos, set Video Download Allowed to True."), scope=Scope.content) account_id = String( default='', display_name=_('Account Id'), help=_('Your Brightcove account id'), scope=Scope.content, ) player_id = String( default='default', display_name=_('Player Id'), help= _('Your Brightcove player id. Use "Luna" theme for all your players. You can choose one of your players' ' from a <a href="https://studio.brightcove.com/products/videocloud/players" target="_blank">list</a>.' ), scope=Scope.content, ) player_name = String(default=PlayerName.DUMMY, scope=Scope.content) 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.content, 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.content, default=datetime.timedelta(seconds=0)) handout = String(default='', scope=Scope.content, display_name=_('Upload handout'), help=_('You can upload handout file for students')) download_transcript_allowed = Boolean( default=False, scope=Scope.content, display_name=_('Download Transcript Allowed'), help= _("Allow students to download the timed transcript. A link to download the file appears below the video." " By default, the transcript is an .vtt or .srt file. If you want to provide the transcript for download" " in a different format, upload a file by using the Upload Handout field." ), resettable_editor=False) default_transcripts = String( default='', scope=Scope.content, display_name=_('Default Timed Transcript'), help= _('Default transcripts are uploaded automatically from a video platform ' 'to the list of available transcripts.<br/>' '<b>Note: valid "Video API Token" should be given in order to make auto fetching possible.</b><br/>' 'Advice: disable transcripts displaying on your video service to avoid transcripts overlapping.' ), resettable_editor=False) token = String( default='', display_name=_('Video API Token'), help= _('You can generate a client token following official documentation of your video platform\'s API.' ), scope=Scope.content, resettable_editor=False) metadata = Dict( default={}, display_name=_('Metadata'), help=_( 'This field stores different metadata, e.g. authentication data. ' 'If new metadata item is designed, this is to add an appropriate key to backend\'s ' '`metadata_fields` property.'), scope=Scope.content) @property def editable_fields(self): """ Return list of xblock's editable fields used by StudioEditableXBlockMixin.clean_studio_edits(). """ return self.get_player().editable_fields @staticmethod def get_brightcove_js_url(account_id, player_id): """ Return url to brightcove player js file, considering `account_id` and `player_id`. Arguments: account_id (str): Account id fetched from video xblock. player_id (str): Player id fetched from video xblock. Returns: Url to brightcove player js (str). """ return "https://players.brightcove.net/{account_id}/{player_id}_default/index.min.js".format( account_id=account_id, player_id=player_id) @staticmethod def add_validation_message(validation, message_text): """ Add error message on xblock fields validation. Attributes: validation (xblock.validation.Validation): Object containing validation information for an xblock instance. message_text (unicode): Message text per se. """ validation.add(ValidationMessage(ValidationMessage.ERROR, message_text)) def validate_account_id_data(self, validation, data): """ Validate account id value which is mandatory. Attributes: validation (xblock.validation.Validation): Object containing validation information for an xblock instance. data (xblock.internal.VideoXBlockWithMixins): Object containing data on xblock. """ account_id_is_empty = data.account_id in ['', u''] # pylint: disable=unsubscriptable-object # Validate provided account id if account_id_is_empty: # Account Id field is mandatory self.add_validation_message( validation, _(u"Account ID can not be empty. Please provide a valid Brightcove Account ID." )) return try: response = requests.head( VideoXBlock.get_brightcove_js_url(data.account_id, data.player_id)) if not response.ok: self.add_validation_message( validation, _(u"Invalid Account ID or Player ID, please recheck.")) except requests.ConnectionError: self.add_validation_message( validation, _(u"Can't validate submitted account ID at the moment. " u"Please try to save settings one more time.")) def validate_href_data(self, validation, data): """ Validate href value. Attributes: validation (xblock.validation.Validation): Object containing validation information for an xblock instance. data (xblock.internal.VideoXBlockWithMixins): Object containing data on xblock. """ is_not_provided_href = \ data.href == self.fields['href'].default # pylint: disable=unsubscriptable-object is_matched_href = False for _player_name, player_class in BaseVideoPlayer.load_classes(): if player_class.match(data.href): is_matched_href = True # Validate provided video href value if not (is_not_provided_href or is_matched_href): self.add_validation_message( validation, _(u"Incorrect or unsupported video URL, please recheck.")) def validate_field_data(self, validation, data): """ Validate data submitted via xblock edit pop-up. Reference: https://github.com/edx/xblock-utils/blob/v1.0.3/xblockutils/studio_editable.py#L245 Attributes: validation (xblock.validation.Validation): Object containing validation information for an xblock instance. data (xblock.internal.VideoXBlockWithMixins): Object containing data on xblock. """ is_brightcove = str(self.player_name) == PlayerName.BRIGHTCOVE if is_brightcove: self.validate_account_id_data(validation, data) self.validate_href_data(validation, data) def get_download_video_url(self): """ Return direct video url for download if download is allowed. Else return `False` which will hide "download video" button. """ return self.download_video_allowed and self.get_player( ).download_video_url def student_view(self, _context=None): """ The primary view of the `VideoXBlock`, shown to students when viewing courses. """ player_url = self.runtime.handler_url(self, 'render_player') download_transcript_handler_url = self.runtime.handler_url( self, 'download_transcript') transcript_download_link = self.get_transcript_download_link() full_transcript_download_link = '' if transcript_download_link: full_transcript_download_link = download_transcript_handler_url + transcript_download_link context = { 'player_url': player_url, 'display_name': self.display_name, 'usage_id': self.usage_id, 'handout': self.handout, 'transcripts': list(self.route_transcripts()), 'download_transcript_allowed': self.download_transcript_allowed, 'transcripts_streaming_enabled': self.threeplaymedia_streaming, 'download_video_url': self.get_download_video_url(), 'handout_file_name': self.get_file_name_from_path(self.handout), 'transcript_download_link': full_transcript_download_link, 'version': __version__ } log.debug("[student_view_context]: transcripts %s", context['transcripts']) frag = Fragment( render_resource('static/html/student_view.html', **context)) frag.add_javascript( resource_string("static/js/student-view/video-xblock.js")) frag.add_css(resource_string("static/css/student-view.css")) frag.initialize_js('VideoXBlockStudentViewInit') return frag def _update_default_transcripts(self, player, transcripts): """ Private method to fetch/update default transcripts. """ log.debug("Default transcripts updating...") # Prepare parameters necessary to make requests to API. video_id = player.media_id(self.href) kwargs = {'video_id': video_id} for k in self.metadata: kwargs[k] = self.metadata[k] # For a Brightcove player only is_not_default_account_id = \ self.account_id is not self.fields['account_id'].default # pylint: disable=unsubscriptable-object if is_not_default_account_id: kwargs['account_id'] = self.account_id # Fetch captions list (available/default transcripts list) from video platform API try: default_transcripts, transcripts_autoupload_message = player.get_default_transcripts( **kwargs) except ApiClientError: default_transcripts, transcripts_autoupload_message = [], _( 'Failed to fetch default transcripts.') log.debug( "Autofetch message: '{}'".format(transcripts_autoupload_message)) # Default transcripts should contain transcripts of distinct languages only distinct_default_transcripts = player.clean_default_transcripts( default_transcripts) # Needed for frontend initial_default_transcripts = distinct_default_transcripts # Exclude enabled transcripts from the list of available ones, and remove duplicates filtered_default_transcripts = player.filter_default_transcripts( distinct_default_transcripts, transcripts) self.default_transcripts = filtered_default_transcripts if self.default_transcripts: self.default_transcripts.sort(key=lambda l: l['label']) return initial_default_transcripts, transcripts_autoupload_message def studio_view(self, _context): """ Render a form for XBlock editing. """ fragment = Fragment() player = self.get_player() languages = [{ 'label': label, 'code': lang } for lang, label in ALL_LANGUAGES] languages.sort(key=lambda l: l['label']) transcripts = self.get_enabled_transcripts() download_transcript_handler_url = self.runtime.handler_url( self, 'download_transcript') auth_error_message = '' # Authenticate to API of the player video platform and update metadata with auth information. # Note that there is no need to authenticate to Youtube API, # whilst for Wistia, a sample authorised request is to be made to ensure authentication succeeded, # since it is needed for the auth status message generation and the player's state update with auth status. if self.token: _auth_data, auth_error_message = self.authenticate_video_api( self.token.encode(encoding='utf-8')) initial_default_transcripts, transcripts_autoupload_message = self._update_default_transcripts( player, transcripts) log.debug("Fetched default transcripts: {}".format( initial_default_transcripts)) # Prepare basic_fields and advanced_fields for them to be rendered basic_fields = self.prepare_studio_editor_fields(player.basic_fields) advanced_fields = self.prepare_studio_editor_fields( player.advanced_fields) context = { 'advanced_fields': advanced_fields, 'auth_error_message': auth_error_message, 'basic_fields': basic_fields, 'courseKey': self.course_key, 'languages': languages, 'player_name': self.player_name, # for players identification 'players': PlayerName, 'sources': TranscriptSource.to_dict().items(), # transcripts context: 'transcripts': filter_transcripts_by_source( transcripts, sources=[TranscriptSource.THREE_PLAY_MEDIA], exclude=True), 'transcripts_fields': self.prepare_studio_editor_fields(player.trans_fields), 'three_pm_fields': self.prepare_studio_editor_fields(player.three_pm_fields), 'transcripts_type': '3PM' if self.threeplaymedia_streaming else 'manual', 'default_transcripts': self.default_transcripts, 'enabled_default_transcripts': filter_transcripts_by_source(transcripts), 'enabled_managed_transcripts': self.get_enabled_managed_transcripts(), 'initial_default_transcripts': initial_default_transcripts, 'transcripts_autoupload_message': transcripts_autoupload_message, 'download_transcript_handler_url': download_transcript_handler_url, } fragment.content = render_template('studio-edit.html', **context) fragment.add_css(resource_string("static/css/student-view.css")) fragment.add_css(resource_string("static/css/transcripts-upload.css")) fragment.add_css(resource_string("static/css/studio-edit.css")) fragment.add_css( resource_string("static/css/studio-edit-accordion.css")) fragment.add_javascript( resource_string("static/js/runtime-handlers.js")) fragment.add_javascript( resource_string("static/js/studio-edit/utils.js")) fragment.add_javascript( resource_string("static/js/studio-edit/studio-edit.js")) fragment.add_javascript( resource_string("static/js/studio-edit/transcripts-autoload.js")) fragment.add_javascript( resource_string( "static/js/studio-edit/transcripts-manual-upload.js")) fragment.initialize_js('StudioEditableXBlock') return fragment @XBlock.handler def render_player(self, _request, _suffix=''): """ View `student_view` loads this handler as an iframe to display actual video player. Arguments: _request (webob.Request): Request to handle. Imposed by `XBlock.handler`. _suffix (string): Slug used for routing. Imposed by `XBlock.handler`. Returns: Rendered html string as a Response (webob.Response). """ player = self.get_player() save_state_url = self.runtime.handler_url(self, 'save_player_state') transcripts = render_resource( 'static/html/transcripts.html', transcripts=self.route_transcripts()).strip() return player.get_player_html( url=self.href, account_id=self.account_id, player_id=self.player_id, video_id=player.media_id(self.href), video_player_id='video_player_{}'.format(self.block_id), save_state_url=save_state_url, player_state=self.player_state, start_time=int(self.start_time.total_seconds()), # pylint: disable=no-member end_time=int(self.end_time.total_seconds()), # pylint: disable=no-member brightcove_js_url=VideoXBlock.get_brightcove_js_url( self.account_id, self.player_id), transcripts=transcripts, ) @XBlock.json_handler def publish_event(self, data, _suffix=''): """ Handler to publish XBlock event from frontend. Called by JavaScript of `student_view`. Arguments: data (dict): Data from frontend on the event. _suffix (string): Slug used for routing. Imposed by `XBlock.json_handler`. Returns: Data on result (dict). """ try: event_type = data.pop('eventType') except KeyError: return { 'result': 'error', 'message': 'Missing eventType in JSON data' } self.runtime.publish(self, event_type, data) return {'result': 'success'} def clean_studio_edits(self, data): """ Given POST data dictionary 'data', clean the data before validating it. Try to detect player by submitted video url. If fails, it defaults to 'dummy-player'. Also, populate xblock's default values from settings. Arguments: data (dict): POST data. """ data['player_name'] = self.fields['player_name'].default # pylint: disable=unsubscriptable-object for player_name, player_class in BaseVideoPlayer.load_classes(): if player_name == PlayerName.DUMMY: continue if player_class.match(data['href']): data['player_name'] = player_name log.debug("Submitted player[{}] with data: {}".format( player_name, data)) break def get_player(self): """ Helper method to load video player by entry-point label. Returns: Current player object (instance of a platform-specific player class). """ player = BaseVideoPlayer.load_class(self.player_name) return player(self) def _get_field_help(self, field_name, field): """ Get help text for field. First try to load override from video backend, then check field definition and lastly fall back to empty string. """ backend_fields_help = self.get_player().fields_help if field_name in backend_fields_help: return backend_fields_help[field_name] elif field.help: return field.help return '' def initialize_studio_field_info(self, field_name, field, field_type=None): """ Initialize studio editor's field info. Arguments: field_name (str): Name of a video XBlock field whose info is to be made. field (xblock.fields): Video XBlock field object. field_type (str): Type of field. Returns: info (dict): Information on a field. """ info = super(VideoXBlock, self)._make_field_info(field_name, field) info['help'] = self._get_field_help(field_name, field) if field_type: info['type'] = field_type if field_name == 'handout': info['file_name'] = self.get_file_name_from_path(self.handout) info['value'] = self.get_path_for(self.handout) return info def populate_default_value(self, field): """ Populate unset default values from settings file. """ for key, value in self.settings.items(): # if field value is empty and there is json-settings default: if field.name == key and getattr(field, 'default', None) in ['', u'', 'default']: setattr(field, '_default', value) # pylint: disable=literal-used-as-attribute return field def _make_field_info(self, field_name, field): """ Override and extend data of built-in method. Create the information that the template needs to render a form field for this field. Reference: https://github.com/edx/xblock-utils/blob/v1.0.3/xblockutils/studio_editable.py#L96 Arguments: field_name (str): Name of a video XBlock field whose info is to be made. field (xblock.fields): Video XBlock field object. Returns: info (dict): Information on a field to be rendered in the studio editor modal. """ if field_name in ('start_time', 'end_time'): # RelativeTime field isn't supported by default. info = { 'name': field_name, 'display_name': field.display_name if field.display_name else "", 'is_set': field.is_set_on(self), 'default': field.default, 'value': field.read_from(self), 'has_values': False, 'allow_reset': field.runtime_options.get('resettable_editor', True), 'list_values': None, 'has_list_values': False, 'type': 'string', } elif field_name in ('handout', 'transcripts', 'default_transcripts', 'token'): info = self.initialize_studio_field_info(field_name, field, field_type=field_name) else: info = self.initialize_studio_field_info(field_name, field) return info def prepare_studio_editor_fields(self, field_names): """ Order xblock fields in studio editor modal. Arguments: field_names (tuple): Names of Xblock fields. Returns: prepared_fields (list): XBlock fields prepared to be rendered in a studio edit modal. """ prepared_fields = [] for field_name in field_names: # set default from json XBLOCK_SETTINGS config: populated_field = self.populate_default_value( self.fields[field_name] # pylint:disable=unsubscriptable-object ) # make extra field configuration for frontend rendering: field_info = self._make_field_info(field_name, populated_field) prepared_fields.append(field_info) return prepared_fields def get_file_name_from_path(self, field): """ Helper for getting filename from string with path to MongoDB storage. Example of string: asset-v1-RaccoonGang+1+2018+type@asset+block@<filename> Arguments: field (str): The path to file. Returns: The name of file with an extension. """ return field.split('@')[-1] def get_path_for(self, file_field): """ Return downloaded asset url with slash in start of it. Url, retrieved after storing of the file field in MongoDB, looks like this: 'asset-v1-RaccoonGang+1+2018+type@asset+block@<filename>' Arguments: file_field (str): name a file is stored in MongoDB under. Returns: Full path of a downloaded asset. """ if file_field: return os.path.join('/', file_field) return '' @XBlock.json_handler def dispatch(self, request, suffix): """ Dispatch request to XBlock's player. Arguments: request (xblock.django.request.DjangoWebobRequest): Incoming request data. suffix (str): Slug used for routing. Imposed by `XBlock.json_handler`. Returns: Depending on player's `dispatch()` entry point, either info on video / Brightcove account or None value (when performing some action via Brightcove API) may be returned. """ return self.get_player().dispatch(request, suffix) @XBlock.handler def ui_dispatch(self, _request, suffix): """ Dispatcher for a requests sent by dynamic Front-end components. Typical use case: Front-end wants to check with backend if it's ok to show certain part of UI. Arguments: _request (xblock.django.request.DjangoWebobRequest): Incoming request data. Not used. suffix (str): Slug used for routing. Returns: Response object, containing response data. """ resp = {'success': True, 'data': {}} if suffix == 'get-metadata': resp['data'] = {'metadata': self.metadata} elif suffix == 'can-show-backend-settings': player = self.get_player() if str(self.player_name) == PlayerName.BRIGHTCOVE: resp['data'] = player.can_show_settings() else: resp['data'] = {'canShow': False} response = Response(json.dumps(resp), content_type='application/json') return response def authenticate_video_api(self, token): """ Authenticate to a video platform's API. Arguments: token (str): token provided by a user before the save button was clicked (for handlers). Returns: error_message (dict): Status message for template rendering. auth_data (dict): Tokens and credentials, necessary to perform authorised API requests. """ # TODO move auth fields validation and kwargs population to specific backends # Handles a case where no token was provided by a user kwargs = {'token': token} # Handles a case where no account_id was provided by a user if str(self.player_name) == PlayerName.BRIGHTCOVE: if self.account_id == self.fields['account_id'].default: # pylint: disable=unsubscriptable-object error_message = 'In order to authenticate to a video platform\'s API, please provide an Account Id.' return {}, error_message kwargs['account_id'] = self.account_id player = self.get_player() if str(self.player_name ) == PlayerName.BRIGHTCOVE and self.metadata.get('client_id'): auth_data = { 'client_secret': self.metadata.get('client_secret'), 'client_id': self.metadata.get('client_id'), } error_message = '' else: auth_data, error_message = player.authenticate_api(**kwargs) # Metadata is to be updated on each authentication effort. self.update_metadata_authentication(auth_data=auth_data, player=player) return auth_data, error_message @XBlock.json_handler def authenticate_video_api_handler(self, data, _suffix=''): """ Xblock handler to authenticate to a video platform's API. Called by JavaScript of `studio_view`. Arguments: data (dict): Data from frontend, necessary for authentication (tokens, account id, etc). _suffix (str): Slug used for routing. Imposed by `XBlock.json_handler`. Returns: response (dict): Status messages key-value pairs. """ # Fetch a token provided by a user before the save button was clicked. token = str(data) is_default_token = token == self.fields['token'].default # pylint: disable=unsubscriptable-object is_youtube_player = str(self.player_name) != PlayerName.YOUTUBE # pylint: disable=unsubscriptable-object if not token or (is_default_token and is_youtube_player): return { 'error_message': "In order to authenticate to a video platform's API, " "please provide a Video API Token." } _auth_data, error_message = self.authenticate_video_api(token) if error_message: return {'error_message': error_message} return { 'success_message': 'Successfully authenticated to the video platform.' } def update_metadata_authentication(self, auth_data, player): """ Update video xblock's metadata field with video platform's API authentication data. Arguments: auth_data (dict): Data containing credentials necessary for authentication. player (object): Object of a platform-specific player class. """ # In case of successful authentication: for key in auth_data: if key not in player.metadata_fields: # Only backend-specific parameters are to be stored continue self.metadata[key] = auth_data[key] # If the last authentication effort was not successful, metadata should be updated as well. # Since video xblock metadata may store various information, this is to update the auth data only. if not auth_data: self.metadata['token'] = '' # Wistia API self.metadata['access_token'] = '' # Brightcove API self.metadata['client_id'] = '' # Brightcove API self.metadata['client_secret'] = '' # Brightcove API @XBlock.json_handler def upload_default_transcript_handler(self, data, _suffix=''): """ Upload a transcript, fetched from a video platform's API, to video xblock. Arguments: data (dict): Data from frontend on a default transcript to be fetched from a video platform. _suffix (str): Slug used for routing. Imposed by `XBlock.json_handler`. Returns: response (dict): Data on a default transcript, fetched from a video platform. """ log.debug("Uploading default transcript with data: {}".format(data)) player = self.get_player() video_id = player.media_id(self.href) lang_code = str(data.get(u'lang')) lang_label = str(data.get(u'label')) source = str(data.get(u'source', '')) sub_url = str(data.get(u'url')) reference_name = create_reference_name(lang_label, video_id, source) # Fetch text of single default transcript: unicode_subs_text = player.download_default_transcript( sub_url, lang_code) if not unicode_subs_text: return {'failure_message': _("Couldn't upload transcript text.")} if not player.default_transcripts_in_vtt: prepared_subs = self.convert_caps_to_vtt(caps=unicode_subs_text) else: prepared_subs = unicode_subs_text file_name, external_url = self.create_transcript_file( trans_str=prepared_subs, reference_name=reference_name) # Exceptions are handled on the frontend success_message = 'Successfully uploaded "{}".'.format(file_name) response = { 'success_message': success_message, 'lang': lang_code, 'url': external_url, 'label': lang_label, 'source': source, } log.debug("Uploaded default transcript: {}".format(response)) return response def get_enabled_transcripts(self): """ Get transcripts from different sources depending on current usage mode. """ if self.threeplaymedia_streaming: transcripts = normalize_transcripts( list(self.fetch_available_3pm_transcripts())) else: transcripts = self.get_enabled_managed_transcripts() log.debug("Getting enabled transcripts: %s", transcripts) return transcripts def get_enabled_managed_transcripts(self): """ Get currently enabled in player `managed` ("manual" & "default") transcripts. Please, for term definitions refer to the module docstring. :return: (list) transcripts that enabled but not directly streamed. """ try: transcripts = json.loads( self.transcripts) if self.transcripts else [] return normalize_transcripts(transcripts) except ValueError: log.exception( "JSON parser can't handle 'self.transcripts' field value: {}". format(self.transcripts)) return [] def index_dictionary(self): """ Part of edx-platform search index API. Is invoked during course [re]index operation. Takes enabled transcripts' content and puts it to search index. """ xblock_body = super(VideoXBlock, self).index_dictionary() video_body = {"display_name": self.display_name} content = None enabled_transcripts = self.route_transcripts() for transcript in enabled_transcripts: asset_file_name = transcript[u'url'].split('@')[-1] try: if transcript['source'] in [ TranscriptSource.MANUAL, TranscriptSource.DEFAULT ]: asset_location = self.static_content.compute_location( self.course_key, asset_file_name) asset = self.contentstore().find(asset_location) # pylint: disable=not-callable content = asset.data elif transcript['source'] == TranscriptSource.THREE_PLAY_MEDIA: external_transcript = self.fetch_single_3pm_translation({ 'id': transcript['id'], 'language_id': transcript['lang_id'] }) content = external_transcript and external_transcript.content except IOError: log.exception( "Transcript indexing failure: can't fetch external transcript[{}]" .format(transcript)) except (ValueError, KeyError, TypeError, AttributeError): log.exception( "Transcript indexing failure: can't parse transcript for indexing: [{}]" .format(transcript)) else: if content: content_ = self.vtt_to_text(content) video_body.update({transcript[u'lang']: content_}) finally: content = None if "content" in xblock_body: xblock_body["content"].update(video_body) else: xblock_body["content"] = video_body xblock_body["content_type"] = "Video" return xblock_body
class EqualityCheckerBlock(CheckerBlock): """An XBlock that checks the equality of two student data fields.""" # Content: the problem will hook us up with our data. content = String(help="Message describing the equality test", scope=Scope.content, default="Equality test") # Student data left = Any(scope=Scope.user_state) right = Any(scope=Scope.user_state) attempted = Boolean(scope=Scope.user_state) def problem_view(self, context=None): """Renders the problem view. The view is specific to whether or not this problem was attempted, and, if so, if it was answered correctly. """ correct = self.left == self.right # TODO: I originally named this class="data", but that conflicted with # the CSS on the page! :( We might have to do something to namespace # things. # TODO: Should we have a way to spit out JSON islands full of data? # Note the horror of mixed Python-Javascript data below... content = string.Template(self.content).substitute(**context) result = Fragment( u""" <span class="mydata" data-attempted='{ecb.attempted}' data-correct='{correct}'> {content} <span class='indicator'></span> </span> """.format(ecb=self, content=content, correct=correct) ) # TODO: This is a runtime-specific URL. But if each XBlock ships their # own copy of underscore.js, we won't be able to uniquify them. # Perhaps runtimes can offer a palette of popular libraries so that # XBlocks can refer to them in XBlock-standard ways? result.add_javascript_url( self.runtime.resource_url("js/vendor/underscore-min.js") ) # TODO: The image tag here needs a magic URL, not a hard-coded one. format_data = { 'correct': self.runtime.local_resource_url( self, 'public/images/correct-icon.png'), 'incorrect': self.runtime.local_resource_url( self, 'public/images/incorrect-icon.png'), } result.add_resource( u""" <script type="text/template" id="xblock-equality-template"> <% if (attempted !== "True") {{ %> (Not attempted) <% }} else if (correct === "True") {{ %> <img src="{correct}"> <% }} else {{ %> <img src="{incorrect}"> <% }} %> </script> """.format(**format_data), "text/html" ) result.add_javascript( u""" function EqualityCheckerBlock(runtime, element) { var template = _.template($("#xblock-equality-template").html()); function render() { var data = $("span.mydata", element).data(); $("span.indicator", element).html(template(data)); } render(); return { handleCheck: function(result) { $("span.mydata", element) .data("correct", result ? "True" : "False") .data("attempted", "True"); render(); } } } """ ) result.initialize_js('EqualityCheckerBlock') return result def check(self, left, right): # pylint: disable=W0221 self.attempted = True self.left = left self.right = right event_data = {'value': 1 if left == right else 0, 'max_value': 1} self.runtime.publish(self, 'grade', event_data) return left == right
class LTIFields(object): """ Fields to define and obtain LTI tool from provider are set here, except credentials, which should be set in course settings:: `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) `launch_url` is launch URL of tool. `custom_parameters` are additional parameters to navigate to proper book and book page. For example, for Vitalsource provider, `launch_url` should be *https://bc-staging.vitalsource.com/books/book*, and to get to proper book and book page, you should set custom parameters as:: vbid=put_book_id_here book_location=page/put_page_number_here Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ display_name = String( display_name=_("Display Name"), help=_( "Enter the name that students see for this component. " "Analytics reports may also use the display name to identify this component." ), scope=Scope.settings, default="LTI", ) lti_id = String( display_name=_("LTI ID"), help=_( "Enter the LTI ID for the external LTI provider. " "This value must be the same LTI ID that you entered in the " "LTI Passports setting on the Advanced Settings page." "<br />See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." ).format(docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>"), default='', scope=Scope.settings) launch_url = String( display_name=_("LTI URL"), help= _("Enter the URL of the external tool that this component launches. " "This setting is only used when Hide External Tool is set to False." "<br />See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." ).format(docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>"), default='http://www.example.com', scope=Scope.settings) custom_parameters = List( display_name=_("Custom Parameters"), help= _("Add the key/value pair for any custom parameters, such as the page your e-book should open to or " "the background color for this component." "<br />See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." ).format(docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, anchor_close="</a>"), scope=Scope.settings) open_in_a_new_page = Boolean( display_name=_("Open in New Page"), help= _("Select True if you want students to click a link that opens the LTI tool in a new window. " "Select False if you want the LTI content to open in an IFrame in the current page. " "This setting is only used when Hide External Tool is set to False. " ), default=True, scope=Scope.settings) has_score = Boolean( display_name=_("Scored"), help= _("Select True if this component will receive a numerical score from the external LTI system." ), default=False, scope=Scope.settings) weight = Float( display_name=_("Weight"), help=_("Enter the number of points possible for this component. " "The default value is 1.0. " "This setting is only used when Scored is set to True."), default=1.0, scope=Scope.settings, values={"min": 0}, ) module_score = Float(help=_( "The score kept in the xblock KVS -- duplicate of the published score in django DB" ), default=None, scope=Scope.user_state) score_comment = String( help=_("Comment as returned from grader, LTI2.0 spec"), default="", scope=Scope.user_state) hide_launch = Boolean( display_name=_("Hide External Tool"), help= _("Select True if you want to use this component as a placeholder for syncing with an external grading " "system rather than launch an external tool. " "This setting hides the Launch button and any IFrames for this component." ), default=False, scope=Scope.settings) # Users will be presented with a message indicating that their e-mail/username would be sent to a third # party application. When "Open in New Page" is not selected, the tool automatically appears without any user action. ask_to_send_username = Boolean( display_name=_("Request user's username"), # Translators: This is used to request the user's username for a third party service. # Usernames can only be requested if "Open in New Page" is set to True. help= _("Select True to request the user's username. You must also set Open in New Page to True to get the user's information." ), default=False, scope=Scope.settings) ask_to_send_email = Boolean( display_name=_("Request user's email"), # Translators: This is used to request the user's email for a third party service. # Emails can only be requested if "Open in New Page" is set to True. help= _("Select True to request the user's email address. You must also set Open in New Page to True to get the user's information." ), default=False, scope=Scope.settings) description = String( display_name=_("LTI Application Information"), help= _("Enter a description of the third party application. If requesting username and/or email, use this text box to inform users " "why their username and/or email will be forwarded to a third party application." ), default="", scope=Scope.settings) button_text = String( display_name=_("Button Text"), help= _("Enter the text on the button used to launch the third party application." ), default="", scope=Scope.settings) accept_grades_past_due = Boolean( display_name=_("Accept grades past deadline"), help= _("Select True to allow third party systems to post grades past the deadline." ), default=True, scope=Scope.settings)
class LibraryRoot(XBlock): """ The LibraryRoot is the root XBlock of a content library. All other blocks in the library are its children. It contains metadata such as the library's display_name. """ display_name = String( help=_("Enter the name of the library as it should appear in Studio."), default="Library", display_name=_("Library Display Name"), scope=Scope.settings) advanced_modules = List( display_name=_("Advanced Module List"), help=_( "Enter the names of the advanced components to use in your library." ), scope=Scope.settings, xml_node=True, ) show_children_previews = Boolean( display_name="Hide children preview", help="Choose if preview of library contents is shown", scope=Scope.user_state, default=True) has_children = True has_author_view = True def __unicode__(self): return u"Library: {}".format(self.display_name) def __str__(self): return unicode(self).encode('utf-8') def author_view(self, context): """ Renders the Studio preview view. """ fragment = Fragment() self.render_children(context, fragment, can_reorder=False, can_add=True) return fragment def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument """ Renders the children of the module with HTML appropriate for Studio. Reordering is not supported. """ contents = [] paging = context.get('paging', None) children_count = len(self.children) # pylint: disable=no-member item_start, item_end = 0, children_count # TODO sort children if paging: page_number = paging.get('page_number', 0) raw_page_size = paging.get('page_size', None) page_size = raw_page_size if raw_page_size is not None else children_count item_start, item_end = page_size * page_number, page_size * ( page_number + 1) children_to_show = self.children[item_start:item_end] # pylint: disable=no-member force_render = context.get('force_render', None) for child_key in children_to_show: # pylint: disable=E1101 # Children must have a separate context from the library itself. Make a copy. child_context = context.copy() child_context['show_preview'] = self.show_children_previews child_context['can_edit_visibility'] = False child = self.runtime.get_block(child_key) child_view_name = StudioEditableModule.get_preview_view_name(child) if unicode(child.location) == force_render: child_context['show_preview'] = True if child_context['show_preview']: rendered_child = self.runtime.render_child( child, child_view_name, child_context) else: rendered_child = self.runtime.render_child_placeholder( child, child_view_name, child_context) fragment.add_frag_resources(rendered_child) contents.append({ 'id': unicode(child.location), 'content': rendered_child.content, }) fragment.add_content( self.runtime.render_template( "studio_render_paged_children_view.html", { 'items': contents, 'xblock_context': context, 'can_add': can_add, 'first_displayed': item_start, 'total_children': children_count, 'displayed_children': len(children_to_show), 'previews': self.show_children_previews })) @property def display_org_with_default(self): """ Org display names are not implemented. This just provides API compatibility with CourseDescriptor. Always returns the raw 'org' field from the key. """ return self.scope_ids.usage_id.course_key.org @property def display_number_with_default(self): """ Display numbers are not implemented. This just provides API compatibility with CourseDescriptor. Always returns the raw 'library' field from the key. """ return self.scope_ids.usage_id.course_key.library @XBlock.json_handler def trigger_previews(self, request_body, suffix): # pylint: disable=unused-argument """ Enable or disable previews in studio for library children. """ self.show_children_previews = request_body.get( 'showChildrenPreviews', self.show_children_previews) return {'showChildrenPreviews': self.show_children_previews}
class OpenAssessmentBlock(MessageMixin, SubmissionMixin, PeerAssessmentMixin, SelfAssessmentMixin, StaffAssessmentMixin, StudioMixin, GradeMixin, LeaderboardMixin, StaffAreaMixin, WorkflowMixin, StudentTrainingMixin, LmsCompatibilityMixin, CourseItemsListingMixin, WaffleMixin, XBlock): """Displays a prompt and provides an area where students can compose a response.""" VALID_ASSESSMENT_TYPES = [ "student-training", "peer-assessment", "self-assessment", "staff-assessment", ] VALID_ASSESSMENT_TYPES_FOR_TEAMS = [ # pylint: disable=invalid-name 'staff-assessment', ] public_dir = 'static' submission_start = String( default=DEFAULT_START, scope=Scope.settings, help="ISO-8601 formatted string representing the submission start date." ) submission_due = String( default=DEFAULT_DUE, scope=Scope.settings, help="ISO-8601 formatted string representing the submission due date.") text_response_raw = String( help= "Specify whether learners must include a text based response to this problem's prompt.", default="required", scope=Scope.settings) file_upload_response_raw = String( help= "Specify whether learners are able to upload files as a part of their response.", default=None, scope=Scope.settings) allow_file_upload = Boolean( default=False, scope=Scope.content, help="Do not use. For backwards compatibility only.") file_upload_type_raw = String( default=None, scope=Scope.content, help= "File upload to be included with submission (can be 'image', 'pdf-and-image', or 'custom')." ) white_listed_file_types = List( default=[], scope=Scope.content, help="Custom list of file types allowed with submission.") allow_latex = Boolean(default=False, scope=Scope.settings, help="Latex rendering allowed with submission.") title = String(default="Open Response Assessment", scope=Scope.content, help="A title to display to a student (plain text).") leaderboard_show = Integer( default=0, scope=Scope.content, help="The number of leaderboard results to display (0 if none)") prompt = String(default=DEFAULT_PROMPT, scope=Scope.content, help="The prompts to display to a student.") prompts_type = String(default='text', scope=Scope.content, help="The type of prompt. html or text") rubric_criteria = List( default=DEFAULT_RUBRIC_CRITERIA, scope=Scope.content, help="The different parts of grading for students giving feedback.") rubric_feedback_prompt = String( default=DEFAULT_RUBRIC_FEEDBACK_PROMPT, scope=Scope.content, help="The rubric feedback prompt displayed to the student") rubric_feedback_default_text = String( default=DEFAULT_RUBRIC_FEEDBACK_TEXT, scope=Scope.content, help="The default rubric feedback text displayed to the student") rubric_assessments = List( default=DEFAULT_ASSESSMENT_MODULES, scope=Scope.content, help= "The requested set of assessments and the order in which to apply them." ) submission_uuid = String( default=None, scope=Scope.user_state, help="The student's submission that others will be assessing.") has_saved = Boolean( default=False, scope=Scope.user_state, help="Indicates whether the user has saved a response.") saved_response = String( default=u"", scope=Scope.user_state, help="Saved response submission for the current user.") saved_files_descriptions = String( default=u"", scope=Scope.user_state, help="Saved descriptions for each uploaded file.") saved_files_names = String( default=u"", scope=Scope.user_state, help="Saved original names for each uploaded file.") saved_files_sizes = String(default=u"", scope=Scope.user_state, help="Filesize of each uploaded file in bytes.") no_peers = Boolean( default=False, scope=Scope.user_state, help="Indicates whether or not there are peers to grade.") teams_enabled = Boolean( default=False, scope=Scope.settings, help="Whether team submissions are enabled for this case study.", ) @property def course_id(self): return text_type(self.xmodule_runtime.course_id) # pylint: disable=no-member @property def text_response(self): """ Backward compatibility for existing blocks that were created without text_response or file_upload_response fields. These blocks will be treated as required text. """ if not self.file_upload_response and not self.text_response_raw: return 'required' return self.text_response_raw @text_response.setter def text_response(self, value): """ Setter for text_response_raw """ self.text_response_raw = value if value else None @property def file_upload_response(self): """ Backward compatibility for existing block before that were created without 'text_response' and 'file_upload_response_raw' fields. """ if not self.file_upload_response_raw and (self.file_upload_type_raw is not None or self.allow_file_upload): return 'optional' return self.file_upload_response_raw @file_upload_response.setter def file_upload_response(self, value): """ Setter for file_upload_response_raw """ self.file_upload_response_raw = value if value else None @property def file_upload_type(self): """ Backward compatibility for existing block before the change from allow_file_upload to file_upload_type_raw. This property will use new file_upload_type_raw field when available, otherwise will fall back to allow_file_upload field for old blocks. """ if self.file_upload_type_raw is not None: return self.file_upload_type_raw if self.allow_file_upload: return 'image' return None @file_upload_type.setter def file_upload_type(self, value): """ Setter for file_upload_type_raw """ self.file_upload_type_raw = value @property def white_listed_file_types_string(self): """ Join the white listed file types into comma delimited string """ if self.white_listed_file_types: return ','.join(self.white_listed_file_types) return '' @white_listed_file_types_string.setter def white_listed_file_types_string(self, value): """ Convert comma delimited white list string into list with some clean up """ self.white_listed_file_types = [ file_type.strip().strip('.').lower() for file_type in value.split(',') ] if value else None def get_anonymous_user_id(self, username, course_id): """ Get the anonymous user id from Xblock user service. Args: username(str): user's name entered by staff to get info. course_id(str): course id. Returns: A unique id for (user, course) pair """ return self.runtime.service(self, 'user').get_anonymous_user_id( username, course_id) def is_user_state_service_available(self): """ Check if the user state service is present in runtime. """ try: self.runtime.service(self, 'user_state') return True except NoSuchServiceError: return False def get_user_state(self, username): """ Get the student module state for the given username for current ORA block. Arguments: username(str): username against which the state is required in the current block. Returns: user state, if found, else empty dict """ if self.is_user_state_service_available(): user_state_service = self.runtime.service(self, 'user_state') return user_state_service.get_state_as_dict( username, str(self.location)) # pylint: disable=no-member return {} def should_use_user_state(self, upload_urls): """ Return a boolean if the user state is used for additional data checks. User state is utilized when all of the following are true: 1. user state service is available(which is only part of courseware) 2. The waffle flag/switch is enabled 3. the file upload is required or optional 4. the file data from submission is missing information """ return not any(upload_urls) \ and self.is_user_state_service_available() \ and self.user_state_upload_data_enabled() \ and self.file_upload_response def get_student_item_dict_from_username(self, username): """ Get the item dict for a given username in the parent course of block. """ anonymous_user_id = self.get_anonymous_user_id(username, self.course_id) return self.get_student_item_dict(anonymous_user_id=anonymous_user_id) def get_student_item_dict(self, anonymous_user_id=None): """Create a student_item_dict from our surrounding context. See also: submissions.api for details. Args: anonymous_user_id(str): A unique anonymous_user_id for (user, course) pair. Returns: (dict): The student item associated with this XBlock instance. This includes the student id, item id, and course id. """ item_id = text_type(self.scope_ids.usage_id) # This is not the real way course_ids should work, but this is a # temporary expediency for LMS integration if hasattr(self, "xmodule_runtime"): course_id = self.course_id if anonymous_user_id: student_id = anonymous_user_id else: student_id = self.xmodule_runtime.anonymous_student_id # pylint:disable=E1101 else: course_id = "edX/Enchantment_101/April_1" if self.scope_ids.user_id is None: student_id = None else: student_id = text_type(self.scope_ids.user_id) student_item_dict = dict(student_id=student_id, item_id=item_id, course_id=course_id, item_type='openassessment') return student_item_dict def add_javascript_files(self, fragment, item): """ Add all the JavaScript files from a directory to the specified fragment """ if pkg_resources.resource_isdir(__name__, item): for child_item in pkg_resources.resource_listdir(__name__, item): path = os.path.join(item, child_item) if not pkg_resources.resource_isdir(__name__, path): fragment.add_javascript_url( self.runtime.local_resource_url(self, path)) else: fragment.add_javascript_url( self.runtime.local_resource_url(self, item)) def student_view(self, context=None): # pylint: disable=unused-argument """The main view of OpenAssessmentBlock, displayed when viewing courses. The main view which displays the general layout for Open Ended Assessment Questions. The contents of the XBlock are determined dynamically based on the assessment workflow configured by the author. Args: context: Not used for this view. Returns: (Fragment): The HTML Fragment for this XBlock, which determines the general frame of the Open Ended Assessment Question. """ # On page load, update the workflow status. # We need to do this here because peers may have graded us, in which # case we may have a score available. try: self.update_workflow_status() except AssessmentWorkflowError: # Log the exception, but continue loading the page logger.exception( 'An error occurred while updating the workflow on page load.') ui_models = self._create_ui_models() # All data we intend to pass to the front end. context_dict = { "title": self.title, "prompts": self.prompts, "prompts_type": self.prompts_type, "rubric_assessments": ui_models, "show_staff_area": self.is_course_staff and not self.in_studio_preview, } template = get_template("openassessmentblock/oa_base.html") return self._create_fragment(template, context_dict, initialize_js_func='OpenAssessmentBlock') def ora_blocks_listing_view(self, context=None): """This view is used in the Open Response Assessment tab in the LMS Instructor Dashboard to display all available course ORA blocks. Args: context: contains two items: "ora_items" - all course items with names and parents, example: [{"parent_name": "Vertical name", "name": "ORA Display Name", "url_grade_available_responses": "/grade_available_responses_view", "staff_assessment": false, "parent_id": "vertical_block_id", "url_base": "/student_view", "id": "openassessment_block_id" }, ...] "ora_item_view_enabled" - enabled LMS API endpoint to serve XBlock view or not Returns: (Fragment): The HTML Fragment for this XBlock. """ ora_items = context.get('ora_items', []) if context else [] ora_item_view_enabled = context.get('ora_item_view_enabled', False) if context else False context_dict = { "ora_items": json.dumps(ora_items), "ora_item_view_enabled": ora_item_view_enabled } template = get_template( 'openassessmentblock/instructor_dashboard/oa_listing.html') min_postfix = '.min' if settings.DEBUG else '' return self._create_fragment( template, context_dict, initialize_js_func='CourseOpenResponsesListingBlock', additional_css=[ "static/css/lib/backgrid/backgrid%s.css" % min_postfix ], additional_js=[ "static/js/lib/backgrid/backgrid%s.js" % min_postfix ]) def grade_available_responses_view(self, context=None): # pylint: disable=unused-argument """Grade Available Responses view. Auxiliary view which displays the staff grading area (used in the Open Response Assessment tab in the Instructor Dashboard of LMS) Args: context: Not used for this view. Returns: (Fragment): The HTML Fragment for this XBlock. """ student_item = self.get_student_item_dict() staff_assessment_required = "staff-assessment" in self.assessment_steps context_dict = { "title": self.title, 'staff_assessment_required': staff_assessment_required } if staff_assessment_required: context_dict.update( self.get_staff_assessment_statistics_context( student_item["course_id"], student_item["item_id"])) template = get_template( 'openassessmentblock/instructor_dashboard/oa_grade_available_responses.html' ) return self._create_fragment(template, context_dict, initialize_js_func='StaffAssessmentBlock') def _create_fragment(self, template, context_dict, initialize_js_func, additional_css=None, additional_js=None): """ Creates a fragment for display. """ fragment = Fragment(template.render(context_dict)) if additional_css is None: additional_css = [] if additional_js is None: additional_js = [] i18n_service = self.runtime.service(self, 'i18n') if hasattr(i18n_service, 'get_language_bidi') and i18n_service.get_language_bidi(): css_url = "static/css/openassessment-rtl.css" else: css_url = "static/css/openassessment-ltr.css" if settings.DEBUG: for css in additional_css: fragment.add_css_url(self.runtime.local_resource_url( self, css)) fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) for js in additional_js: # pylint: disable=invalid-name self.add_javascript_files(fragment, js) self.add_javascript_files(fragment, "static/js/src/oa_shared.js") self.add_javascript_files(fragment, "static/js/src/oa_server.js") self.add_javascript_files(fragment, "static/js/src/lms") else: # TODO: load CSS and JavaScript as URLs once they can be served by the CDN for css in additional_css: fragment.add_css(load(css)) fragment.add_css(load(css_url)) # minified additional_js should be already included in 'make javascript' fragment.add_javascript( load("static/js/openassessment-lms.min.js")) js_context_dict = { "ALLOWED_IMAGE_MIME_TYPES": self.ALLOWED_IMAGE_MIME_TYPES, "ALLOWED_FILE_MIME_TYPES": self.ALLOWED_FILE_MIME_TYPES, "FILE_EXT_BLACK_LIST": self.FILE_EXT_BLACK_LIST, "FILE_TYPE_WHITE_LIST": self.white_listed_file_types, "MAXIMUM_FILE_UPLOAD_COUNT": self.MAX_FILES_COUNT, "TEAM_ASSIGNMENT": self.teams_enabled and self.team_submissions_enabled() } fragment.initialize_js(initialize_js_func, js_context_dict) return fragment @property def is_admin(self): """ Check whether the user has global staff permissions. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_admin', False) # pylint: disable=no-member return False @property def is_course_staff(self): """ Check whether the user has course staff permissions for this XBlock. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_staff', False) # pylint: disable=no-member return False @property def is_beta_tester(self): """ Check whether the user is a beta tester. Returns: bool """ if hasattr(self, 'xmodule_runtime'): return getattr(self.xmodule_runtime, 'user_is_beta_tester', False) # pylint: disable=no-member return False @property def in_studio_preview(self): """ Check whether we are in Studio preview mode. Returns: bool """ # When we're running in Studio Preview mode, the XBlock won't provide us with a user ID. # (Note that `self.xmodule_runtime` will still provide an anonymous # student ID, so we can't rely on that) return self.scope_ids.user_id is None def _create_ui_models(self): """Combine UI attributes and XBlock configuration into a UI model. This method takes all configuration for this XBlock instance and appends UI attributes to create a UI Model for rendering all assessment modules. This allows a clean separation of static UI attributes from persistent XBlock configuration. """ ui_models = [UI_MODELS["submission"]] staff_assessment_required = False for assessment in self.valid_assessments: if assessment["name"] == "staff-assessment": if not assessment["required"]: continue else: staff_assessment_required = True ui_model = UI_MODELS.get(assessment["name"]) if ui_model: ui_models.append(dict(assessment, **ui_model)) if not staff_assessment_required and self.staff_assessment_exists( self.submission_uuid): ui_models.append(UI_MODELS["staff-assessment"]) ui_models.append(UI_MODELS["grade"]) if self.leaderboard_show > 0: ui_models.append(UI_MODELS["leaderboard"]) return ui_models @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench. These scenarios are only intended to be used for Workbench XBlock Development. """ return [ ("OpenAssessmentBlock File Upload: Images", load('static/xml/file_upload_image_only.xml')), ("OpenAssessmentBlock File Upload: PDF and Images", load('static/xml/file_upload_pdf_and_image.xml')), ("OpenAssessmentBlock File Upload: Custom File Types", load('static/xml/file_upload_custom.xml')), ("OpenAssessmentBlock File Upload: allow_file_upload compatibility", load('static/xml/file_upload_compat.xml')), ("OpenAssessmentBlock Unicode", load('static/xml/unicode.xml')), ("OpenAssessmentBlock Poverty Rubric", load('static/xml/poverty_rubric_example.xml')), ("OpenAssessmentBlock Leaderboard", load('static/xml/leaderboard.xml')), ("OpenAssessmentBlock Leaderboard with Custom File Type", load('static/xml/leaderboard_custom.xml')), ("OpenAssessmentBlock (Peer Only) Rubric", load('static/xml/poverty_peer_only_example.xml')), ("OpenAssessmentBlock (Self Only) Rubric", load('static/xml/poverty_self_only_example.xml')), ("OpenAssessmentBlock Censorship Rubric", load('static/xml/censorship_rubric_example.xml')), ("OpenAssessmentBlock Promptless Rubric", load('static/xml/promptless_rubric_example.xml')), ] @classmethod def parse_xml(cls, node, runtime, keys, id_generator): """Instantiate XBlock object from runtime XML definition. Inherited by XBlock core. """ config = parse_from_xml(node) block = runtime.construct_xblock_from_class(cls, keys) xblock_validator = validator(block, block._, strict_post_release=False) xblock_validator(create_rubric_dict(config['prompts'], config['rubric_criteria']), config['rubric_assessments'], submission_start=config['submission_start'], submission_due=config['submission_due'], leaderboard_show=config['leaderboard_show']) block.rubric_criteria = config['rubric_criteria'] block.rubric_feedback_prompt = config['rubric_feedback_prompt'] block.rubric_feedback_default_text = config[ 'rubric_feedback_default_text'] block.rubric_assessments = config['rubric_assessments'] block.submission_start = config['submission_start'] block.submission_due = config['submission_due'] block.title = config['title'] block.prompts = config['prompts'] block.prompts_type = config['prompts_type'] block.text_response = config['text_response'] block.file_upload_response = config['file_upload_response'] block.allow_file_upload = config['allow_file_upload'] block.file_upload_type = config['file_upload_type'] block.white_listed_file_types_string = config[ 'white_listed_file_types'] block.allow_latex = config['allow_latex'] block.leaderboard_show = config['leaderboard_show'] block.group_access = config['group_access'] return block @property def _(self): i18nService = self.runtime.service(self, 'i18n') # pylint: disable=invalid-name return i18nService.ugettext @property def prompts(self): """ Return the prompts. Initially a block had a single prompt which was saved as a simple string in the prompt field. Now prompts are saved as a serialized list of dicts in the same field. If prompt field contains valid json, parse and return it. Otherwise, assume it is a simple string prompt and return it in a list of dict. Returns: list of dict """ return create_prompts_list(self.prompt) @prompts.setter def prompts(self, value): """ Serialize the prompts and save to prompt field. Args: value (list of dict): The prompts to set. """ if value is None: self.prompt = None elif len(value) == 1: # For backwards compatibility. To be removed after all code # is migrated to use prompts property instead of prompt field. self.prompt = value[0]['description'] else: self.prompt = json.dumps(value) @property def valid_assessments(self): """ Return a list of assessment dictionaries that we recognize. This allows us to gracefully handle situations in which unrecognized assessment types are stored in the XBlock field (e.g. because we roll back code after releasing a feature). Returns: list """ assessment_types = self.VALID_ASSESSMENT_TYPES if self.teams_enabled: assessment_types = self.VALID_ASSESSMENT_TYPES_FOR_TEAMS _valid_assessments = [ asmnt for asmnt in self.rubric_assessments if asmnt.get('name') in assessment_types ] return update_assessments_format(copy.deepcopy(_valid_assessments)) @property def assessment_steps(self): return [asmnt['name'] for asmnt in self.valid_assessments] @lazy def rubric_criteria_with_labels(self): """ Backwards compatibility: We used to treat "name" as both a user-facing label and a unique identifier for criteria and options. Now we treat "name" as a unique identifier, and we've added an additional "label" field that we display to the user. If criteria/options in the problem definition do NOT have a "label" field (because they were created before this change), we create a new label that has the same value as "name". The result of this call is cached, so it should NOT be used in a runtime that can modify the XBlock settings (in the LMS, settings are read-only). Returns: list of criteria dictionaries """ criteria = copy.deepcopy(self.rubric_criteria) for criterion in criteria: if 'label' not in criterion: criterion['label'] = criterion['name'] for option in criterion['options']: if 'label' not in option: option['label'] = option['name'] return criteria def render_assessment(self, path, context_dict=None): """Render an Assessment Module's HTML Given the name of an assessment module, find it in the list of configured modules, and ask for its rendered HTML. Args: path (str): The path to the template used to render this HTML section. context_dict (dict): A dictionary of context variables used to populate this HTML section. Returns: (Response): A Response Object with the generated HTML fragment. This is intended for AJAX calls to load dynamically into a larger document. """ if not context_dict: context_dict = {} template = get_template(path) return Response(template.render(context_dict), content_type='application/html', charset='UTF-8') def add_xml_to_node(self, node): """ Serialize the XBlock to XML for exporting. """ serialize_content_to_xml(self, node) def render_error(self, error_msg): """ Render an error message. Args: error_msg (unicode): The error message to display. Returns: Response: A response object with an HTML body. """ context = {'error_msg': error_msg} template = get_template('openassessmentblock/oa_error.html') return Response(template.render(context), content_type='application/html', charset='UTF-8') def is_closed(self, step=None, course_staff=None): """ Checks if the question is closed. Determines if the start date is in the future or the end date has passed. Optionally limited to a particular step in the workflow. Start/due dates do NOT apply to course staff, since course staff may need to get to the peer grading step AFTER the submission deadline has passed. This may not be necessary when we implement a grading interface specifically for course staff. Keyword Arguments: step (str): The step in the workflow to check. Options are: None: check whether the problem as a whole is open. "submission": check whether the submission section is open. "peer-assessment": check whether the peer-assessment section is open. "self-assessment": check whether the self-assessment section is open. course_staff (bool): Whether to treat the user as course staff (disable start/due dates). If not specified, default to the current user's status. Returns: tuple of the form (is_closed, reason, start_date, due_date), where is_closed (bool): indicates whether the step is closed. reason (str or None): specifies the reason the step is closed ("start" or "due") start_date (datetime): is the start date of the step/problem. due_date (datetime): is the due date of the step/problem. Examples: >>> is_closed() False, None, datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) >>> is_closed(step="submission") True, "due", datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) >>> is_closed(step="self-assessment") True, "start", datetime.datetime(2014, 3, 27, 22, 7, 38, 788861), datetime.datetime(2015, 3, 27, 22, 7, 38, 788861) """ submission_range = (self.submission_start, self.submission_due) assessment_ranges = [(asmnt.get('start'), asmnt.get('due')) for asmnt in self.valid_assessments] # Resolve unspecified dates and date strings to datetimes start, due, date_ranges = resolve_dates( self.start, self.due, [submission_range] + assessment_ranges, self._) open_range = (start, due) assessment_steps = self.assessment_steps if step == 'submission': open_range = date_ranges[0] elif step in assessment_steps: step_index = assessment_steps.index(step) open_range = date_ranges[1 + step_index] # Course staff always have access to the problem if course_staff is None: course_staff = self.is_course_staff if course_staff: return False, None, DISTANT_PAST, DISTANT_FUTURE if self.is_beta_tester: beta_start = self._adjust_start_date_for_beta_testers( open_range[0]) open_range = (beta_start, open_range[1]) # Check if we are in the open date range now = dt.datetime.utcnow().replace(tzinfo=pytz.utc) if now < open_range[0]: return True, "start", open_range[0], open_range[1] elif now >= open_range[1]: return True, "due", open_range[0], open_range[1] return False, None, open_range[0], open_range[1] def get_waiting_details(self, status_details): """ Returns waiting status (boolean value) based on the given status_details. Args: status_details (dict): A dictionary containing the details of each assessment module status. This will contain keys such as "peer", "ai", and "staff", referring to dictionaries, which in turn will have the key "graded". If this key has a value set, these assessment modules have been graded. Returns: True if waiting for a grade from peer, ai, or staff assessment, else False. Examples: >>> now = dt.datetime.utcnow().replace(tzinfo=pytz.utc) >>> status_details = { >>> 'peer': { >>> 'completed': None, >>> 'graded': now >>> }, >>> 'ai': { >>> 'completed': now, >>> 'graded': None >>> } >>> } >>> self.get_waiting_details(status_details) True """ steps = [ "peer", "ai", "staff" ] # These are the steps that can be submitter-complete, but lack a grade for step in steps: if step in status_details and not status_details[step]["graded"]: return True return False def is_released(self, step=None): """ Check if a question has been released. Keyword Arguments: step (str): The step in the workflow to check. None: check whether the problem as a whole is open. "submission": check whether the submission section is open. "peer-assessment": check whether the peer-assessment section is open. "self-assessment": check whether the self-assessment section is open. Returns: bool """ # By default, assume that we're published, in case the runtime doesn't support publish date. if hasattr(self.runtime, 'modulestore'): is_published = self.runtime.modulestore.has_published_version(self) else: is_published = True is_closed, reason, __, __ = self.is_closed(step=step) is_released = is_published and (not is_closed or reason == 'due') if self.start: is_released = is_released and dt.datetime.now( pytz.UTC) > parse_date_value(self.start, self._) return is_released def get_assessment_module(self, mixin_name): """ Get a configured assessment module by name. Args: mixin_name (str): The name of the mixin (e.g. "self-assessment" or "peer-assessment") Returns: dict Example: >>> self.get_assessment_module('peer-assessment') { "name": "peer-assessment", "start": None, "due": None, "must_grade": 5, "must_be_graded_by": 3, } """ for assessment in self.valid_assessments: if assessment["name"] == mixin_name: return assessment def publish_assessment_event(self, event_name, assessment, **kwargs): """ Emit an analytics event for the peer assessment. Args: event_name (str): An identifier for this event type. assessment (dict): The serialized assessment model. Returns: None """ parts_list = [] for part in assessment["parts"]: # Some assessment parts do not include point values, # only written feedback. In this case, the assessment # part won't have an associated option. option_dict = None if part["option"] is not None: option_dict = { "name": part["option"]["name"], "points": part["option"]["points"], } # All assessment parts are associated with criteria criterion_dict = { "name": part["criterion"]["name"], "points_possible": part["criterion"]["points_possible"] } parts_list.append({ "option": option_dict, "criterion": criterion_dict, "feedback": part["feedback"] }) event_data = { "feedback": assessment["feedback"], "rubric": { "content_hash": assessment["rubric"]["content_hash"], }, "scorer_id": assessment["scorer_id"], "score_type": assessment["score_type"], "scored_at": assessment["scored_at"], "submission_uuid": assessment["submission_uuid"], "parts": parts_list } for key in kwargs: event_data[key] = kwargs[key] self.runtime.publish(self, event_name, event_data) @XBlock.json_handler def publish_event(self, data, suffix=''): # pylint: disable=unused-argument """ Publish the given data to an event. Expects key 'event_name' to be present in the data dictionary. """ try: event_name = data['event_name'] except KeyError: logger.exception( "Could not find the name of the event to be triggered.") return {'success': False} # Remove the name so we don't publish as part of the data. del data['event_name'] self.runtime.publish(self, event_name, data) return {'success': True} def get_real_user(self, anonymous_user_id): """ Return the user associated with anonymous_user_id Args: anonymous_user_id (str): the anonymous user id of the user Returns: the user model for the user if it can be identified. If the xblock service to converts to a real user fails, returns None and logs the error. """ if hasattr(self, "xmodule_runtime"): user = self.xmodule_runtime.get_real_user(anonymous_user_id) # pylint: disable=no-member if user: return user logger.exception( u"XBlock service could not find user for anonymous_user_id '{}'" .format(anonymous_user_id)) return None def get_username(self, anonymous_user_id): """ Return the username of the user associated with anonymous_user_id Args: anonymous_user_id (str): the anonymous user id of the user Returns: the username if it can be identified. """ user = self.get_real_user(anonymous_user_id) if user: return user.username return None def _adjust_start_date_for_beta_testers(self, start): """ Returns the start date for a Beta tester. """ if hasattr(self, "xmodule_runtime"): days_early_for_beta = getattr(self.xmodule_runtime, 'days_early_for_beta', 0) # pylint: disable=no-member if days_early_for_beta is not None: delta = dt.timedelta(days_early_for_beta) effective = start - delta return effective return start def get_xblock_id(self): """ Returns the xblock id """ return text_type(self.scope_ids.usage_id)
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."), 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."), 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."), 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."), 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."), 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."), 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."), 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 CourseFields(object): lti_passports = List( display_name=_("LTI Passports"), help= _("Enter the passports for course LTI tools in the following format: \"id\":\"client_key:client_secret\"." ), scope=Scope.settings) textbooks = TextbookList( help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date( help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", default=DEFAULT_START_DATE, scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String( display_name=_("Course Advertised Start Date"), help= _("Enter the date you want to advertise as the course start date, if this date is different from the set start date. To advertise the set start date, enter null." ), scope=Scope.settings) grading_policy = Dict(help="Grading policy definition for this class", default={ "GRADER": [{ "type": "Homework", "min_count": 12, "drop_count": 2, "short_label": "HW", "weight": 0.15 }, { "type": "Lab", "min_count": 12, "drop_count": 2, "weight": 0.15 }, { "type": "Midterm Exam", "short_label": "Midterm", "min_count": 1, "drop_count": 0, "weight": 0.3 }, { "type": "Final Exam", "short_label": "Final", "min_count": 1, "drop_count": 0, "weight": 0.4 }], "GRADE_CUTOFFS": { "Pass": 0.5 } }, scope=Scope.content) show_calculator = Boolean( display_name=_("Show Calculator"), help= _("Enter true or false. When true, students can see the calculator in the course." ), default=False, scope=Scope.settings) display_name = String(help=_( "Enter the name of the course as it should appear in the edX.org course list." ), default="Empty", display_name=_("Course Display Name"), scope=Scope.settings) #nicky added here course_kinds = String(help=_('Input the kinds of the course'), default="", display_name=_("The Kinds of Course"), scope=Scope.settings) course_level = String(help=_( 'The level of the course.Primary is the lowest grade,intermediate is the middle grade,senior is the highest grade.' ), default="", display_name=_("The Level of Course"), scope=Scope.settings) course_introduce = Boolean(help=_( 'Enter true or false. when True:the course has been introduced course.' ), default=False, display_name=_('Course Introduced'), scope=Scope.settings) course_edit_method = String( display_name=_("Course Editor"), help= _("Enter the method by which this course is edited (\"XML\" or \"Studio\")." ), default="Studio", scope=Scope.settings, deprecated= True # Deprecated because someone would not edit this value within Studio. ) show_chat = Boolean( display_name=_("Show Chat Widget"), help= _("Enter true or false. When true, students can see the chat widget in the course." ), default=False, scope=Scope.settings) tabs = CourseTabList(help="List of tabs to enable in this course", scope=Scope.settings, default=[]) end_of_course_survey_url = String( display_name=_("Course Survey URL"), help= _("Enter the URL for the end-of-course survey. If your course does not have a survey, enter null." ), scope=Scope.settings) discussion_blackouts = List( display_name=_("Discussion Blackout Dates"), help= _("Enter pairs of dates between which students cannot post to discussion forums, formatted as \"YYYY-MM-DD-YYYY-MM-DD\". To specify times as well as dates, format the pairs as \"YYYY-MM-DDTHH:MM-YYYY-MM-DDTHH:MM\" (be sure to include the \"T\" between the date and time)." ), scope=Scope.settings) discussion_topics = Dict( display_name=_("Discussion Topic Mapping"), help= _("Enter discussion categories in the following format: \"CategoryName\": {\"id\": \"i4x-InstitutionName-CourseNumber-course-CourseRun\"}. For example, one discussion category may be \"Lydian Mode\": {\"id\": \"i4x-UniversityX-MUS101-course-2014_T1\"}." ), scope=Scope.settings) discussion_sort_alpha = Boolean( display_name=_("Discussion Sorting Alphabetical"), scope=Scope.settings, default=False, help= _("Enter true or false. If true, discussion categories and subcategories are sorted alphabetically. If false, they are sorted chronologically." )) announcement = Date(display_name=_("Course Announcement Date"), help=_("Enter the date to announce your course."), scope=Scope.settings) cohort_config = Dict(display_name=_("Cohort Configuration"), help=_("Cohorts are not currently supported by edX."), scope=Scope.settings) is_new = Boolean( display_name=_("Course Is New"), help= _("Enter true or false. If true, the course appears in the list of new courses on edx.org, and a New! badge temporarily appears next to the course image." ), scope=Scope.settings) no_grade = Boolean( display_name=_("Course Not Graded"), help=_("Enter true or false. If true, the course will not be graded."), default=False, scope=Scope.settings) disable_progress_graph = Boolean( display_name=_("Disable Progress Graph"), help= _("Enter true or false. If true, students cannot view the progress graph." ), default=False, scope=Scope.settings) pdf_textbooks = List( display_name=_("PDF Textbooks"), help=_("List of dictionaries containing pdf_textbook configuration"), scope=Scope.settings) html_textbooks = List( display_name=_("HTML Textbooks"), help= _("For HTML textbooks that appear as separate tabs in the courseware, enter the name of the tab (usually the name of the book) as well as the URLs and titles of all the chapters in the book." ), scope=Scope.settings) remote_gradebook = Dict( display_name=_("Remote Gradebook"), help= _("Enter the remote gradebook mapping. Only use this setting when REMOTE_GRADEBOOK_URL has been specified." ), scope=Scope.settings) allow_anonymous = Boolean( display_name=_("Allow Anonymous Discussion Posts"), help= _("Enter true or false. If true, students can create discussion posts that are anonymous to all users." ), scope=Scope.settings, default=True) allow_anonymous_to_peers = Boolean( display_name=_("Allow Anonymous Discussion Posts to Peers"), help= _("Enter true or false. If true, students can create discussion posts that are anonymous to other students. This setting does not make posts anonymous to course staff." ), scope=Scope.settings, default=False) advanced_modules = List( display_name=_("Advanced Module List"), help=_( "Enter the names of the advanced components to use in your course." ), scope=Scope.settings) has_children = True checklists = List( scope=Scope.settings, default=[{ "short_description": _("Getting Started With Studio"), "items": [{ "short_description": _("Add Course Team Members"), "long_description": _("Grant your collaborators permission to edit your course so you can work together." ), "is_checked": False, "action_url": "ManageUsers", "action_text": _("Edit Course Team"), "action_external": False }, { "short_description": _("Set Important Dates for Your Course"), "long_description": _("Establish your course's student enrollment and launch dates on the Schedule and Details page." ), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Details & Schedule"), "action_external": False }, { "short_description": _("Draft Your Course's Grading Policy"), "long_description": _("Set up your assignment types and grading policy even if you haven't created all your assignments." ), "is_checked": False, "action_url": "SettingsGrading", "action_text": _("Edit Grading Settings"), "action_external": False }, { "short_description": _("Explore the Other Studio Checklists"), "long_description": _("Discover other available course authoring tools, and find help when you need it." ), "is_checked": False, "action_url": "", "action_text": "", "action_external": False }] }, { "short_description": _("Draft a Rough Course Outline"), "items": [{ "short_description": _("Create Your First Section and Subsection"), "long_description": _("Use your course outline to build your first Section and Subsection." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Set Section Release Dates"), "long_description": _("Specify the release dates for each Section in your course. Sections become visible to students on their release dates." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Designate a Subsection as Graded"), "long_description": _("Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Reordering Course Content"), "long_description": _("Use drag and drop to reorder the content in your course."), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Renaming Sections"), "long_description": _("Rename Sections by clicking the Section name from the Course Outline." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Deleting Course Content"), "long_description": _("Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }, { "short_description": _("Add an Instructor-Only Section to Your Outline"), "long_description": _("Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future." ), "is_checked": False, "action_url": "CourseOutline", "action_text": _("Edit Course Outline"), "action_external": False }] }, { "short_description": _("Explore edX's Support Tools"), "items": [{ "short_description": _("Explore the Studio Help Forum"), "long_description": _("Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio." ), "is_checked": False, "action_url": "http://help.edge.edx.org/", "action_text": _("Visit Studio Help"), "action_external": True }, { "short_description": _("Enroll in edX 101"), "long_description": _("Register for edX 101, edX's primer for course creation."), "is_checked": False, "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", "action_text": _("Register for edX 101"), "action_external": True }, { "short_description": _("Download the Studio Documentation"), "long_description": _("Download the searchable Studio reference documentation in PDF form." ), "is_checked": False, "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", "action_text": _("Download Documentation"), "action_external": True }] }, { "short_description": _("Draft Your Course About Page"), "items": [{ "short_description": _("Draft a Course Description"), "long_description": _("Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course." ), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False }, { "short_description": _("Add Staff Bios"), "long_description": _("Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page." ), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False }, { "short_description": _("Add Course FAQs"), "long_description": _("Include a short list of frequently asked questions about your course." ), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False }, { "short_description": _("Add Course Prerequisites"), "long_description": _("Let students know what knowledge and/or skills they should have before they enroll in your course." ), "is_checked": False, "action_url": "SettingsDetails", "action_text": _("Edit Course Schedule & Details"), "action_external": False }] }]) info_sidebar_name = String( display_name=_("Course Info Sidebar Name"), help= _("Enter the heading that you want students to see above your course handouts on the Course Info page. Your course handouts appear in the right panel of the page." ), scope=Scope.settings, default='Course Handouts') show_timezone = Boolean( help= "True if timezones should be shown on dates in the courseware. Deprecated in favor of due_date_display_format.", scope=Scope.settings, default=True) due_date_display_format = String( display_name=_("Due Date Display Format"), help= _("Enter the format due dates are displayed in. Due dates must be in MM-DD-YYYY, DD-MM-YYYY, YYYY-MM-DD, or YYYY-DD-MM format." ), scope=Scope.settings, default=None) enrollment_domain = String( display_name=_("External Login Domain"), help=_( "Enter the external login method students can use for the course." ), scope=Scope.settings) certificates_show_before_end = Boolean( display_name=_("Certificates Downloadable Before End"), help= _("Enter true or false. If true, students can download certificates before the course ends, if they've met certificate requirements." ), scope=Scope.settings, default=False, deprecated=True) certificates_display_behavior = String( display_name=_("Certificates Display Behavior"), help= _("Has three possible states: 'end', 'early_with_info', 'early_no_info'. 'end' is the default behavior, where certificates will only appear after a course has ended. 'early_with_info' will display all certificate information before a course has ended. 'early_no_info' will hide all certificate information unless a student has earned a certificate." ), scope=Scope.settings, default="end") course_image = String( display_name=_("Course About Page Image"), help= _("Edit the name of the course image file. You must upload this file on the Files & Uploads page. You can also set the course image on the Settings & Details page." ), scope=Scope.settings, # Ensure that courses imported from XML keep their image default="images_course_image.jpg") course_video = String( display_name=_("Course Introduce Video"), help= _("Edit the name of the course video file. You must upload this file on the Files & Uploads page." ), scope=Scope.settings, default="course_video_course.mp4") ## Course level Certificate Name overrides. cert_name_short = String(help=_( "Between quotation marks, enter the short name of the course to use on the certificate that students receive when they complete the course." ), display_name=_("Certificate Name (Short)"), scope=Scope.settings, default="") cert_name_long = String(help=_( "Between quotation marks, enter the long name of the course to use on the certificate that students receive when they complete the course." ), display_name=_("Certificate Name (Long)"), scope=Scope.settings, default="") # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows # courses to share the same css_class across runs even if they have # different numbers. # # TODO get rid of this as soon as possible or potentially build in a robust # way to add in course-specific styling. There needs to be a discussion # about the right way to do this, but arjun will address this ASAP. Also # note that the courseware template needs to change when this is removed. css_class = String( display_name=_("CSS Class for Course Reruns"), help= _("Allows courses to share the same css class across runs even if they have different numbers." ), scope=Scope.settings, default="", deprecated=True) # TODO: This is a quick kludge to allow CS50 (and other courses) to # specify their own discussion forums as external links by specifying a # "discussion_link" in their policy JSON file. This should later get # folded in with Syllabus, Course Info, and additional Custom tabs in a # more sensible framework later. discussion_link = String( display_name=_("Discussion Forum External Link"), help= _("Allows specification of an external link to replace discussion forums." ), scope=Scope.settings, deprecated=True) # TODO: same as above, intended to let internal CS50 hide the progress tab # until we get grade integration set up. # Explicit comparison to True because we always want to return a bool. hide_progress_tab = Boolean(display_name=_("Hide Progress Tab"), help=_("Allows hiding of the progress tab."), scope=Scope.settings, deprecated=True) display_organization = String( display_name=_("Course Organization Display String"), help= _("Enter the course organization that you want to appear in the courseware. This setting overrides the organization that you entered when you created the course. To use the organization that you entered when you created the course, enter null." ), scope=Scope.settings) display_coursenumber = String( display_name=_("Course Number Display String"), help= _("Enter the course number that you want to appear in the courseware. This setting overrides the course number that you entered when you created the course. To use the course number that you entered when you created the course, enter null." ), scope=Scope.settings) max_student_enrollments_allowed = Integer( display_name=_("Course Maximum Student Enrollment"), help= _("Enter the maximum number of students that can enroll in the course. To allow an unlimited number of students, enter null." ), scope=Scope.settings) allow_public_wiki_access = Boolean( display_name=_("Allow Public Wiki Access"), help= _("Enter true or false. If true, edX users can view the course wiki even if they're not enrolled in the course." ), default=False, scope=Scope.settings) invitation_only = Boolean( display_name=_("Invitation Only"), help= "Whether to restrict enrollment to invitation by the course staff.", default=False, scope=Scope.settings)
class DmCloud(XBlock): """ XBlock providing a video player for videos hosted on DMCloud This Xblock show video in iframe using DMCloud Api and video_id """ URL_TIMEOUT = 3600 * 24 * 7 # To make sure that js files are called in proper order we use numerical # index. We do that to avoid issues that occurs in tests. #display name already defined by xblock - we just redefined it to update translation 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=_("New video"), scope=Scope.settings) id_video = String( scope=Scope.settings, help=_('Fill this with the ID of the video found on DM Cloud'), default="", display_name=_('Video ID')) allow_download_video = Boolean( help=_("Allow students to download this video."), display_name=_("Video Download Allowed"), scope=Scope.settings, default=False) saved_video_position = Integer(help="Current position in the video.", scope=Scope.user_state, default=0) player = String(display_name=_("Player"), help=_("Player used to display the video"), scope=Scope.settings, values=[ { 'name': "HTML5", 'val': "HTML5" }, { 'name': "Dailymotion", 'val': "Dailymotion" }, ], default="HTML5") def __init__(self, *args, **kwargs): super(DmCloud, self).__init__(*args, **kwargs) self._cloudkey = None self._univ = None def get_icon_class(self): """Return the CSS class to be used in courseware sequence list.""" return 'video' @property def univ(self): """ Get the university from the organisation code filled during the course creation. """ if self._univ is None: try: self._univ = University.objects.get(code=self.location.org) except University.DoesNotExist: log.error("university not found: %s", self.location.org) raise return self._univ @property def cloudkey(self): """ Initialize cloudKey to get video url with video_id. """ if self._cloudkey is None: self._cloudkey = CloudKey(self.univ.dm_user_id, self.univ.dm_api_key) return self._cloudkey def resource_string(self, path): """Gets the content of a resource""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def render_template(self, template_path, context=None): """ Evaluate a template by resource path, applying the provided context """ context = context or {} template_str = self.resource_string(template_path) template = Template(template_str) return template.render(Context(context)) def student_view(self, context=None): """ Player view, displayed to the student """ ### # VIDEO MODULE # #### => work and load dailymotion cloud player stream_url = "" stream_url_hd = "" download_url_ld = "" download_url_std = "" download_url_hd = "" thumbnail_url = "" subs_url = {} auth_key = "" auth_key_secure = "" msg = {} if self.id_video != "": try: assets = self.cloudkey.media.get_assets(id=self.id_video) if self.player == "Dailymotion": auth_key = self.get_dailymotion_auth_key(False) auth_key_secure = self.get_dailymotion_auth_key(True) else: #assets['jpeg_thumbnail_source']['stream_url'] #mp4_h264_aac #mp4_h264_aac_ld #mp4_h264_aac_hq -> 480 #mp4_h264_aac_hd -> 720 #jpeg_thumbnail_medium thumbnail_url = self.cloudkey.media.get_stream_url( id=self.id_video, asset_name='jpeg_thumbnail_source') stream_url = self.get_stream_url() if assets.get('mp4_h264_aac_hd'): stream_url_hd = self.get_stream_url('mp4_h264_aac_hd', download=False) elif assets.get('mp4_h264_aac_hq'): stream_url_hd = self.get_stream_url('mp4_h264_aac_hq', download=False) subs_url = self.get_subs_url() if self.allow_download_video: download_url_ld = self.get_stream_url('mp4_h264_aac_ld', download=True) download_url_std = self.get_stream_url(download=True) if assets.get('mp4_h264_aac_hd'): download_url_hd = self.get_stream_url( 'mp4_h264_aac_hd', download=True) elif assets.get('mp4_h264_aac_hq'): download_url_hd = self.get_stream_url( 'mp4_h264_aac_hq', download=True) except InvalidParameter: msg = { "level": "warning", "message": _("Your video ID is invalid") } except AuthenticationError: msg = { "level": "warning", "message": _("Your university ID is invalid") } except Exception as e: # we use Exception to catch everything because if one fails, all xblock on page fail # and become uneditable msg = { "level": "error", "message": u'Unexpected error : %r' % e } log.error(msg['message']) else: msg = { "level": "warning", "message": _("You must provide a video ID") } #create url for videojs to add it directly in the template dmjsurl = self.runtime.local_resource_url( self, "public/js/src/dmplayer-sdk.js") frag = Fragment() frag.add_content( self.render_template( "templates/html/dmcloud.html", { 'self': self, 'msg': msg, 'id': self.location.html_id(), #dmplayer 'auth_key': auth_key, 'auth_key_secure': auth_key_secure, 'video_id': self.id_video, 'user_id': self.univ.dm_user_id, 'dmjsurl': dmjsurl, #end dmplayer 'download_url_ld': download_url_ld, 'download_url_std': download_url_std, 'download_url_hd': download_url_hd, 'stream_url': stream_url, 'stream_url_hd': stream_url_hd, 'subs_url': subs_url, 'thumbnail_url': thumbnail_url, "transcript_url": self.runtime.handler_url(self, 'transcript', 'translation').rstrip('/?'), })) #load locally to work with more than one instance on page frag.add_css_url( self.runtime.local_resource_url(self, "public/css/dmcloud.css")) if self.player == "Dailymotion": frag.add_javascript( self.resource_string("public/js/src/dmcloud-dm.js")) frag.initialize_js('DmCloudPlayer') else: frag.add_css_url( self.runtime.local_resource_url( self, "public/video-js-4.10.2/video-js.min.css")) frag.add_javascript_url( self.runtime.local_resource_url( self, "public/video-js-4.10.2/video.dev.js")) frag.add_javascript( self.resource_string("public/js/src/dmcloud-video.js")) frag.initialize_js('DmCloudVideo') return frag def get_dailymotion_auth_key(self, secure): embed_url = self.get_embed_url(secure) auth_index = embed_url.find("auth=") auth_keys = embed_url[auth_index + 5:len(embed_url)] return auth_keys def get_embed_url(self, secure): return self.cloudkey.media._get_url(base_path="/player/embed", id=self.id_video, expires=time.time() + self.URL_TIMEOUT, secure=secure) def get_stream_url(self, asset_name='mp4_h264_aac', download=False): return self.cloudkey.media.get_stream_url(id=self.id_video, asset_name=asset_name, download=download, expires=time.time() + self.URL_TIMEOUT) def get_subs_url(self): try: return self.cloudkey.media.get_subs_urls(id=self.id_video, type="srt") except SerializerError: return "" def studio_view(self, context=None): """ Editing view in Studio """ frag = Fragment() frag.add_content( self.render_template("/templates/html/dmcloud-studio.html", {'self': self})) frag.add_css(self.resource_string("public/css/dmcloud.css")) frag.add_javascript(self.resource_string("public/js/src/dmcloud.js")) frag.initialize_js('DmCloud') return frag @XBlock.json_handler def studio_submit(self, submissions, suffix=''): if submissions['id_video'] == "": response = { 'result': 'error', 'message': 'You should give a video ID' } else: log.info(u'Received submissions: %s', submissions) self.display_name = submissions['display_name'] self.id_video = submissions['id_video'].strip() self.allow_download_video = submissions['allow_download_video'] self.player = submissions['dmcloud_player'] response = { 'result': 'success', } return response @XBlock.json_handler def save_user_state(self, submissions, suffix=''): self.saved_video_position = submissions['saved_video_position'] response = { 'result': 'success', } return response @XBlock.handler def transcript(self, request, dispatch): if request.GET.get('url'): r = requests.get(request.GET.get('url')) response = Response("%s" % r.content) response.content_type = 'text/plain' else: response = Response(status=404) return response @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ("DmCloud", """<vertical_demo> <dmcloud/> </vertical_demo> """), ]
class SwipeBlock( QuestionMixin, StudioEditableXBlockMixin, StudentViewUserStateMixin, XBlockWithPreviewMixin, XBlock, ): """ An XBlock used to ask binary-choice questions with a swiping interface """ CATEGORY = 'pb-swipe' STUDIO_LABEL = _(u"Swipeable Binary Choice Question") USER_STATE_FIELDS = ['student_choice'] text = String( display_name=_("Text"), help= _("Text to display on this card. The student must determine if this statement is true or false." ), scope=Scope.content, default="", multiline_editor=True, ) img_url = String( display_name=_("Image"), help=_("Specify the URL of an image associated with this question."), scope=Scope.content, default="") correct = Boolean( display_name=_("Correct Choice"), help=_("Specifies whether the card is correct."), scope=Scope.content, default=False, ) feedback_correct = String( display_name=_("Correct Answer Feedback"), help=_("Feedback to display when student answers correctly."), scope=Scope.content, ) feedback_incorrect = String( display_name=_("Incorrect Answer Feedback"), help=_("Feedback to display when student answers incorrectly."), scope=Scope.content, ) student_choice = Boolean(scope=Scope.user_state, help=_("Last input submitted by the student.")) editable_fields = ('display_name', 'text', 'img_url', 'correct', 'feedback_correct', 'feedback_incorrect') def calculate_results(self, submission): correct = submission == self.correct return { 'submission': submission, 'status': 'correct' if correct else 'incorrect', 'score': 1 if correct else 0, 'feedback': self.feedback_correct if correct else self.feedback_incorrect, } def get_results(self, previous_result): return self.calculate_results(previous_result['submission']) def get_last_result(self): return self.get_results({'submission': self.student_choice }) if self.student_choice else {} def submit(self, submission): log.debug(u'Received Swipe submission: "%s"', submission) # We expect to receive a boolean indicating the swipe the student made (left -> false, right -> true) self.student_choice = submission['value'] result = self.calculate_results(self.student_choice) log.debug(u'Swipe submission result: %s', result) return result def student_view_data(self, context=None): """ Returns a JSON representation of the student_view of this XBlock, retrievable from the Course Block API. """ return { 'id': self.name, 'block_id': six.text_type(self.scope_ids.usage_id), 'display_name': self.display_name_with_default, 'type': self.CATEGORY, 'text': self.text, 'img_url': self.expand_static_url(self.img_url), 'correct': self.correct, 'feedback': { 'correct': self.feedback_correct, 'incorrect': self.feedback_incorrect, }, } def expand_static_url(self, url): """ This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the only portable URL format for static files that works across export/import and reruns). This method is unfortunately a bit hackish since XBlock does not provide a low-level API for this. """ if hasattr(self.runtime, 'replace_urls'): url = self.runtime.replace_urls('"{}"'.format(url))[1:-1] elif hasattr(self.runtime, 'course_id'): # edX Studio uses a different runtime for 'studio_view' than 'student_view', # and the 'studio_view' runtime doesn't provide the replace_urls API. try: from static_replace import replace_static_urls # pylint: disable=import-error url = replace_static_urls( '"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1] except ImportError: pass return url def mentoring_view(self, context=None): """ Render the swipe image, text & whether it's correct within a mentoring block question. """ return Fragment((u'<img src="{img_url}" style="max-width: 100%;" />' u'<p class="swipe-text">"{text}"</p>').format( img_url=self.expand_static_url(self.img_url), text=self.text, )) def student_view(self, context=None): """ Normal view of this XBlock, identical to mentoring_view """ return self.mentoring_view(context)
class ScormXBlock(XBlock): """ When a user uploads a Scorm package, the zip file is stored in: media/{org}/{course}/{block_type}/{block_id}/{sha1}{ext} This zip file is then extracted to the media/{scorm_location}/{block_id}. The scorm location is defined by the LOCATION xblock setting. If undefined, this is "scorm". This setting can be set e.g: XBLOCK_SETTINGS["ScormXBlock"] = { "LOCATION": "alternatevalue", } Note that neither the folder the folder nor the package file are deleted when the xblock is removed. """ has_custom_completion = True completion_mode = XBlockCompletionMode.COMPLETABLE display_name = String( display_name=_("Display Name"), help=_("Display name for this module"), default="Scorm module", scope=Scope.settings, ) index_page_path = String( display_name=_("Path to the index page in scorm file"), scope=Scope.settings ) package_meta = Dict(scope=Scope.content) scorm_version = 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_score = Float(scope=Scope.user_state, default=0) weight = Float( default=1, display_name=_("Weight"), help=_("Weight/Maximum grade"), scope=Scope.settings, ) has_score = Boolean( display_name=_("Scored"), help=_( "Select False if this component will not receive a numerical score from the Scorm" ), default=True, scope=Scope.settings, ) # See the Scorm data model: # https://scorm.com/scorm-explained/technical-scorm/run-time/ scorm_data = Dict(scope=Scope.user_state, default={}) icon_class = String(default="video", scope=Scope.settings) width = Integer( display_name=_("Display width (px)"), help=_("Width of iframe (default: 100%)"), scope=Scope.settings, ) height = Integer( display_name=_("Display height (px)"), help=_("Height of iframe"), default=450, scope=Scope.settings, ) has_author_view = True def render_template(self, template_path, context): template_str = self.resource_string(template_path) template = Template(template_str) return template.render(Context(context)) @staticmethod def resource_string(path): """Handy helper for getting static resources from our kit.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def author_view(self, context=None): context = context or {} if not self.index_page_path: context[ "message" ] = "Click 'Edit' to modify this module and upload a new SCORM package." return self.student_view(context=context) def student_view(self, context=None): student_context = { "index_page_url": self.index_page_url, "completion_status": self.get_completion_status(), "grade": self.get_grade(), "scorm_xblock": self, } student_context.update(context or {}) template = self.render_template("static/html/scormxblock.html", student_context) 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={"scorm_version": self.scorm_version} ) return frag def studio_view(self, context=None): # Note that we cannot use xblockutils's StudioEditableXBlockMixin because we # need to support package file uploads. studio_context = { "field_display_name": self.fields["display_name"], "field_has_score": self.fields["has_score"], "field_weight": self.fields["weight"], "field_width": self.fields["width"], "field_height": self.fields["height"], "scorm_xblock": self, } studio_context.update(context or {}) template = self.render_template("static/html/studio.html", studio_context) 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 @staticmethod def json_response(data): return Response( json.dumps(data), content_type="application/json", charset="utf8" ) @XBlock.handler def studio_submit(self, request, _suffix): self.display_name = request.params["display_name"] self.width = request.params["width"] self.height = request.params["height"] self.has_score = request.params["has_score"] self.weight = request.params["weight"] self.icon_class = "problem" if self.has_score == "True" else "video" response = {"result": "success", "errors": []} if not hasattr(request.params["file"], "file"): # File not uploaded return self.json_response(response) package_file = request.params["file"].file self.update_package_meta(package_file) # First, save scorm file in the storage for mobile clients if scorm_storage_instance.exists(self.package_path): logger.info('Removing previously uploaded "%s"', self.package_path) scorm_storage_instance.delete(self.package_path) scorm_storage_instance.save(self.package_path, File(package_file)) logger.info('Scorm "%s" file stored at "%s"', package_file, self.package_path) # Then, extract zip file if scorm_storage_instance.exists(self.extract_folder_base_path): logger.info( 'Removing previously unzipped "%s"', self.extract_folder_base_path ) recursive_delete(self.extract_folder_base_path) def unzip_member(_scorm_storage_instance,uncompressed_file,extract_folder_path, filename): logger.info('Started uploading file {fname}'.format(fname=filename)) _scorm_storage_instance.save( os.path.join(extract_folder_path, filename), uncompressed_file, ) logger.info('End uploadubg file {fname}'.format(fname=filename)) with zipfile.ZipFile(package_file, "r") as scorm_zipfile: futures = [] with concurrent.futures.ThreadPoolExecutor() as executor: logger.info("started concurrent.futures.ThreadPoolExecutor") for zipinfo in scorm_zipfile.infolist(): fp = tempfile.TemporaryFile() fp.write(scorm_zipfile.open(zipinfo.filename).read()) logger.info("started uploadig file {fname}".format(fname=zipinfo.filename)) # Do not unzip folders, only files. In Python 3.6 we will have access to # the is_dir() method to verify whether a ZipInfo object points to a # directory. # https://docs.python.org/3.6/library/zipfile.html#zipfile.ZipInfo.is_dir if not zipinfo.filename.endswith("/"): futures.append( executor.submit( unzip_member, scorm_storage_instance, fp, self.extract_folder_path, zipinfo.filename, ) ) logger.info("end concurrent.futures.ThreadPoolExecutor") try: self.update_package_fields() except ScormError as e: response["errors"].append(e.args[0]) return self.json_response(response) @property def index_page_url(self): if not self.package_meta or not self.index_page_path: return "" folder = self.extract_folder_path if scorm_storage_instance.exists( os.path.join(self.extract_folder_base_path, self.index_page_path) ): # For backward-compatibility, we must handle the case when the xblock data # is stored in the base folder. folder = self.extract_folder_base_path logger.warning("Serving SCORM content from old-style path: %s", folder) url = scorm_storage_instance.url(os.path.join(folder, self.index_page_path)) if SCORM_MEDIA_BASE_URL: splitted_url = list(urlparse(url)) url = "{base_url}{path}".format(base_url=SCORM_MEDIA_BASE_URL, path=splitted_url[2]) return url @property def package_path(self): """ Get file path of storage. """ return ( "{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/{sha1}{ext}" ).format( loc=self.location, sha1=self.package_meta["sha1"], ext=os.path.splitext(self.package_meta["name"])[1], ) @property def extract_folder_path(self): """ This path needs to depend on the content of the scorm package. Otherwise, served media files might become stale when the package is update. """ return os.path.join(self.extract_folder_base_path, self.package_meta["sha1"]) @property def extract_folder_base_path(self): """ Path to the folder where packages will be extracted. """ return os.path.join(self.scorm_location(), self.location.block_id) @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} if name == "cmi.success_status": return {"value": self.success_status} if name in ["cmi.core.score.raw", "cmi.score.raw"]: return {"value": self.lesson_score * 100} return {"value": self.scorm_data.get(name, "")} @XBlock.json_handler def scorm_set_value(self, data, _suffix): context = {"result": "success"} name = data.get("name") if name in ["cmi.core.lesson_status", "cmi.completion_status"]: self.lesson_status = data.get("value") if self.has_score and data.get("value") in [ "completed", "failed", "passed", ]: self.publish_grade() context.update({"lesson_score": self.lesson_score}) elif name == "cmi.success_status": self.success_status = data.get("value") if self.has_score: if self.success_status == "unknown": self.lesson_score = 0 self.publish_grade() context.update({"lesson_score": self.lesson_score}) elif name in ["cmi.core.score.raw", "cmi.score.raw"] and self.has_score: self.lesson_score = float(data.get("value", 0)) / 100.0 self.publish_grade() context.update({"lesson_score": self.lesson_score}) else: self.scorm_data[name] = data.get("value", "") context.update({"completion_status": self.get_completion_status()}) return context def publish_grade(self): self.runtime.publish( self, "grade", {"value": self.get_grade(), "max_value": self.weight}, ) self.publish_completion() def publish_completion(self): """ Mark scorm xbloxk as completed if user has completed the scorm course unit. it will work along with the edX completion tool: https://github.com/edx/completion """ if not completion_waffle.waffle().is_enabled(completion_waffle.ENABLE_COMPLETION_TRACKING): return if XBlockCompletionMode.get_mode(self) != XBlockCompletionMode.COMPLETABLE: return completion_value = 0.0 if not self.has_score: # component does not have any score if self.get_completion_status() == "completed": completion_value = 1.0 else: if self.get_completion_status() in ["passed", "failed"]: completion_value = 1.0 data = { "completion": completion_value } self.runtime.publish(self, "completion", data) def get_grade(self): lesson_score = self.lesson_score if self.lesson_status == "failed" or ( self.scorm_version == "SCORM_2004" and self.success_status in ["failed", "unknown"] ): lesson_score = 0 return lesson_score * self.weight def set_score(self, score): """ Utility method used to rescore a problem. """ self.lesson_score = score.raw_earned / self.weight def max_score(self): """ Return the maximum score possible. """ return self.weight if self.has_score else None def update_package_meta(self, package_file): self.package_meta["sha1"] = self.get_sha1(package_file) self.package_meta["name"] = package_file.name self.package_meta["last_updated"] = timezone.now().strftime( DateTime.DATETIME_FORMAT ) self.package_meta["size"] = package_file.seek(0, 2) package_file.seek(0) def update_package_fields(self): """ Update version and index page path fields. """ self.index_page_path = "" imsmanifest_path = os.path.join(self.extract_folder_path, "imsmanifest.xml") try: imsmanifest_file = scorm_storage_instance.open(imsmanifest_path) except IOError: raise ScormError( "Invalid package: could not find 'imsmanifest.xml' file at the root of the zip file" ) else: tree = ET.parse(imsmanifest_file) imsmanifest_file.seek(0) self.index_page_path = "index.html" namespace = "" for _, node in ET.iterparse(imsmanifest_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: self.index_page_path = resource.get("href") if (schemaversion is not None) and ( re.match("^1.2$", schemaversion.text) is None ): self.scorm_version = "SCORM_2004" else: self.scorm_version = "SCORM_12" def get_completion_status(self): completion_status = self.lesson_status if self.scorm_version == "SCORM_2004" and self.success_status != "unknown": completion_status = self.success_status return completion_status def scorm_location(self): """ Unzipped files will be stored in a media folder with this name, and thus accessible at a url with that also includes this name. """ default_scorm_location = "scorm" settings_service = self.runtime.service(self, "settings") if not settings_service: return default_scorm_location xblock_settings = settings_service.get_settings_bucket(self) return xblock_settings.get("LOCATION", default_scorm_location) @staticmethod def get_sha1(file_descriptor): """ Get file hex digest (fingerprint). """ block_size = 8 * 1024 sha1 = hashlib.sha1() while True: block = file_descriptor.read(block_size) if not block: break sha1.update(block) file_descriptor.seek(0) return sha1.hexdigest() def student_view_data(self): """ Inform REST api clients about original file location and it's "freshness". Make sure to include `student_view_data=openedxscorm` to URL params in the request. """ if self.index_page_url: return { "last_modified": self.package_meta.get("last_updated", ""), "scorm_data": scorm_storage_instance.url(self.package_path), "size": self.package_meta.get("size", 0), "index_page": self.index_page_path, } return {} @staticmethod def workbench_scenarios(): """A canned scenario for display in the workbench.""" return [ ( "ScormXBlock", """<vertical_demo> <openedxscorm/> </vertical_demo> """, ), ]
class UcDockerXBlock(XBlock): logger = Util.uc_logger() is_new = Boolean(default=True, scope=Scope.user_state, help="is new") private_key = String(default="", scope=Scope.user_state, help="SHH Private Key") public_key = String(default="", scope=Scope.user_state, help="SHH Public Key") git_password = String(default="", scope=Scope.user_state, help="Git password") git_id = Integer(default="", scope=Scope.user_state, help="Git id") git_user_token = String(default="", scope=Scope.user_state, help="Git private token") dockers = List(default=[], scope=Scope.user_state, help="dockers") labs = List(default=[], scope=Scope.content, help="labs") # config CONFIG = Config.CONFIG git_host = CONFIG["GIT"]["HOST"] git_port = CONFIG["GIT"]["PORT"] git_admin_token = CONFIG["GIT"]["ADMIN_TOKEN"] docker_host = CONFIG["DOCKER"]["HOST"] docker_url = CONFIG["DOCKER"]["REMOTE_API"]["URL"] docker_namespace = CONFIG["DOCKER"]["NAMESPACE"] docker_mem = CONFIG["DOCKER"]["MEM_LIMIT"] ca = CONFIG["DOCKER"]["REMOTE_API"]["CA"] cert = CONFIG["DOCKER"]["REMOTE_API"]["CERT"] key = CONFIG["DOCKER"]["REMOTE_API"]["KEY"] version = CONFIG["DOCKER"]["REMOTE_API"]["VERSION"] git_teacher_token = CONFIG["GIT"]["TEACHER"]["TOKEN"] principal_name = CONFIG["LDAP"]["PRINCIPAL_NAME"] ldap_password = CONFIG["LDAP"]["PASSWORD"] ldap_url = CONFIG["LDAP"]["LDAP_URL"] base_dn = CONFIG["LDAP"]["BASE_DN"] docker_helper = DockerRawHelper(docker_host, docker_url, ca, cert, key) def student_view(self, context=None): # runtime error if not hasattr(self.runtime, "anonymous_student_id"): return self.message_view( "Error in uc_docker (get anonymous student id)", "Cannot get anonymous_student_id in runtime", context) # preview in studio if self.runtime.anonymous_student_id == "student": result, message = GitLabUtil.get_user_projects( self.git_host, self.git_port, self.git_teacher_token) if not result: return self.message_view( "Error in uc_docker (get git projects)", "Cannot get user's projects in git", context) context_dict = {"labs": self.labs, "message": ""} fragment = Fragment() fragment.add_content( Util.render_template('static/html/uc_lab.html', context_dict)) fragment.add_css(Util.load_resource("static/css/uc_docker.css")) fragment.add_javascript( Util.load_resource("static/js/src/uc_lab.js")) fragment.initialize_js("UcDockerXBlock") return fragment # student view in open-edx if self.is_new: # create git account when first visiting student = self.runtime.get_real_user( self.runtime.anonymous_student_id) email = student.email name = student.first_name + " " + student.last_name username = student.username self.git_password = Util.create_random_password() self.save() # first_name, last_name are empty if name == " ": name = username self.logger.info("password is " + self.git_password) # create ldap account l = ldap.initialize(self.ldap_url) l.bind(self.principal_name, self.ldap_password) dn = "uid=" + username + "," + self.base_dn attrs = {} attrs['objectclass'] = ['top', 'inetOrgPerson', 'eduPerson'] attrs['cn'] = str(username) attrs['sn'] = str(username) attrs['givenName'] = str(username) attrs['uid'] = str(username) attrs['userPassword'] = str(self.git_password) attrs['description'] = 'ldap user for shibboleth' attrs['eduPersonPrincipalName'] = str(email) # Convert our dict to nice syntax for the add-function using modlist-module ldif = modlist.addModlist(attrs) l.add_s(dn, ldif) l.unbind_s() self.logger.info("create ldap account " + username + "," + dn) self.logger.info(self.git_host + "," + str(self.git_port) + "," + self.git_admin_token + "," + name + "," + username + "," + email + "," + self.git_password) result, message = GitLabUtil.create_account( self.git_host, self.git_port, self.git_admin_token, name, username, email, self.git_password) self.logger.info("create_account result:") self.logger.info(result) self.logger.info(message) if not result: return self.message_view( "Error in uc_docker (create git account)", message, context) result, message = GitLabUtil.login(self.git_host, self.git_port, username, self.git_password) self.logger.info("login result:") self.logger.info(result) self.logger.info(message) if not result: return self.message_view( "Error in uc_docker (login git account)", message, context) try: message = json.loads(message) self.git_id = message["id"] self.git_user_token = message["private_token"] self.save() except Exception, ex: return self.message_view( "Error in uc_docker (load json string)", message, context) try: self.private_key, self.public_key = Util.gen_ssh_keys(email) self.logger.info("private_key:" + self.private_key) self.save() conn = pymongo.Connection('localhost', 27017) db = conn.test token = db.token token.insert({ "username": username, "token": message["private_token"], "password": self.git_password, "private_key": self.private_key, "public_key": self.public_key }) conn.disconnect() except Exception, ex: return self.message_view("Error in uc_docker (gen ssh key)", ex, context) result, message = GitLabUtil.add_ssh_key(self.git_host, self.git_port, self.git_user_token, "uClassroom default", self.public_key) self.logger.info("add_ssh_key result:") self.logger.info(result) self.logger.info(message) if not result: return self.message_view( "Error in uc_docker (add git ssh key)", message, context) self.is_new = False self.save()
class DashboardBlock(StudioEditableXBlockMixin, ExportMixin, XBlock): """ A block to summarize self-assessment results. """ display_name = String( display_name=_("Display Name"), help=_("Display name for this module"), scope=Scope.settings, default=_('Self-Assessment Summary'), ) mentoring_ids = List( display_name=_("Mentoring Blocks"), help= _("This should be an ordered list of the url_names of each mentoring block whose multiple choice question " "values are to be shown on this dashboard. The list should be in JSON format. Example: {example_here}" ). format( example_here= '["2754b8afc03a439693b9887b6f1d9e36", "215028f7df3d4c68b14fb5fea4da7053"]' ), scope=Scope.settings, ) exclude_questions = Dict( display_name=_("Questions to be hidden"), help= _("Optional rules to exclude specific questions both from displaying in dashboard and from the calculated " "average. Rules must start with the url_name of a mentoring block, followed by list of question numbers " "to exclude. Rule set must be in JSON format. Question numbers are one-based (the first question being " "number 1). Must be in JSON format. Examples: {examples_here}"). format( examples_here= '{"2754b8afc03a439693b9887b6f1d9e36":[1,2], "215028f7df3d4c68b14fb5fea4da7053":[1,5]}' ), scope=Scope.content, multiline_editor=True, resettable_editor=False, ) color_rules = String( display_name=_("Color Coding Rules"), help= _("Optional rules to assign colors to possible answer values and average values. " "One rule per line. First matching rule will be used. Light colors are recommended. " "Examples: {examples_here}").format( examples_here= '"1: LightCoral", "0 <= x < 5: LightBlue", "LightGreen"'), scope=Scope.content, default="", multiline_editor=True, resettable_editor=False, ) visual_rules = String( display_name=_("Visual Representation"), default="", help= _("Optional: Enter the JSON configuration of the visual representation desired (Advanced)." ), scope=Scope.content, multiline_editor=True, resettable_editor=False, ) visual_title = String( display_name=_("Visual Representation Title"), default=_("Visual Representation"), help= _("This text is not displayed visually but is exposed to screen reader users who may not see the image." ), scope=Scope.content, ) visual_desc = String( display_name=_("Visual Repr. Description"), default= _("The data represented in this image is available in the tables below." ), help=_( "This longer description is not displayed visually but is exposed to screen reader " "users who may not see the image."), scope=Scope.content, ) average_labels = Dict( display_name=_("Label for average value"), help=_( "This settings allows overriding label for the calculated average per mentoring block. Must be in JSON " "format. Examples: {examples_here}."). format( examples_here= '{"2754b8afc03a439693b9887b6f1d9e36": "Avg.", "215028f7df3d4c68b14fb5fea4da7053": "Mean"}' ), scope=Scope.content, ) show_numbers = Boolean(display_name=_("Display values"), default=True, help=_("Toggles if numeric values are displayed"), scope=Scope.content) header_html = String( display_name=_("Header HTML"), default="", help=_("Custom text to include at the beginning of the report."), multiline_editor="html", resettable_editor=False, scope=Scope.content, ) footer_html = String( display_name=_("Footer HTML"), default="", help=_("Custom text to include at the end of the report."), multiline_editor="html", resettable_editor=False, scope=Scope.content, ) editable_fields = ( 'display_name', 'mentoring_ids', 'exclude_questions', 'average_labels', 'show_numbers', 'color_rules', 'visual_rules', 'visual_title', 'visual_desc', 'header_html', 'footer_html', ) css_path = 'public/css/dashboard.css' js_path = 'public/js/review_blocks.js' def get_mentoring_blocks(self, mentoring_ids, ignore_errors=True): """ Generator returning the specified mentoring blocks, in order. Will yield None for every invalid mentoring block ID, or if ignore_errors is False, will raise InvalidUrlName. """ for url_name in mentoring_ids: try: mentoring_id = self.scope_ids.usage_id.course_key.make_usage_key( 'problem-builder', url_name) yield self.runtime.get_block(mentoring_id) except Exception: # Catch-all b/c we could get XBlockNotFoundError, ItemNotFoundError, InvalidKeyError, ... # Maybe it's using the deprecated block type "mentoring": try: mentoring_id = self.scope_ids.usage_id.course_key.make_usage_key( 'mentoring', url_name) yield self.runtime.get_block(mentoring_id) except Exception: if ignore_errors: yield None else: raise InvalidUrlName(url_name) def parse_color_rules_str(self, color_rules_str, ignore_errors=True): """ Parse the color rules. Returns a list of ColorRule objects. Color rules are like: "0 < x < 4: red" or "blue" (for a catch-all rule) """ rules = [] for lineno, line in enumerate(color_rules_str.splitlines()): line = line.strip() if line: try: if ":" in line: condition, value = line.split(':') value = value.strip() if condition.isnumeric( ): # A condition just listed as an exact value condition = "x == " + condition else: condition = "1" # Always true value = line rules.append(ColorRule(condition, value)) except ValueError: if ignore_errors: continue raise ValueError( _("Invalid color rule on line {line_number}").format( line_number=lineno + 1)) return rules @lazy def color_rules_parsed(self): """ Caching property to get parsed color rules. Returns a list of ColorRule objects. """ return self.parse_color_rules_str( self.color_rules) if self.color_rules else [] def _get_submission_key(self, usage_key): """ Given the usage_key of an MCQ block, get the dict key needed to look it up with the submissions API. """ return dict( student_id=self.runtime.anonymous_student_id, course_id=unicode(usage_key.course_key), item_id=unicode(usage_key), item_type=usage_key.block_type, ) def color_for_value(self, value): """ Given a string value, get the color rule that matches, if any """ if isinstance(value, basestring): if value.isnumeric(): value = float(value) else: return None for rule in self.color_rules_parsed: if rule.matches(value): return rule.color_str return None def _get_problem_questions(self, mentoring_block): """ Generator returning only children of specified block that are MCQs """ for child_id in mentoring_block.children: if child_isinstance(mentoring_block, child_id, MCQBlock): yield child_id @XBlock.supports("multi_device") # Mark as mobile-friendly def student_view(self, context=None): # pylint: disable=unused-argument """ Standard view of this XBlock. """ if not self.mentoring_ids: return Fragment(u"<h1>{}</h1><p>{}</p>".format( self.display_name, _("Not configured."))) blocks = [] for mentoring_block in self.get_mentoring_blocks(self.mentoring_ids): if mentoring_block is None: continue block = {'display_name': mentoring_block.display_name, 'mcqs': []} try: hide_questions = self.exclude_questions.get( mentoring_block.url_name, []) except Exception: # pylint: disable=broad-except-clause log.exception( "Cannot parse exclude_questions setting - probably malformed: %s", self.exclude_questions) hide_questions = [] for question_number, child_id in enumerate( self._get_problem_questions(mentoring_block), 1): try: if question_number in hide_questions: continue except TypeError: log.exception( "Cannot check question number - expected list of ints got: %s", hide_questions) # Get the student's submitted answer to this MCQ from the submissions API: mcq_block = self.runtime.get_block(child_id) mcq_submission_key = self._get_submission_key(child_id) try: value = sub_api.get_submissions(mcq_submission_key, limit=1)[0]["answer"] except IndexError: value = None block['mcqs'].append({ "display_name": mcq_block.display_name_with_default, "value": value, "accessible_value": _("Score: {score}").format( score=value) if value else _("No value yet"), "color": self.color_for_value(value) if value is not None else None, }) # If the values are numeric, display an average: numeric_values = [ float(mcq['value']) for mcq in block['mcqs'] if mcq['value'] is not None and mcq['value'].isnumeric() ] if numeric_values: average_value = sum(numeric_values) / len(numeric_values) block['average'] = average_value # average block is shown only if average value exists, so accessible text for no data is not required block['accessible_average'] = _("Score: {score}").format( score=floatformat(average_value)) block['average_label'] = self.average_labels.get( mentoring_block.url_name, _("Average")) block['has_average'] = True block['average_color'] = self.color_for_value(average_value) blocks.append(block) visual_repr = None if self.visual_rules: try: rules_parsed = json.loads(self.visual_rules) except ValueError: pass # JSON errors should be shown as part of validation else: visual_repr = DashboardVisualData(blocks, rules_parsed, self.color_for_value, self.visual_title, self.visual_desc) report_template = loader.render_template( 'templates/html/dashboard_report.html', { 'title': self.display_name, 'css': loader.load_unicode(self.css_path), 'student_name': self._get_user_full_name(), 'course_name': self._get_course_name(), }) html = loader.render_template( 'templates/html/dashboard.html', { 'blocks': blocks, 'display_name': self.display_name, 'visual_repr': visual_repr, 'show_numbers': self.show_numbers, 'header_html': self.header_html, 'footer_html': self.footer_html, }) fragment = Fragment(html) fragment.add_css_url( self.runtime.local_resource_url(self, self.css_path)) fragment.add_javascript_url( self.runtime.local_resource_url(self, self.js_path)) fragment.initialize_js( 'PBDashboardBlock', { 'reportTemplate': report_template, 'reportContentSelector': '.dashboard-report' }) return fragment def validate_field_data(self, validation, data): """ Validate this block's field data. """ super(DashboardBlock, self).validate_field_data(validation, data) def add_error(msg): validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) try: list( self.get_mentoring_blocks(data.mentoring_ids, ignore_errors=False)) except InvalidUrlName as e: add_error( _(u'Invalid block url_name given: "{bad_url_name}"').format( bad_url_name=unicode(e))) if data.exclude_questions: for key, value in data.exclude_questions.iteritems(): if not isinstance(value, list): add_error( _(u"'Questions to be hidden' is malformed: value for key {key} is {value}, " u"expected list of integers").format(key=key, value=value)) if key not in data.mentoring_ids: add_error( _(u"'Questions to be hidden' is malformed: mentoring url_name {url_name} " u"is not added to Dashboard").format(url_name=key)) if data.average_labels: for key, value in data.average_labels.iteritems(): if not isinstance(value, basestring): add_error( _(u"'Label for average value' is malformed: value for key {key} is {value}, expected string" ).format(key=key, value=value)) if key not in data.mentoring_ids: add_error( _(u"'Label for average value' is malformed: mentoring url_name {url_name} " u"is not added to Dashboard").format(url_name=key)) if data.color_rules: try: self.parse_color_rules_str(data.color_rules, ignore_errors=False) except ValueError as e: add_error(unicode(e)) if data.visual_rules: try: rules = json.loads(data.visual_rules) except ValueError as e: add_error( _(u"Visual rules contains an error: {error}").format( error=e)) else: if not isinstance(rules, dict): add_error( _(u"Visual rules should be a JSON dictionary/object: {...}" ))
class CombinedOpenEndedFields(object): display_name = String( display_name=_("Display Name"), help= _("This name appears in the horizontal navigation at the top of the page." ), default=_("Open Response Assessment"), scope=Scope.settings) current_task_number = Integer( help=_("Current task that the student is on."), default=0, scope=Scope.user_state) old_task_states = List( help= _("A list of lists of state dictionaries for student states that are saved. " "This field is only populated if the instructor changes tasks after " "the module is created and students have attempted it (for example, if a self assessed problem is " "changed to self and peer assessed)."), scope=Scope.user_state, ) task_states = List( help=_("List of state dictionaries of each task within this module."), scope=Scope.user_state) state = String( help=_("Which step within the current task that the student is on."), default="initial", scope=Scope.user_state) graded = Boolean( display_name=_("Graded"), help= _("Defines whether the student gets credit for this problem. Credit is based on peer grades of this problem." ), default=False, scope=Scope.settings) student_attempts = Integer( help=_("Number of attempts taken by the student on this problem"), default=0, scope=Scope.user_state) ready_to_reset = Boolean( help=_("If the problem is ready to be reset or not."), default=False, scope=Scope.user_state) max_attempts = Integer( display_name=_("Maximum Attempts"), help=_( "The number of times the student can try to answer this problem."), default=1, scope=Scope.settings, values={"min": 1}) accept_file_upload = Boolean( display_name=_("Allow File Uploads"), help=_("Whether or not the student can submit files as a response."), default=False, scope=Scope.settings) skip_spelling_checks = Boolean( display_name=_("Disable Quality Filter"), help= _("If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed." ), default=False, scope=Scope.settings) due = Date(help=_("Date that this problem is due by"), scope=Scope.settings) graceperiod = Timedelta(help=_( "Amount of time after the due date that submissions will be accepted"), scope=Scope.settings) version = VersionInteger(help=_("Current version number"), default=DEFAULT_VERSION, scope=Scope.settings) data = String(help=_("XML data for the problem"), scope=Scope.content, default=DEFAULT_DATA) weight = Float( display_name=_("Problem Weight"), help= _("Defines the number of points each problem is worth. If the value is not set, each problem is worth one point." ), scope=Scope.settings, values={ "min": 0, "step": ".1" }, default=1) min_to_calibrate = Integer( display_name=_("Minimum Peer Grading Calibrations"), help= _("The minimum number of calibration essays each student will need to complete for peer grading." ), default=3, scope=Scope.settings, values={ "min": 1, "max": 20, "step": "1" }) max_to_calibrate = Integer( display_name=_("Maximum Peer Grading Calibrations"), help= _("The maximum number of calibration essays each student will need to complete for peer grading." ), default=6, scope=Scope.settings, values={ "min": 1, "max": 20, "step": "1" }) peer_grader_count = Integer( display_name=_("Peer Graders per Response"), help=_("The number of peers who will grade each submission."), default=3, scope=Scope.settings, values={ "min": 1, "step": "1", "max": 5 }) required_peer_grading = Integer( display_name=_("Required Peer Grading"), help= _("The number of other students each student making a submission will have to grade." ), default=3, scope=Scope.settings, values={ "min": 1, "step": "1", "max": 5 }) peer_grade_finished_submissions_when_none_pending = Boolean( display_name=_('Allow "overgrading" of peer submissions'), help= _("EXPERIMENTAL FEATURE. Allow students to peer grade submissions that already have the requisite number of graders, " "but ONLY WHEN all submissions they are eligible to grade already have enough graders. " "This is intended for use when settings for `Required Peer Grading` > `Peer Graders per Response`" ), default=False, scope=Scope.settings, ) markdown = String(help=_("Markdown source of this module"), default=textwrap.dedent("""\ [prompt] <h3>Censorship in the Libraries</h3> <p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author </p> <p> Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. </p> [prompt] [rubric] + Ideas - Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus. - Attempts a main idea. Sometimes loses focus or ineffectively displays focus. - Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task. - Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task. + Content - Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic. - Includes little information and few or no details. Explores only one or two facets of the topic. - Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic. - Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic. + Organization - Ideas organized illogically, transitions weak, and response difficult to follow. - Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions. - Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions. + Style - Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns. - Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns). - Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences. + Voice - Demonstrates language and tone that may be inappropriate to task and reader. - Demonstrates an attempt to adjust language and tone to task and reader. - Demonstrates effective adjustment of language and tone to task and reader. [rubric] [tasks] (Self), ({4-12}AI), ({9-12}Peer) [tasks] """), scope=Scope.settings)
class LmsBlockMixin(XBlockMixin): """ Mixin that defines fields common to all blocks used in the LMS """ hide_from_toc = Boolean( help=_("Whether to display this module in the table of contents"), default=False, scope=Scope.settings) format = String( # Translators: "TOC" stands for "Table of Contents" help=_("What format this module is in (used for deciding which " "grader to apply, and what to show in the TOC)"), scope=Scope.settings, ) chrome = String( display_name=_("Course Chrome"), # Translators: DO NOT translate the words in quotes here, they are # specific words for the acceptable values. help=_( "Enter the chrome, or navigation tools, to use for the XBlock in the LMS. Valid values are: \n" "\"chromeless\" -- to not use tabs or the accordion; \n" "\"tabs\" -- to use tabs only; \n" "\"accordion\" -- to use the accordion only; or \n" "\"tabs,accordion\" -- to use tabs and the accordion."), scope=Scope.settings, default=None, ) default_tab = String( display_name=_("Default Tab"), help= _("Enter the tab that is selected in the XBlock. If not set, the Course tab is selected." ), scope=Scope.settings, default=None, ) source_file = String(display_name=_("LaTeX Source File Name"), help=_("Enter the source file name for LaTeX."), scope=Scope.settings, deprecated=True) visible_to_staff_only = Boolean( help= _("If true, can be seen only by course staff, regardless of start date." ), default=False, scope=Scope.settings, ) group_access = GroupAccessDict( help= _("A dictionary that maps which groups can be shown this block. The keys " "are group configuration ids and the values are a list of group IDs. " "If there is no key for a group configuration or if the set of group IDs " "is empty then the block is considered visible to all. Note that this " "field is ignored if the block is visible_to_staff_only."), default={}, scope=Scope.settings, ) @lazy def merged_group_access(self): """ This computes access to a block's group_access rules in the context of its position within the courseware structure, in the form of a lazily-computed attribute. Each block's group_access rule is merged recursively with its parent's, guaranteeing that any rule in a parent block will be enforced on descendants, even if a descendant also defined its own access rules. The return value is always a dict, with the same structure as that of the group_access field. When merging access rules results in a case where all groups are denied access in a user partition (which effectively denies access to that block for all students), the special value False will be returned for that user partition key. """ parent = self.get_parent() if not parent: return self.group_access or {} merged_access = parent.merged_group_access.copy() if self.group_access is not None: for partition_id, group_ids in self.group_access.items(): # pylint: disable=no-member if group_ids: # skip if the "local" group_access for this partition is None or empty. if partition_id in merged_access: if merged_access[partition_id] is False: # special case - means somewhere up the hierarchy, merged access rules have eliminated # all group_ids from this partition, so there's no possible intersection. continue # otherwise, if the parent defines group access rules for this partition, # intersect with the local ones. merged_access[partition_id] = list( set(merged_access[partition_id]).intersection( group_ids)) or False else: # add the group access rules for this partition to the merged set of rules. merged_access[partition_id] = group_ids return merged_access # Specified here so we can see what the value set at the course-level is. user_partitions = UserPartitionList(help=_( "The list of group configurations for partitioning students in content experiments." ), default=[], scope=Scope.settings) def _get_user_partition(self, user_partition_id): """ Returns the user partition with the specified id. Note that this method can return an inactive user partition. Raises `NoSuchUserPartitionError` if the lookup fails. """ for user_partition in self.runtime.service( self, 'partitions').course_partitions: if user_partition.id == user_partition_id: return user_partition raise NoSuchUserPartitionError( u"could not find a UserPartition with ID [{}]".format( user_partition_id)) def _has_nonsensical_access_settings(self): """ Checks if a block's group access settings do not make sense. By nonsensical access settings, we mean a component's access settings which contradict its parent's access in that they restrict access to the component to a group that already will not be able to see that content. Note: This contradiction can occur when a component restricts access to the same partition but a different group than its parent, or when there is a parent access restriction but the component attempts to allow access to all learners. Returns: bool: True if the block's access settings contradict its parent's access settings. """ parent = self.get_parent() if not parent: return False parent_group_access = parent.group_access component_group_access = self.group_access for user_partition_id, parent_group_ids in parent_group_access.iteritems( ): component_group_ids = component_group_access.get(user_partition_id) # pylint: disable=no-member if component_group_ids: return parent_group_ids and not set( component_group_ids).issubset(set(parent_group_ids)) else: return not component_group_access return False def validate(self): """ Validates the state of this xblock instance. """ _ = self.runtime.service(self, "i18n").ugettext validation = super(LmsBlockMixin, self).validate() has_invalid_user_partitions = False has_invalid_groups = False block_is_unit = is_unit(self) for user_partition_id, group_ids in self.group_access.iteritems(): # pylint: disable=no-member try: user_partition = self._get_user_partition(user_partition_id) except NoSuchUserPartitionError: has_invalid_user_partitions = True else: # Skip the validation check if the partition has been disabled if user_partition.active: for group_id in group_ids: try: user_partition.get_group(group_id) except NoSuchUserPartitionGroupError: has_invalid_groups = True if has_invalid_user_partitions: validation.add( ValidationMessage( ValidationMessage.ERROR, (INVALID_USER_PARTITION_VALIDATION_UNIT if block_is_unit else INVALID_USER_PARTITION_VALIDATION_COMPONENT))) if has_invalid_groups: validation.add( ValidationMessage( ValidationMessage.ERROR, (INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT if block_is_unit else INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT))) if self._has_nonsensical_access_settings(): validation.add( ValidationMessage(ValidationMessage.ERROR, NONSENSICAL_ACCESS_RESTRICTION)) return validation @XBlock.json_handler def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument """ Publish completion data from the front end. """ completion_service = self.runtime.service(self, 'completion') if completion_service is None: raise JsonHandlerError(500, u"No completion service found") elif not completion_service.completion_tracking_enabled(): raise JsonHandlerError( 404, u"Completion tracking is not enabled and API calls are unexpected" ) if not completion_service.can_mark_block_complete_on_view(self): raise JsonHandlerError( 400, u"Block not configured for completion on view.") self.runtime.publish(self, "completion", data) return {'result': 'ok'}
class videojsXBlock(XBlock): ''' Icon of the XBlock. Values : [other (default), video, problem] ''' icon_class = "video" ''' Fields ''' display_name = String( display_name="Display Name", default="Video JS", scope=Scope.settings, help= "This name appears in the horizontal navigation at the top of the page." ) url = String(display_name="Video URL", default="http://vjs.zencdn.net/v/oceans.mp4", scope=Scope.content, help="The URL for your video.") allow_download = Boolean(display_name="Video Download Allowed", default=True, scope=Scope.content, help="Allow students to download this video.") source_text = String( display_name="Source document button text", default="", scope=Scope.content, help= "Add a download link for the source file of your video. Use it for example to provide the PowerPoint or PDF file used for this video." ) source_url = String( display_name="Source document URL", default="", scope=Scope.content, help= "Add a download link for the source file of your video. Use it for example to provide the PowerPoint or PDF file used for this video." ) start_time = String( display_name="Start time", default="", scope=Scope.content, help= "The start and end time of your video. Equivalent to 'video.mp4#t=startTime,endTime' in the url." ) end_time = String( display_name="End time", default="", scope=Scope.content, help= "The start and end time of your video. Equivalent to 'video.mp4#t=startTime,endTime' in the url." ) ''' Util functions ''' 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) 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)) ''' Main functions ''' def student_view(self, context=None): """ The primary view of the XBlock, shown to students when viewing courses. """ fullUrl = self.url if self.start_time != "" and self.end_time != "": fullUrl += "#t=" + self.start_time + "," + self.end_time elif self.start_time != "": fullUrl += "#t=" + self.start_time elif self.end_time != "": fullUrl += "#t=0," + self.end_time context = { 'display_name': self.display_name, 'url': fullUrl, 'allow_download': self.allow_download, 'source_text': self.source_text, 'source_url': self.source_url } html = self.render_template('static/html/videojs_view.html', context) frag = Fragment(html) frag.add_css(self.load_resource("static/css/video-js.min.css")) frag.add_css(self.load_resource("static/css/videojs.css")) frag.add_javascript(self.load_resource("static/js/video-js.js")) frag.add_javascript(self.load_resource("static/js/videojs_view.js")) frag.initialize_js('videojsXBlockInitView') return frag def studio_view(self, context=None): """ The secondary view of the XBlock, shown to teachers when editing the XBlock. """ context = { 'display_name': self.display_name, 'url': self.url, 'allow_download': self.allow_download, 'source_text': self.source_text, 'source_url': self.source_url, 'start_time': self.start_time, 'end_time': self.end_time } html = self.render_template('static/html/videojs_edit.html', context) frag = Fragment(html) frag.add_javascript(self.load_resource("static/js/videojs_edit.js")) frag.initialize_js('videojsXBlockInitStudio') return frag @XBlock.json_handler def save_videojs(self, data, suffix=''): """ The saving handler. """ self.display_name = data['display_name'] self.url = data['url'] self.allow_download = True if data[ 'allow_download'] == "True" else False # Str to Bool translation self.source_text = data['source_text'] self.source_url = data['source_url'] self.start_time = ''.join( data['start_time'].split()) # Remove whitespace self.end_time = ''.join(data['end_time'].split()) # Remove whitespace return { 'result': 'success', } @staticmethod def workbench_scenarios(): return [ ("videojs demo", "<videojs />") #the name should be "<videojs />" ]
class ProctoringFields(object): """ Fields that are specific to Proctored or Timed Exams """ is_time_limited = Boolean( display_name=_("Is Time Limited"), help=_("This setting indicates whether students have a limited time" " to view or interact with this courseware component."), default=False, scope=Scope.settings, ) default_time_limit_minutes = Integer( display_name=_("Time Limit in Minutes"), help= _("The number of minutes available to students for viewing or interacting with this courseware component." ), default=None, scope=Scope.settings, ) is_proctored_enabled = Boolean( display_name=_("Is Proctoring Enabled"), help=_( "This setting indicates whether this exam is a proctored exam."), default=False, scope=Scope.settings, ) exam_review_rules = String( display_name=_("Software Secure Review Rules"), help= _("This setting indicates what rules the proctoring team should follow when viewing the videos." ), default='', scope=Scope.settings, ) is_practice_exam = Boolean( display_name=_("Is Practice Exam"), help= _("This setting indicates whether this exam is for testing purposes only. Practice exams are not verified." ), default=False, scope=Scope.settings, ) is_onboarding_exam = Boolean( display_name=_("Is Onboarding Exam"), help=_( "This setting indicates whether this exam is an onboarding exam."), default=False, scope=Scope.settings, ) def _get_course(self): """ Return course by course id. """ return self.descriptor.runtime.modulestore.get_course(self.course_id) # pylint: disable=no-member @property def is_timed_exam(self): """ Alias the permutation of above fields that corresponds to un-proctored timed exams to the more clearly-named is_timed_exam """ return not self.is_proctored_enabled and not self.is_practice_exam and self.is_time_limited @property def is_proctored_exam(self): """ Alias the is_proctored_enabled field to the more legible is_proctored_exam """ return self.is_proctored_enabled @property def allow_proctoring_opt_out(self): """ Returns true if the learner should be given the option to choose between taking a proctored exam, or opting out to take the exam without proctoring. """ return self._get_course().allow_proctoring_opt_out @is_proctored_exam.setter def is_proctored_exam(self, value): """ Alias the is_proctored_enabled field to the more legible is_proctored_exam """ self.is_proctored_enabled = value
class LEARCTATXBLOCKCLASS(XBlock): """ A XBlock providing CTAT tutors. """ ### xBlock tag variables width = Integer(help="Width of the StatTutor frame.", default=690, scope=Scope.content) height = Integer(help="Height of the StatTutor frame.", default=550, scope=Scope.content) ### Grading variables has_score = Boolean(default=True, scope=Scope.content) icon_class = String(default="problem", scope=Scope.content) score = Integer(help="Current count of correctly completed student steps", scope=Scope.user_state, default=0) max_problem_steps = Integer(help="Total number of steps", scope=Scope.user_state, default=1) def max_score(self): """ The maximum raw score of the problem. """ return 1 #self.max_problem_steps attempted = Boolean(help="True if at least one step has been completed", scope=Scope.user_state, default=False) completed = Boolean( help="True if all of the required steps are correctly completed", scope=Scope.user_state, default=False) weight = Float( display_name="Problem Weight", help=("Defines the number of points each problem is worth. " "If the value is not set, the problem is worth the sum of the " "option point values."), values={ "min": 0, "step": .1 }, scope=Scope.settings) # weight needs to be set to something ### Basic interface variables src = String(help="The source html file for CTAT interface.", default="public/test1.html", scope=Scope.settings) brd = String(help="The behavior graph.", default="public/problem_files/additionTest.brd", scope=Scope.settings) ### CTATConfiguration variables log_name = String(help="Problem name to log", default="CTATEdXProblem", scope=Scope.settings) log_dataset = String(help="Dataset name to log", default="edxdataset", scope=Scope.settings) log_level1 = String(help="Level name to log", default="unit1", scope=Scope.settings) log_type1 = String(help="Level type to log", default="unit", scope=Scope.settings) log_level2 = String(help="Level name to log", default="unit2", scope=Scope.settings) log_type2 = String(help="Level type to log", default="unit", scope=Scope.settings) log_url = String(help="URL of the logging service", default="http://pslc-qa.andrew.cmu.edu/log/server", scope=Scope.settings) logtype = String(help="How should data be logged", default="clienttologserver", scope=Scope.settings) log_diskdir = String( help="Directory for log files relative to the tutoring service", default=".", scope=Scope.settings) log_port = String(help="Port used by the tutoring service", default="8080", scope=Scope.settings) log_remoteurl = String( help="Location of the tutoring service (localhost or domain name)", default="localhost", scope=Scope.settings) ctat_connection = String(help="", default="javascript", scope=Scope.settings) ### user information saveandrestore = String(help="Internal data blob used by the tracer", default="", scope=Scope.user_state) skillstring = String(help="Internal data blob used by the tracer", default="", scope=Scope.user_info) def logdebug(self, aMessage): global dbgopen, tmp_file if (dbgopen == False): tmp_file = open("/tmp/edx-tmp-log-ctat.txt", "a", 0) dbgopen = True tmp_file.write(aMessage + "\n") def resource_string(self, path): """ Read in the contents of a resource file. """ data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def strip_local(self, url): """ Returns the given url with //localhost:port removed. """ return re.sub('//localhost(:\d*)?', '', url) def get_local_resource_url(self, url): """ Wrapper for self.runtime.local_resource_url. """ return self.strip_local(self.runtime.local_resource_url(self, url)) # ------------------------------------------------------------------- # TO-DO: change this view to display your data your own way. # ------------------------------------------------------------------- def student_view(self, context=None): """ Create a Fragment used to display a CTAT StatTutor xBlock to a student. Returns a Fragment object containing the HTML to display """ # read in template html html = self.resource_string("static/html/ctatxblock.html") frag = Fragment( html.format(tutor_html=self.get_local_resource_url(self.src))) config = self.resource_string("static/js/CTATConfig.js") frag.add_javascript( config.format( self=self, tutor_html=self.get_local_resource_url(self.src), question_file=self.get_local_resource_url(self.brd), student_id=self.runtime.anonymous_student_id if hasattr( self.runtime, 'anonymous_student_id') else 'bogus-sdk-id', guid=str(uuid.uuid4()))) frag.add_javascript( self.resource_string("static/js/Initialize_CTATXBlock.js")) frag.initialize_js('Initialize_CTATXBlock') return frag @XBlock.json_handler def ctat_grade(self, data, suffix=''): #self.logdebug ("ctat_grade ()") #print('ctat_grade:',data,suffix) self.attempted = True self.score = int(data.get('value')) self.max_problem_steps = int(data.get('max_value')) self.completed = self.score >= self.max_problem_steps scaled = float(self.score) / float(self.max_problem_steps) # trying with max of 1. event_data = {'value': scaled, 'max_value': 1} self.runtime.publish(self, 'grade', event_data) return { 'result': 'success', 'finished': self.completed, 'score': scaled } # ------------------------------------------------------------------- # TO-DO: change this view to display your data your own way. # ------------------------------------------------------------------- def studio_view(self, context=None): html = self.resource_string("static/html/ctatstudio.html") frag = Fragment(html.format(self=self)) js = self.resource_string("static/js/ctatstudio.js") frag.add_javascript(unicode(js)) frag.initialize_js('CTATXBlockStudio') return frag @XBlock.json_handler def studio_submit(self, data, suffix=''): """ Called when submitting the form in Studio. """ self.src = data.get('src') self.brd = data.get('brd') self.width = data.get('width') self.height = data.get('height') return {'result': 'success'} @XBlock.json_handler def ctat_save_problem_state(self, data, suffix=''): """Called from CTATLMS.saveProblemState.""" if data.get('state') is not None: self.saveandrestore = data.get('state') return {'result': 'success'} return {'result': 'failure'} @XBlock.json_handler def ctat_get_problem_state(self, data, suffix=''): return {'result': 'success', 'state': self.saveandrestore} @XBlock.json_handler def ctat_set_variable(self, data, suffix=''): self.logdebug("ctat_set_variable ()") for key in data: #value = base64.b64decode(data[key]) value = data[key] self.logdebug("Setting ({}) to ({})".format(key, value)) if (key == "href"): self.href = value elif (key == "ctatmodule"): self.ctatmodule = value elif (key == "problem"): self.problem = value elif (key == "dataset"): self.dataset = value elif (key == "level1"): self.level1 = value elif (key == "type1"): self.type1 = value elif (key == "level2"): self.level2 = value elif (key == "type2"): self.type2 = value elif (key == "logurl"): self.logurl = value elif (key == "logtype"): self.logtype = value elif (key == "diskdir"): self.diskdir = value elif (key == "port"): self.port = value elif (key == "remoteurl"): self.remoteurl = value elif (key == "connection"): self.connection = value #elif (key=="src"): # self.src = value elif (key == "saveandrestore"): self.logdebug("Received saveandrestore request") self.saveandrestore = value #elif (key=="skillstring"): # self.skillstring = value return {'result': 'success'} # ------------------------------------------------------------------- # TO-DO: change this to create the scenarios you'd like to see in the # workbench while developing your XBlock. # ------------------------------------------------------------------- @staticmethod def workbench_scenarios(): return [ ("LEARCTATXBLOCKCLASS", """<vertical_demo> <learctatxblock width="" height=""/> </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} 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 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, ) points = 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 _api_teams = None _api_rocket_chat = None VIEWS = ["Main View", "Team Discussion", "Specific Channel"] # Possible editable fields editable_fields = ('selected_view', 'default_channel', 'graded_activity', 'emoji', 'target_reaction', 'oldest', 'latest', 'points', '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. """ 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, "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 frag = Fragment(u"Studio Runtime RocketChatXBlock") frag.add_javascript(self.resource_string("static/js/src/rocketc.js")) 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> """), ] @property def api_rocket_chat(self): """ Creates an ApiRocketChat object """ if not self._api_rocket_chat: 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 self._api_rocket_chat = ApiRocketChat( # pylint: disable=attribute-defined-outside-init user=user, password=password, server_url=self.server_data["private_url_service"], ) LOG.info("Api rocketChat initialize: %s ", self._api_rocket_chat) return self._api_rocket_chat @property def api_teams(self): """ Creates an ApiTeams object """ if not self._api_teams: try: client_id = self.xblock_settings["client_id"] client_secret = self.xblock_settings["client_secret"] except KeyError as xblock_settings_error: LOG.error('Get rocketchat xblock settings error: %s', xblock_settings_error) raise server_url = settings.LMS_ROOT_URL self._api_teams = ApiTeams( # pylint: disable=attribute-defined-outside-init client_id, client_secret, server_url) LOG.info("Api Teams initialize: %s ", self._api_teams) return self._api_teams @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.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["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() @property def course_id(self): """ This method allows to get the course_id """ try: return re.sub('[^A-Za-z0-9]+', '', unicode(self.xmodule_runtime.course_id)) except AttributeError: course_id = unicode(self.runtime.course_id) return re.sub('[^A-Za-z0-9]+', '', course_id.split("+branch", 1)[0]) 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 """ # the following instructions get all the groups # except the team groups and specific teams course = self.course_id kwargs = generate_query_dict(course) groups = [ group.split("__", 1)[0] for group in self.api_rocket_chat.get_groups(**kwargs) ] kwargs = generate_query_dict(course, specific_team=True) # This instruction adds the string "(Team Group)" if the group is in team_groups groups += [ '{}-{}'.format("(Team Group)", team_group.split("__", 1)[0]) for team_group in self.api_rocket_chat.get_groups(**kwargs) ] 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 key = hashlib.sha1("{}-{}-{}-{}-{}".format( ROCKET_CHAT_DATA, user_data["username"], self.selected_view, self.default_channel, self._get_team(user_data["username"]), )).hexdigest() response = cache.get(key) if response: return response 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) response['team_view'] = self.team_view self._update_user(user_id, user_data) self._grading_discussions(response['default_group']) cache.set(key, response, CACHE_TIMEOUT) return response 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 """ key = hashlib.sha1("{}_{}".format(ROCKET_CHAT_DATA, user_data["username"])).hexdigest() data = cache.get(key) if data: return data 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"]) if 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, user_id): """ This method add the user to the default course channel """ group_name = "{}__{}".format("General", self.course_id) self._add_user_to_group( user_id, group_name, members=[self.user_data["username"]], custom_fields=generate_custom_fields(course=self.course_id), create=True) def _add_user_to_specific_group(self, group_name, user_id): """ This method allows to add a user to a given group. """ group = "{}__{}".format(group_name, self.course_id) result = self._add_user_to_group(user_id, group) return result, group def _add_user_to_team_group(self, user_id, username, auth_token): """ Add the user to team's group in rocketChat """ team = self._get_team(username) if team is None: self._remove_user_from_group(self.team_channel, user_id, auth_token) return False team_name, topic_id = generate_team_variables(team) group_name = "{}__{}__{}".format(team_name, topic_id, self.course_id) if self.team_channel != group_name: self._remove_user_from_group(self.team_channel, user_id, auth_token) self.team_channel = group_name return self._add_user_to_group(user_id, group_name, members=[username], custom_fields=generate_custom_fields( self.course_id, team), create=True) def _get_team(self, username): """ This method gets the user's team """ course_team_membership = CourseTeamMembership.objects.filter( user=User.objects.get(username=username), ) if course_team_membership: return course_team_membership[0].team 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"], 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, default_channel = self._add_user_to_specific_group( default_channel, user_id) return default_channel else: self.ui_is_block = False self._add_user_to_course_group(user_id) return None def _add_user_to_group(self, user_id, group_name, **kwargs): """ This method allows to add a user to any channel, returns True if it's successful """ group = self.api_rocket_chat.search_rocket_chat_group(group_name) response = {} if group["success"]: response = self.api_rocket_chat.add_user_to_group( user_id, group['group']['_id']) elif kwargs.get("create", False): response = self.api_rocket_chat.create_group( group_name, kwargs.get("members", []), **kwargs.get("custom_fields", {})) return response.get("success", False) def _join_user_to_specific_team_group(self, user_id, user_data, default_channel): team = self._get_team(user_data["username"]) if team is None: self.team_view = False return None default_channel = self._create_team_group_name( team, default_channel.replace("(Team Group)-", ""), self.course_id) self.ui_is_block = self._add_user_to_group( user_id, default_channel, members=[user_data["username"]], custom_fields=generate_custom_fields(self.course_id, team), create=True) 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 group_name = data.get("groupName", None) 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) specific_team = False members = [] team = None if data.get("asTeam"): group_name = "{}__{}".format(group_name, self.course_id) specific_team = True elif data.get("teamGroup"): team = self._get_team(self.user_data["username"]) members = list(self.get_team_members(team)) group_name = self._create_team_group_name(team, group_name, self.course_id) else: group_name = "{}__{}".format(group_name, self.course_id) custom_fields = generate_custom_fields(self.course_id, team, specific_team) group = self.api_rocket_chat.create_group(group_name, members, **custom_fields) if "group" in group: group_id = group["group"]["_id"] self.api_rocket_chat.set_group_description(group_id, data.get("description")) self.api_rocket_chat.set_group_topic(group_id, data.get("topic")) LOG.info("Method Public Create Group: %s", group) return group @staticmethod def _create_team_group_name(team, group_name, course): """ This method returns the formated name for given team and group name, in order to ensure a unique name in the rocketchat server. **Example** ** group_name = "Test" ** team = { "name": "team1", "topic": "animals" ...... } ** course = "coursev1:edx-c101-2019T2" returns "Test(animals/team1)__coursev1:edx-c101-2019T2" """ team_name, team_topic = generate_team_variables(team) return "{}({}/{})__{}".format(group_name, team_topic, team_name, course) def _remove_user_from_group(self, group_name, user_id, auth_token=None): """ This method removes a user form a team """ if not group_name: return False 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)} 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 {"success": 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 return {"success": False, "error": "Channel not found"} 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 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"} team = self._get_team(self.user_data["username"]) team_name, topic_id = generate_team_variables(team) if group_name == team_name or group_name == "General": return { "success": False, "error": "You Can Not Leave a Main Group" } group_name = "{}__{}__{}__{}".format(group_name, team_name, topic_id, self.course_id) 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) if not user_id or not auth_token: LOG.warn("Invalid data for method get_list_of_groups: %s", data) return None groups = list(self._get_list_groups(user_id, auth_token)) 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) team, topic = generate_team_variables( self._get_team(self.user_data["username"])) if groups["success"]: for group in groups["groups"]: fields = group.get("customFields", {}) if (team == fields.get("team") and topic == fields.get("topic") and self.course_id == fields.get("course")): 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 reaction not 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.points: 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.points self.runtime.publish(self, 'grade', { 'value': self.grade, 'max_value': self.points }) def max_score(self): if self.graded_activity: return self.points @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) if response.get("status") == "success": cache.delete(key) return Response(status=202) return Response(status=404)
class embedurlXBlock(XBlock): ''' Icon of the XBlock. Values : [other (default), video, problem] ''' icon_class = "other" ''' Fields ''' display_name = String( display_name="Display Name", default="Embed URL", scope=Scope.settings, help= "This name appears in the horizontal navigation at the top of the page." ) url = String( display_name="Embed URL", default="http://tutorial.math.lamar.edu/pdf/Trig_Cheat_Sheet.pdf", scope=Scope.content, help="The URL for your Doc.") new_window_button = Boolean(display_name="Enable Open In New Window", default=False, scope=Scope.content, help="Display Open New Window Button.") min_height = String(display_name="Mininum height", default="450", scope=Scope.content, help="Add Minimum Height for Doc") ''' Util functions ''' 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) 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)) ''' Main functions ''' def student_view(self, context=None): """ The primary view of the XBlock, shown to students when viewing courses. """ if self.min_height == '': self.min_height = "450" context = { 'display_name': self.display_name, 'url': self.url, 'new_window_button': self.new_window_button, 'min_height': self.min_height, } html = self.render_template('static/html/embedurl_view.html', context) frag = Fragment(html) frag.add_css(self.load_resource("static/css/embedurl.css")) frag.add_javascript(self.load_resource("static/js/embedurl_view.js")) frag.initialize_js('embedurlXBlockInitView') return frag def studio_view(self, context=None): """ The secondary view of the XBlock, shown to teachers when editing the XBlock. """ if self.min_height == '': self.min_height = "450" context = { 'display_name': self.display_name, 'url': self.url, 'new_window_button': self.new_window_button, 'min_height': self.min_height, } html = self.render_template('static/html/embedurl_edit.html', context) frag = Fragment(html) frag.add_javascript(self.load_resource("static/js/embedurl_edit.js")) frag.initialize_js('embedurlXBlockInitEdit') return frag @XBlock.json_handler def save_pdf(self, data, suffix=''): """ The saving handler. """ self.display_name = data['display_name'] self.url = data['url'] self.new_window_button = True if data[ 'new_window_button'] == "True" else False # Str to Bool translation self.min_height = data['min_height'] return { 'result': 'success', } def student_view_data(self): """ Inform REST api clients about original file location and it's "freshness". Make sure to include `student_view_data=scorm` to URL params in the request. """ return { 'last_modified': self.new_window_button, 'scorm_data': self.url }
class CapaFields(object): """ Define the possible fields for a Capa problem """ display_name = String( display_name="Display Name", help= "This name appears in the horizontal navigation at the top of the page.", scope=Scope.settings, # it'd be nice to have a useful default but it screws up other things; so, # use display_name_with_default for those default="Blank Advanced Problem") attempts = Integer( help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) max_attempts = Integer( display_name="Maximum Attempts", help= ("Defines the number of times a student can try to answer this problem. " "If the value is not set, infinite attempts are allowed."), values={"min": 0}, scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " "can be set by an instructor, and will override the global due " "date if it is set to a date that is later than the global due " "date.", default=None, scope=Scope.user_state, ) graceperiod = Timedelta( help= "Amount of time after the due date that submissions will be accepted", scope=Scope.settings) showanswer = String( display_name="Show Answer", help=("Defines when to show the answer to the problem. " "A default value can be set in Advanced Settings."), scope=Scope.settings, default="finished", values=[{ "display_name": "Always", "value": "always" }, { "display_name": "Answered", "value": "answered" }, { "display_name": "Attempted", "value": "attempted" }, { "display_name": "Closed", "value": "closed" }, { "display_name": "Finished", "value": "finished" }, { "display_name": "Past Due", "value": "past_due" }, { "display_name": "Never", "value": "never" }]) force_save_button = Boolean( help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) rerandomize = Randomization( display_name="Randomization", help= "Defines how often inputs are randomized when a student loads the problem. " "This setting only applies to problems that can have randomly generated numeric values. " "A default value can be set in Advanced Settings.", default="never", scope=Scope.settings, values=[{ "display_name": "Always", "value": "always" }, { "display_name": "On Reset", "value": "onreset" }, { "display_name": "Never", "value": "never" }, { "display_name": "Per Student", "value": "per_student" }]) data = String(help="XML data for the problem", scope=Scope.content, default="<problem></problem>") correct_map = Dict( help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={}) input_state = Dict( help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state) student_answers = Dict( help="Dictionary with the current student responses", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) seed = Integer(help="Random seed for this student", scope=Scope.user_state) weight = Float( display_name="Problem Weight", help= ("Defines the number of points each problem is worth. " "If the value is not set, each response field in the problem is worth one point." ), values={ "min": 0, "step": .1 }, scope=Scope.settings) markdown = String(help="Markdown source of this module", default=None, scope=Scope.settings) source_code = String( help= "Source code for LaTeX and Word problems. This feature is not well-supported.", scope=Scope.settings) text_customization = Dict( help="String customization substitutions for particular locations", scope=Scope.settings # TODO: someday it should be possible to not duplicate this definition here # and in inheritance.py ) use_latex_compiler = Boolean(help="Enable LaTeX templates?", default=False, scope=Scope.settings)
class ProblemBlock(XBlock): """A generalized container of InputBlocks and Checkers. """ script = String(help="Python code to compute values", scope=Scope.content, default="") seed = Integer(help="Random seed for this student", scope=Scope.user_state, default=0) problem_attempted = Boolean(help="Has the student attempted this problem?", scope=Scope.user_state, default=False) has_children = True @classmethod def parse_xml(cls, node, runtime, keys, id_generator): block = runtime.construct_xblock_from_class(cls, keys) # Find <script> children, turn them into script content. for child in node: if child.tag == "script": block.script += child.text else: block.runtime.add_node_as_child(block, child, id_generator) return block def set_student_seed(self): """Set a random seed for the student so they each have different but repeatable data.""" # Don't return zero, that's the default, and the sign that we should make a new seed. self.seed = int(time.time() * 1000) % 100 + 1 def calc_context(self, context): """If we have a script, run it, and return the resulting context.""" if self.script: # Seed the random number for the student if not self.seed: self.set_student_seed() random.seed(self.seed) script_vals = run_script(self.script) context = dict(context) context.update(script_vals) return context # The content controls how the Inputs attach to Graders def student_view(self, context=None): """Provide the default student view.""" if context is None: context = {} context = self.calc_context(context) result = Fragment() named_child_frags = [] # self.children is an attribute obtained from ChildrenModelMetaclass, so disable the # static pylint checking warning about this. for child_id in self.children: # pylint: disable=E1101 child = self.runtime.get_block(child_id) frag = self.runtime.render_child(child, "problem_view", context) result.add_frag_resources(frag) named_child_frags.append((child.name, frag)) result.add_css(u""" .problem { border: solid 1px #888; padding: 3px; } """) result.add_content(self.runtime.render_template( "problem.html", named_children=named_child_frags )) result.add_javascript(u""" function ProblemBlock(runtime, element) { function callIfExists(obj, fn) { if (typeof obj[fn] == 'function') { return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); } else { return undefined; } } function handleCheckResults(results) { $.each(results.submitResults || {}, function(input, result) { callIfExists(runtime.childMap(element, input), 'handleSubmit', result); }); $.each(results.checkResults || {}, function(checker, result) { callIfExists(runtime.childMap(element, checker), 'handleCheck', result); }); } // To submit a problem, call all the named children's submit() // function, collect their return values, and post that object // to the check handler. $(element).find('.check').bind('click', function() { var data = {}; var children = runtime.children(element); for (var i = 0; i < children.length; i++) { var child = children[i]; if (child.name !== undefined) { data[child.name] = callIfExists(child, 'submit'); } } var handlerUrl = runtime.handlerUrl(element, 'check') $.post(handlerUrl, JSON.stringify(data)).success(handleCheckResults); }); $(element).find('.rerandomize').bind('click', function() { var handlerUrl = runtime.handlerUrl(element, 'rerandomize'); $.post(handlerUrl, JSON.stringify({})); }); } """) result.initialize_js('ProblemBlock') return result @XBlock.json_handler def check(self, submissions, suffix=''): # pylint: disable=unused-argument """ Processess the `submissions` with each provided Checker. First calls the submit() method on each InputBlock. Then, for each Checker, finds the values it needs and passes them to the appropriate `check()` method. Returns a dictionary of 'submitResults': {input_name: user_submitted_results}, 'checkResults': {checker_name: results_passed_through_checker} """ self.problem_attempted = True context = self.calc_context({}) child_map = {} # self.children is an attribute obtained from ChildrenModelMetaclass, so disable the # static pylint checking warning about this. for child_id in self.children: # pylint: disable=E1101 child = self.runtime.get_block(child_id) if child.name: child_map[child.name] = child # For each InputBlock, call the submit() method with the browser-sent # input data. submit_results = {} for input_name, submission in submissions.items(): child = child_map[input_name] submit_results[input_name] = child.submit(submission) child.save() # For each Checker, find the values it wants, and pass them to its # check() method. checkers = list(self.runtime.querypath(self, "./checker")) check_results = {} for checker in checkers: arguments = checker.arguments kwargs = {} kwargs.update(arguments) for arg_name, arg_value in arguments.items(): if arg_value.startswith("."): values = list(self.runtime.querypath(self, arg_value)) # TODO: What is the specific promised semantic of the iterability # of the value returned by querypath? kwargs[arg_name] = values[0] elif arg_value.startswith("$"): kwargs[arg_name] = context.get(arg_value[1:]) elif arg_value.startswith("="): kwargs[arg_name] = int(arg_value[1:]) else: raise ValueError(u"Couldn't interpret checker argument: %r" % arg_value) result = checker.check(**kwargs) if checker.name: check_results[checker.name] = result return { 'submitResults': submit_results, 'checkResults': check_results, } @XBlock.json_handler def rerandomize(self, unused, suffix=''): # pylint: disable=unused-argument """Set a new random seed for the student.""" self.set_student_seed() return {'status': 'ok'} @staticmethod def workbench_scenarios(): """A few canned scenarios for display in the workbench.""" return [ ("problem with thumbs and textbox", """\ <problem_demo> <html_demo> <p>You have three constraints to satisfy:</p> <ol> <li>The upvotes and downvotes must be equal.</li> <li>You must enter the number of upvotes into the text field.</li> <li>The number of upvotes must be $numvotes.</li> </ol> </html_demo> <thumbs name='thumb'/> <textinput_demo name='vote_count' input_type='int'/> <script> # Compute the random answer. import random numvotes = random.randrange(2,5) </script> <equality_demo name='votes_equal' left='./thumb/@upvotes' right='./thumb/@downvotes'> Upvotes match downvotes </equality_demo> <equality_demo name='votes_named' left='./thumb/@upvotes' right='./vote_count/@student_input'> Number of upvotes matches entered string </equality_demo> <equality_demo name='votes_specified' left='./thumb/@upvotes' right='$numvotes'> Number of upvotes is $numvotes </equality_demo> </problem_demo> """), ("three problems 2", """ <vertical_demo> <attempts_scoreboard_demo/> <problem_demo> <html_demo><p>What is $a+$b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(2, 5) b = random.randint(1, 4) c = a + b </script> </problem_demo> <sidebar_demo> <problem_demo> <html_demo><p>What is $a × $b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(2, 6) b = random.randint(3, 7) c = a * b </script> </problem_demo> </sidebar_demo> <problem_demo> <html_demo><p>What is $a+$b?</p></html_demo> <textinput_demo name="sum_input" input_type="int" /> <equality_demo name="sum_checker" left="./sum_input/@student_input" right="$c" /> <script> import random a = random.randint(3, 5) b = random.randint(2, 6) c = a + b </script> </problem_demo> </vertical_demo> """), ]
class PDFXBlock(XBlock): """ PDF XBlock. """ loader = ResourceLoader(__name__) # Icon of the XBlock. Values : [other (default), video, problem] icon_class = 'other' # Enable view as specific student show_in_read_only_mode = True # Fields display_name = String( display_name=_('Display Name'), default=_('PDF'), scope=Scope.settings, help= _('This name appears in the horizontal navigation at the top of the page.' )) url = String( display_name=_('PDF URL'), default=_('https://tutorial.math.lamar.edu/pdf/Trig_Cheat_Sheet.pdf'), scope=Scope.content, help=_('The URL for your PDF.'), ) allow_download = Boolean( display_name=_('PDF Download Allowed'), default=True, scope=Scope.content, help=_('Display a download button for this PDF.'), ) source_text = String( display_name=_('Source document button text'), default='', scope=Scope.content, help=_( 'Add a download link for the source file of your PDF. ' 'Use it for example to provide the PowerPoint file used to create this PDF.' )) source_url = String( display_name=_('Source document URL'), default='', scope=Scope.content, help=_( 'Add a download link for the source file of your PDF. ' 'Use it for example to provide the PowerPoint file used to create this PDF.' )) def load_resource(self, resource_path): # pylint: disable=no-self-use """ Gets the content of a resource """ resource_content = pkg_resources.resource_string( __name__, resource_path) return resource_content.decode('utf-8') def render_template(self, path, context=None): """ Evaluate a template by resource path, applying the provided context """ return self.loader.render_django_template( os.path.join('static/html', path), context=Context(context or {}), i18n_service=self.runtime.service(self, 'i18n')) def student_view(self, context=None): """ The primary view of the XBlock, shown to students when viewing courses. """ context = { 'display_name': self.display_name, 'url': self.url, 'allow_download': self.allow_download, 'source_text': self.source_text, 'source_url': self.source_url, } html = self.render_template('pdf_view.html', context) frag = Fragment(html) frag.add_css(self.load_resource('static/css/pdf.css')) frag.add_javascript(self.load_resource('static/js/pdf_view.js')) frag.initialize_js('pdfXBlockInitView') return frag def studio_view(self, context=None): """ The secondary view of the XBlock, shown to teachers when editing the XBlock. """ context = { 'display_name': self.display_name, 'url': self.url, 'allow_download': self.allow_download, 'source_text': self.source_text, 'source_url': self.source_url, } html = self.render_template('pdf_edit.html', context) frag = Fragment(html) frag.add_javascript(self.load_resource('static/js/pdf_edit.js')) frag.initialize_js('pdfXBlockInitEdit') return frag @XBlock.json_handler def save_pdf(self, data, suffix=''): # pylint: disable=unused-argument """ The saving handler. """ self.display_name = data['display_name'] self.url = data['url'] self.allow_download = data[ 'allow_download'] == 'True' # Basic str to translation self.source_text = data['source_text'] self.source_url = data['source_url'] return {'result': 'success'}
class QuizBlock(ResourceMixin, QuizResultMixin, ExportDataBlock, XBlockWithTranslationServiceMixin): """ An XBlock which can be used to add diagnostic quiz """ BUZZFEED_QUIZ_VALUE = "BFQ" BUZZFEED_QUIZ_LABEL = _("BuzzFeed-style") DIAGNOSTIC_QUIZ_VALUE = "DG" DIAGNOSTIC_QUIZ_LABEL = _("Diagnostic-style") DEFAULT_GROUP = _('Default Group') display_name = String( display_name=_("Diagnostic Feedback"), help= _("This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default="") title = String(default='', scope=Scope.content, help=_("Title of quiz")) description = String(default="", scope=Scope.content, help=_("Description of quiz")) questions = List( default=[], help=_("This will hold list of question with respective choices"), scope=Scope.content, ) student_choices = Dict( default={}, help=_("This will hold user provided answers of questions"), scope=Scope.user_state, ) quiz_type = String(default="", scope=Scope.content, help=_("Type of quiz")) results = List(default=[], scope=Scope.content, help=_("List of results")) student_result = String(default='', scope=Scope.user_state, help=_("Calculated feedback of each user")) types = List(default=[ { "value": BUZZFEED_QUIZ_VALUE, "label": BUZZFEED_QUIZ_LABEL }, { "value": DIAGNOSTIC_QUIZ_VALUE, "label": DIAGNOSTIC_QUIZ_LABEL }, ], scope=Scope.content, help=_("List of results")) groups = List(default=[DEFAULT_GROUP], scope=Scope.content, help=_("List of results")) current_step = Integer( default=0, scope=Scope.user_state, help=_("To control which question should be shown to student")) weight = Float(display_name=_("Weight"), help=_("Defines the maximum total grade of this question."), default=1, scope=Scope.content, enforce_type=True) completed = Boolean(default=False, scope=Scope.user_state, help=_("Has the student completed this quiz")) @property def display_name_with_default(self): return self.title @property def additional_publish_event_data(self): return { 'user_id': self.scope_ids.user_id, 'block_id': self.get_block_id(), 'component_id': self.scope_ids.usage_id } has_score = True def get_fragment(self, context, view='studio', json_args=None): """ return fragment after loading css/js/html either for studio OR student view :param context: context for templates :param view: view_type i;e studio/student :return: fragment after loading all assets """ """ Return fragment after adding required css/js/html """ fragment = Fragment() self.add_templates(fragment, context, view) self.add_css(fragment, view) self.add_js(fragment, view) self.initialize_js_classes(fragment, view, json_args) return fragment def append_choice(self, questions): """ append student choice with each question if available :param questions: list of questions :return: """ """ """ for question in questions: if self.quiz_type == self.DIAGNOSTIC_QUIZ_VALUE: question['student_choice'] = float(self.student_choices.get(question['id'])) if \ self.student_choices.get(question['id']) else '' else: question['student_choice'] = self.student_choices.get( question['id'], '') def get_block_id(self): """ Return ID of `block` """ usage_id = self.scope_ids.usage_id # Try accessing block ID. If usage_id does not have it, return usage_id itself return six.text_type(getattr(usage_id, 'block_id', usage_id)) def get_question(self, question_id): """ Return Question object for given question id """ question = {} for question in self.questions: if question['id'] == question_id: question = question break return question def get_buzzfeed_answer(self, choices, student_choice): """ Return buzzfeed quiz answer label from question choices using student choice """ choice_name = '' for choice in choices: if choice['category_id'] == student_choice: choice_name = choice['name'] break return choice_name def get_diagnostic_answer(self, choices, student_choice): """ Return diagnostic quiz answer label from question choices using student choice """ choice_name = '' for choice in choices: if str(choice['value']) == student_choice: choice_name = choice['name'] break return choice_name @XBlock.supports("multi_device") # Mark as mobile-friendly def student_view(self, context=None): """ it will loads student view :param context: context :return: fragment """ context = { 'questions': copy.deepcopy(self.questions), 'self': self, 'block_id': "xblock-{}".format(self.get_block_id()), 'user_is_staff': self.user_is_staff() } if self.student_choices: self.append_choice(context['questions']) # return final result to show if user already completed the quiz if self.questions and self.current_step: if len(self.questions) == self.current_step: context['result'] = self.get_result() return self.get_fragment(context, 'student', { 'quiz_type': self.quiz_type, 'quiz_title': self.title }) def student_view_data(self, context=None): """ Returns a JSON representation of the Diagnostic Feedback Xblock, that can be retrieved using Course Block API. """ return { 'quiz_type': self.quiz_type, 'quiz_title': self.title, 'questions': self.questions, 'description': self.description, } @XBlock.handler def student_view_user_state(self, data, suffix=''): """ Returns a JSON representation of the student data for Diagnostic Feedback Xblock """ response = { 'student_choices': self.student_choices, 'student_result': self.student_result, 'current_step': self.current_step, 'completed': self.completed, } return Response(json.dumps(response), content_type='application/json', charset='utf8') def get_attached_groups(self): # return already attached groups groups = [] for r in self.results: if r['group'] not in groups: groups.append(r['group']) return groups def studio_view(self, context): """ it will loads studio view :param context: context :return: fragment """ block_id = "xblock-{}".format(self.get_block_id()) course_key = getattr(self.scope_ids.usage_id, 'course_key', None) context['self'] = self context['block_id'] = block_id try: from xmodule.contentstore.content import StaticContent base_asset_url = StaticContent.get_base_url_path_for_course_assets( course_key) except Exception: base_asset_url = '' return self.get_fragment( context, 'studio', { 'base_asset_url': base_asset_url, 'quiz_type': self.quiz_type, 'block_id': block_id, 'results': self.results, 'BUZZFEED_QUIZ_VALUE': self.BUZZFEED_QUIZ_VALUE, 'DIAGNOSTIC_QUIZ_VALUE': self.DIAGNOSTIC_QUIZ_VALUE, 'DEFAULT_GROUP': self.DEFAULT_GROUP, 'questions': self.questions, 'groups': self.groups, 'attachedGroups': self.get_attached_groups(), 'categoryTpl': loader.load_unicode('templates/underscore/category.html'), 'rangeTpl': loader.load_unicode('templates/underscore/range.html'), 'questionTpl': loader.load_unicode('templates/underscore/question.html'), 'choiceTpl': loader.load_unicode('templates/underscore/choice.html') }) @XBlock.json_handler def save_data(self, data, suffix=''): """ ajax handler to save data after applying required validation & filtration :param data: step data to save :param suffix: :return: response dict """ success = True response_message = "" step = data.get('step', '') if not step: success = False response_message = self._('missing step number') else: try: is_valid_data, response_message = Validator.validate( self, data) if is_valid_data: response_message = MainHelper.save_filtered_data( self, data) else: success = False except Exception as ex: success = False response_message += ex.message if ex.message else str(ex) return {'step': step, 'success': success, 'msg': response_message} @XBlock.json_handler def save_choice(self, data, suffix=''): """ save student choice for a question after validations :param data: answer data :param suffix: :return: response dict """ # Import is placed here to avoid model import at project startup. try: from submissions import api as submissions_api except ImportError: log.info("Cannot import submissions_api") submissions_api = None student_result = "" response_message = "" try: success, response_message = Validator.validate_student_answer( self, data) if success: question_id = data['question_id'] # save student answer self.student_choices[question_id] = data['student_choice'] if (self.current_step) < int(data['currentStep']): self.current_step = int(data['currentStep']) # calculate feedback result if user answering last question if data['isLast']: student_result = self.get_result() if not self.completed: # Save the latest score and make quiz completed self.runtime.publish(self, 'grade', { 'value': 1.0, 'max_value': 1.0 }) self.completed = True if submissions_api: log.info("have sub_api instance") # Also send to the submissions API: item_key = self.student_item_key item_key['item_id'] = self.get_block_id() submission_data = self.create_submission_data() submission_data['final_result'] = student_result submissions_api.create_submission( item_key, json.dumps(submission_data)) response_message = self._("Your response is saved") except Exception as ex: success = False response_message += str(ex) return { 'success': success, 'student_result': student_result, 'response_msg': response_message } @XBlock.json_handler def start_over_quiz(self, data, suffix=''): """ reset student_choices, student_result, current_step for current user :param data: empty dict :param suffix: :return: response dict """ success = True response_message = self._("student data cleared") self.student_choices = {} self.student_result = "" self.current_step = 0 return {'success': success, 'msg': response_message} @XBlock.json_handler def add_group(self, data, suffix=''): """ Add new group in self.groups list """ success = True grp_name = data.get('name', '') if grp_name not in self.groups: msg = self._('Group added successfully.') self.groups.append(grp_name) else: msg = self._('Group already exist.') success = False return {'success': success, 'msg': msg, 'group_name': grp_name} @XBlock.json_handler def publish_event(self, data, suffix=''): """ Publish data for analytics purposes """ event_type = data.pop('event_type') data['time'] = datetime.now() self.runtime.publish(self, event_type, data) return {'result': 'ok'} def create_submission_data(self): """ Return a complete submission data as quiz completed """ submission = {} for question in self.questions: question_id = question['id'] question_data = self.get_question(question_id) if self.quiz_type == self.BUZZFEED_QUIZ_VALUE: question_answer = self.get_buzzfeed_answer( question_data['choices'], self.student_choices[question_id]) else: question_answer = self.get_diagnostic_answer( question_data['choices'], self.student_choices[question_id]) submission[question_id] = { 'question_text': question['text'], 'answer': question_answer } return submission
class MentoringBlock(XBlockWithLightChildren, StepParentMixin): """ An XBlock providing mentoring capabilities Composed of text, answers input fields, and a set of MRQ/MCQ with advices. A set of conditions on the provided answers and MCQ/MRQ choices will determine if the student is a) provided mentoring advices and asked to alter his answer, or b) is given the ok to continue. """ @staticmethod def is_default_xml_content(value): return _is_default_xml_content(value) attempted = Boolean(help="Has the student attempted this mentoring step?", default=False, scope=Scope.user_state) completed = Boolean(help="Has the student completed this mentoring step?", default=False, scope=Scope.user_state) next_step = String(help="url_name of the next step the student must complete (global to all blocks)", default='mentoring_first', scope=Scope.preferences) followed_by = String(help="url_name of the step after the current mentoring block in workflow", default=None, scope=Scope.content) url_name = String(help="Name of the current step, used for URL building", default='mentoring-default', scope=Scope.content) enforce_dependency = Boolean(help="Should the next step be the current block to complete?", default=False, scope=Scope.content, enforce_type=True) display_submit = Boolean(help="Allow submission of the current block?", default=True, scope=Scope.content, enforce_type=True) xml_content = String(help="XML content", default=DEFAULT_XML_CONTENT, scope=Scope.content) weight = Float(help="Defines the maximum total grade of the block.", default=1, scope=Scope.content, enforce_type=True) num_attempts = Integer(help="Number of attempts a user has answered for this questions", default=0, scope=Scope.user_state, enforce_type=True) max_attempts = Integer(help="Number of max attempts for this questions", default=0, scope=Scope.content, enforce_type=True) mode = String(help="Mode of the mentoring. 'standard' or 'assessment'", default='standard', scope=Scope.content) step = Integer(help="Keep track of the student assessment progress.", default=0, scope=Scope.user_state, enforce_type=True) student_results = List(help="Store results of student choices.", default=[], scope=Scope.user_state) extended_feedback = Boolean(help="Show extended feedback details when all attempts are used up.", default=False, Scope=Scope.content) display_name = String(help="Display name of the component", default="Mentoring XBlock", scope=Scope.settings) icon_class = 'problem' has_score = True MENTORING_MODES = ('standard', 'assessment') FLOATING_BLOCKS = (TitleBlock, MentoringMessageBlock, SharedHeaderBlock) FIELDS_TO_INIT = ('xml_content',) @property def is_assessment(self): return self.mode == 'assessment' def get_question_number(self, question_id): """ Get the step number of the question id """ for question in self.get_children_objects(): if hasattr(question, 'step_number') and (question.name == question_id): return question.step_number raise ValueError("Question ID in answer set not a step of this Mentoring Block!") def answer_mapper(self, answer_status): """ Create a JSON-dumpable object with readable key names from a list of student answers. """ answer_map = [] for answer in self.student_results: if answer[1]['status'] == answer_status: try: answer_map.append({ 'number': self.get_question_number(answer[0]), 'id': answer[0], 'details': answer[1], }) except ValueError: pass return answer_map @property def score(self): """Compute the student score taking into account the light child weight.""" total_child_weight = sum(float(step.weight) for step in self.steps) if total_child_weight == 0: return Score(0, 0, [], [], []) steps_map = {q.name: q for q in self.steps} points_earned = 0 for q_name, q_details in self.student_results: question = steps_map.get(q_name) if question: points_earned += q_details['score'] * question.weight score = points_earned / total_child_weight correct = self.answer_mapper(CORRECT) incorrect = self.answer_mapper(INCORRECT) partially_correct = self.answer_mapper(PARTIAL) return Score(score, int(round(score * 100)), correct, incorrect, partially_correct) @property def assessment_message(self): if not self.max_attempts_reached: return self.get_message_html('on-assessment-review') else: return None def show_extended_feedback(self): return self.extended_feedback and self.max_attempts_reached def feedback_dispatch(self, target_data, stringify): if self.show_extended_feedback(): if stringify: return json.dumps(target_data) else: return target_data def correct_json(self, stringify=True): return self.feedback_dispatch(self.score.correct, stringify) def incorrect_json(self, stringify=True): return self.feedback_dispatch(self.score.incorrect, stringify) def partial_json(self, stringify=True): return self.feedback_dispatch(self.score.partially_correct, stringify) def student_view(self, context): # Migrate stored data if necessary self.migrate_fields() # Validate self.step: num_steps = len([child for child in self.get_children_objects() if not isinstance(child, self.FLOATING_BLOCKS)]) if self.step > num_steps: self.step = num_steps fragment, named_children = self.get_children_fragment( context, view_name='mentoring_view', not_instance_of=self.FLOATING_BLOCKS, ) fragment.add_content(loader.render_template('templates/html/mentoring.html', { 'self': self, 'named_children': named_children, 'missing_dependency_url': self.has_missing_dependency and self.next_step_url, })) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/mentoring.css')) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js')) js_view = 'mentoring_assessment_view.js' if self.is_assessment else 'mentoring_standard_view.js' fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/'+js_view)) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js')) fragment.add_resource(loader.load_unicode('templates/html/mentoring_attempts.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_grade.html'), "text/html") fragment.add_resource(loader.load_unicode('templates/html/mentoring_review_questions.html'), "text/html") fragment.initialize_js('MentoringBlock') if not self.display_submit: self.runtime.publish(self, 'progress', {}) return fragment def migrate_fields(self): """ Migrate data stored in the fields, when a format change breaks backward-compatibility with previous data formats """ # Partial answers replaced the `completed` with `status` in `self.student_results` if self.student_results and 'completed' in self.student_results[0][1]: # Rename the field and use the new value format (text instead of boolean) for result in self.student_results: result[1]['status'] = CORRECT if result[1]['completed'] else INCORRECT del result[1]['completed'] @property def additional_publish_event_data(self): return { 'user_id': self.scope_ids.user_id, 'component_id': self.url_name, } @property def title(self): """ Returns the title child. """ for child in self.get_children_objects(): if isinstance(child, TitleBlock): return child return None @property def header(self): """ Return the header child. """ for child in self.get_children_objects(): if isinstance(child, SharedHeaderBlock): return child return None @property def has_missing_dependency(self): """ Returns True if the student needs to complete another step before being able to complete the current one, and False otherwise """ return self.enforce_dependency and (not self.completed) and (self.next_step != self.url_name) @property def next_step_url(self): """ Returns the URL of the next step's page """ return '/jump_to_id/{}'.format(self.next_step) @XBlock.json_handler def get_results(self, queries, suffix=''): """ Gets detailed results in the case of extended feedback. It may be a good idea to eventually have this function get results in the general case instead of loading them in the template in the future, and only using it for extended feedback situations. Right now there are two ways to get results-- through the template upon loading up the mentoring block, or after submission of an AJAX request like in submit or get_results here. """ results = [] if not self.show_extended_feedback(): return { 'results': [], 'error': 'Extended feedback results cannot be obtained.' } completed = True choices = dict(self.student_results) step = self.step # Only one child should ever be of concern with this method. for child in self.get_children_objects(): if child.name and child.name in queries: results = [child.name, child.get_results(choices[child.name])] # Children may have their own definition of 'completed' which can vary from the general case # of the whole mentoring block being completed. This is because in standard mode, all children # must be correct to complete the block. In assessment mode with extended feedback, completion # happens when you're out of attempts, no matter how you did. completed = choices[child.name]['status'] break # The 'completed' message should always be shown in this case, since no more attempts are available. message = self.get_message(True) return { 'results': results, 'completed': completed, 'attempted': self.attempted, 'message': message, 'step': step, 'max_attempts': self.max_attempts, 'num_attempts': self.num_attempts, } def get_message(self, completed): if self.max_attempts_reached: return self.get_message_html('max_attempts_reached') elif completed: return self.get_message_html('completed') else: return self.get_message_html('incomplete') @XBlock.json_handler def submit(self, submissions, suffix=''): log.info(u'Received submissions: {}'.format(submissions)) self.attempted = True if self.is_assessment: return self.handleAssessmentSubmit(submissions, suffix) submit_results = [] completed = True for child in self.get_children_objects(): if child.name and child.name in submissions: submission = submissions[child.name] child_result = child.submit(submission) submit_results.append([child.name, child_result]) child.save() completed = completed and (child_result['status'] == CORRECT) message = self.get_message(completed) # Once it has been completed once, keep completion even if user changes values if self.completed: completed = True # server-side check to not set completion if the max_attempts is reached if self.max_attempts_reached: completed = False if self.has_missing_dependency: completed = False message = 'You need to complete all previous steps before being able to complete the current one.' elif completed and self.next_step == self.url_name: self.next_step = self.followed_by # Once it was completed, lock score if not self.completed: # save user score and results while self.student_results: self.student_results.pop() for result in submit_results: self.student_results.append(result) self.runtime.publish(self, 'grade', { 'value': self.score.raw, 'max_value': 1, }) if not self.completed and self.max_attempts > 0: self.num_attempts += 1 self.completed = completed is True raw_score = self.score.raw self.publish_event_from_dict('xblock.mentoring.submitted', { 'num_attempts': self.num_attempts, 'submitted_answer': submissions, 'grade': raw_score, }) return { 'results': submit_results, 'completed': self.completed, 'attempted': self.attempted, 'message': message, 'max_attempts': self.max_attempts, 'num_attempts': self.num_attempts } def handleAssessmentSubmit(self, submissions, suffix): completed = False current_child = None children = [child for child in self.get_children_objects() if not isinstance(child, self.FLOATING_BLOCKS)] assessment_message = None for child in children: if child.name and child.name in submissions: submission = submissions[child.name] # Assessment mode doesn't allow to modify answers # This will get the student back at the step he should be current_child = child step = children.index(child) if self.step > step or self.max_attempts_reached: step = self.step completed = False break self.step = step + 1 child_result = child.submit(submission) if 'tips' in child_result: del child_result['tips'] self.student_results.append([child.name, child_result]) child.save() completed = child_result['status'] event_data = {} score = self.score if current_child == self.steps[-1]: log.info(u'Last assessment step submitted: {}'.format(submissions)) if not self.max_attempts_reached: self.runtime.publish(self, 'grade', { 'value': score.raw, 'max_value': 1, 'score_type': 'proficiency', }) event_data['final_grade'] = score.raw assessment_message = self.assessment_message self.num_attempts += 1 self.completed = True event_data['exercise_id'] = current_child.name event_data['num_attempts'] = self.num_attempts event_data['submitted_answer'] = submissions self.publish_event_from_dict('xblock.mentoring.assessment.submitted', event_data) return { 'completed': completed, 'attempted': self.attempted, 'max_attempts': self.max_attempts, 'num_attempts': self.num_attempts, 'step': self.step, 'score': score.percentage, 'correct_answer': len(score.correct), 'incorrect_answer': len(score.incorrect), 'partially_correct_answer': len(score.partially_correct), 'extended_feedback': self.show_extended_feedback() or '', 'correct': self.correct_json(stringify=False), 'incorrect': self.incorrect_json(stringify=False), 'partial': self.partial_json(stringify=False), 'assessment_message': assessment_message, } @XBlock.json_handler def try_again(self, data, suffix=''): if self.max_attempts_reached: return { 'result': 'error', 'message': 'max attempts reached' } # reset self.step = 0 self.completed = False while self.student_results: self.student_results.pop() return { 'result': 'success' } @property def max_attempts_reached(self): return self.max_attempts > 0 and self.num_attempts >= self.max_attempts def get_message_fragment(self, message_type): for child in self.get_children_objects(): if isinstance(child, MentoringMessageBlock) and child.type == message_type: frag = self.render_child(child, 'mentoring_view', {}) return self.fragment_text_rewriting(frag) def get_message_html(self, message_type): fragment = self.get_message_fragment(message_type) if fragment: return fragment.body_html() else: return '' def studio_view(self, context): """ Editing view in Studio """ fragment = Fragment() fragment.add_content(loader.render_template('templates/html/mentoring_edit.html', { 'self': self, 'xml_content': self.xml_content, })) fragment.add_javascript_url( self.runtime.local_resource_url(self, 'public/js/mentoring_edit.js')) fragment.add_css_url( self.runtime.local_resource_url(self, 'public/css/mentoring_edit.css')) fragment.initialize_js('MentoringEditBlock') return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): log.info(u'Received studio submissions: {}'.format(submissions)) xml_content = submissions['xml_content'] try: content = etree.parse(StringIO(xml_content)) except etree.XMLSyntaxError as e: response = { 'result': 'error', 'message': e.message } else: success = True root = content.getroot() if 'mode' in root.attrib: if root.attrib['mode'] not in self.MENTORING_MODES: response = { 'result': 'error', 'message': "Invalid mentoring mode: should be 'standard' or 'assessment'" } success = False elif root.attrib['mode'] == 'assessment' and 'max_attempts' not in root.attrib: # assessment has a default of 2 max_attempts root.attrib['max_attempts'] = '2' if success: response = { 'result': 'success', } self.xml_content = etree.tostring(content, pretty_print=True) log.debug(u'Response from Studio: {}'.format(response)) return response @property def url_name_with_default(self): """ Ensure the `url_name` is set to a unique, non-empty value. This should ideally be handled by Studio, but we need to declare the attribute to be able to use it from the workbench, and when this happen Studio doesn't set a unique default value - this property gives either the set value, or if none is set a randomized default value """ if self.url_name == 'mentoring-default': return 'mentoring-{}'.format(uuid.uuid4()) else: return self.url_name @staticmethod def workbench_scenarios(): """ Scenarios displayed by the workbench. Load them from external (private) repository """ return loader.load_scenarios_from_path('templates/xml')
class XBlockDataMixin: """ Mixin XBlock field data """ # pylint: disable=too-few-public-methods display_name = String( display_name=_('Display Name'), help=_('The display name for this component.'), default=_('SQL Problem'), scope=Scope.content, ) dataset = String( display_name=_('Dataset'), help=_('Which initial dataset/database to be used for queries'), default='rating', scope=Scope.content, values=list(DATABASES), ) answer_query = String( display_name=_('Answer Query'), help=_('A correct response SQL query'), default='', scope=Scope.content, multiline_editor=True, ) verify_query = String( display_name=_('Verify Query'), help=_( 'A secondary verification SQL query, to be used if the ' 'answer_query modifies the database (UPDATE, INSERT, DELETE, etc.)' ), default='', scope=Scope.content, multiline_editor=True, ) is_ordered = Boolean( display_name=_('Is Ordered?'), help=_('Should results be in order?'), default=False, scope=Scope.content, ) editable_fields = [ 'answer_query', 'dataset', 'display_name', 'verify_query', 'is_ordered', 'prompt', 'weight', ] prompt = String( display_name=_('Prompt'), help=_('Explanatory text to accompany the problem'), default='', scope=Scope.content, ) raw_response = String( display_name=_('Submission Query'), help=_('A Submission Query'), default='', scope=Scope.user_state, ) def provide_context(self, context): # pragma: no cover """ Build a context dictionary to render the student view """ context = context or {} context = dict(context) error_class = '' if not bool(self.score) and bool(self.raw_response): error_class = 'error' context.update({ 'display_name': self.display_name, 'prompt': self.prompt, 'answer': self.raw_response, 'score': self.score, 'score_weighted': int(self.score * self.weight), 'max_score': int(self.max_score()), 'error_class': error_class, 'raw_response': self.raw_response, 'verify_query': self.verify_query, }) return context
class DragAndDropBlock(ScorableXBlockMixin, XBlock, XBlockWithSettingsMixin, ThemableXBlockMixin): """ XBlock that implements a friendly Drag-and-Drop problem """ CATEGORY = "drag-and-drop-v2" SOLUTION_CORRECT = "correct" SOLUTION_PARTIAL = "partial" SOLUTION_INCORRECT = "incorrect" GRADE_FEEDBACK_CLASSES = { SOLUTION_CORRECT: FeedbackMessages.MessageClasses.CORRECT_SOLUTION, SOLUTION_PARTIAL: FeedbackMessages.MessageClasses.PARTIAL_SOLUTION, SOLUTION_INCORRECT: FeedbackMessages.MessageClasses.INCORRECT_SOLUTION, } PROBLEM_FEEDBACK_CLASSES = { SOLUTION_CORRECT: FeedbackMessages.MessageClasses.CORRECT_SOLUTION, SOLUTION_PARTIAL: None, SOLUTION_INCORRECT: None } display_name = String( display_name=_("Title"), help= _("The title of the drag and drop problem. The title is displayed to learners." ), scope=Scope.settings, default=_("Drag and Drop"), enforce_type=True, ) mode = String( display_name=_("Mode"), help=_( "Standard mode: the problem provides immediate feedback each time " "a learner drops an item on a target zone. " "Assessment mode: the problem provides feedback only after " "a learner drops all available items on target zones."), scope=Scope.settings, values=[ { "display_name": _("Standard"), "value": Constants.STANDARD_MODE }, { "display_name": _("Assessment"), "value": Constants.ASSESSMENT_MODE }, ], default=Constants.STANDARD_MODE, enforce_type=True, ) max_attempts = Integer( display_name=_("Maximum attempts"), help=_( "Defines the number of times a student can try to answer this problem. " "If the value is not set, infinite attempts are allowed."), scope=Scope.settings, default=None, enforce_type=True, ) show_title = Boolean( display_name=_("Show title"), help=_("Display the title to the learner?"), scope=Scope.settings, default=True, enforce_type=True, ) question_text = String( display_name=_("Problem text"), help= _("The description of the problem or instructions shown to the learner." ), scope=Scope.settings, default="", enforce_type=True, ) show_question_header = Boolean( display_name=_('Show "Problem" heading'), help=_('Display the heading "Problem" above the problem text?'), scope=Scope.settings, default=True, enforce_type=True, ) weight = Float( display_name=_("Problem Weight"), help=_("Defines the number of points the problem is worth."), scope=Scope.settings, default=1, enforce_type=True, ) item_background_color = String( display_name=_("Item background color"), help= _("The background color of draggable items in the problem (example: 'blue' or '#0000ff')." ), scope=Scope.settings, default="", enforce_type=True, ) item_text_color = String( display_name=_("Item text color"), help= _("Text color to use for draggable items (example: 'white' or '#ffffff')." ), scope=Scope.settings, default="", enforce_type=True, ) max_items_per_zone = Integer( display_name=_("Maximum items per zone"), help= _("This setting limits the number of items that can be dropped into a single zone." ), scope=Scope.settings, default=None, enforce_type=True, ) data = Dict( display_name=_("Problem data"), help= _("Information about zones, items, feedback, and background image for this problem. " "This information is derived from the input that a course author provides via the interactive editor " "when configuring the problem."), scope=Scope.content, default=DEFAULT_DATA, enforce_type=True, ) item_state = Dict( help= _("Information about current positions of items that a learner has dropped on the target image." ), scope=Scope.user_state, default={}, enforce_type=True, ) attempts = Integer( help=_("Number of attempts learner used"), scope=Scope.user_state, default=0, enforce_type=True, ) completed = Boolean( help= _("Indicates whether a learner has completed the problem at least once" ), scope=Scope.user_state, default=False, enforce_type=True, ) grade = Float(help=_( "DEPRECATED. Keeps maximum score achieved by student as a weighted value." ), scope=Scope.user_state, default=0) raw_earned = Float( help= _("Keeps maximum score achieved by student as a raw value between 0 and 1." ), scope=Scope.user_state, default=0, enforce_type=True, ) block_settings_key = 'drag-and-drop-v2' def max_score(self): # pylint: disable=no-self-use """ Return the problem's max score, which for DnDv2 always equals 1. Required by the grading system in the LMS. """ return 1 def get_score(self): """ Return the problem's current score as raw values. """ if self._get_raw_earned_if_set() is None: self.raw_earned = self._learner_raw_score() return Score(self.raw_earned, self.max_score()) def set_score(self, score): """ Sets the score on this block. Takes a Score namedtuple containing a raw score and possible max (for this block, we expect that this will always be 1). """ assert score.raw_possible == self.max_score() self.raw_earned = score.raw_earned def calculate_score(self): """ Returns a newly-calculated raw score on the problem for the learner based on the learner's current state. """ return Score(self._learner_raw_score(), self.max_score()) def has_submitted_answer(self): """ Returns True if the user has made a submission. """ return self.fields['raw_earned'].is_set_on( self) or self.fields['grade'].is_set_on(self) def weighted_grade(self): """ Returns the block's current saved grade multiplied by the block's weight- the number of points earned by the learner. """ return self.raw_earned * self.weight def _learner_raw_score(self): """ Calculate raw score for learner submission. As it is calculated as ratio of correctly placed (or left in bank in case of decoys) items to total number of items, it lays in interval [0..1] """ correct_count, total_count = self._get_item_stats() return correct_count / float(total_count) @staticmethod def _get_statici18n_js_url(): """ Returns the Javascript translation file for the currently selected language, if any found by `pkg_resources` """ lang_code = translation.get_language() if not lang_code: return None text_js = 'public/js/translations/{lang_code}/text.js' country_code = lang_code.split('-')[0] for code in (lang_code, country_code): if pkg_resources.resource_exists(loader.module_name, text_js.format(lang_code=code)): return text_js.format(lang_code=code) return None @XBlock.supports( "multi_device" ) # Enable this block for use in the mobile app via webview def student_view(self, context): """ Player view, displayed to the student """ fragment = Fragment() fragment.add_content( loader.render_django_template('/templates/html/drag_and_drop.html', i18n_service=self.i18n_service)) css_urls = ('public/css/drag_and_drop.css', ) js_urls = [ 'public/js/vendor/virtual-dom-1.3.0.min.js', 'public/js/drag_and_drop.js', ] statici18n_js_url = self._get_statici18n_js_url() if statici18n_js_url: js_urls.append(statici18n_js_url) for css_url in css_urls: fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) for js_url in js_urls: fragment.add_javascript_url( self.runtime.local_resource_url(self, js_url)) self.include_theme_files(fragment) fragment.initialize_js('DragAndDropBlock', self.student_view_data()) return fragment def student_view_data(self, context=None): """ Get the configuration data for the student_view. The configuration is all the settings defined by the author, except for correct answers and feedback. """ def items_without_answers(): """ Removes feedback and answer from items """ items = copy.deepcopy(self.data.get('items', '')) for item in items: del item['feedback'] # Use item.pop to remove both `item['zone']` and `item['zones']`; we don't have # a guarantee that either will be present, so we can't use `del`. Legacy instances # will have `item['zone']`, while current versions will have `item['zones']`. item.pop('zone', None) item.pop('zones', None) # Fall back on "backgroundImage" to be backward-compatible. image_url = item.get('imageURL') or item.get('backgroundImage') if image_url: item['expandedImageURL'] = self._expand_static_url( image_url) else: item['expandedImageURL'] = '' return items return { "block_id": six.text_type(self.scope_ids.usage_id), "display_name": self.display_name, "type": self.CATEGORY, "weight": self.weight, "mode": self.mode, "zones": self.zones, "max_attempts": self.max_attempts, "graded": getattr(self, 'graded', False), "weighted_max_score": self.max_score() * self.weight, "max_items_per_zone": self.max_items_per_zone, # SDK doesn't supply url_name. "url_name": getattr(self, 'url_name', ''), "display_zone_labels": self.data.get('displayLabels', False), "display_zone_borders": self.data.get('displayBorders', False), "items": items_without_answers(), "title": self.display_name, "show_title": self.show_title, "problem_text": self.question_text, "show_problem_header": self.show_question_header, "target_img_expanded_url": self.target_img_expanded_url, "target_img_description": self.target_img_description, "item_background_color": self.item_background_color or None, "item_text_color": self.item_text_color or None, "has_deadline_passed": self.has_submission_deadline_passed, # final feedback (data.feedback.finish) is not included - it may give away answers. } def studio_view(self, context): """ Editing view in Studio """ js_templates = loader.load_unicode('/templates/html/js_templates.html') # Get an 'id_suffix' string that is unique for this block. # We append it to HTML element ID attributes to ensure multiple instances of the DnDv2 block # on the same page don't share the same ID value. # We avoid using ID attributes in preference to classes, but sometimes we still need IDs to # connect 'for' and 'aria-describedby' attributes to the associated elements. id_suffix = self._get_block_id() js_templates = js_templates.replace('{{id_suffix}}', id_suffix) context = { 'js_templates': js_templates, 'id_suffix': id_suffix, 'fields': self.fields, 'self': self, 'data': six.moves.urllib.parse.quote(json.dumps(self.data)), } fragment = Fragment() fragment.add_content( loader.render_django_template( '/templates/html/drag_and_drop_edit.html', context=context, i18n_service=self.i18n_service)) css_urls = ('public/css/drag_and_drop_edit.css', ) js_urls = [ 'public/js/vendor/handlebars-v1.1.2.js', 'public/js/drag_and_drop_edit.js', ] statici18n_js_url = self._get_statici18n_js_url() if statici18n_js_url: js_urls.append(statici18n_js_url) for css_url in css_urls: fragment.add_css_url(self.runtime.local_resource_url( self, css_url)) for js_url in js_urls: fragment.add_javascript_url( self.runtime.local_resource_url(self, js_url)) # Do a bit of manipulation so we get the appearance of a list of zone options on # items that still have just a single zone stored items = self.data.get('items', []) for item in items: zones = self.get_item_zones(item['id']) # Note that we appear to be mutating the state of the XBlock here, but because # the change won't be committed, we're actually just affecting the data that # we're going to send to the client, not what's saved in the backing store. item['zones'] = zones item.pop('zone', None) fragment.initialize_js( 'DragAndDropEditBlock', { 'data': self.data, 'target_img_expanded_url': self.target_img_expanded_url, 'default_background_image_url': self.default_background_image_url, }) return fragment @XBlock.json_handler def studio_submit(self, submissions, suffix=''): """ Handles studio save. """ self.display_name = submissions['display_name'] self.mode = submissions['mode'] self.max_attempts = submissions['max_attempts'] self.show_title = submissions['show_title'] self.question_text = submissions['problem_text'] self.show_question_header = submissions['show_problem_header'] self.weight = float(submissions['weight']) self.item_background_color = submissions['item_background_color'] self.item_text_color = submissions['item_text_color'] self.max_items_per_zone = self._get_max_items_per_zone(submissions) self.data = submissions['data'] return { 'result': 'success', } def _get_block_id(self): """ Return unique ID of this block. Useful for HTML ID attributes. Works both in LMS/Studio and workbench runtimes: - In LMS/Studio, use the location.html_id method. - In the workbench, use the usage_id. """ if hasattr(self, 'location'): return self.location.html_id() # pylint: disable=no-member else: return six.text_type(self.scope_ids.usage_id) @staticmethod def _get_max_items_per_zone(submissions): """ Parses Max items per zone value coming from editor. Returns: * None if invalid value is passed (i.e. not an integer) * None if value is parsed into zero or negative integer * Positive integer otherwise. Examples: * _get_max_items_per_zone(None) -> None * _get_max_items_per_zone('string') -> None * _get_max_items_per_zone('-1') -> None * _get_max_items_per_zone(-1) -> None * _get_max_items_per_zone('0') -> None * _get_max_items_per_zone('') -> None * _get_max_items_per_zone('42') -> 42 * _get_max_items_per_zone(42) -> 42 """ raw_max_items_per_zone = submissions.get('max_items_per_zone', None) # Entries that aren't numbers should be treated as null. We assume that if we can # turn it into an int, a number was submitted. try: max_attempts = int(raw_max_items_per_zone) if max_attempts > 0: return max_attempts else: return None except (ValueError, TypeError): return None @XBlock.json_handler def drop_item(self, item_attempt, suffix=''): """ Handles dropping item into a zone. """ self._validate_drop_item(item_attempt) if self.mode == Constants.ASSESSMENT_MODE: return self._drop_item_assessment(item_attempt) elif self.mode == Constants.STANDARD_MODE: return self._drop_item_standard(item_attempt) else: raise JsonHandlerError( 500, self.i18n_service.gettext( "Unknown DnDv2 mode {mode} - course is misconfigured"). format(self.mode)) @XBlock.json_handler def do_attempt(self, data, suffix=''): """ Checks submitted solution and returns feedback. Raises: * JsonHandlerError with 400 error code in standard mode. * JsonHandlerError with 409 error code if no more attempts left """ self._validate_do_attempt() self.attempts += 1 # pylint: disable=fixme # TODO: Refactor this method to "freeze" item_state and pass it to methods that need access to it. # These implicit dependencies between methods exist because most of them use `item_state` or other # fields, either as an "input" (i.e. read value) or as output (i.e. set value) or both. As a result, # incorrect order of invocation causes issues: self._mark_complete_and_publish_grade( ) # must happen before _get_feedback - sets grade correct = self._is_answer_correct( ) # must happen before manipulating item_state - reads item_state overall_feedback_msgs, misplaced_ids = self._get_feedback( include_item_feedback=True) misplaced_items = [] for item_id in misplaced_ids: # Don't delete misplaced item states on the final attempt. if self.attempts_remain: del self.item_state[item_id] misplaced_items.append(self._get_item_definition(int(item_id))) feedback_msgs = [ FeedbackMessage(item['feedback']['incorrect'], None) for item in misplaced_items ] return { 'correct': correct, 'attempts': self.attempts, 'grade': self._get_weighted_earned_if_set(), 'misplaced_items': list(misplaced_ids), 'feedback': self._present_feedback(feedback_msgs), 'overall_feedback': self._present_feedback(overall_feedback_msgs) } @XBlock.json_handler def publish_event(self, data, suffix=''): """ Handler to publish XBlock event from frontend """ try: event_type = data.pop('event_type') except KeyError: return { 'result': 'error', 'message': 'Missing event_type in JSON data' } self.runtime.publish(self, event_type, data) return {'result': 'success'} @XBlock.json_handler def reset(self, data, suffix=''): """ Resets problem to initial state """ self.item_state = {} return self._get_user_state() @XBlock.json_handler def show_answer(self, data, suffix=''): """ Returns correct answer in assessment mode. Raises: * JsonHandlerError with 400 error code in standard mode. * JsonHandlerError with 409 error code if there are still attempts left """ if self.mode != Constants.ASSESSMENT_MODE: raise JsonHandlerError( 400, self.i18n_service.gettext( "show_answer handler should only be called for assessment mode" )) if self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("There are attempts remaining")) return self._get_correct_state() @XBlock.json_handler def expand_static_url(self, url, suffix=''): """ AJAX-accessible handler for expanding URLs to static [image] files """ return {'url': self._expand_static_url(url)} @property def i18n_service(self): """ Obtains translation service """ i18n_service = self.runtime.service(self, "i18n") if i18n_service: return i18n_service else: return DummyTranslationService() @property def target_img_expanded_url(self): """ Get the expanded URL to the target image (the image items are dragged onto). """ if self.data.get("targetImg"): return self._expand_static_url(self.data["targetImg"]) else: return self.default_background_image_url @property def target_img_description(self): """ Get the description for the target image (the image items are dragged onto). """ return self.data.get("targetImgDescription", "") @property def default_background_image_url(self): """ The URL to the default background image, shown when no custom background is used """ return self.runtime.local_resource_url(self, "public/img/triangle.png") @property def attempts_remain(self): """ Checks if current student still have more attempts. """ return self.max_attempts is None or self.max_attempts == 0 or self.attempts < self.max_attempts @property def has_submission_deadline_passed(self): """ Returns a boolean indicating if the submission is past its deadline. Using the `has_deadline_passed` method from InheritanceMixin which gets added on the LMS/Studio, return if the submission is past its due date. If the method not found, which happens for pure DragAndDropXblock, return False which makes sure submission checks don't affect other functionality. """ if hasattr(self, "has_deadline_passed"): return self.has_deadline_passed() # pylint: disable=no-member else: return False @XBlock.handler def student_view_user_state(self, request, suffix=''): """ GET all user-specific data, and any applicable feedback """ data = self._get_user_state() return webob.Response(body=json.dumps(data).encode('utf-8'), content_type='application/json') def _validate_do_attempt(self): """ Validates if `do_attempt` handler should be executed """ if self.mode != Constants.ASSESSMENT_MODE: raise JsonHandlerError( 400, self.i18n_service.gettext( "do_attempt handler should only be called for assessment mode" )) if not self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("Max number of attempts reached")) if self.has_submission_deadline_passed: raise JsonHandlerError( 409, self.i18n_service.gettext("Submission deadline has passed.")) def _get_feedback(self, include_item_feedback=False): """ Builds overall feedback for both standard and assessment modes """ answer_correctness = self._answer_correctness() is_correct = answer_correctness == self.SOLUTION_CORRECT if self.mode == Constants.STANDARD_MODE or not self.attempts: feedback_key = 'finish' if is_correct else 'start' return [ FeedbackMessage(self.data['feedback'][feedback_key], None) ], set() items = self._get_item_raw_stats() missing_ids = items.required - items.placed misplaced_ids = items.placed - items.correctly_placed feedback_msgs = [] def _add_msg_if_exists(ids_list, message_template, message_class): """ Adds message to feedback messages if corresponding items list is not empty """ if ids_list: message = message_template(len(ids_list), self.i18n_service.ngettext) feedback_msgs.append(FeedbackMessage(message, message_class)) if self.item_state or include_item_feedback: _add_msg_if_exists( items.correctly_placed, FeedbackMessages.correctly_placed, FeedbackMessages.MessageClasses.CORRECTLY_PLACED) # Misplaced items are not returned to the bank on the final attempt. if self.attempts_remain: misplaced_template = FeedbackMessages.misplaced_returned else: misplaced_template = FeedbackMessages.misplaced _add_msg_if_exists(misplaced_ids, misplaced_template, FeedbackMessages.MessageClasses.MISPLACED) _add_msg_if_exists(missing_ids, FeedbackMessages.not_placed, FeedbackMessages.MessageClasses.NOT_PLACED) if self.attempts_remain and (misplaced_ids or missing_ids): problem_feedback_message = self.data['feedback']['start'] else: problem_feedback_message = self.data['feedback']['finish'] problem_feedback_class = self.PROBLEM_FEEDBACK_CLASSES.get( answer_correctness, None) grade_feedback_class = self.GRADE_FEEDBACK_CLASSES.get( answer_correctness, None) feedback_msgs.append( FeedbackMessage(problem_feedback_message, problem_feedback_class)) if self.weight > 0: if self.attempts_remain: grade_feedback_template = FeedbackMessages.GRADE_FEEDBACK_TPL else: grade_feedback_template = FeedbackMessages.FINAL_ATTEMPT_TPL feedback_msgs.append( FeedbackMessage( self.i18n_service.gettext(grade_feedback_template).format( score=self.weighted_grade()), grade_feedback_class)) return feedback_msgs, misplaced_ids @staticmethod def _present_feedback(feedback_messages): """ Transforms feedback messages into format expected by frontend code """ return [{ "message": msg.message, "message_class": msg.message_class } for msg in feedback_messages if msg.message] def _drop_item_standard(self, item_attempt): """ Handles dropping item to a zone in standard mode. """ item = self._get_item_definition(item_attempt['val']) is_correct = self._is_attempt_correct( item_attempt) # Student placed item in a correct zone if is_correct: # In standard mode state is only updated when attempt is correct self.item_state[str(item['id'])] = self._make_state_from_attempt( item_attempt, is_correct) self._mark_complete_and_publish_grade( ) # must happen before _get_feedback self._publish_item_dropped_event(item_attempt, is_correct) item_feedback_key = 'correct' if is_correct else 'incorrect' item_feedback = FeedbackMessage( self._expand_static_url(item['feedback'][item_feedback_key]), None) overall_feedback, __ = self._get_feedback() return { 'correct': is_correct, 'grade': self._get_weighted_earned_if_set(), 'finished': self._is_answer_correct(), 'overall_feedback': self._present_feedback(overall_feedback), 'feedback': self._present_feedback([item_feedback]) } def _drop_item_assessment(self, item_attempt): """ Handles dropping item into a zone in assessment mode """ if not self.attempts_remain: raise JsonHandlerError( 409, self.i18n_service.gettext("Max number of attempts reached")) item = self._get_item_definition(item_attempt['val']) is_correct = self._is_attempt_correct(item_attempt) if item_attempt['zone'] is None: self.item_state.pop(str(item['id']), None) self._publish_item_to_bank_event(item['id'], is_correct) else: # State is always updated in assessment mode to store intermediate item positions self.item_state[str(item['id'])] = self._make_state_from_attempt( item_attempt, is_correct) self._publish_item_dropped_event(item_attempt, is_correct) return {} def _validate_drop_item(self, item): """ Validates `drop_item` parameters. Assessment mode allows returning items to the bank, so validation is unnecessary. """ if self.mode != Constants.ASSESSMENT_MODE: zone = self._get_zone_by_uid(item['zone']) if not zone: raise JsonHandlerError(400, "Item zone data invalid.") @staticmethod def _make_state_from_attempt(attempt, correct): """ Converts "attempt" data coming from browser into "state" entry stored in item_state """ return {'zone': attempt['zone'], 'correct': correct} def _mark_complete_and_publish_grade(self): """ Helper method to update `self.completed` and submit grade event if appropriate conditions met. """ # pylint: disable=fixme # TODO: (arguable) split this method into "clean" functions (with no side effects and implicit state) # This method implicitly depends on self.item_state (via _is_answer_correct and _learner_raw_score) # and also updates self.raw_earned if some conditions are met. As a result this method implies some order of # invocation: # * it should be called after learner-caused updates to self.item_state is applied # * it should be called before self.item_state cleanup is applied (i.e. returning misplaced items to item bank) # * it should be called before any method that depends on self.raw_earned (i.e. self._get_feedback) # Splitting it into a "clean" functions will allow to capture this implicit invocation order in caller method # and help avoid bugs caused by invocation order violation in future. # There's no going back from "completed" status to "incomplete" self.completed = self.completed or self._is_answer_correct( ) or not self.attempts_remain current_raw_earned = self._learner_raw_score() # ... and from higher grade to lower # if we have an old-style (i.e. unreliable) grade, override no matter what saved_raw_earned = self._get_raw_earned_if_set() current_raw_earned_is_greater = False if current_raw_earned is None or saved_raw_earned is None: current_raw_earned_is_greater = True if current_raw_earned is not None and saved_raw_earned is not None and current_raw_earned > saved_raw_earned: current_raw_earned_is_greater = True if current_raw_earned is None or current_raw_earned_is_greater: self.raw_earned = current_raw_earned self._publish_grade(Score(self.raw_earned, self.max_score())) # and no matter what - emit progress event for current user self.runtime.publish(self, "progress", {}) def _publish_item_dropped_event(self, attempt, is_correct): """ Publishes item dropped event. """ item = self._get_item_definition(attempt['val']) # attempt should already be validated here - not doing the check for existing zone again zone = self._get_zone_by_uid(attempt['zone']) item_label = item.get("displayName") if not item_label: item_label = item.get("imageURL") self.runtime.publish( self, 'edx.drag_and_drop_v2.item.dropped', { 'item': item_label, 'item_id': item['id'], 'location': zone.get("title"), 'location_id': zone.get("uid"), 'is_correct': is_correct, }) def _publish_item_to_bank_event(self, item_id, is_correct): """ Publishes event when item moved back to the bank in assessment mode. """ item = self._get_item_definition(item_id) item_label = item.get("displayName") if not item_label: item_label = item.get("imageURL") self.runtime.publish( self, 'edx.drag_and_drop_v2.item.dropped', { 'item': item_label, 'item_id': item['id'], 'location': 'item bank', 'location_id': -1, 'is_correct': is_correct, }) def _is_attempt_correct(self, attempt): """ Check if the item was placed correctly. """ correct_zones = self.get_item_zones(attempt['val']) if correct_zones == [] and attempt[ 'zone'] is None and self.mode == Constants.ASSESSMENT_MODE: return True return attempt['zone'] in correct_zones def _expand_static_url(self, url): """ This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the only portable URL format for static files that works across export/import and reruns). This method is unfortunately a bit hackish since XBlock does not provide a low-level API for this. """ if hasattr(self.runtime, 'replace_urls'): url = self.runtime.replace_urls(u'"{}"'.format(url))[1:-1] elif hasattr(self.runtime, 'course_id'): # edX Studio uses a different runtime for 'studio_view' than 'student_view', # and the 'studio_view' runtime doesn't provide the replace_urls API. try: from static_replace import replace_static_urls # pylint: disable=import-error url = replace_static_urls( u'"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1] except ImportError: pass return url def _get_user_state(self): """ Get all user-specific data, and any applicable feedback """ item_state = self._get_item_state() # In assessment mode, we do not want to leak the correctness info for individual items to the frontend, # so we remove "correct" from all items when in assessment mode. if self.mode == Constants.ASSESSMENT_MODE: for item in item_state.values(): del item["correct"] overall_feedback_msgs, __ = self._get_feedback() if self.mode == Constants.STANDARD_MODE: is_finished = self._is_answer_correct() else: is_finished = not self.attempts_remain return { 'items': item_state, 'finished': is_finished, 'attempts': self.attempts, 'grade': self._get_weighted_earned_if_set(), 'overall_feedback': self._present_feedback(overall_feedback_msgs) } def _get_correct_state(self): """ Returns one of the possible correct states for the configured data. """ state = {} items = copy.deepcopy(self.data.get('items', [])) for item in items: zones = item.get('zones') # For backwards compatibility if zones is None: zones = [] zone = item.get('zone') if zone is not None and zone != 'none': zones.append(zone) if zones: zone = zones.pop() state[str(item['id'])] = { 'zone': zone, 'correct': True, } return {'items': state} def _get_item_state(self): """ Returns a copy of the user item state. Converts to a dict if data is stored in legacy tuple form. """ # IMPORTANT: this method should always return a COPY of self.item_state - it is called from # student_view_user_state handler and the data it returns is manipulated there to hide # correctness of items placed. state = {} migrator = StateMigration(self) for item_id, item in six.iteritems(self.item_state): state[item_id] = migrator.apply_item_state_migrations( item_id, item) return state def _get_item_definition(self, item_id): """ Returns definition (settings) for item identified by `item_id`. """ return next(i for i in self.data['items'] if i['id'] == item_id) def get_item_zones(self, item_id): """ Returns a list of the zones that are valid options for the item. If the item is configured with a list of zones, return that list. If the item is configured with a single zone, encapsulate that zone's ID in a list and return the list. If the item is not configured with any zones, or if it's configured explicitly with no zones, return an empty list. """ item = self._get_item_definition(item_id) if item.get('zones') is not None: return item.get('zones') elif item.get('zone') is not None and item.get('zone') != 'none': return [item.get('zone')] else: return [] @property def zones(self): """ Get drop zone data, defined by the author. """ # Convert zone data from old to new format if necessary migrator = StateMigration(self) return [ migrator.apply_zone_migrations(zone) for zone in self.data.get('zones', []) ] def _get_zone_by_uid(self, uid): """ Given a zone UID, return that zone, or None. """ for zone in self.zones: if zone["uid"] == uid: return zone def _get_item_stats(self): """ Returns a tuple representing the number of correctly placed items, and the total number of items required (including decoy items). """ items = self._get_item_raw_stats() correct_count = len(items.correctly_placed) + len(items.decoy_in_bank) total_count = len(items.required) + len(items.decoy) return correct_count, total_count def _get_item_raw_stats(self): """ Returns a named tuple containing required, decoy, placed, correctly placed, and correctly unplaced decoy items. Returns: namedtuple: (required, placed, correctly_placed, decoy, decoy_in_bank) * required - IDs of items that must be placed on the board * placed - IDs of items actually placed on the board * correctly_placed - IDs of items that were placed correctly * decoy - IDs of decoy items * decoy_in_bank - IDs of decoy items that were unplaced """ item_state = self._get_item_state() all_items = set(str(item['id']) for item in self.data['items']) required = set(item_id for item_id in all_items if self.get_item_zones(int(item_id)) != []) placed = set(item_id for item_id in all_items if item_id in item_state) correctly_placed = set(item_id for item_id in placed if item_state[item_id]['correct']) decoy = all_items - required decoy_in_bank = set(item_id for item_id in decoy if item_id not in item_state) return ItemStats(required, placed, correctly_placed, decoy, decoy_in_bank) def _get_raw_earned_if_set(self): """ Returns student's grade if already explicitly set, otherwise returns None. This is different from self.raw_earned which returns 0 by default. """ if self.fields['raw_earned'].is_set_on(self): return self.raw_earned else: return None def _get_weighted_earned_if_set(self): """ Returns student's grade with the problem weight applied if set, otherwise None. """ if self.fields['raw_earned'].is_set_on(self): return self.weighted_grade() else: return None def _answer_correctness(self): """ Checks answer correctness: Returns: string: Correct/Incorrect/Partial * Correct: All items are at their correct place. * Partial: Some items are at their correct place. * Incorrect: None items are at their correct place. """ correct_count, total_count = self._get_item_stats() if correct_count == total_count: return self.SOLUTION_CORRECT elif correct_count == 0: return self.SOLUTION_INCORRECT else: return self.SOLUTION_PARTIAL def _is_answer_correct(self): """ Helper - checks if answer is correct Returns: bool: True if current answer is correct """ return self._answer_correctness() == self.SOLUTION_CORRECT @staticmethod def workbench_scenarios(): """ A canned scenario for display in the workbench. """ return [ ("Drag-and-drop-v2 standard", "<vertical_demo><drag-and-drop-v2/></vertical_demo>"), ("Drag-and-drop-v2 assessment", "<vertical_demo><drag-and-drop-v2 mode='assessment' max_attempts='3'/></vertical_demo>" ), ]