def get_stage_content_fragment(self, context, view='student_view'): final_grade = self.final_grade context_extension = { 'final_grade': final_grade if final_grade is not None else _(u"N/A") } context_extension.update(context) return super(GradeDisplayStage, self).get_stage_content_fragment(context_extension, view)
def persist_and_submit_file(self, activity, context, file_stream): """ Saves uploaded files to their permanent location, sends them to submissions backend and emits submission events """ uploaded_file = UploadFile(file_stream, self.upload_id, context) # Save the files first try: uploaded_file.save_file() except Exception as save_file_error: # pylint: disable=broad-except original_message = save_file_error.message if hasattr(save_file_error, "message") else "" save_file_error.message = _("Error storing file {} - {}").format(uploaded_file.file.name, original_message) raise # It have been saved... note the submission try: uploaded_file.submit() # Emit analytics event... self.runtime.publish( self, self.SUBMISSION_RECEIVED_EVENT, { "submission_id": uploaded_file.submission_id, "filename": uploaded_file.file.name, "content_id": activity.content_id, "group_id": activity.workgroup.id, "user_id": activity.user_id, } ) except Exception as save_record_error: # pylint: disable=broad-except original_message = save_record_error.message if hasattr(save_record_error, "message") else "" save_record_error.message = _("Error recording file information {} - {}").format( uploaded_file.file.name, original_message ) raise # See if the xBlock Notification Service is available, and - if so - # dispatch a notification to the entire workgroup that a file has been uploaded # Note that the NotificationService can be disabled, so it might not be available # in the list of services notifications_service = self.runtime.service(self, 'notifications') if notifications_service: self.stage.fire_file_upload_notification(notifications_service) return uploaded_file
def question_ids_values_provider(self): not_selected = { "display_name": _(u"--- Not selected ---"), "value": self.DEFAULT_QUESTION_ID_VALUE } question_values = [ {"display_name": question.title, "value": question.question_id} for question in self.activity_questions ] return [not_selected] + question_values
def validate_field_data(self, validation, data): super(GroupActivityXBlock, self).validate_field_data(validation, data) should_be_ints = ('weight', 'group_reviews_required_count', 'user_review_count') for field_name in should_be_ints: try: int(getattr(data, field_name)) except (TypeError, ValueError): message = _(u"{field_name} must be integer, {field_value} given").format( field_name=field_name, field_value=getattr(data, field_name) ) validation.add(ValidationMessage(ValidationMessage.ERROR, message))
class BasicStage(SimpleCompletionStageMixin, BaseGroupActivityStage): display_name = String( display_name=DISPLAY_NAME_NAME, help=DISPLAY_NAME_HELP, scope=Scope.content, default=_(u"Text Stage") ) CATEGORY = 'gp-v2-stage-basic' NAVIGATION_LABEL = _(u'Overview') STUDIO_LABEL = _(u"Text") def student_view(self, context): fragment = super(BasicStage, self).student_view(context) if self.can_mark_complete: self.mark_complete() return fragment
class SubmissionsViewXBlock(ProjectNavigatorViewXBlockBase): """ Submissions View - displays submissions grouped by Activity. Allows uploading new files and downloading earlier uploads """ CATEGORY = "gp-v2-navigator-submissions" STUDIO_LABEL = _(u"Submissions View") STUDENT_VIEW_TITLE = _(u"Upload") type = ViewTypes.SUBMISSIONS icon = u"fa fa-upload" display_name_with_default = STUDIO_LABEL SORT_ORDER = 1 template = "submissions_view.html" css_file = "submissions_view.css" js_file = "submissions_view.js" initialize_js_function = "GroupProjectNavigatorSubmissionsView" additional_js_files = ('public/js/vendor/jquery.ui.widget.js', 'public/js/vendor/jquery.fileupload.js', 'public/js/vendor/jquery.iframe-transport.js') @property def allow_admin_grader_access(self): return True def student_view(self, context): """ Student view """ activity_fragments = [] for activity in self.navigator.group_project.activities: activity_fragment = activity.render("submissions_view", context) activity_fragments.append(activity_fragment) context = { 'view': self, 'activity_contents': [frag.content for frag in activity_fragments] } return self.render_student_view(context, activity_fragments)
class AskTAViewXBlock(ProjectNavigatorViewXBlockBase): """ Ask a TA view - displays a form to send message to Teaching Assistant """ CATEGORY = "gp-v2-navigator-ask-ta" STUDIO_LABEL = _(u"Ask a TA View") STUDENT_VIEW_TITLE = _(u"Ask a McKinsey TA") type = ViewTypes.ASK_TA selector_text = u"TA" display_name_with_default = STUDIO_LABEL SORT_ORDER = 4 template = "ask_ta_view.html" css_file = "ask_ta_view.css" js_file = "ask_ta_view.js" initialize_js_function = "GroupProjectNavigatorAskTAView" @property def allow_admin_grader_access(self): return False @classmethod def is_view_type_available(cls): # TODO: LMS support - check if TAs are available at all return True def student_view(self, context): """ Student view """ img_url = self.runtime.local_resource_url(self, "public/img/ask_ta.png") context = { 'view': self, 'course_id': self.course_id, 'img_url': img_url } return self.render_student_view(context)
class BaseGroupProjectResourceXBlock(BaseStageComponentXBlock, StudioEditableXBlockMixin, XBlockWithPreviewMixin): display_name = String(display_name=_(u"Display Name"), help=_(U"This is a name of the resource"), scope=Scope.settings, default=_(u"Group Project V2 Resource")) description = String(display_name=_(u"Resource Description"), scope=Scope.settings) editable_fields = ('display_name', 'description') def student_view(self, _context): # pylint: disable=no-self-use return Fragment() def resources_view(self, context): fragment = Fragment() render_context = {'resource': self} render_context.update(context) fragment.add_content( loader.render_template(self.PROJECT_NAVIGATOR_VIEW_TEMPLATE, render_context)) return fragment
class FeedbackDisplayBaseStage(SimpleCompletionStageMixin, BaseGroupActivityStage): NAVIGATION_LABEL = _(u'Review') FEEDBACK_DISPLAY_BLOCK_CATEGORY = None @property def feedback_display_blocks(self): return self.get_children_by_category(self.FEEDBACK_DISPLAY_BLOCK_CATEGORY) @lazy def required_questions(self): return [ feedback_display.question_id for feedback_display in self.feedback_display_blocks if feedback_display.question and feedback_display.question.required ] def get_reviewer_ids(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) def get_reviews(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) def _make_review_keys(self, review_items): return [(self.real_user_id(item['reviewer']), item['question']) for item in review_items] def validate(self): violations = super(FeedbackDisplayBaseStage, self).validate() if not self.feedback_display_blocks: violations.add(ValidationMessage( ValidationMessage.ERROR, self._(messages.FEEDBACK_BLOCKS_ARE_MISSING).format( class_name=self.__class__.__name__, stage_title=self._(self.display_name) ) )) return violations def student_view(self, context): fragment = super(FeedbackDisplayBaseStage, self).student_view(context) # TODO: should probably check for all reviews to be ready if self.can_mark_complete: self.mark_complete() return fragment
class GroupProjectGradeEvaluationDisplayXBlock( GroupProjectBaseFeedbackDisplayXBlock): CATEGORY = "gp-v2-group-assessment" STUDIO_LABEL = _(u"Grade Evaluation Display") @property def activity_questions(self): return self.stage.activity.peer_review_questions def get_feedback(self): all_feedback = self.project_api.get_workgroup_review_items_for_group( self.group_id, self.stage.activity_content_id, ) return [ item for item in all_feedback if item["question"] == self.question_id ]
class PrivateDiscussionViewXBlock(ProjectNavigatorViewXBlockBase): CATEGORY = 'gp-v2-navigator-private-discussion' STUDIO_LABEL = _(u"Private Discussion View") type = ViewTypes.PRIVATE_DISCUSSION icon = 'fa fa-comment' skip_content = True # there're no content in this view so far - it only shows discussion in a popup display_name_with_default = STUDIO_LABEL SORT_ORDER = 3 js_file = "private_discussion_view.js" initialize_js_function = "GroupProjectPrivateDiscussionView" def _project_has_discussion(self): return self.navigator.group_project.has_child_of_category( DiscussionXBlockShim.CATEGORY) @property def is_view_available(self): return self._project_has_discussion() def selector_view(self, context): """ Selector view - this view is used by GroupProjectNavigatorXBlock to render selector buttons """ fragment = super(PrivateDiscussionViewXBlock, self).selector_view(context) add_resource(self, 'javascript', self.JS_BASE + self.js_file, fragment) fragment.initialize_js(self.initialize_js_function) return fragment def validate(self): validation = super(PrivateDiscussionViewXBlock, self).validate() if not self._project_has_discussion(): validation.add( ValidationMessage( ValidationMessage.WARNING, self._(messages.NO_DISCUSSION_IN_GROUP_PROJECT).format( block_type=self._(self.STUDIO_LABEL)))) return validation
class ProjectTeamXBlock( BaseStageComponentXBlock, XBlockWithPreviewMixin, NoStudioEditableSettingsMixin, StudioEditableXBlockMixin, ): CATEGORY = 'gp-v2-project-team' STUDIO_LABEL = _(u"Project Team") display_name_with_default = STUDIO_LABEL def student_view(self, context): fragment = Fragment() # Could be a TA not in the group. if self.stage.is_group_member: user_details = [ self.stage.project_api.get_member_data(self.stage.user_id) ] else: user_details = [] render_context = { 'team_members': user_details + self.stage.team_members, 'course_id': self.stage.course_id, 'group_id': self.stage.workgroup.id } render_context.update(context) fragment.add_content( loader.render_django_template( "templates/html/components/project_team.html", render_context, i18n_service=self.i18n_service, )) add_resource(self, 'css', "public/css/components/project_team.css", fragment) add_resource(self, 'javascript', "public/js/components/project_team.js", fragment) fragment.initialize_js("ProjectTeamXBlock") return fragment
def student_view(self, context): if self.question is None: return Fragment(messages.COMPONENT_MISCONFIGURED) raw_feedback = self.get_feedback() feedback = [] for item in raw_feedback: feedback.append(html.escape(item['answer'])) fragment = Fragment() title = self.question.assessment_title if self.question.assessment_title else self.question.title render_context = {'assessment': self, 'question_title': title, 'feedback': feedback} if self.show_mean: try: render_context['mean'] = "{0:.1f}".format(mean(feedback)) except ValueError as exc: log.warn(exc) render_context['mean'] = _(u"N/A") render_context.update(context) fragment.add_content(loader.render_template("templates/html/components/review_assessment.html", render_context)) return fragment
def display_name_with_default(self): return self.title or _(u"Review Question")
class GroupProjectReviewQuestionXBlock(BaseStageComponentXBlock, StudioEditableXBlockMixin, XBlockWithPreviewMixin): CATEGORY = "gp-v2-review-question" STUDIO_LABEL = _(u"Review Question") @property def display_name_with_default(self): return self.title or _(u"Review Question") question_id = String(display_name=_(u"Question ID"), default=UNIQUE_ID, scope=Scope.content, force_export=True) title = String(display_name=_(u"Question Text"), default=_(u""), scope=Scope.content) assessment_title = String( display_name=_(u"Assessment Question Text"), help=_(u"Overrides question title when displayed in assessment mode"), default=None, scope=Scope.content) question_content = String(display_name=_(u"Question Content"), help=_(u"HTML control"), default=_(u""), scope=Scope.content, multiline_editor="xml", xml_node=True) required = Boolean(display_name=_(u"Required"), default=False, scope=Scope.content) grade = Boolean( display_name=_(u"Grading"), help= _(u"IF True, answers to this question will be used to calculate student grade for Group Project." ), default=False, scope=Scope.content) single_line = Boolean( display_name=_(u"Single Line"), help=_( u"If True question label and content will be displayed on single line, allowing for more compact layout." u"Only affects presentation."), default=False, scope=Scope.content) question_css_classes = String( display_name=_(u"CSS Classes"), help= _(u"CSS classes to be set on question element. Only affects presentation." ), scope=Scope.content) editable_fields = ("question_id", "title", "assessment_title", "question_content", "required", "grade", "single_line", "question_css_classes") has_author_view = True @lazy def stage(self): return self.get_parent() def render_content(self): try: answer_node = ElementTree.fromstring(self.question_content) except ElementTree.ParseError: message_tpl = "Exception when parsing question content for question {question_id}. Content is [{content}]." message_tpl.format(question_id=self.question_id, content=self.question_content) log.exception(message_tpl) return "" answer_node.set('name', self.question_id) answer_node.set('id', self.question_id) current_class = answer_node.get('class') answer_classes = ['answer'] if current_class: answer_classes.append(current_class) if self.single_line: answer_classes.append('side') if self.stage.is_closed: answer_node.set('disabled', 'disabled') else: answer_classes.append('editable') answer_node.set('class', ' '.join(answer_classes)) return outer_html(answer_node) def student_view(self, context): question_classes = ["question"] if self.required: question_classes.append("required") if self.question_css_classes: question_classes.append(self.question_css_classes) fragment = Fragment() render_context = { 'question': self, 'question_classes': " ".join(question_classes), 'question_content': self.render_content() } render_context.update(context) fragment.add_content( loader.render_template( "templates/html/components/review_question.html", render_context)) return fragment def studio_view(self, context): fragment = super(GroupProjectReviewQuestionXBlock, self).studio_view(context) # TODO: StudioEditableXBlockMixin should really support Codemirror XML editor add_resource(self, 'css', "public/css/components/question_edit.css", fragment) add_resource(self, 'javascript', "public/js/components/question_edit.js", fragment) fragment.initialize_js("GroupProjectQuestionEdit") return fragment def author_view(self, context): fragment = self.student_view(context) add_resource(self, 'css', "public/css/components/question_edit.css", fragment) return fragment
class PeerReviewStage(ReviewBaseStage): display_name = String(display_name=DISPLAY_NAME_NAME, help=DISPLAY_NAME_HELP, scope=Scope.content, default=_(u"Peer Grading Stage")) CATEGORY = 'gp-v2-stage-peer-review' STAGE_CONTENT_TEMPLATE = 'templates/html/stages/peer_review.html' EXTERNAL_STATUSES_LABEL_MAPPING = { StageState.NOT_STARTED: _("Pending Review"), StageState.INCOMPLETE: _("Pending Review"), StageState.COMPLETED: _("Review Complete"), } DEFAULT_EXTERNAL_STATUS_LABEL = _("Unknown") STUDIO_LABEL = _(u"Peer Grading") REVIEW_ITEM_KEY = "workgroup" @property def allowed_nested_blocks(self): blocks = super(PeerReviewStage, self).allowed_nested_blocks blocks.extend([GroupSelectorXBlock]) return blocks @property def allow_admin_grader_access(self): return True @property def is_graded_stage(self): return True @property def available_to_current_user(self): if not super(PeerReviewStage, self).available_to_current_user: return False if not self.is_admin_grader and self.activity.is_ta_graded: return False return True @property def can_mark_complete(self): if self.is_admin_grader: return True return super(PeerReviewStage, self).can_mark_complete @lazy def review_subjects(self): """ Returns groups to review. May throw `class`: OutsiderDisallowedError :rtype: list[group_project_v2.project_api.dtos.WorkgroupDetails] """ if self.is_admin_grader: return [self.workgroup] try: return self.get_review_subjects(self.user_id) except ApiError: log.exception( "Error obtaining list of groups to grade - assuming no groups to grade" ) return [] @property def review_groups(self): return self.review_subjects def get_review_subjects(self, user_id): """ Gets a list of workgroups to review for selected user :param int user_id: User ID :return: list[WorkgroupDetails] """ return self.project_api.get_workgroups_to_review( user_id, self.course_id, self.activity_content_id) def _get_review_items(self, review_groups, with_caching=False): """ Gets review items for a list of groups :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] review_groups: Target groups :param bool with_caching: Underlying implementation uses get_workgroup_review_items_for_group for both cached and non-cached version. However, one of the users of this method (get_review_data) might benefit from caching, while the other (review_status) is affected by the issue outlined in get_workgroup_review_items_for_group comment (i.e. cached value is not updated when new feedback is posted). So, caching is conditionally enabled here to serve both users of this method as efficiently as possible. :return: """ def do_get_items(group_id): if with_caching: return self._get_review_items_for_group( self.project_api, group_id, self.activity_content_id) else: return self.project_api.get_workgroup_review_items_for_group( group_id, self.activity_content_id) return list( itertools.chain.from_iterable( do_get_items(group.id) for group in review_groups)) def review_status(self): review_subjects_ids = [group.id for group in self.review_groups] all_review_items = self._get_review_items(self.review_groups, with_caching=False) review_items = [ item for item in all_review_items if item['reviewer'] == self.anonymous_student_id ] return self._calculate_review_status(review_subjects_ids, review_items) def get_review_state(self, review_subject_id): review_items = self.project_api.get_workgroup_review_items( self.anonymous_student_id, review_subject_id, self.activity_content_id) return self._calculate_review_status([review_subject_id], review_items) def _get_ta_reviews(self, target_workgroup): review_items = self._get_review_items([target_workgroup], with_caching=True) grouped_items = defaultdict(list) for item in review_items: grouped_items[item['reviewer']].append(item) ta_reviews = { reviewer: items for reviewer, items in grouped_items.iteritems() if self.is_user_ta(self.real_user_id(reviewer), self.course_id) } return ta_reviews def get_external_group_status(self, group): """ Calculates external group status for the Stage. For Peer Grading stage, external status means "have students in other groups provided grades to this group?" :param group_project_v2.project_api.dtos.WorkgroupDetails group: workgroup :rtype: StageState """ if not self.activity.is_ta_graded: reviewer_ids = [ user['id'] for user in self.project_api.get_workgroup_reviewers( group.id, self.activity_content_id) ] reviews_for_group = self._get_review_items([group], with_caching=True) review_results = [ self._calculate_review_status([group.id], self._get_reviews_by_user( reviews_for_group, reviewer_id)) for reviewer_id in reviewer_ids ] # if review_results is empty (e.g. no reviewers are configured) all will return True, and any will return # False. It would result in a "broken" state (has_all, but not has_some) - it is cleared later has_some = any(status != ReviewState.NOT_STARTED for status in review_results) has_all = all(status == ReviewState.COMPLETED for status in review_results) else: ta_reviews = self._get_ta_reviews(group) review_results = [ self._calculate_review_status([group.id], ta_review_items) for ta_review_items in ta_reviews.values() ] has_some = any(status != ReviewState.NOT_STARTED for status in review_results) # any completed TA review counts as "stage completed" has_all = any(status == ReviewState.COMPLETED for status in review_results) has_all = has_some and has_all # has_all should never be True if has_some is False review_state = self.REVIEW_STATE_CONDITIONS.get((has_some, has_all)) return self.STAGE_STATE_REVIEW_STATE_MAPPING.get(review_state) def get_review_data(self, user_id): """ :param int user_id: :rtype: (set[int], dict) """ review_subjects = self.get_review_subjects(user_id) review_items = self._get_review_items(review_subjects, with_caching=True) reviews_by_user = self._get_reviews_by_user(review_items, user_id) return set(group.id for group in review_subjects), reviews_by_user @staticmethod @memoize_with_expiration() def _get_review_items_for_group(project_api, workgroup_id, activity_content_id): return project_api.get_workgroup_review_items_for_group( workgroup_id, activity_content_id) def validate(self): violations = super(PeerReviewStage, self).validate() if not self.grade_questions: violations.add( ValidationMessage( ValidationMessage.ERROR, messages.GRADED_QUESTIONS_ARE_REQUIRED.format( class_name=self.STUDIO_LABEL, stage_title=self.display_name))) if not self.has_child_of_category(GroupSelectorXBlock.CATEGORY): violations.add( ValidationMessage( ValidationMessage.ERROR, messages.GROUP_SELECTOR_BLOCK_IS_MISSING.format( class_name=self.STUDIO_LABEL, stage_title=self.display_name, group_selector_class_name=GroupSelectorXBlock. STUDIO_LABEL))) return violations def get_stage_state(self): if not self.review_subjects: return StageState.NOT_STARTED return super(PeerReviewStage, self).get_stage_state() @XBlock.handler @groupwork_protected_handler @key_error_protected_handler @conversion_protected_handler def other_submission_links(self, request, _suffix=''): group_id = int(request.GET["group_id"]) target_stages = [ stage for stage in self.activity.stages if stage.submissions_stage ] submission_stage_contents = [] for stage in target_stages: stage_fragment = stage.render('review_submissions_view', {'group_id': group_id}) submission_stage_contents.append(stage_fragment.content) context = { 'block': self, 'submission_stage_contents': submission_stage_contents } html_output = loader.render_template( '/templates/html/stages/stages_group_review_other_team_submissions.html', context) return webob.response.Response(body=json.dumps({"html": html_output})) @XBlock.handler @groupwork_protected_handler @key_error_protected_handler def load_other_group_feedback(self, request, _suffix=''): group_id = int(request.GET["group_id"]) feedback = self.project_api.get_workgroup_review_items( self.anonymous_student_id, group_id, self.activity_content_id) results = self._pivot_feedback(feedback) return webob.response.Response(body=json.dumps(results)) def do_submit_review(self, submissions): group_id = int(submissions["review_subject_id"]) del submissions["review_subject_id"] self.project_api.submit_workgroup_review_items( self.anonymous_student_id, group_id, self.activity_content_id, submissions) for question_id in self.grade_questions: if question_id in submissions: # Emit analytics event... self.runtime.publish( self, "group_activity.received_grade_question_score", { "question": question_id, "answer": submissions[question_id], "reviewer_id": self.anonymous_student_id, "is_admin_grader": self.is_admin_grader, "group_id": group_id, "content_id": self.activity_content_id, }) self.activity.calculate_and_send_grade(group_id)
class GroupProjectSubmissionXBlock(BaseStageComponentXBlock, ProjectAPIXBlockMixin, StudioEditableXBlockMixin, XBlockWithPreviewMixin): CATEGORY = "gp-v2-submission" STUDIO_LABEL = _(u"Submission") PROJECT_NAVIGATOR_VIEW_TEMPLATE = 'templates/html/components/submission_navigator_view.html' REVIEW_VIEW_TEMPLATE = 'templates/html/components/submission_review_view.html' display_name = String(display_name=_(u"Display Name"), help=_(U"This is a name of the submission"), scope=Scope.settings, default=_(u"Group Project V2 Submission")) description = String(display_name=_(u"Submission Description"), scope=Scope.settings) upload_id = String( display_name=_(u"Upload ID"), help=_( U"This string is used as an identifier for an upload. " U"Submissions sharing the same Upload ID will be updated simultaneously" ), ) editable_fields = ('display_name', 'description', 'upload_id') SUBMISSION_RECEIVED_EVENT = "activity.received_submission" # TODO: Make configurable via XBlock settings DEFAULT_FILE_FILTERS = { "mime-types": ( # Images "image/png", "image/jpeg", "image/tiff", # Excel "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # Word "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # PowerPoint "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", # PDF "application/pdf"), "extensions": ( "png", "jpg", "jpeg", "tif", "tiff", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf", ) } validator = FileTypeValidator( allowed_types=DEFAULT_FILE_FILTERS["mime-types"], allowed_extensions=[ ".{}".format(ext) for ext in DEFAULT_FILE_FILTERS["extensions"] ]) def get_upload(self, group_id): submission_map = self.project_api.get_latest_workgroup_submissions_by_id( group_id) submission_data = submission_map.get(self.upload_id, None) if submission_data is None: return None document_signed_url = make_s3_link_temporary( submission_data.get('workgroup'), submission_data['document_url'].split('/')[-2], submission_data['document_filename'], submission_data["document_url"]) return SubmissionUpload( document_signed_url, submission_data["document_filename"], format_date(build_date_field(submission_data["modified"])), submission_data.get("user_details", None)) @property def upload(self): return self.get_upload(self.stage.activity.workgroup.id) def student_view(self, _context): # pylint: disable=no-self-use return Fragment() def submissions_view(self, context): fragment = Fragment() uploading_allowed = ( self.stage.available_now and self.stage.is_group_member) or self.stage.is_admin_grader render_context = { 'submission': self, 'upload': self.upload, 'disabled': not uploading_allowed } render_context.update(context) fragment.add_content( loader.render_template(self.PROJECT_NAVIGATOR_VIEW_TEMPLATE, render_context)) add_resource(self, 'javascript', 'public/js/components/submission.js', fragment) fragment.initialize_js("GroupProjectSubmissionBlock") return fragment def submission_review_view(self, context): group_id = context.get('group_id', self.stage.activity.workgroup.id) fragment = Fragment() render_context = { 'submission': self, 'upload': self.get_upload(group_id) } render_context.update(context) fragment.add_content( loader.render_template(self.REVIEW_VIEW_TEMPLATE, render_context)) # NOTE: adding js/css likely won't work here, as the result of this view is added as an HTML to an existing DOM # element return fragment def _validate_upload(self, request): if not self.stage.available_now: template = messages.STAGE_NOT_OPEN_TEMPLATE if not self.stage.is_open else messages.STAGE_CLOSED_TEMPLATE # 422 = unprocessable entity return 422, { 'result': 'error', 'message': template.format(action=self.stage.STAGE_ACTION) } if not self.stage.is_group_member and not self.stage.is_admin_grader: # 403 - forbidden return 403, { 'result': 'error', 'message': messages.NON_GROUP_MEMBER_UPLOAD } try: self.validator(request.params[self.upload_id].file) except ValidationError as validationError: message = validationError.message % validationError.params # 400 - BAD REQUEST return 400, {'result': 'error', 'message': message} return None, None @XBlock.handler def upload_submission(self, request, _suffix=''): """ Handles submission upload and marks stage as completed if all submissions in stage have uploads. :param request: HTTP request :param str _suffix: """ failure_code, response_data = self._validate_upload(request) if failure_code is None and response_data is None: target_activity = self.stage.activity response_data = { "title": messages.SUCCESSFUL_UPLOAD_TITLE, "message": messages.SUCCESSFUL_UPLOAD_MESSAGE_TPL.format( icon='fa fa-paperclip') } failure_code = 0 try: context = { "user_id": target_activity.user_id, "group_id": target_activity.workgroup.id, "project_api": self.project_api, "course_id": target_activity.course_id } uploaded_file = self.persist_and_submit_file( target_activity, context, request.params[self.upload_id].file) response_data["submissions"] = { uploaded_file.submission_id: make_s3_link_temporary( uploaded_file.group_id, uploaded_file.sha1, uploaded_file.file.name, uploaded_file.file_url, ) } self.stage.check_submissions_and_mark_complete() response_data["new_stage_states"] = [ self.stage.get_new_stage_state_data() ] response_data[ 'user_label'] = self.project_api.get_user_details( target_activity.user_id).user_label response_data['submission_date'] = format_date(date.today()) except Exception as exception: # pylint: disable=broad-except log.exception(exception) failure_code = 500 if isinstance(exception, ApiError): failure_code = exception.code error_message = getattr(exception, "message", messages.UNKNOWN_ERROR) response_data.update({ "title": messages.FAILED_UPLOAD_TITLE, "message": messages.FAILED_UPLOAD_MESSAGE_TPL.format( error_goes_here=error_message) }) response = webob.response.Response(body=json.dumps(response_data)) if failure_code: response.status_code = failure_code return response def persist_and_submit_file(self, activity, context, file_stream): """ Saves uploaded files to their permanent location, sends them to submissions backend and emits submission events """ uploaded_file = UploadFile(file_stream, self.upload_id, context) # Save the files first try: uploaded_file.save_file() except Exception as save_file_error: # pylint: disable=broad-except original_message = save_file_error.message if hasattr( save_file_error, "message") else "" save_file_error.message = _("Error storing file {} - {}").format( uploaded_file.file.name, original_message) raise # It have been saved... note the submission try: uploaded_file.submit() # Emit analytics event... self.runtime.publish( self, self.SUBMISSION_RECEIVED_EVENT, { "submission_id": uploaded_file.submission_id, "filename": uploaded_file.file.name, "content_id": activity.content_id, "group_id": activity.workgroup.id, "user_id": activity.user_id, }) except Exception as save_record_error: # pylint: disable=broad-except original_message = save_record_error.message if hasattr( save_record_error, "message") else "" save_record_error.message = _( "Error recording file information {} - {}").format( uploaded_file.file.name, original_message) raise # See if the xBlock Notification Service is available, and - if so - # dispatch a notification to the entire workgroup that a file has been uploaded # Note that the NotificationService can be disabled, so it might not be available # in the list of services notifications_service = self.runtime.service(self, 'notifications') if notifications_service: self.stage.fire_file_upload_notification(notifications_service) return uploaded_file
class SubmissionStage(BaseGroupActivityStage): display_name = String(display_name=DISPLAY_NAME_NAME, help=DISPLAY_NAME_HELP, scope=Scope.content, default=_(u"Submission Stage")) CATEGORY = 'gp-v2-stage-submission' NAVIGATION_LABEL = _(u'Task') STUDIO_LABEL = _(u"Deliverable") EXTERNAL_STATUSES_LABEL_MAPPING = { StageState.NOT_STARTED: _("Pending Upload"), StageState.INCOMPLETE: _("Pending Upload"), StageState.COMPLETED: _("Uploaded"), } DEFAULT_EXTERNAL_STATUS_LABEL = _("Unknown") submissions_stage = True STAGE_ACTION = _(u"upload submission") @property def allowed_nested_blocks(self): blocks = super(SubmissionStage, self).allowed_nested_blocks blocks.extend( [SubmissionsStaticContentXBlock, GroupProjectSubmissionXBlock]) return blocks @property def is_graded_stage(self): return True @property def submissions(self): """ :rtype: collections.Iterable[GroupProjectSubmissionXBlock] """ return self.get_children_by_category( GroupProjectSubmissionXBlock.CATEGORY) @property def is_upload_available(self): return self.submissions and self.is_open and not self.is_closed @property def has_submissions(self): return bool( self.submissions ) # explicitly converting to bool to indicate that it is bool property def validate(self): violations = super(SubmissionStage, self).validate() if not self.submissions: violations.add( ValidationMessage( ValidationMessage.ERROR, messages.SUBMISSIONS_BLOCKS_ARE_MISSING.format( class_name=self.__class__.__name__, stage_title=self.display_name))) return violations @property def has_some_submissions(self): return any(submission.upload is not None for submission in self.submissions) @property def has_all_submissions(self): return all(submission.upload is not None for submission in self.submissions) def check_submissions_and_mark_complete(self): if self.has_all_submissions: for user in self.workgroup.users: self.mark_complete(user.id) def get_stage_state(self): if self.has_all_submissions: return StageState.COMPLETED elif self.has_some_submissions: return StageState.INCOMPLETE else: return StageState.NOT_STARTED def _render_view(self, child_view, template, context): fragment = Fragment() submission_contents = [] for submission in self.submissions: submission_fragment = submission.render(child_view, context) fragment.add_frag_resources(submission_fragment) submission_contents.append(submission_fragment.content) context = {'stage': self, 'submission_contents': submission_contents} fragment.add_content(loader.render_template(template, context)) return fragment def review_submissions_view(self, context): # transparently passing group_id via context return self._render_view( 'submission_review_view', "templates/html/stages/submissions_review_view.html", context) def get_users_completion(self, target_workgroups, target_users): """ Returns sets of completed user ids and partially completed user ids :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users: :rtype: (set[int], set[int]) """ completed_users = [] partially_completed_users = [] for group in target_workgroups: group_stage_state = self.get_external_group_status(group) workgroup_user_ids = [user.id for user in group.users] if group_stage_state == StageState.COMPLETED: completed_users.extend(workgroup_user_ids) if group_stage_state == StageState.INCOMPLETE: partially_completed_users.extend(workgroup_user_ids) return set(completed_users), set( partially_completed_users) # removing duplicates - just in case def get_external_group_status(self, group): """ Calculates external group status for the Stage. For Submissions stage, external status is the same as internal one: "have workgroup submitted all uploads?" :param group_project_v2.project_api.dtos.WorkgroupDetails group: workgroup :rtype: StageState """ upload_ids = set(submission.upload_id for submission in self.submissions) group_submissions = self.project_api.get_latest_workgroup_submissions_by_id( group.id) uploaded_submissions = set(group_submissions.keys()) has_all = uploaded_submissions >= upload_ids has_some = bool(uploaded_submissions & upload_ids) if has_all: return StageState.COMPLETED elif has_some: return StageState.INCOMPLETE else: return StageState.NOT_STARTED
from group_project_v2.utils import gettext as _ # Generic messages UNKNOWN_ERROR = _(u"Unknown error.") COMPONENT_MISCONFIGURED = _( u"This component is misconfigured and can't be displayed. It needs to be fixed by the course authors." ) STAGE_NOT_OPEN_TEMPLATE = _(u"Can't {action} as stage is not yet opened.") STAGE_CLOSED_TEMPLATE = _(u"Can't {action} as stage is closed.") # Group Project XBlock messages NO_ACTIVITIES = _(u"This Group Project does not contain any activities.") NO_PROJECT_NAVIGATOR = _( u"This Group Project V2 does not contain Project Navigator - please edit course outline " u"in Studio to include one." ) NO_DISCUSSION = _(u"This Group Project V2 does not contain a discussion.") MUST_CONTAIN_PROJECT_NAVIGATOR_BLOCK = _(u"Group Project must contain Project Navigator Block.") # Group Activity XBlock messages NO_STAGES = _(u"This Group Project Activity does not contain any stages.") SHOULD_BE_INTEGER = _(u"{field_name} must be integer, {field_value} given.") ASSIGNED_TO_GROUPS_LABEL = _(u"This project is assigned to {group_count} group(s)") # no full stop (period) by design # Project Navigator messages MUST_CONTAIN_NAVIGATION_VIEW = _(u"Project Navigator must contain Navigation view.") NO_DISCUSSION_IN_GROUP_PROJECT = _( u"Parent group project does not contain discussion XBlock - this {block_type} " u"will not function properly and will not be displayed to students." )
def url_name_caption(self): return _(u"url_name to link to this {project_navigator_view}:").format( project_navigator_view=self.display_name_with_default )
class BaseGroupActivityStage( CommonMixinCollection, DashboardXBlockMixin, XBlockWithPreviewMixin, StageNotificationsMixin, XBlockWithUrlNameDisplayMixin, AdminAccessControlXBlockMixin, XBlock, ): open_date = DateTime(display_name=_(u"Open Date"), help=_(u"Stage open date"), scope=Scope.settings) close_date = DateTime(display_name=_(u"Close Date"), help=_(u"Stage close date"), scope=Scope.settings) hide_stage_label = Boolean( display_name=_(u"Hide stage type label"), help=_(u"If true, hides stage type label in Project Navigator"), scope=Scope.settings, default=True) editable_fields = ('display_name', 'open_date', 'close_date', 'hide_stage_label') has_children = True has_score = False # TODO: Group project V1 are graded at activity level. Check if we need to follow that submissions_stage = False CATEGORY = None STAGE_WRAPPER_TEMPLATE = 'templates/html/stages/stage_wrapper.html' STAGE_CONTENT_TEMPLATE = 'templates/html/stages/default_view.html' NAVIGATION_LABEL = None STUDIO_LABEL = _(u"Stage") EXTERNAL_STATUSES_LABEL_MAPPING = {} DEFAULT_EXTERNAL_STATUS_LABEL = "" js_file = None js_init = None template_location = 'stages' @property def id(self): return self.scope_ids.usage_id @property def navigation_label(self): return self._(self.NAVIGATION_LABEL) @property def allowed_nested_blocks(self): # pylint: disable=no-self-use """ This property outputs an ordered dictionary of allowed nested XBlocks in form of block_category: block_caption. """ blocks = [HtmlXBlockShim, GroupProjectResourceXBlock] if GroupProjectVideoResourceXBlock.is_available(): blocks.append(GroupProjectVideoResourceXBlock) blocks.append(ProjectTeamXBlock) return blocks @lazy def activity(self): """ :rtype: group_project_v2.group_project.GroupActivityXBlock """ return self.get_parent() @property def allow_admin_grader_access(self): return False @property def content_id(self): return get_block_content_id(self) @property def activity_content_id(self): return self.activity.content_id @property def resources(self): return self.get_children_by_category( GroupProjectResourceXBlock.CATEGORY, GroupProjectVideoResourceXBlock.CATEGORY) @property def team_members(self): """ Returns teammates to review. May throw `class`: OutsiderDisallowedError """ if not self.is_group_member: return [] try: result = [] for team_member in self.workgroup.users: team_member_id = team_member.id if self.user_id == int(team_member_id): continue result.append(self.project_api.get_member_data(team_member_id)) return result except ApiError: return [] @property def formatted_open_date(self): return format_date(self.open_date) @property def formatted_close_date(self): return format_date(self.close_date) @property def is_open(self): return (self.open_date is None) or ( self.open_date <= datetime.utcnow().replace(tzinfo=pytz.UTC)) @property def is_closed(self): # If this stage is being loaded for the purposes of a TA grading, # then we never close the stage - in this way a TA can impose any # action necessary even if it has been closed to the group members if not self.is_group_member: return False if self.close_date is None: return False # A stage is closed at the end of the closing day. return self.close_date + timedelta( days=1) <= datetime.utcnow().replace(tzinfo=pytz.UTC) @property def completed(self): return self.get_stage_state() == StageState.COMPLETED @property def available_now(self): return self.is_open and not self.is_closed @property def url_name_caption(self): return self._(messages.STAGE_URL_NAME_TEMPLATE).format( stage_name=self._(self.STUDIO_LABEL)) @property def can_mark_complete(self): return self.available_now and self.is_group_member @property def is_graded_stage(self): # pylint: disable=no-self-use """ If a stage is graded it is shown as graded on the the main dashboard, this property also is used by default implementation of ``shown_on_detail_view``. :rtype: bool """ return False @property def shown_on_detail_view(self): """ If true details of this stage are shown on the dashboard detail view, by default it returns ``is_graded_stage``. :rtype: bool """ return self.is_graded_stage @property def dashboard_details_view_url(self): return self.activity.dashboard_details_url() def is_current_stage(self, context): target_stage_id = context.get( Constants.CURRENT_STAGE_ID_PARAMETER_NAME, None) if not target_stage_id: return False return target_stage_id == self.id def _view_render(self, context, view='student_view'): stage_fragment = self.get_stage_content_fragment(context, view) fragment = Fragment() fragment.add_fragment_resources(stage_fragment) render_context = { 'stage': self, 'stage_content': stage_fragment.content, "ta_graded": self.activity.group_reviews_required_count } fragment.add_content( loader.render_django_template( self.STAGE_WRAPPER_TEMPLATE, render_context, i18n_service=self.i18n_service, )) if stage_fragment.js_init_fn: fragment.initialize_js(stage_fragment.js_init_fn) return fragment @groupwork_protected_view def student_view(self, context): return self._view_render(context) @groupwork_protected_view def author_preview_view(self, context): # if we use student_view or author_view Studio will wrap it in HTML that we don't want in the preview fragment = self._view_render(context, "preview_view") url_name_fragment = self.get_url_name_fragment(self.url_name_caption) fragment.add_content(url_name_fragment.content) fragment.add_fragment_resources(url_name_fragment) return fragment @groupwork_protected_view def author_edit_view(self, context): fragment = super(BaseGroupActivityStage, self).author_edit_view(context) url_name_fragment = self.get_url_name_fragment(self.url_name_caption) fragment.add_content(url_name_fragment.content) fragment.add_fragment_resources(url_name_fragment) return fragment def render_children_fragments(self, context, view='student_view'): children_fragments = [] for child in self._children: child_fragment = self._render_child_fragment(child, context, view) children_fragments.append(child_fragment) return children_fragments def get_stage_content_fragment(self, context, view='student_view'): fragment = Fragment() children_fragments = self.render_children_fragments(context, view=view) render_context = { 'stage': self, 'children_contents': [frag.content for frag in children_fragments] } for frag in children_fragments: fragment.add_fragment_resources(frag) render_context.update(context) fragment.add_content( loader.render_django_template( self.STAGE_CONTENT_TEMPLATE, render_context, i18n_service=self.i18n_service, )) if self.js_file: add_resource(self, 'javascript', self.js_file, fragment) if self.js_init: fragment.initialize_js(self.js_init) return fragment def mark_complete(self, user_id=None): user_id = user_id if user_id is not None else self.user_id self.runtime.publish(self, 'progress', {'user_id': user_id}) def get_stage_state(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) def get_dashboard_stage_state(self, target_workgroups, target_students): """ :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :return (str, dict[str, float]): Stage state and detailed stage stats as returned by `get_stage_stats` """ state_stats = self.get_stage_stats(target_workgroups, target_students) if state_stats.get(StageState.COMPLETED, 0) == 1: stage_state = StageState.COMPLETED elif state_stats.get(StageState.INCOMPLETE, 0) > 0 or state_stats.get( StageState.COMPLETED, 0) > 0: stage_state = StageState.INCOMPLETE else: stage_state = StageState.NOT_STARTED return stage_state, state_stats def get_stage_stats(self, target_workgroups, target_students): # pylint: disable=no-self-use """ Calculates stage state stats for given workgroups and students :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :return dict[str, float]: Percentage of students completed, partially completed and not started the stage as floats in range[0..1] """ target_user_ids = set(user.id for user in target_students) if not target_user_ids: return { StageState.COMPLETED: None, StageState.INCOMPLETE: None, StageState.NOT_STARTED: None } target_user_count = float(len(target_user_ids)) completed_users_ids, partially_completed_users_ids = self.get_users_completion( target_workgroups, target_students) log_format_data = dict( stage=self.display_name, target_users=target_user_ids, completed=completed_users_ids, partially_completed=partially_completed_users_ids) log.info(STAGE_STATS_LOG_TPL, log_format_data) completed_ratio = len(completed_users_ids & target_user_ids) / target_user_count partially_completed_ratio = len(partially_completed_users_ids & target_user_ids) / target_user_count return { StageState.COMPLETED: completed_ratio, StageState.INCOMPLETE: partially_completed_ratio, StageState.NOT_STARTED: 1 - completed_ratio - partially_completed_ratio } def get_users_completion(self, target_workgroups, target_users): """ Returns sets of completed user ids and partially completed user ids :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users: :rtype: (set[int], set[int]) """ raise NotImplementedError(MUST_BE_OVERRIDDEN) def get_external_group_status(self, group): # pylint: disable=unused-argument, no-self-use """ Calculates external group status for the Stage. Meaning of external status varies by Stage - see actual implementations docstrings. :param group_project_v2.project_api.dtos.WorkgroupDetails group: workgroup :rtype: StageState """ return StageState.NOT_AVAILABLE def get_external_status_label(self, status): """ Gets human-friendly label for external status. Label vary by stage, so consult with actual implementaiton for details :param StageState status: external stage status :rtype: str """ return self.EXTERNAL_STATUSES_LABEL_MAPPING.get( status, self.DEFAULT_EXTERNAL_STATUS_LABEL) def navigation_view(self, context): """ Renders stage content for navigation view :param dict context: :rtype: Fragment """ fragment = Fragment() rendering_context = { 'stage': self, 'activity_id': self.activity.id, 'stage_state': self.get_stage_state(), 'block_link': get_link_to_block(self), 'is_current_stage': self.is_current_stage(context) } rendering_context.update(context) fragment.add_content( loader.render_django_template( "templates/html/stages/navigation_view.html", rendering_context, i18n_service=self.i18n_service, )) return fragment @classmethod def make_human_stats(cls, stats): """ Readies stats dictionary for presentation, by sorting it's contents, and converting ratios to percentages. """ return OrderedDict([ (StageState.get_human_name(stage), stats[stage] * 100 if stats[stage] is not None else None) for stage in (StageState.NOT_STARTED, StageState.INCOMPLETE, StageState.COMPLETED) ]) @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_view(self, context): """ Renders stage content for dashboard view. :param dict context: :rtype: Fragment """ fragment = Fragment() target_workgroups = context.get(Constants.TARGET_WORKGROUPS) target_students = context.get(Constants.TARGET_STUDENTS) filtered_students = context.get(Constants.FILTERED_STUDENTS) students_to_display = [ student for student in target_students if student.id not in filtered_students ] state, stats = self.get_dashboard_stage_state(target_workgroups, students_to_display) human_stats = self.make_human_stats(stats) render_context = { 'stage': self, 'stats': human_stats, 'stage_state': state, 'ta_graded': self.activity.is_ta_graded } render_context.update(context) fragment.add_content( self.render_template('dashboard_view', render_context)) return fragment @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_detail_view(self, context): """ Renders stage header for dashboard details view. :param dict context: :rtype: Fragment """ fragment = Fragment() render_context = { 'stage': self, 'ta_graded': self.activity.is_ta_graded, 'download_incomplete_emails_handler_url': self.get_incomplete_emails_handler_url() } fragment.add_content( self.render_template('dashboard_detail_view', render_context)) return fragment def get_incomplete_emails_handler_url(self): base_url = self.runtime.handler_url(self.activity.project, 'download_incomplete_list') query_params = {Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME: self.id} return base_url + '?' + urlencode(query_params) def get_new_stage_state_data(self): return { "activity_id": str(self.activity.id), "stage_id": str(self.id), "state": self.get_stage_state() }
class ReviewBaseStage(BaseGroupActivityStage): NAVIGATION_LABEL = _(u'Task') visited = Boolean(default=False, scope=Scope.user_state) js_file = "public/js/stages/review_stage.js" js_init = "GroupProjectReviewStage" REVIEW_ITEM_KEY = None STAGE_ACTION = _(u"save feedback") # (has_some, has_all) -> ReviewState. have_all = True and have_some = False is obviously an error REVIEW_STATE_CONDITIONS = { (True, True): ReviewState.COMPLETED, (True, False): ReviewState.INCOMPLETE, (False, False): ReviewState.NOT_STARTED } # pretty much obvious mapping, but still it is useful to separate the two - more stage states could be theoretically # added, i.e. Open, Closed, etc. THose won't have a mapping to ReviewState STAGE_STATE_REVIEW_STATE_MAPPING = { ReviewState.COMPLETED: StageState.COMPLETED, ReviewState.INCOMPLETE: StageState.INCOMPLETE, ReviewState.NOT_STARTED: StageState.NOT_STARTED, } @property def allowed_nested_blocks(self): blocks = super(ReviewBaseStage, self).allowed_nested_blocks blocks.extend( [GradeRubricStaticContentXBlock, GroupProjectReviewQuestionXBlock]) return blocks @property def review_subjects(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) @property def questions(self): return self.get_children_by_category( GroupProjectReviewQuestionXBlock.CATEGORY) @property def required_questions(self): return [question for question in self.questions if question.required] @property def grade_questions(self): return [question for question in self.questions if question.grade] def validate(self): violations = super(ReviewBaseStage, self).validate() if not self.questions: violations.add( ValidationMessage( ValidationMessage.ERROR, messages.QUESTION_BLOCKS_ARE_MISSING.format( class_name=self.__class__.__name__, stage_title=self.display_name))) return violations def _convert_review_items_to_keys(self, review_items): empty_values = (None, '') return set( make_key(review_item[self.REVIEW_ITEM_KEY], review_item["question"]) for review_item in review_items if review_item["answer"] not in empty_values) def _make_required_keys(self, items_to_grade): return set( make_key(item_id, question.question_id) for item_id in items_to_grade for question in self.required_questions) def _calculate_review_status(self, review_subject_ids, review_items): """ Calculates review status for all reviewers listed in review_items collection :param collections.Iterable[int] review_subject_ids: Ids of review subjects (teammates or other groups) :param collections.Iterable[dict] review_items: Review feedback items :rtype: ReviewState """ required_keys = self._make_required_keys(review_subject_ids) review_item_keys = self._convert_review_items_to_keys(review_items) has_all = bool(required_keys) and review_item_keys >= required_keys has_some = bool(review_item_keys & required_keys) return self.REVIEW_STATE_CONDITIONS.get((has_some, has_all)) def get_stage_state(self): review_status = self.review_status() if not self.visited: return StageState.NOT_STARTED return self.STAGE_STATE_REVIEW_STATE_MAPPING[review_status] def _pivot_feedback(self, feedback): # pylint: disable=no-self-use """ Pivots the feedback to show question -> answer """ return {pi['question']: pi['answer'] for pi in feedback} def get_users_completion(self, target_workgroups, target_users): """ :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users: :rtype: (set[int], set[int]) """ completed_users, partially_completed_users = set(), set() for user in target_users: review_subjects_ids, review_items = self.get_review_data(user.id) review_status = self._calculate_review_status( review_subjects_ids, review_items) if review_status == ReviewState.COMPLETED: completed_users.add(user.id) elif review_status == ReviewState.INCOMPLETE: partially_completed_users.add(user.id) return completed_users, partially_completed_users def get_review_data(self, user_id): """ :param gint user_id: :rtype: (set[int], dict) """ raise NotImplementedError(MUST_BE_OVERRIDDEN) def _get_reviews_by_user(self, review_items, user_id): return [ item for item in review_items if self.real_user_id(item['reviewer']) == user_id ] @XBlock.json_handler @groupwork_protected_handler @key_error_protected_handler @conversion_protected_handler def submit_review(self, submissions, _context=''): # if admin grader - still allow providing grades even for non-TA-graded activities if self.is_admin_grader and not self.allow_admin_grader_access: return {'result': 'error', 'msg': messages.TA_GRADING_NOT_ALLOWED} if not self.available_now: reason = messages.STAGE_NOT_OPEN_TEMPLATE if not self.is_open else messages.STAGE_CLOSED_TEMPLATE return { 'result': 'error', 'msg': reason.format(action=self.STAGE_ACTION) } try: self.do_submit_review(submissions) if self.can_mark_complete and self.review_status( ) == ReviewState.COMPLETED: self.mark_complete() except ApiError as exception: log.exception(exception.message) return {'result': 'error', 'msg': exception.message} return { 'result': 'success', 'msg': messages.FEEDBACK_SAVED_MESSAGE, 'new_stage_states': [self.get_new_stage_state_data()] } def do_submit_review(self, submissions): raise NotImplementedError(MUST_BE_OVERRIDDEN) def student_view(self, context): if self.can_mark_complete: self.visited = True return super(ReviewBaseStage, self).student_view(context)
def display_name_with_default(self): if self.question: return _(u'Review Assessment for question "{question_title}"').format(question_title=self.question.title) else: return _(u"Review Assessment")
class GroupActivityXBlock(CommonMixinCollection, DashboardXBlockMixin, XBlockWithPreviewMixin, ActivityNotificationsMixin, XBlock): """ XBlock providing a group activity project for a group of students to collaborate upon """ display_name = String( display_name=_(u"Display Name"), help= _(u"This name appears in the horizontal navigation at the top of the page." ), scope=Scope.settings, default=_(u"Group Project Activity")) weight = Float( display_name=_(u"Weight"), help= _(u"This is the maximum score that the user receives when he/she successfully completes the problem." ), scope=Scope.settings, default=100.0) group_reviews_required_count = Integer( display_name=_(u"Reviews Required Minimum"), help=_( u"The minimum number of group-reviews that should be applied to a set of submissions " u"(set to 0 to be 'TA Graded')"), scope=Scope.settings, default=3) user_review_count = Integer( display_name=_(u"User Reviews Required Minimum"), help= _(u"The minimum number of other-group reviews that an individual user should perform" ), scope=Scope.settings, default=1) due_date = DateTime(display_name=_(u"Due date"), help=_(u"Activity due date"), has_score=Scope.settings, default=None) CATEGORY = "gp-v2-activity" STUDIO_LABEL = _(u"Group Project Activity") editable_fields = ("display_name", "weight", "group_reviews_required_count", "user_review_count", "due_date") has_score = True has_children = True template_location = 'activity' DASHBOARD_DETAILS_URL_KEY = 'dashboard_details_url' DEFAULT_DASHBOARD_DETAILS_URL_TPL = "/dashboard_details_view?activate_block_id={activity_id}" TA_REVIEW_URL_KEY = 'ta_review_url' DEFAULT_TA_REVIEW_URL_TPL = "ta_grading=true&activate_block_id={activate_block_id}&group_id={group_id}" @property def id(self): return self.scope_ids.usage_id def max_score(self): """ Used for grading purposes: * As max grade for submitting grade event. See :method:`assign_grade_to_group` * As theoretical max score for grade calculation when grade is not yet available :rtype: Float """ return self.weight @property def project(self): return self.get_parent() @property def content_id(self): return get_block_content_id(self) @property def is_ta_graded(self): return self.group_reviews_required_count == 0 @property def allowed_nested_blocks(self): # pylint: disable=no-self-use return [ BasicStage, CompletionStage, SubmissionStage, TeamEvaluationStage, PeerReviewStage, EvaluationDisplayStage, GradeDisplayStage ] @property def stages(self): return self._children @property def available_stages(self): for stage in self.stages: if stage.available_to_current_user: yield stage @property def default_stage(self): def_stage = get_default_stage(self.available_stages) if def_stage: return def_stage else: return self.stages[0] if self.stages else None @property def questions(self): return list(self._chain_questions(self.stages, 'questions')) @property def grade_questions(self): return list(self._chain_questions(self.stages, 'grade_questions')) @property def team_evaluation_questions(self): stages = self.get_children_by_category(TeamEvaluationStage.CATEGORY) return list(self._chain_questions(stages, 'questions')) @property def peer_review_questions(self): stages = self.get_children_by_category(PeerReviewStage.CATEGORY) return list(self._chain_questions(stages, 'questions')) def dashboard_details_url(self): """ Gets dashboard details view URL for current activity. If settings service is not available or does not provide URL template, default template is used. """ template = self._get_setting(self.DASHBOARD_DETAILS_URL_KEY, self.DEFAULT_DASHBOARD_DETAILS_URL_TPL) return template.format(program_id=self.user_preferences.get( self.DASHBOARD_PROGRAM_ID_KEY, None), course_id=self.course_id, project_id=self.project.scope_ids.usage_id, activity_id=self.id) def get_ta_review_link(self, group_id, target_block_id=None): target_block_id = target_block_id if target_block_id else self.id template = self._get_setting(self.TA_REVIEW_URL_KEY, self.DEFAULT_TA_REVIEW_URL_TPL) return template.format(course_id=self.course_id, group_id=group_id, activate_block_id=target_block_id) @staticmethod def _chain_questions(stages, question_type): return itertools.chain.from_iterable( getattr(stage, question_type, ()) for stage in stages) def get_stage_to_display(self, target_block_id): try: if target_block_id: stage = self.runtime.get_block(target_block_id) if self.get_child_category( stage ) in STAGE_TYPES and stage.available_to_current_user: return stage except (InvalidKeyError, KeyError, NoSuchUsage) as exc: log.exception(exc) return self.default_stage @groupwork_protected_view def student_view(self, context): """ Player view, displayed to the student """ fragment = Fragment() current_stage_id = context.get( Constants.CURRENT_STAGE_ID_PARAMETER_NAME, None) target_stage = self.get_stage_to_display(current_stage_id) if not target_stage: fragment.add_content(messages.NO_STAGES) else: stage_fragment = target_stage.render('student_view', context) fragment.add_frag_resources(stage_fragment) render_context = { 'activity': self, 'stage_content': stage_fragment.content, } render_context.update(context) fragment.add_content( self.render_template('student_view', render_context)) return fragment @groupwork_protected_view def navigation_view(self, context): fragment = Fragment() children_context = {} children_context.update(context) stage_fragments = self._render_children('navigation_view', children_context, self.available_stages) stage_contents = [frag.content for frag in stage_fragments] fragment.add_frags_resources(stage_fragments) render_context = {'activity': self, 'stage_contents': stage_contents} fragment.add_content( self.render_template('navigation_view', render_context)) return fragment @groupwork_protected_view def resources_view(self, context): fragment = Fragment() resources = [ resource for stage in self.stages for resource in stage.resources ] has_resources = bool(resources) resource_fragments = self._render_children('resources_view', context, resources) resource_contents = [frag.content for frag in resource_fragments] fragment.add_frags_resources(resource_fragments) render_context = { 'activity': self, 'resource_contents': resource_contents, 'has_resources': has_resources } fragment.add_content( self.render_template('resources_view', render_context)) return fragment @groupwork_protected_view def submissions_view(self, context): fragment = Fragment() submissions = [ submission for stage in self.stages if isinstance(stage, SubmissionStage) for submission in stage.submissions ] has_submissions = bool(submissions) submission_fragments = self._render_children('submissions_view', context, submissions) submission_contents = [frag.content for frag in submission_fragments] fragment.add_frags_resources(submission_fragments) render_context = { 'activity': self, 'submission_contents': submission_contents, 'has_submissions': has_submissions } fragment.add_content( self.render_template('submissions_view', render_context)) return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_view(self, context): fragment = Fragment() children_context = context.copy() stage_fragments = self._render_children('dashboard_view', children_context, self.stages) stage_contents = [frag.content for frag in stage_fragments] fragment.add_frags_resources(stage_fragments) render_context = {'activity': self, 'stage_contents': stage_contents} fragment.add_content( self.render_template('dashboard_view', render_context)) return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_detail_view(self, context): fragment = Fragment() children_context = context.copy() target_workgroups = context.get(Constants.TARGET_WORKGROUPS) target_users = context.get(Constants.TARGET_STUDENTS) filtered_users = children_context[Constants.FILTERED_STUDENTS] stages = [] stage_stats = {} for stage in self.stages: if not stage.is_graded_stage: continue stage_fragment = stage.render('dashboard_detail_view', children_context) stage_fragment.add_frag_resources(fragment) stages.append({"id": stage.id, 'content': stage_fragment.content}) stage_stats[stage.id] = self._get_stage_completion_details( stage, target_workgroups, target_users) groups_data = self._build_groups_data(target_workgroups, stage_stats, filtered_users) visible_groups = [ group for group in groups_data if group["group_visible"] ] render_context = { 'activity': self, 'StageState': StageState, 'stages': stages, 'stages_count': len(stages), 'groups': visible_groups, 'filtered_out_workgroups': len(groups_data) - len(visible_groups), 'stage_cell_width_percent': (100 - 30) / float(len(stages)), # 30% is reserved for first column 'assigned_to_groups_label': messages.ASSIGNED_TO_GROUPS_LABEL.format( group_count=len(groups_data)) } fragment.add_content( self.render_template('dashboard_detail_view', render_context)) return fragment def _render_user(self, user, stage_stats, filtered_students): """ :param group_project_v2.project_api.dtos.ReducedUserDetail user: :param dict[str, StageCompletionDetailsData] stage_stats: Stage completion statistics :param set[int] filtered_students: users filtered out from view :return: dict """ return { 'id': user.id, 'full_name': user.full_name, 'email': user.email, 'is_filtered_out': user.id in filtered_students, 'stage_states': { stage_id: stage_data.user_stats.get(user.id, StageState.UNKNOWN) for stage_id, stage_data in stage_stats.iteritems() }, 'groups_to_grade': { stage_id: [{ 'id': group.id, 'ta_grade_link': self.get_ta_review_link(group.id, stage_id) } for group in stage_data.groups_to_grade.get(user.id, [])] for stage_id, stage_data in stage_stats.iteritems() } } def _render_workgroup(self, workgroup, stage_stats, filtered_students): """ :param group_project_v2.project_api.dtos.WorkgroupDetails workgroup: :param dict[str, StageCompletionDetailsData] stage_stats: Stage completion statistics :param set[int] filtered_students: users filtered out from view :return: dict """ users = [ self._render_user(user, stage_stats, filtered_students) for user in workgroup.users ] users.sort(key=itemgetter('is_filtered_out')) group_visible = any((not user['is_filtered_out'] for user in users)) return { 'id': workgroup.id, 'ta_grade_link': self.get_ta_review_link(workgroup.id), 'group_visible': group_visible, 'stage_states': { stage_id: { 'internal_status': stage_data.internal_group_status.get( workgroup.id, StageState.UNKNOWN), 'external_status': stage_data.external_group_status.get( workgroup.id, StageState.NOT_AVAILABLE), 'external_status_label': stage_data.external_group_status_label.get( workgroup.id, ""), } for stage_id, stage_data in stage_stats.iteritems() }, 'users': users } def _build_groups_data(self, workgroups, stage_stats, filtered_users): """ Converts WorkgroupDetails into dict expected by dashboard_detail_view template. :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] workgroups: Workgroups :param dict[str, StageCompletionDetailsData] stage_stats: Stage statistics - group-wise and user-wise completion data and groups_to_review. :param set[int] filtered_users: users filtered out from view - depending on actual view (dashboard or dashboard details) such students are either completely excluded, or included but diplayed differently :rtype: list[dict] :returns: List of dictionaries with the following format: * id - Group ID * stage_states - dictionary stage_id -> StateState * users - dictionary with the following format: * id - User ID * full_name - User full name * email - user email * stage_states - dictionary stage_id -> StageState * groups_to_grade - dictionary stage_id -> list of groups to grade """ return [ self._render_workgroup(workgroup, stage_stats, filtered_users) for workgroup in workgroups ] @classmethod def _get_stage_completion_details(cls, stage, target_workgroups, target_students): """ Gets stage completion stats from individual stage :param group_project_v2.stage.BaseGroupActivityStage stage: Get stage stats from this stage :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups: :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_students: :rtype: StageCompletionDetailsData :returns: Stage completion stats """ completed_users, partially_completed_users = stage.get_users_completion( target_workgroups, target_students) user_stats = {} groups_to_grade = {} for user in target_students: state = StageState.NOT_STARTED if user.id in completed_users: state = StageState.COMPLETED elif user.id in partially_completed_users: state = StageState.INCOMPLETE user_stats[user.id] = state if isinstance(stage, PeerReviewStage): groups_to_grade[user.id] = stage.get_review_subjects(user.id) external_group_status, external_group_status_label, internal_group_status = cls._get_group_statuses( stage, target_workgroups, user_stats) return StageCompletionDetailsData( internal_group_status=internal_group_status, external_group_status=external_group_status, external_group_status_label=external_group_status_label, user_stats=user_stats, groups_to_grade=groups_to_grade) @classmethod def _get_group_statuses(cls, stage, target_workgroups, user_stats): internal_group_status, external_group_status, external_group_status_label = {}, {}, {} for group in target_workgroups: user_completions = [ user_stats.get(user.id, StageState.UNKNOWN) for user in group.users ] student_review_state = StageState.NOT_STARTED if all(completion == StageState.COMPLETED for completion in user_completions): student_review_state = StageState.COMPLETED elif any(completion != StageState.NOT_STARTED for completion in user_completions): student_review_state = StageState.INCOMPLETE elif any(completion == StageState.UNKNOWN for completion in user_completions): student_review_state = StageState.UNKNOWN internal_group_status[group.id] = student_review_state external_status = stage.get_external_group_status(group) external_group_status[group.id] = external_status external_group_status_label[ group.id] = stage.get_external_status_label(external_status) return external_group_status, external_group_status_label, internal_group_status def mark_complete(self, user_id): self.runtime.publish(self, 'progress', {'user_id': user_id}) def validate_field_data(self, validation, data): super(GroupActivityXBlock, self).validate_field_data(validation, data) should_be_ints = ('weight', 'group_reviews_required_count', 'user_review_count') for field_name in should_be_ints: try: int(getattr(data, field_name)) except (TypeError, ValueError): message = _( u"{field_name} must be integer, {field_value} given" ).format(field_name=field_name, field_value=getattr(data, field_name)) validation.add( ValidationMessage(ValidationMessage.ERROR, message)) def calculate_and_send_grade(self, group_id): grade_value = self.calculate_grade(group_id) if grade_value: self.assign_grade_to_group(group_id, grade_value) workgroup = self.project_api.get_workgroup_by_id(group_id) for user in workgroup.users: self.mark_complete(user.id) def assign_grade_to_group(self, group_id, grade_value): """ Assigns grade to group, fires required events and notifications :param int group_id: Group ID :param float grade_value: Grade to assign :return: """ self.project_api.set_group_grade(group_id, self.course_id, self.content_id, grade_value, self.max_score()) # Emit analytics event... self.runtime.publish( self, "group_activity.final_grade", { "grade_value": grade_value, "group_id": group_id, "content_id": self.content_id, }) notifications_service = self.runtime.service(self, 'notifications') if notifications_service: self.fire_grades_posted_notification(group_id, notifications_service) def calculate_grade(self, group_id): # pylint:disable=too-many-locals,too-many-branches review_item_data = self.project_api.get_workgroup_review_items_for_group( group_id, self.content_id) review_item_map = { make_key(review_item['question'], self.real_user_id(review_item['reviewer'])): review_item['answer'] for review_item in review_item_data } all_reviewer_ids = set([ self.real_user_id(review_item['reviewer']) for review_item in review_item_data ]) group_reviewer_ids = [ user["id"] for user in self.project_api.get_workgroup_reviewers( group_id, self.content_id) ] admin_reviewer_ids = [ reviewer_id for reviewer_id in all_reviewer_ids if reviewer_id not in group_reviewer_ids ] def get_user_grade_value_list(user_id): user_grades = [] for question in self.grade_questions: user_value = review_item_map.get( make_key(question.question_id, user_id), None) if user_value is None: # if any are incomplete, we consider the whole set to be unusable return None else: user_grades.append(user_value) return user_grades admin_provided_grades = None if len(admin_reviewer_ids) > 0: admin_provided_grades = [] # Only include complete admin gradesets admin_reviewer_grades = [ arg for arg in [ get_user_grade_value_list(admin_id) for admin_id in admin_reviewer_ids ] if arg ] admin_grader_count = len(admin_reviewer_grades) if admin_grader_count > 1: for idx in range(len(self.grade_questions)): admin_provided_grades.append( mean([adm[idx] for adm in admin_reviewer_grades])) elif admin_grader_count > 0: # which actually means admin_grader_count == 1 admin_provided_grades = admin_reviewer_grades[0] user_grades = {} if len(group_reviewer_ids) > 0: for reviewer_id in group_reviewer_ids: this_reviewers_grades = get_user_grade_value_list(reviewer_id) if this_reviewers_grades is None: if admin_provided_grades: this_reviewers_grades = admin_provided_grades else: return None user_grades[reviewer_id] = this_reviewers_grades elif admin_provided_grades: group_reviewer_ids = [self.user_id] user_grades[self.user_id] = admin_provided_grades else: return None # Okay, if we've got here we have a complete set of marks to calculate the grade reviewer_grades = [ mean(user_grades[reviewer_id]) for reviewer_id in group_reviewer_ids if len(user_grades[reviewer_id]) > 0 ] group_grade = round( mean(reviewer_grades)) if len(reviewer_grades) > 0 else None return group_grade
def url_name_caption(self): return _(u"url_name to link to this {project_navigator_view}:").format( project_navigator_view=self.display_name_with_default)
class GroupProjectVideoResourceXBlock(BaseGroupProjectResourceXBlock): CATEGORY = "gp-v2-video-resource" STUDIO_LABEL = _(u"Video Resource") PROJECT_NAVIGATOR_VIEW_TEMPLATE = 'templates/html/components/video_resource.html' video_id = String( display_name=_(u"Ooyala/Brightcove content ID"), help=_(u"This is the Ooyala/Brightcove Content Identifier"), default="Q1eXg5NzpKqUUzBm5WTIb6bXuiWHrRMi", scope=Scope.content, ) editable_fields = ('display_name', 'description', 'video_id') @classmethod def is_available(cls): return True # TODO: restore conditional availability when switched to use actual Ooyala XBlock @classmethod def brightcove_account_id(cls): """ Gets bcove account id from settings """ xblock_settings = settings.XBLOCK_SETTINGS if hasattr( settings, "XBLOCK_SETTINGS") else {} return xblock_settings.get('OoyalaPlayerBlock', {}).get('BCOVE_ACCOUNT_ID') @property def video_type(self): """ Checks if video_id belongs to Brightcove or Ooyala """ try: # Brightcove IDs are numeric int(self.video_id) return 'brightcove' except (ValueError, TypeError): return 'ooyala' def resources_view(self, context): render_context = { 'video_id': self.video_id, 'player_type': self.video_type, 'bc_account_id': self.brightcove_account_id(), } render_context.update(context) fragment = super(GroupProjectVideoResourceXBlock, self).resources_view(render_context) fragment.add_javascript_url( url='//players.brightcove.net/{}/default_default/index.min.js'. format(self.brightcove_account_id())) return fragment def author_view(self, context): return self.resources_view(context) def validate_field_data(self, validation, data): if not data.video_id: validation.add( ValidationMessage(ValidationMessage.ERROR, self._(messages.MUST_CONTAIN_CONTENT_ID))) return validation
class GroupProjectBaseFeedbackDisplayXBlock(BaseStageComponentXBlock, StudioEditableXBlockMixin, XBlockWithPreviewMixin, WorkgroupAwareXBlockMixin): DEFAULT_QUESTION_ID_VALUE = None NO_QUESTION_SELECTED = _(u"No question selected") QUESTION_NOT_FOUND = _(u"Selected question not found") QUESTION_ID_IS_NOT_UNIQUE = _(u"Question ID is not unique") question_id = String(display_name=_(u"Question ID"), help=_(u"Question to be assessed"), scope=Scope.content, default=DEFAULT_QUESTION_ID_VALUE) show_mean = Boolean( display_name=_(u"Show Mean Value"), help= _(u"If True, converts review answers to numbers and calculates mean value" ), default=False, scope=Scope.content) editable_fields = ("question_id", "show_mean") has_author_view = True @property def activity_questions(self): raise NotImplementedError(MUST_BE_OVERRIDDEN) @property def display_name_with_default(self): if self.question: return _(u'Review Assessment for question "{question_title}"' ).format(question_title=self.question.title) else: return _(u"Review Assessment") @lazy def question(self): matching_questions = [ question for question in self.activity_questions if question.question_id == self.question_id ] if len(matching_questions) > 1: raise ValueError(self.QUESTION_ID_IS_NOT_UNIQUE) if not matching_questions: return None return matching_questions[0] @groupwork_protected_view def student_view(self, context): if self.question is None: return Fragment(messages.COMPONENT_MISCONFIGURED) raw_feedback = self.get_feedback() feedback = [] for item in raw_feedback: feedback.append(html.escape(item['answer'])) fragment = Fragment() title = self.question.assessment_title if self.question.assessment_title else self.question.title render_context = { 'assessment': self, 'question_title': title, 'feedback': feedback } if self.show_mean: try: render_context['mean'] = "{0:.1f}".format(mean(feedback)) except ValueError as exc: log.warn(exc) render_context['mean'] = _(u"N/A") render_context.update(context) fragment.add_content( loader.render_template( "templates/html/components/review_assessment.html", render_context)) return fragment def validate(self): validation = super(GroupProjectBaseFeedbackDisplayXBlock, self).validate() if not self.question_id: validation.add( ValidationMessage(ValidationMessage.ERROR, self.NO_QUESTION_SELECTED)) if self.question_id and self.question is None: validation.add( ValidationMessage(ValidationMessage.ERROR, self.QUESTION_NOT_FOUND)) return validation def author_view(self, context): if self.question: return self.student_view(context) fragment = Fragment() fragment.add_content(messages.QUESTION_NOT_SELECTED) return fragment def studio_view(self, context): # can't use values_provider as we need it to be bound to current block instance with FieldValuesContextManager(self, 'question_id', self.question_ids_values_provider): return super(GroupProjectBaseFeedbackDisplayXBlock, self).studio_view(context) def question_ids_values_provider(self): not_selected = { "display_name": _(u"--- Not selected ---"), "value": self.DEFAULT_QUESTION_ID_VALUE } question_values = [{ "display_name": question.title, "value": question.question_id } for question in self.activity_questions] return [not_selected] + question_values
from group_project_v2.utils import _ class StageState(object): NOT_STARTED = 'not_started' INCOMPLETE = 'incomplete' COMPLETED = 'completed' UNKNOWN = 'unknown' NOT_AVAILABLE = 'na' HUMAN_NAMES_MAP = { NOT_STARTED: _("Not started"), INCOMPLETE: _("Partially complete"), COMPLETED: _("Complete") } @classmethod def get_human_name(cls, state): return cls.HUMAN_NAMES_MAP.get(state) class ReviewState(object): NOT_STARTED = 'not_started' INCOMPLETE = 'incomplete' COMPLETED = 'completed' DISPLAY_NAME_NAME = _(u"Display Name") DISPLAY_NAME_HELP = _(U"This is a name of the stage")
def display_name_with_default(self): if self.question: return _(u'Review Assessment for question "{question_title}"' ).format(question_title=self.question.title) else: return _(u"Review Assessment")
class GroupProjectNavigatorXBlock(ChildrenNavigationXBlockMixin, XBlockWithComponentsMixin, XBlockWithPreviewMixin, NoStudioEditableSettingsMixin, StudioContainerXBlockMixin, CompletionMixin, XBlock, I18NService): """ XBlock that provides basic layout and switching between children XBlocks (views) Should only be added as a child to GroupProjectXBlock """ CATEGORY = "gp-v2-navigator" STUDIO_LABEL = _(u"Group Project Navigator") INITIAL_VIEW = ViewTypes.NAVIGATION display_name_with_default = _(u"Group Project Navigator") editable = False has_score = False has_children = True @lazy def group_project(self): """ Reference to parent XBlock """ return self.get_parent() @property def allowed_nested_blocks(self): # pylint: disable=no-self-use return [ NestedXBlockSpec(NavigationViewXBlock, single_instance=True), NestedXBlockSpec(ResourcesViewXBlock, single_instance=True), NestedXBlockSpec(SubmissionsViewXBlock, single_instance=True), NestedXBlockSpec(AskTAViewXBlock, single_instance=True), NestedXBlockSpec(PrivateDiscussionViewXBlock, single_instance=True), ] def _get_activated_view_type(self, target_block_id): try: if target_block_id: block = self.runtime.get_block(target_block_id) if self.get_child_category( block) in PROJECT_NAVIGATOR_VIEW_TYPES: return block.type except (InvalidKeyError, KeyError, NoSuchUsage) as exc: log.exception(exc) return ViewTypes.NAVIGATION def _sorted_child_views(self): all_views = [] for child_id in self.children: view = self.runtime.get_block(child_id) if view.available_to_current_user and view.is_view_available: all_views.append(view) all_views.sort(key=lambda view_instance: view_instance.SORT_ORDER) return all_views @staticmethod def resource_string(path): """Handy helper for getting resources.""" data = pkg_resources.resource_string(__name__, path) return data.decode("utf8") def get_translation_content(self): """ Returns JS content containing translations for user's language. """ try: return self.resource_string( 'public/js/translations/{lang}/textjs.js'.format( lang=utils.translation.to_locale( utils.translation.get_language()), )) except IOError: return self.resource_string('public/js/translations/en/textjs.js') def student_view(self, context): """ Student view """ fragment = Fragment() children_items = [] for view in self._sorted_child_views(): item = { 'id': str(view.scope_ids.usage_id).replace("/", ";_"), 'type': view.type, } if not view.skip_content: child_fragment = view.render('student_view', context) item['content'] = child_fragment.content fragment.add_fragment_resources(child_fragment) else: item['content'] = '' if not view.skip_selector: child_selector_fragment = view.render('selector_view', context) item['selector'] = child_selector_fragment.content fragment.add_fragment_resources(child_selector_fragment) else: item['selector'] = '' children_items.append(item) activate_block_id = self.get_block_id_from_string( context.get('activate_block_id', None)) js_parameters = { 'selected_view': self._get_activated_view_type(activate_block_id) } fragment.add_content( loader.render_django_template( 'templates/html/project_navigator/project_navigator.html', {'children': children_items}, i18n_service=self.i18n_service, )) add_resource(self, 'css', 'public/css/project_navigator/project_navigator.css', fragment) add_resource(self, 'javascript', 'public/js/project_navigator/project_navigator.js', fragment) fragment.add_javascript(self.get_translation_content()) fragment.initialize_js("GroupProjectNavigatorBlock", js_parameters) return fragment def author_preview_view(self, context): fragment = Fragment() children_contents = [] for child in self._children: child_fragment = child.render('preview_view', context) fragment.add_fragment_resources(child_fragment) children_contents.append(child_fragment.content) fragment.add_content( loader.render_django_template( "templates/html/project_navigator/project_navigator_author_view.html", { 'navigator': self, 'children_contents': children_contents }, i18n_service=self.i18n_service, )) add_resource(self, 'css', 'public/css/project_navigator/project_navigator.css', fragment) return fragment def validate(self): validation = super(GroupProjectNavigatorXBlock, self).validate() if not self.has_child_of_category(NavigationViewXBlock.CATEGORY): validation.add( ValidationMessage( ValidationMessage.ERROR, self._(messages.MUST_CONTAIN_NAVIGATION_VIEW))) return validation
class GroupProjectXBlock(CommonMixinCollection, DashboardXBlockMixin, DashboardRootXBlockMixin, XBlock): display_name = String(display_name=_(u"Display Name"), help=_(u"This is a name of the project"), scope=Scope.settings, default=_(u"Group Project V2")) CATEGORY = "gp-v2-project" REPORT_FILENAME = "group_project_{group_project_name}_stage_{stage_name}_incomplete_report_{timestamp}.csv" CSV_HEADERS = ['Name', 'Username', 'Email'] CSV_TIMESTAMP_FORMAT = "%Y_%m_%d_%H_%M_%S" editable_fields = ('display_name', ) has_score = False has_children = True template_location = "project" @staticmethod def _sanitize_context(context): """ Context passed into views is :class:QueryDict instance, which has a particular feature of supporting multiple values for the same key. It is achieved at a cost of always using lists even for singular values. When `get` is invoked for the singular key it detects there's only one value and returns value, not the list itself The caveat is that if it is used with dict(context) or update(context), those singular values are returned as lists. This breaks the approach of passing context as intact as possible to children by making a copy and adding necessary children data. So, this method serves as a filter and converter for context coming from LMS. """ if not context: return {} raw_client_id = context.get( Constants.CURRENT_CLIENT_FILTER_ID_PARAMETER_NAME) client_id = None if raw_client_id is not None and raw_client_id.strip(): client_id = int(raw_client_id) return { Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME: context.get(Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME, None), Constants.CURRENT_CLIENT_FILTER_ID_PARAMETER_NAME: client_id } @property def allowed_nested_blocks(self): # pylint: disable=no-self-use return [ NestedXBlockSpec(GroupActivityXBlock), NestedXBlockSpec(GroupProjectNavigatorXBlock, single_instance=True), NestedXBlockSpec(DiscussionXBlockShim, single_instance=True) ] @property def content_id(self): return get_block_content_id(self) @lazy def activities(self): all_children = [ self.runtime.get_block(child_id) for child_id in self.children ] return [ child for child in all_children if isinstance(child, GroupActivityXBlock) ] @lazy def navigator(self): return self.get_child_of_category(GroupProjectNavigatorXBlock.CATEGORY) @property def default_stage(self): default_stages = [ activity.default_stage for activity in self.activities ] return get_default_stage(default_stages) @staticmethod def _render_child_fragment_with_fallback(child, context, fallback_message, view='student_view'): if child: log.debug("Rendering {child} with context: {context}".format( child=child.__class__.__name__, context=context, )) return child.render(view, context) else: return Fragment(fallback_message) @groupwork_protected_view def student_view(self, context): ctx = self._sanitize_context(context) fragment = Fragment() render_context = { 'project': self, 'course_id': self.course_id, 'group_id': self.workgroup.id } render_context.update(context) def render_child_fragment(child, content_key, fallback_message, extra_context=None): """ Renders child, appends child fragment resources to parent fragment and updates parent's rendering context """ internal_context = dict(ctx) if extra_context: internal_context.update(extra_context) child_fragment = self._render_child_fragment_with_fallback( child, internal_context, fallback_message, 'student_view') fragment.add_frag_resources(child_fragment) render_context[content_key] = child_fragment.content target_block_id = self.get_block_id_from_string( ctx.get(Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME, None)) target_stage = self.get_stage_to_display(target_block_id) child_context = {} if target_stage: child_context[ Constants.CURRENT_STAGE_ID_PARAMETER_NAME] = target_stage.id # activity should be rendered first, as some stages might report completion in student-view - this way stage # PN sees updated state. target_activity = target_stage.activity if target_stage else None render_child_fragment(target_activity, 'activity_content', messages.NO_ACTIVITIES, child_context) # TODO: project nav is slow, mostly due to navigation view. It might make sense to rework it into # asynchronously loading navigation and stage states. project_navigator = self.get_child_of_category( GroupProjectNavigatorXBlock.CATEGORY) render_child_fragment(project_navigator, 'project_navigator_content', messages.NO_PROJECT_NAVIGATOR, child_context) discussion = self.get_child_of_category(DiscussionXBlockShim.CATEGORY) render_child_fragment(discussion, 'discussion_content', messages.NO_DISCUSSION, child_context) fragment.add_content( self.render_template('student_view', render_context)) add_resource(self, 'css', 'public/css/group_project.css', fragment) add_resource(self, 'css', 'public/css/group_project_common.css', fragment) add_resource(self, 'css', 'public/css/vendor/font-awesome/font-awesome.css', fragment, via_url=True) add_resource(self, 'javascript', 'public/js/group_project.js', fragment) add_resource(self, 'javascript', 'public/js/group_project_common.js', fragment) fragment.initialize_js("GroupProjectBlock") return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_view(self, context): fragment = Fragment() children_context = self._sanitize_context(context) self._add_students_and_workgroups_to_context(children_context) activity_fragments = self._render_children('dashboard_view', children_context, self.activities) activity_contents = [frag.content for frag in activity_fragments] fragment.add_frags_resources(activity_fragments) render_context = { 'project': self, 'activity_contents': activity_contents } fragment.add_content( self.render_template('dashboard_view', render_context)) add_resource(self, 'css', 'public/css/group_project_common.css', fragment) add_resource(self, 'css', 'public/css/group_project_dashboard.css', fragment) add_resource(self, 'css', 'public/css/vendor/font-awesome/font-awesome.css', fragment, via_url=True) return fragment @groupwork_protected_view @AuthXBlockMixin.check_dashboard_access_for_current_user def dashboard_detail_view(self, context): ctx = self._sanitize_context(context) self._add_students_and_workgroups_to_context(ctx) fragment = Fragment() render_context = { 'project': self, 'course_id': self.course_id, 'group_id': self.workgroup.id } render_context.update(ctx) target_block_id = self.get_block_id_from_string( ctx.get(Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME, None)) target_activity = self._get_target_block(target_block_id) if target_activity is None and self.activities: target_activity = self.activities[0] activity_fragment = self._render_child_fragment_with_fallback( target_activity, ctx, messages.NO_ACTIVITIES, view='dashboard_detail_view') render_context['activity_content'] = activity_fragment.content fragment.add_frag_resources(activity_fragment) fragment.add_content( self.render_template('dashboard_detail_view', render_context)) add_resource(self, 'css', 'public/css/group_project_common.css', fragment) add_resource(self, 'css', 'public/css/group_project_dashboard.css', fragment) add_resource(self, 'css', 'public/css/vendor/font-awesome/font-awesome.css', fragment, via_url=True) add_resource(self, 'javascript', 'public/js/group_project_dashboard_detail.js', fragment) fragment.initialize_js('GroupProjectBlockDashboardDetailsView') return fragment @XBlock.handler def download_incomplete_list(self, request, _suffix=''): target_stage_id = self.get_block_id_from_string( request.GET.get(Constants.ACTIVATE_BLOCK_ID_PARAMETER_NAME)) target_stage = self._get_target_block(target_stage_id) if target_stage is None: return webob.response.Response( u"Stage {stage_id} not found".format(stage_id=target_stage_id), status=404) workgroups, users = self.get_workgroups_and_students() completed, _partially_completed = target_stage.get_users_completion( workgroups, users) users_to_export = [user for user in users if user.id not in completed] filename = self.REPORT_FILENAME.format( group_project_name=self.display_name, stage_name=target_stage.display_name, timestamp=datetime.utcnow().strftime(self.CSV_TIMESTAMP_FORMAT)) return self.export_users(users_to_export, filename) @classmethod def export_users(cls, users_to_export, filename): response = webob.response.Response(charset='UTF-8', content_type="text/csv") response.headers[ 'Content-Disposition'] = 'attachment; filename="{filename}"'.format( filename=filename) user_data = [[user.full_name, user.username, user.email] for user in users_to_export] export_to_csv(user_data, response, headers=cls.CSV_HEADERS) return response def validate(self): validation = super(GroupProjectXBlock, self).validate() if not self.has_child_of_category( GroupProjectNavigatorXBlock.CATEGORY): validation.add( ValidationMessage( ValidationMessage.ERROR, messages.MUST_CONTAIN_PROJECT_NAVIGATOR_BLOCK)) return validation def _get_target_block(self, target_block_id): try: if target_block_id: return self.runtime.get_block(target_block_id) except (InvalidKeyError, KeyError, NoSuchUsage) as exc: log.exception(exc) return None def get_stage_to_display(self, target_block_id): target_block = self._get_target_block(target_block_id) if target_block is not None: if self.get_child_category( target_block ) in STAGE_TYPES and target_block.available_to_current_user: return target_block if isinstance(target_block, GroupActivityXBlock): return target_block.default_stage default_stage = self.default_stage if default_stage: return default_stage if self.activities: return self.activities[0].get_stage_to_display(target_block_id) return None # if there are no activities there's no stages as well - nothing we can really do
from group_project_v2.utils import gettext as _ # Generic messages UNKNOWN_ERROR = _(u"Unknown error.") COMPONENT_MISCONFIGURED = _( u"This component is misconfigured and can't be displayed. It needs to be fixed by the course authors." ) STAGE_NOT_OPEN_TEMPLATE = _(u"Can't {action} as stage is not yet opened.") STAGE_CLOSED_TEMPLATE = _(u"Can't {action} as stage is closed.") USER_NOT_ACCESS_DASHBOARD = _(u"User can't access dashboard") USER_NOT_ACCESS_GROUPWORK = _(u"User can't access this group work") # Group Project XBlock messages NO_ACTIVITIES = _(u"This Group Project does not contain any activities.") NO_PROJECT_NAVIGATOR = _( u"This Group Project V2 does not contain Project Navigator - please edit course outline " u"in Studio to include one.") NO_DISCUSSION = _(u"This Group Project V2 does not contain a discussion.") MUST_CONTAIN_PROJECT_NAVIGATOR_BLOCK = _( u"Group Project must contain Project Navigator Block.") # Group Activity XBlock messages NO_STAGES = _(u"This Group Project Activity does not contain any stages.") SHOULD_BE_INTEGER = _(u"{field_name} must be integer, {field_value} given.") ASSIGNED_TO_GROUPS_LABEL = _( u"This project is assigned to {group_count} group(s)" ) # no full stop (period) by design # Project Navigator messages MUST_CONTAIN_NAVIGATION_VIEW = _( u"Project Navigator must contain Navigation view.")
class TeamEvaluationStage(ReviewBaseStage): display_name = String(display_name=DISPLAY_NAME_NAME, help=DISPLAY_NAME_HELP, scope=Scope.content, default=_(u"Team Evaluation Stage")) CATEGORY = 'gp-v2-stage-team-evaluation' STAGE_CONTENT_TEMPLATE = 'templates/html/stages/team_evaluation.html' STUDIO_LABEL = _(u"Team Evaluation") REVIEW_ITEM_KEY = "user" @lazy def review_subjects(self): return [ user for user in self.workgroup.users if user.id != self.user_id ] @property def allowed_nested_blocks(self): blocks = super(TeamEvaluationStage, self).allowed_nested_blocks blocks.extend([PeerSelectorXBlock]) return blocks def review_status(self): review_subjects_ids = [user.id for user in self.review_subjects] all_review_items = self.project_api.get_peer_review_items_for_group( self.workgroup.id, self.activity_content_id) review_items = [ item for item in all_review_items if item['reviewer'] == self.anonymous_student_id ] return self._calculate_review_status(review_subjects_ids, review_items) def get_review_data(self, user_id): """ :param int user_id: User ID :rtype: (set[int], dict) """ workgroup = self.project_api.get_user_workgroup_for_course( user_id, self.course_id) review_subjects_ids = set(user.id for user in workgroup.users) - {user_id} review_items = self._get_review_items_for_group( self.project_api, workgroup.id, self.activity_content_id) review_items_by_user = self._get_reviews_by_user(review_items, user_id) return review_subjects_ids, review_items_by_user def get_review_state(self, review_subject_id): review_items = self.project_api.get_peer_review_items( self.anonymous_student_id, review_subject_id, self.group_id, self.activity_content_id) return self._calculate_review_status([review_subject_id], review_items) @staticmethod @memoize_with_expiration() def _get_review_items_for_group(project_api, workgroup_id, activity_content_id): return project_api.get_peer_review_items_for_group( workgroup_id, activity_content_id) def validate(self): violations = super(TeamEvaluationStage, self).validate() # Technically, nothing prevents us from allowing graded peer review questions. The only reason why # they are considered not supported is that GroupActivityXBlock.calculate_grade does not # take them into account. if self.grade_questions: violations.add( ValidationMessage( ValidationMessage.ERROR, messages.GRADED_QUESTIONS_NOT_SUPPORTED.format( class_name=self.STUDIO_LABEL, stage_title=self.display_name))) if not self.has_child_of_category(PeerSelectorXBlock.CATEGORY): violations.add( ValidationMessage( ValidationMessage.ERROR, messages.PEER_SELECTOR_BLOCK_IS_MISSING.format( class_name=self.STUDIO_LABEL, stage_title=self.display_name, peer_selector_class_name=PeerSelectorXBlock. STUDIO_LABEL))) return violations @XBlock.handler @groupwork_protected_handler @key_error_protected_handler @conversion_protected_handler def load_peer_feedback(self, request, _suffix=''): peer_id = int(request.GET["peer_id"]) feedback = self.project_api.get_peer_review_items( self.anonymous_student_id, peer_id, self.workgroup.id, self.activity_content_id, ) results = self._pivot_feedback(feedback) return webob.response.Response(body=json.dumps(results)) def do_submit_review(self, submissions): peer_id = int(submissions["review_subject_id"]) del submissions["review_subject_id"] self.project_api.submit_peer_review_items( self.anonymous_student_id, peer_id, self.workgroup.id, self.activity_content_id, submissions, )