diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index d2c9eb3a4f27..847720102a40 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -1553,58 +1553,68 @@ def extras_update_lti_grades(request): usage_id = request.POST.get("usage_id", "") user_object = User.objects.get(email = user_email) user_id = user_object.id - grade = request.POST.get("user_grade", "") course_id = request.POST.get("course_id", "") - block_data = get_course_blocks(User.objects.get(id = user_id), modulestore().make_course_usage_key(CourseKey.from_string(str(course_id))), allow_start_dates_in_future=True, include_completion=True) + + #SA || letter_grade changes + letter_grade = request.POST.get("letter_grade", "") + grade = 0 if letter_grade else request.POST.get("user_grade", "") try: - studentmodule = StudentModule.objects.get(student_id = user_id, module_state_key = usage_id) - - #Update Grades - studentmodule.grade = grade - student_state = json.loads(studentmodule.state) - student_state["module_score"] = grade - studentmodule.state = json.dumps(student_state) - studentmodule.save() - grades_signals.PROBLEM_RAW_SCORE_CHANGED.send( - sender=None, - raw_earned=grade, - raw_possible=studentmodule.max_grade, - weight=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), - user_id=user_id, - course_id=str(studentmodule.course_id), - usage_id=str(usage_id), - score_deleted=True, - only_if_higher=False, - modified=datetime.datetime.now().replace(tzinfo=pytz.UTC), - score_db_table=grades_constants.ScoreDatabaseTableEnum.courseware_student_module, - ) - except StudentModule.DoesNotExist: - studentmodule = StudentModule.objects.create(student_id=user_id,course_id=request.POST.get("course_id"),module_state_key=usage_id,state=json.dumps({"module_score" : grade, "score_comment" : ""}), max_grade= block_data.get_xblock_field(usage_id, 'weight')) + block_data = get_course_blocks(User.objects.get(id = user_id), modulestore().make_course_usage_key(CourseKey.from_string(str(course_id))), allow_start_dates_in_future=True, include_completion=True) + + try: + studentmodule = StudentModule.objects.get(student_id = user_id, module_state_key = usage_id) + + #Update Grades + studentmodule.grade = grade + studentmodule.letter_grade = letter_grade + student_state = json.loads(studentmodule.state) + student_state["module_score"] = grade + studentmodule.state = json.dumps(student_state) + studentmodule.save() + grades_signals.PROBLEM_RAW_SCORE_CHANGED.send( + sender=None, + raw_earned=grade, + letter_grade=letter_grade, + raw_possible = 0 if letter_grade else studentmodule.max_grade, + weight=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), + user_id=user_id, + course_id=str(studentmodule.course_id), + usage_id=str(usage_id), + score_deleted=True, + only_if_higher=False, + modified=datetime.datetime.now().replace(tzinfo=pytz.UTC), + score_db_table=grades_constants.ScoreDatabaseTableEnum.courseware_student_module, + ) + except StudentModule.DoesNotExist: + studentmodule = StudentModule.objects.create(student_id=user_id,course_id=request.POST.get("course_id"),module_state_key=usage_id,state=json.dumps({"module_score" : grade, "score_comment" : ""}), max_grade= 0 if letter_grade else block_data.get_xblock_field(usage_id, 'weight'), letter_grade=letter_grade) - log.info("Student module created {0}".format(studentmodule)) + log.info("Student module created {0}".format(studentmodule)) + + grades_signals.SCORE_PUBLISHED.send( + sender=None, + block=modulestore().get_item(studentmodule.module_state_key), + user=user_object, + raw_earned=grade, + raw_possible=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), + only_if_higher=False, + score_deleted=False, + ) - grades_signals.SCORE_PUBLISHED.send( - sender=None, - block=modulestore().get_item(studentmodule.module_state_key), - user=user_object, - raw_earned=grade, - raw_possible=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), - only_if_higher=False, - score_deleted=False, - ) + handlers.scorable_block_completion( + sender="", + user_id=user_id, + course_id=course_id, + usage_id=usage_id, + weighted_earned=grade, + weighted_possible=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), + modified=datetime.datetime.now().replace(tzinfo=pytz.UTC), + score_db_table=grades_constants.ScoreDatabaseTableEnum.courseware_student_module + ) - handlers.scorable_block_completion( - sender="", - user_id=user_id, - course_id=course_id, - usage_id=usage_id, - weighted_earned=grade, - weighted_possible=block_data.get_xblock_field(studentmodule.module_state_key, 'weight'), - modified=datetime.datetime.now().replace(tzinfo=pytz.UTC), - score_db_table=grades_constants.ScoreDatabaseTableEnum.courseware_student_module - ) - + except Exception as e: + log.error(e) + return JsonResponse({"Status" : "Failed", "message" : "Something went wrong! Unable to update Grades."}) return JsonResponse({"Status" : "Success", "message" : "Grades updated successfully"}) diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 6bdc204434af..f0eebc703b2b 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -36,6 +36,9 @@ class SubsectionScoresSerializer(ReadOnlySerializer): show_correctness = serializers.CharField() show_grades = serializers.SerializerMethodField() url = serializers.SerializerMethodField() + + #SA || letter_grade changes + letter_grade = serializers.CharField() def get_override(self, subsection): """Proctoring or grading score override""" diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 59fcc725ed36..0e502f25b487 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -915,7 +915,8 @@ class ScoresClient: Eventually, this should read and write scores, but at the moment it only handles the read side of things. """ - Score = namedtuple('Score', 'correct total created') + #SA || letter_grade changes + Score = namedtuple('Score', 'correct total created letter_grade') def __init__(self, course_key, user_id): self.course_key = course_key @@ -938,8 +939,8 @@ def fetch_scores(self, locations): # attached to them (since old mongo identifiers don't include runs). # So we have to add that info back in before we put it into our lookup. self._locations_to_scores.update({ - location.map_into_course(self.course_key): self.Score(correct, total, created) - for location, correct, total, created + location.map_into_course(self.course_key): self.Score(correct, total, created, letter_grade) + for location, correct, total, created, letter_grade in scores_qset.values_list('module_state_key', 'grade', 'max_grade', 'created') }) self._has_fetched = True diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index b5cd3839c351..c531def779ed 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -121,6 +121,9 @@ class Meta: created = models.DateTimeField(auto_now_add=True, db_index=True) modified = models.DateTimeField(auto_now=True, db_index=True) + #SA || letter_grade changes + letter_grade = models.TextField(null=True, blank=True) + @classmethod def all_submitted_problems_read_only(cls, course_id): """ @@ -139,6 +142,7 @@ def all_submitted_problems_read_only(cls, course_id): return queryset def __repr__(self): + #SA || letter_grade changes return 'StudentModule<{!r}>'.format( { 'course_id': self.course_id, @@ -149,6 +153,7 @@ def __repr__(self): 'student_id': self.student_id, 'module_state_key': self.module_state_key, 'state': str(self.state)[:20], + 'letter_grade': self.letter_grade, }) def __str__(self): diff --git a/lms/djangoapps/grades/events.py b/lms/djangoapps/grades/events.py index 90279a3e69fe..c7e4172f52a1 100644 --- a/lms/djangoapps/grades/events.py +++ b/lms/djangoapps/grades/events.py @@ -50,6 +50,7 @@ def grade_updated(**kwargs): if not root_id: root_id = create_new_event_transaction_id() set_event_transaction_type(PROBLEM_SUBMITTED_EVENT_TYPE) + #SA || letter_grade changes tracker.emit( str(PROBLEM_SUBMITTED_EVENT_TYPE), { @@ -60,6 +61,7 @@ def grade_updated(**kwargs): 'event_transaction_type': str(PROBLEM_SUBMITTED_EVENT_TYPE), 'weighted_earned': kwargs.get('weighted_earned'), 'weighted_possible': kwargs.get('weighted_possible'), + 'letter_grade': kwargs.get('letter_grade'), } ) diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index b36d84794b3b..78f04155b518 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -342,6 +342,9 @@ class Meta: earned_graded = models.FloatField(blank=False) possible_graded = models.FloatField(blank=False) + #SA || letter_grade changes + letter_grade = models.CharField(max_length=255, blank=True, null=True) + # timestamp for the learner's first attempt at content in # this subsection. If null, indicates no attempt # has yet been made. @@ -368,8 +371,9 @@ def __str__(self): """ Returns a string representation of this model. """ + #SA || letter_grade changes return ( - "{} user: {}, course version: {}, subsection: {} ({}). {}/{} graded, {}/{} all, first_attempted: {}" + "{} user: {}, course version: {}, subsection: {} ({}). {}/{} graded, {}/{} all, first_attempted: {}, letter_grade: {}" ).format( type(self).__name__, self.user_id, @@ -381,6 +385,7 @@ def __str__(self): self.earned_all, self.possible_all, self.first_attempted, + self.letter_grade, ) @classmethod diff --git a/lms/djangoapps/grades/rest_api/serializers.py b/lms/djangoapps/grades/rest_api/serializers.py index b5c757f31bc6..5e6db42e29d4 100644 --- a/lms/djangoapps/grades/rest_api/serializers.py +++ b/lms/djangoapps/grades/rest_api/serializers.py @@ -44,6 +44,9 @@ class SectionBreakdownSerializer(serializers.Serializer): score_earned = serializers.FloatField() score_possible = serializers.FloatField() subsection_name = serializers.CharField() + + #SA || letter_grade changes + letter_grade = serializers.CharField() class StudentGradebookEntrySerializer(serializers.Serializer): diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index 4dc556bbdaf1..0e86badbda65 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -475,6 +475,7 @@ def _section_breakdown(self, course, graded_subsections, course_grade): # TODO: https://openedx.atlassian.net/browse/EDUCATOR-3559 -- Some fields should be renamed, others removed: # 'displayed_value' should maybe be 'description_percent' # 'grade_description' should be 'description_ratio' + #SA || letter_grade changes breakdown.append({ 'attempted': attempted, 'category': subsection_grade.format, @@ -484,6 +485,7 @@ def _section_breakdown(self, course, graded_subsections, course_grade): 'score_earned': score_earned, 'score_possible': score_possible, 'subsection_name': subsection_grade.display_name, + 'letter_grade': subsection_grade.letter_grade, }) return breakdown diff --git a/lms/djangoapps/grades/scores.py b/lms/djangoapps/grades/scores.py index 504579c45e80..907b3c52894e 100644 --- a/lms/djangoapps/grades/scores.py +++ b/lms/djangoapps/grades/scores.py @@ -109,7 +109,8 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): # Priority order for retrieving the scores: # submissions API -> CSM -> grades persisted block -> latest block content - raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted = ( + #SA || letter_grade changes + raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted, letter_grade = ( _get_score_from_submissions(submissions_scores, block) or _get_score_from_csm(csm_scores, block, weight) or _get_score_from_persisted_or_latest_block(persisted_block, block, weight) @@ -129,6 +130,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): has_valid_denominator = weighted_possible > 0.0 graded = _get_graded_from_block(persisted_block, block) if has_valid_denominator else False + #SA || letter_grade changes return ProblemScore( raw_earned, raw_possible, @@ -137,6 +139,7 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): weight, graded, first_attempted=first_attempted, + letter_grade=letter_grade, ) @@ -174,12 +177,14 @@ def _get_score_from_submissions(submissions_scores, block): """ if submissions_scores: submission_value = submissions_scores.get(str(block.location)) + #SA || letter_grade changes if submission_value: first_attempted = submission_value['created_at'] weighted_earned = submission_value['points_earned'] weighted_possible = submission_value['points_possible'] + letter_grade = submission_value.get('letter_grade', '') assert weighted_earned >= 0.0 and weighted_possible > 0.0 # per contract from submissions API - return (None, None) + (weighted_earned, weighted_possible) + (first_attempted,) + return (None, None) + (weighted_earned, weighted_possible) + (first_attempted, letter_grade,) def _get_score_from_csm(csm_scores, block, weight): @@ -209,7 +214,8 @@ def _get_score_from_csm(csm_scores, block, weight): raw_earned = 0.0 raw_possible = score.total - return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (first_attempted,) + #SA || letter_grade changes + return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (first_attempted, score.letter_grade,) def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): @@ -227,6 +233,9 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): raw_earned = 0.0 first_attempted = None + #SA || letter_grade changes + letter_grade = None + if persisted_block: raw_possible = persisted_block.raw_possible else: @@ -242,7 +251,8 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): else: weighted_scores = weighted_score(raw_earned, raw_possible, weight) - return (raw_earned, raw_possible) + weighted_scores + (first_attempted,) + #SA || letter_grade changes + return (raw_earned, raw_possible) + weighted_scores + (first_attempted, letter_grade,) def _get_weight_from_block(persisted_block, block): diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index 58c5fb90aace..361c509021b7 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -207,6 +207,7 @@ def problem_raw_score_changed_handler(sender, **kwargs): # pylint: disable=unus else: # TODO: remove as part of TNL-5982 weighted_earned, weighted_possible = kwargs['raw_earned'], kwargs['raw_possible'] + #SA || letter_grade changes PROBLEM_WEIGHTED_SCORE_CHANGED.send( sender=None, weighted_earned=weighted_earned, @@ -218,7 +219,8 @@ def problem_raw_score_changed_handler(sender, **kwargs): # pylint: disable=unus score_deleted=kwargs.get('score_deleted', False), modified=kwargs['modified'], score_db_table=kwargs['score_db_table'], - grader_response=kwargs.get('grader_response', False) + grader_response=kwargs.get('grader_response', False), + letter_grade=kwargs.get('letter_grade', '') ) @@ -233,6 +235,7 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum context_key = LearningContextKey.from_string(kwargs['course_id']) if not context_key.is_course: return # If it's not a course, it has no subsections, so skip the subsection grading update + #SA || letter_grade changes recalculate_subsection_grade_v3.apply_async( kwargs=dict( user_id=kwargs['user_id'], @@ -246,6 +249,7 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum event_transaction_type=str(get_event_transaction_type()), score_db_table=kwargs['score_db_table'], force_update_subsections=kwargs.get('force_update_subsections', False), + letter_grade=kwargs.get('letter_grade', ''), ), countdown=RECALCULATE_GRADE_DELAY_SECONDS, ) diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index ba098a92a417..13d048f4c1b3 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -140,6 +140,33 @@ def problem_scores(self): if problem_score is not None: locations[block_key] = problem_score return locations + + #SA || letter_grade changes + @property + def letter_grade(self): + """ + Overrides the problem_scores member variable in order + to return empty scores for all scorable problems in the + course. + NOTE: The use of `course_data.structure` here is very intentional. + It means we look through the user-specific subtree of this subsection, + taking into account which problems are visible to the user. + """ + letter_grades = OrderedDict() # dict of problem locations to ProblemScore + letter_grade = '' + for block_key in self.course_data.structure.post_order_traversal( + filter_func=possibly_scored, + start_node=self.location, + ): + block = self.course_data.structure[block_key] + if getattr(block, 'has_score', False): + problem_score = get_score( + submissions_scores={}, csm_scores={}, persisted_block=None, block=block, + ) + if problem_score is not None: + letter_grades[block_key] = problem_score.letter_grade + letter_grade = problem_score.letter_grade + return letter_grade class NonZeroSubsectionGrade(SubsectionGradeBase, metaclass=ABCMeta): @@ -270,6 +297,26 @@ def problem_scores(self): if problem_score: problem_scores[block.locator] = problem_score return problem_scores + + #SA || letter_grade changes + @property + def letter_grade(self): + """ + Returns the letter grade from model + """ + # pylint: disable=protected-access + letter_grade = '' + for block in self.model.visible_blocks.blocks: + problem_score = self._compute_block_score( + block.locator, + self.factory.course_data.structure, + self.factory._submissions_scores, + self.factory._csm_scores, + block, + ) + if problem_score: + letter_grade = problem_score.letter_grade + return letter_grade class CreateSubsectionGrade(NonZeroSubsectionGrade): @@ -278,6 +325,9 @@ class CreateSubsectionGrade(NonZeroSubsectionGrade): """ def __init__(self, subsection, course_structure, submissions_scores, csm_scores): self.problem_scores = OrderedDict() + + #SA || letter_grade changes + self.letter_grade = '' for block_key in course_structure.post_order_traversal( filter_func=possibly_scored, start_node=subsection.location, @@ -291,6 +341,7 @@ def __init__(self, subsection, course_structure, submissions_scores, csm_scores) .format(problem_score, block_key, subsection.location)) if problem_score: self.problem_scores[block_key] = problem_score + self.letter_grade = problem_score.letter_grade all_total, graded_total = graders.aggregate_scores(list(self.problem_scores.values())) @@ -361,6 +412,7 @@ def _persisted_model_params(self, student): Returns the parameters for creating/updating the persisted model for this subsection grade. """ + #SA || letter_grade changes return dict( user_id=student.id, usage_key=self.location, @@ -372,6 +424,7 @@ def _persisted_model_params(self, student): possible_graded=self.graded_total.possible, visible_blocks=self._get_visible_blocks, first_attempted=self.all_total.first_attempted, + letter_grade=self.letter_grade, ) @property diff --git a/xmodule/graders.py b/xmodule/graders.py index a587204d682e..0312f49c9f9f 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -25,7 +25,8 @@ class ScoreBase(metaclass=abc.ABCMeta): Abstract base class for encapsulating fields of values scores. """ - def __init__(self, graded, first_attempted): + #SA || letter_grade changes + def __init__(self, graded, first_attempted, letter_grade=None): """ Fields common to all scores include: @@ -37,6 +38,7 @@ def __init__(self, graded, first_attempted): """ self.graded = graded self.first_attempted = first_attempted + self.letter_grade = letter_grade def __eq__(self, other): if type(other) is type(self):