Commit 51133c5a by Eric Fischer Committed by GitHub

Merge pull request #14771 from edx/neem/grade-only-for-engaged

Grade only engaged learners
parents 02f67150 1503e5f7
......@@ -115,7 +115,7 @@ class CourseEndingTest(TestCase):
)
cert_status = {'status': 'generating', 'grade': '67', 'mode': 'honor'}
with patch('lms.djangoapps.grades.new.course_grade.CourseGradeFactory.get_persisted') as patch_persisted_grade:
with patch('lms.djangoapps.grades.new.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = Mock(percent=100)
self.assertEqual(
_cert_info(user, course, cert_status, course_mode),
......
......@@ -68,7 +68,7 @@ from certificates.api import ( # pylint: disable=import-error
get_certificate_url,
has_html_certificates_enabled,
)
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
......@@ -423,7 +423,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
)
if status in {'generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified'}:
persisted_grade = CourseGradeFactory().get_persisted(user, course_overview)
persisted_grade = CourseGradeFactory().read(user, course=course_overview)
if persisted_grade is not None:
status_dict['grade'] = unicode(persisted_grade.percent)
elif 'grade' in cert_status:
......
......@@ -14,7 +14,7 @@ class ProgressPage(CoursePage):
def is_browser_on_page(self):
is_present = (
self.q(css='.course-info').present and
self.q(css='#grade-detail-graph').present
self.q(css='.grade-detail-graph').present
)
return is_present
......@@ -115,7 +115,7 @@ class ProgressPage(CoursePage):
Return the CSS index of the chapter with `title`.
Returns `None` if it cannot find such a chapter.
"""
chapter_css = '.chapters section .hd'
chapter_css = '.chapters>section h3'
chapter_titles = self.q(css=chapter_css).map(lambda el: el.text.lower().strip()).results
try:
......
......@@ -231,18 +231,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (24, 6),
('no_overrides', 2, True, False): (24, 6),
('no_overrides', 3, True, False): (24, 6),
('ccx', 1, True, False): (24, 6),
('ccx', 2, True, False): (24, 6),
('ccx', 3, True, False): (24, 6),
('no_overrides', 1, False, False): (24, 6),
('no_overrides', 2, False, False): (24, 6),
('no_overrides', 3, False, False): (24, 6),
('ccx', 1, False, False): (24, 6),
('ccx', 2, False, False): (24, 6),
('ccx', 3, False, False): (24, 6),
('no_overrides', 1, True, False): (25, 1),
('no_overrides', 2, True, False): (25, 1),
('no_overrides', 3, True, False): (25, 1),
('ccx', 1, True, False): (25, 1),
('ccx', 2, True, False): (25, 1),
('ccx', 3, True, False): (25, 1),
('no_overrides', 1, False, False): (25, 1),
('no_overrides', 2, False, False): (25, 1),
('no_overrides', 3, False, False): (25, 1),
('ccx', 1, False, False): (25, 1),
('ccx', 2, False, False): (25, 1),
('ccx', 3, False, False): (25, 1),
}
......@@ -254,19 +254,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (24, 3),
('no_overrides', 2, True, False): (24, 3),
('no_overrides', 3, True, False): (24, 3),
('ccx', 1, True, False): (24, 3),
('ccx', 2, True, False): (24, 3),
('ccx', 3, True, False): (24, 3),
('ccx', 1, True, True): (25, 3),
('ccx', 2, True, True): (25, 3),
('ccx', 3, True, True): (25, 3),
('no_overrides', 1, False, False): (24, 3),
('no_overrides', 2, False, False): (24, 3),
('no_overrides', 3, False, False): (24, 3),
('ccx', 1, False, False): (24, 3),
('ccx', 2, False, False): (24, 3),
('ccx', 3, False, False): (24, 3),
('no_overrides', 1, True, False): (25, 3),
('no_overrides', 2, True, False): (25, 3),
('no_overrides', 3, True, False): (25, 3),
('ccx', 1, True, False): (25, 3),
('ccx', 2, True, False): (25, 3),
('ccx', 3, True, False): (25, 3),
('ccx', 1, True, True): (26, 3),
('ccx', 2, True, True): (26, 3),
('ccx', 3, True, True): (26, 3),
('no_overrides', 1, False, False): (25, 3),
('no_overrides', 2, False, False): (25, 3),
('no_overrides', 3, False, False): (25, 3),
('ccx', 1, False, False): (25, 3),
('ccx', 2, False, False): (25, 3),
('ccx', 3, False, False): (25, 3),
}
......@@ -33,7 +33,7 @@ from courseware.field_overrides import disable_overrides
from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, assign_role
from django_comment_common.utils import seed_permissions_roles
from edxmako.shortcuts import render_to_response
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from opaque_keys.edx.keys import CourseKey
from ccx_keys.locator import CCXLocator
from student.roles import CourseCcxCoachRole
......
......@@ -7,7 +7,7 @@ from optparse import make_option
from certificates.models import GeneratedCertificate
from courseware import courses
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
log = logging.getLogger(__name__)
......
......@@ -11,7 +11,7 @@ from django.conf import settings
from django.core.urlresolvers import reverse
from requests.auth import HTTPBasicAuth
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from xmodule.modulestore.django import modulestore
from capa.xqueue_interface import XQueueInterface
from capa.xqueue_interface import make_xheader, make_hashkey
......@@ -271,7 +271,7 @@ class XQueueCertInterface(object):
self.request.session = {}
is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists()
grade = CourseGradeFactory().create(student, course).summary
course_grade = CourseGradeFactory().create(student, course)
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id)
mode_is_verified = enrollment_mode in GeneratedCertificate.VERIFIED_CERTS_MODES
user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student)
......@@ -295,8 +295,6 @@ class XQueueCertInterface(object):
else:
# honor code and audit students
template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
if forced_grade:
grade['grade'] = forced_grade
LOGGER.info(
(
......@@ -317,13 +315,13 @@ class XQueueCertInterface(object):
cert.mode = cert_mode
cert.user = student
cert.grade = grade['percent']
cert.grade = course_grade.percent
cert.course_id = course_id
cert.name = profile_name
cert.download_url = ''
# Strip HTML from grade range label
grade_contents = grade.get('grade', None)
grade_contents = forced_grade or course_grade.letter_grade
try:
grade_contents = lxml.html.fromstring(grade_contents).text_content()
passing = True
......
......@@ -22,7 +22,7 @@ from capa.tests.response_xml_factory import (
from course_modes.models import CourseMode
from courseware.models import StudentModule, BaseStudentModuleHistory
from courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from openedx.core.djangoapps.credit.api import (
set_credit_requirements, get_credit_requirement_status
)
......
......@@ -86,7 +86,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
# The student progress tab is not accessible to a student
# before launch, so the instructor view-as-student feature
# should return a 403.
# should return a 404.
# TODO (vshnayder): If this is not the behavior we want, will need
# to make access checking smarter and understand both the effective
# user (the student), and the requesting user (the prof)
......@@ -97,7 +97,7 @@ class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase):
'student_id': self.enrolled_user.id,
}
)
self.assert_request_status_code(403, url)
self.assert_request_status_code(404, url)
# The courseware url should redirect, not 200
url = self._reverse_urls(['courseware'], course)[0]
......
......@@ -46,6 +46,7 @@ from courseware.tests.factories import StudentModuleFactory, GlobalStaffFactory
from courseware.url_helpers import get_redirect_url
from courseware.user_state_client import DjangoXBlockUserStateClient
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
from lms.djangoapps.grades.config.waffle import waffle as grades_waffle, ASSUME_ZERO_GRADE_IF_ABSENT
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory, CourseRunFactory
......@@ -1440,17 +1441,23 @@ class ProgressPageTests(ModuleStoreTestCase):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(41), check_mongo_calls(4):
with self.assertNumQueries(42), check_mongo_calls(1):
self._get_progress_page()
def test_progress_queries(self):
@ddt.data(
(False, 42, 28),
(True, 35, 24)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
self.setup_course()
with self.assertNumQueries(41), check_mongo_calls(4):
with grades_waffle().override_in_model(ASSUME_ZERO_GRADE_IF_ABSENT, active=enable_waffle):
with self.assertNumQueries(initial), check_mongo_calls(1):
self._get_progress_page()
# subsequent accesses to the progress page require fewer queries.
for _ in range(2):
with self.assertNumQueries(27), check_mongo_calls(4):
with self.assertNumQueries(subsequent), check_mongo_calls(1):
self._get_progress_page()
@patch(
......
......@@ -26,7 +26,7 @@ import urllib
import waffle
from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
......
......@@ -40,7 +40,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
from rest_framework import status
from lms.djangoapps.instructor.views.api import require_global_staff
from lms.djangoapps.ccx.utils import prep_course_for_grading
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from lms.djangoapps.instructor.enrollment import uses_shib
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
......@@ -832,8 +832,7 @@ def _progress(request, course_key, student_id):
except ValueError:
raise Http404
course = get_course_with_access(request.user, 'load', course_key, depth=None, check_if_enrolled=True)
prep_course_for_grading(course, request)
course = get_course_with_access(request.user, 'load', course_key)
# check to see if there is a required survey that must be taken before
# the user can access the course.
......@@ -861,12 +860,16 @@ def _progress(request, course_key, student_id):
except User.DoesNotExist:
raise Http404
# NOTE: To make sure impersonation by instructor works, use
# student instead of request.user in the rest of the function.
# The pre-fetching of groups is done to make auth checks not require an
# additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id)
if request.user.id != student.id:
# refetch the course as the assumed student
course = get_course_with_access(student, 'load', course_key, check_if_enrolled=True)
prep_course_for_grading(course, request)
# NOTE: To make sure impersonation by instructor works, use
# student instead of request.user in the rest of the function.
course_grade = CourseGradeFactory().create(student, course)
courseware_summary = course_grade.chapter_grades.values()
......
......@@ -78,7 +78,7 @@ def evaluate_entrance_exam(course_grade, user):
minimum score required, the dependent milestones will be marked
fulfilled for the user.
"""
course = course_grade.course
course = course_grade.course_data.course
if milestones_helpers.is_entrance_exams_enabled() and getattr(course, 'entrance_exam_enabled', False):
if get_entrance_exam_content(user, course):
exam_chapter_key = get_entrance_exam_usage_key(course)
......
......@@ -7,7 +7,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.grades.tests.utils import answer_problem
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.djangolib.testing.utils import get_mock_request
......
......@@ -14,7 +14,7 @@ from courseware.access import has_access
from lms.djangoapps.ccx.utils import prep_course_for_grading
from lms.djangoapps.courseware import courses
from lms.djangoapps.grades.api.serializers import GradingPolicySerializer
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from student.roles import CourseStaffRole
......
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from lms.djangoapps.grades.config.waffle import waffle, ASSUME_ZERO_GRADE_IF_ABSENT
def assume_zero_if_absent(course_key):
"""
Returns whether an absent grade should be assumed to be zero.
"""
return should_persist_grades(course_key) and waffle().is_enabled(ASSUME_ZERO_GRADE_IF_ABSENT)
def should_persist_grades(course_key):
"""
Returns whether grades should be persisted.
"""
return PersistentGradesEnabledFlag.feature_enabled(course_key)
"""
This module contains various configuration settings via
waffle switches for the Grades app.
"""
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus
# Namespace
WAFFLE_NAMESPACE = u'grades'
# Switches
WRITE_ONLY_IF_ENGAGED = u'write_only_if_engaged'
ASSUME_ZERO_GRADE_IF_ABSENT = u'assume_zero_grade_if_absent'
def waffle():
"""
Returns the namespaced, cached, audited Waffle class for Grades.
"""
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE, log_prefix=u'Grades: ')
......@@ -101,7 +101,7 @@ class Command(BaseCommand):
kwargs=kwargs,
options=task_options,
)
log.info("Persistent grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
log.info("Grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
task_name=tasks.compute_grades_for_course.name,
task_id=result.task_id,
kwargs=kwargs,
......
......@@ -7,7 +7,7 @@ from django.core.management.base import BaseCommand, CommandError
import os
from lms.djangoapps.courseware import courses
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
......
from lms.djangoapps.course_blocks.api import get_course_blocks
from xmodule.modulestore.django import modulestore
from ..transformer import GradesTransformer
class CourseData(object):
"""
Utility access layer to intelligently get and cache the
requested course data as long as at least one property is
provided upon initialization.
This is an in-memory object that maintains its own internal
cache during its lifecycle.
"""
def __init__(self, user, course=None, collected_block_structure=None, structure=None, course_key=None):
if not any([course, collected_block_structure, structure, course_key]):
raise ValueError(
"You must specify one of course, collected_block_structure, structure, or course_key to this method."
)
self.user = user
self._collected_block_structure = collected_block_structure
self._structure = structure
self._course = course
self._course_key = course_key
self._location = None
@property
def course_key(self):
if not self._course_key:
if self._course:
self._course_key = self._course.id
else:
structure = self.effective_structure
self._course_key = structure.root_block_usage_key.course_key
return self._course_key
@property
def location(self):
if not self._location:
structure = self.effective_structure
if structure:
self._location = structure.root_block_usage_key
elif self._course:
self._location = self._course.location
else:
self._location = modulestore().make_course_usage_key(self.course_key)
return self._location
@property
def structure(self):
if not self._structure:
self._structure = get_course_blocks(
self.user,
self.location,
collected_block_structure=self._collected_block_structure,
)
return self._structure
@property
def course(self):
if not self._course:
self._course = modulestore().get_course(self.course_key)
return self._course
@property
def grading_policy_hash(self):
structure = self.effective_structure
if structure:
return structure.get_transformer_block_field(
structure.root_block_usage_key,
GradesTransformer,
'grading_policy_hash',
)
else:
return GradesTransformer.grading_policy_hash(self.course)
@property
def version(self):
structure = self.effective_structure
course_block = structure[self.location] if structure else self.course
return getattr(course_block, 'course_version', None)
@property
def edited_on(self):
# get course block from structure only; subtree_edited_on field on modulestore's course block isn't optimized.
course_block = self.structure[self.location]
return getattr(course_block, 'subtree_edited_on', None)
@property
def effective_structure(self):
return self._structure or self._collected_block_structure
def __unicode__(self):
return u'Course: course_key: {}'.format(self.course_key)
def full_string(self):
if self.effective_structure:
return u'Course: course_key: {}, version: {}, edited_on: {}, grading_policy: {}'.format(
self.course_key, self.version, self.edited_on, self.grading_policy_hash,
)
else:
return u'Course: course_key: {}, empty course structure'.format(self.course_key)
"""
CourseGrade Class
"""
from collections import defaultdict, namedtuple, OrderedDict
from logging import getLogger
from abc import abstractmethod
from collections import defaultdict, OrderedDict
from django.conf import settings
from django.core.exceptions import PermissionDenied
import dogstats_wrapper as dog_stats_api
from lazy import lazy
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
from xmodule import block_metadata_utils
from .subsection_grade_factory import SubsectionGradeFactory
from .subsection_grade import ZeroSubsectionGrade
from ..models import PersistentCourseGrade
from .subsection_grade import SubsectionGradeFactory
from ..transformer import GradesTransformer
class CourseGradeBase(object):
"""
Base class for Course Grades.
"""
def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False):
self.user = user
self.course_data = course_data
log = getLogger(__name__)
self.percent = percent
self.letter_grade = letter_grade
self.passed = passed
def __unicode__(self):
return u'Course Grade: percent: %s, letter_grade: %s, passed: %s'.format(
unicode(self.percent), self.letter_grade, self.passed,
)
class CourseGrade(object):
def attempted(self):
"""
Course Grade class
Returns whether at least one problem was attempted
by the user in the course.
"""
def __init__(self, student, course, course_structure):
self.student = student
self.course = course
self._percent = None
self._letter_grade = None
self.course_structure = course_structure
if self.course_structure:
course_block = course_structure[course.location]
self.course_version = getattr(course_block, 'course_version', None)
self.course_edited_timestamp = getattr(course_block, 'subtree_edited_on', None)
self._subsection_grade_factory = SubsectionGradeFactory(self.student, self.course, self.course_structure)
return False
@lazy
def graded_subsections_by_format(self):
......@@ -58,34 +51,6 @@ class CourseGrade(object):
return subsections_by_format
@lazy
def locations_to_scores(self):
"""
Returns a dict of problem scores keyed by their locations.
"""
locations_to_scores = {}
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
locations_to_scores.update(subsection_grade.locations_to_scores)
return locations_to_scores
@lazy
def grade_value(self):
"""
Helper function to extract the grade value as calculated by the course's grader.
"""
# Grading policy might be overriden by a CCX, need to reset it
self.course.set_grading_policy(self.course.grading_policy)
grade_value = self.course.grader.grade(
self.graded_subsections_by_format,
generate_random_scores=settings.GENERATE_PROFILE_SCORES
)
# can't use the existing properties due to recursion issues caused by referencing self.grade_value
percent = self._calc_percent(grade_value)
letter_grade = self._compute_letter_grade(percent)
self._log_event(log.warning, u"grade_value, percent: {0}, grade: {1}".format(percent, letter_grade))
return grade_value
@lazy
def chapter_grades(self):
"""
Returns a dictionary of dictionaries.
......@@ -93,101 +58,22 @@ class CourseGrade(object):
The secondary dictionary contains the chapter's
subsection grades, display name, and url name.
"""
chapter_grades = OrderedDict()
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
chapter_subsection_grades = []
children = self.course_structure.get_children(chapter_key)
for subsection_key in children:
chapter_subsection_grades.append(
self._subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
chapter_grades[chapter_key] = {
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': chapter_subsection_grades
course_structure = self.course_data.structure
return {
chapter_key: self._get_chapter_grade_info(course_structure[chapter_key], course_structure)
for chapter_key in course_structure.get_children(self.course_data.location)
}
return chapter_grades
@property
def percent(self):
"""
Returns a rounded percent from the overall grade.
"""
if self._percent is None:
self._percent = self._calc_percent(self.grade_value)
return self._percent
@property
def letter_grade(self):
"""
Returns a letter representing the grade.
"""
if self._letter_grade is None:
self._letter_grade = self._compute_letter_grade(self.percent)
return self._letter_grade
@property
def passed(self):
"""
Check user's course passing status. Return True if passed.
"""
nonzero_cutoffs = [cutoff for cutoff in self.course.grade_cutoffs.values() if cutoff > 0]
success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None
return success_cutoff and self.percent >= success_cutoff
@property
def summary(self):
"""
Returns the grade summary as calculated by the course's grader.
"""
grade_summary = self.grade_value
grade_summary['percent'] = self.percent
grade_summary['grade'] = self.letter_grade
return grade_summary
def compute_and_update(self, read_only=False):
@lazy
def locations_to_scores(self):
"""
Computes the grade for the given student and course.
If read_only is True, doesn't save any updates to the grades.
Returns a dict of problem scores keyed by their locations.
"""
subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades.itervalues())
total_graded_subsections = sum(len(x) for x in self.graded_subsections_by_format.itervalues())
subsections_created = len(self._subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access
subsections_read = subsections_total - subsections_created
blocks_total = len(self.locations_to_scores)
if not read_only:
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
self._subsection_grade_factory.bulk_create_unsaved()
grading_policy_hash = self.get_grading_policy_hash(self.course.location, self.course_structure)
PersistentCourseGrade.update_or_create_course_grade(
user_id=self.student.id,
course_id=self.course.id,
course_version=self.course_version,
course_edited_timestamp=self.course_edited_timestamp,
grading_policy_hash=grading_policy_hash,
percent_grade=self.percent,
letter_grade=self.letter_grade or "",
passed=self.passed,
)
self._signal_listeners_when_grade_computed()
self._log_event(
log.warning,
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks accessed: {3}, total "
u"graded subsections: {4}".format(
read_only,
subsections_read,
subsections_created,
blocks_total,
total_graded_subsections,
)
)
locations_to_scores = {}
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
locations_to_scores.update(subsection_grade.locations_to_scores)
return locations_to_scores
def score_for_chapter(self, chapter_key):
"""
......@@ -206,7 +92,6 @@ class CourseGrade(object):
"""
Calculate the aggregate weighted score for any location in the course.
This method returns a tuple containing (earned_score, possible_score).
If the location is of 'problem' type, this method will return the
possible and earned scores for that problem. If the location refers to a
composite module (a vertical or section ) the scores will be the sums of
......@@ -215,7 +100,7 @@ class CourseGrade(object):
if location in self.locations_to_scores:
score = self.locations_to_scores[location]
return score.earned, score.possible
children = self.course_structure.get_children(location)
children = self.course_data.structure.get_children(location)
earned, possible = 0.0, 0.0
for child in children:
child_earned, child_possible = self.score_for_module(child)
......@@ -223,229 +108,136 @@ class CourseGrade(object):
possible += child_possible
return earned, possible
@staticmethod
def get_grading_policy_hash(course_location, course_structure):
@lazy
def grader_result(self):
"""
Gets the grading policy of the course at the given location
in the given course structure.
Returns the result from the course grader.
"""
return course_structure.get_transformer_block_field(
course_location,
GradesTransformer,
'grading_policy_hash'
course = self.course_data.course
course.set_grading_policy(course.grading_policy)
return course.grader.grade(
self.graded_subsections_by_format,
generate_random_scores=settings.GENERATE_PROFILE_SCORES,
)
@classmethod
def load_persisted_grade(cls, user, course, course_structure):
@property
def summary(self):
"""
Initializes a CourseGrade object, filling its members with persisted values from the database.
If the grading policy is out of date, recomputes the grade.
If no persisted values are found, returns None.
Returns the grade summary as calculated by the course's grader.
DEPRECATED: To be removed as part of TNL-5291.
"""
try:
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course.id)
except PersistentCourseGrade.DoesNotExist:
return None
course_grade = CourseGrade(user, course, course_structure)
current_grading_policy_hash = course_grade.get_grading_policy_hash(course.location, course_structure)
if current_grading_policy_hash != persistent_grade.grading_policy_hash:
return None
else:
course_grade._percent = persistent_grade.percent_grade # pylint: disable=protected-access
course_grade._letter_grade = persistent_grade.letter_grade # pylint: disable=protected-access
course_grade.course_version = persistent_grade.course_version
course_grade.course_edited_timestamp = persistent_grade.course_edited_timestamp
course_grade._log_event(log.info, u"load_persisted_grade") # pylint: disable=protected-access
return course_grade
@classmethod
def get_persisted_grade(cls, user, course):
"""
Gets the persisted grade in the database, without checking
whether it is up-to-date with the course's grading policy.
For read use only.
"""
try:
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course.id)
except PersistentCourseGrade.DoesNotExist:
return None
else:
course_grade = CourseGrade(user, course, None) # no course structure needed
course_grade._percent = persistent_grade.percent_grade # pylint: disable=protected-access
course_grade._letter_grade = persistent_grade.letter_grade # pylint: disable=protected-access
course_grade.course_version = persistent_grade.course_version
course_grade.course_edited_timestamp = persistent_grade.course_edited_timestamp
return course_grade
# TODO(TNL-5291) Remove usages of this deprecated property.
grade_summary = self.grader_result
grade_summary['percent'] = self.percent
grade_summary['grade'] = self.letter_grade
return grade_summary
@staticmethod
def _calc_percent(grade_value):
def _get_chapter_grade_info(self, chapter, course_structure):
"""
Helper for percent calculation.
Helper that returns a dictionary of chapter grade information.
"""
return round(grade_value['percent'] * 100 + 0.05) / 100
chapter_subsection_grades = self._get_subsection_grades(course_structure, chapter.location)
return {
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': chapter_subsection_grades,
}
def _compute_letter_grade(self, percentage):
def _get_subsection_grades(self, course_structure, chapter_key):
"""
Returns a letter grade as defined in grading_policy (e.g. 'A' 'B' 'C' for 6.002x) or None.
Arguments
- grade_cutoffs is a dictionary mapping a grade to the lowest
possible percentage to earn that grade.
- percentage is the final percent across all problems in a course
Returns a list of subsection grades for the given chapter.
"""
return [
self._get_subsection_grade(course_structure[subsection_key])
for subsection_key in course_structure.get_children(chapter_key)
]
letter_grade = None
grade_cutoffs = self.course.grade_cutoffs
# Possible grades, sorted in descending order of score
descending_grades = sorted(grade_cutoffs, key=lambda x: grade_cutoffs[x], reverse=True)
for possible_grade in descending_grades:
if percentage >= grade_cutoffs[possible_grade]:
letter_grade = possible_grade
break
return letter_grade
def _signal_listeners_when_grade_computed(self):
@abstractmethod
def _get_subsection_grade(self, subsection):
"""
Signal all listeners when grades are computed.
Abstract method to be implemented by subclasses for returning
the grade of the given subsection.
"""
responses = COURSE_GRADE_CHANGED.send_robust(
sender=None,
user=self.student,
course_grade=self,
course_key=self.course.id,
deadline=self.course.end
)
raise NotImplementedError
for receiver, response in responses:
log.debug(
'Signal fired when student grade is calculated. Receiver: %s. Response: %s',
receiver, response
)
def _log_event(self, log_func, log_statement):
class ZeroCourseGrade(CourseGradeBase):
"""
Logs the given statement, for this instance.
Course Grade class for Zero-value grades when no problems were
attempted in the course.
"""
log_func(u"Persistent Grades: CourseGrade.{0}, course: {1}, user: {2}".format(
log_statement,
self.course.id,
self.student.id
))
def __init__(self, user, course_data):
super(ZeroCourseGrade, self).__init__(user, course_data)
def _get_subsection_grade(self, subsection):
return ZeroSubsectionGrade(subsection, self.course_data)
class CourseGradeFactory(object):
"""
Factory class to create Course Grade objects
"""
def create(self, student, course, collected_block_structure=None, read_only=True):
"""
Returns the CourseGrade object for the given student and course.
If read_only is True, doesn't save any updates to the grades.
Raises a PermissionDenied if the user does not have course access.
class CourseGrade(CourseGradeBase):
"""
course_structure = get_course_blocks(
student,
course.location,
collected_block_structure=collected_block_structure,
)
# if user does not have access to this course, throw an exception
if not self._user_has_access_to_course(course_structure):
raise PermissionDenied("User does not have access to this course")
return (
self._get_saved_grade(student, course, course_structure) or
self._compute_and_update_grade(student, course, course_structure, read_only)
)
GradeResult = namedtuple('GradeResult', ['student', 'course_grade', 'err_msg'])
def iter(self, course, students, read_only=True):
Course Grade class when grades are updated or read from storage.
"""
Given a course and an iterable of students (User), yield a GradeResult
for every student enrolled in the course. GradeResult is a named tuple of:
def __init__(self, user, course_data, *args, **kwargs):
super(CourseGrade, self).__init__(user, course_data, *args, **kwargs)
self._subsection_grade_factory = SubsectionGradeFactory(user, course_data=course_data)
(student, course_grade, err_msg)
If an error occurred, course_grade will be None and err_msg will be an
exception message. If there was no error, err_msg is an empty string.
"""
# Pre-fetch the collected course_structure so:
# 1. Correctness: the same version of the course is used to
# compute the grade for all students.
# 2. Optimization: the collected course_structure is not
# retrieved from the data store multiple times.
collected_block_structure = get_block_structure_manager(course.id).get_collected()
for student in students:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]):
try:
course_grade = CourseGradeFactory().create(student, course, collected_block_structure, read_only=read_only)
yield self.GradeResult(student, course_grade, "")
except Exception as exc: # pylint: disable=broad-except
# Keep marching on even if this student couldn't be graded for
# some reason, but log it for future reference.
log.exception(
'Cannot grade student %s (%s) in course %s because of exception: %s',
student.username,
student.id,
course.id,
exc.message
)
yield self.GradeResult(student, None, exc.message)
def update(self, student, course, course_structure):
def update(self):
"""
Updates the CourseGrade for this Factory's student.
Updates the grade for the course.
"""
self._compute_and_update_grade(student, course, course_structure)
grade_cutoffs = self.course_data.course.grade_cutoffs
self.percent = self._compute_percent(self.grader_result)
self.letter_grade = self._compute_letter_grade(grade_cutoffs, self.percent)
self.passed = self._compute_passed(grade_cutoffs, self.percent)
def get_persisted(self, student, course):
@lazy
def attempted(self):
"""
Returns the saved grade for the given course and student,
irrespective of whether the saved grade is up-to-date.
Returns whether any of the subsections in this course
have been attempted by the student.
"""
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return None
for chapter in self.chapter_grades.itervalues():
for subsection_grade in chapter['sections']:
if subsection_grade.attempted:
return True
return False
return CourseGrade.get_persisted_grade(student, course)
def _get_subsection_grade(self, subsection):
# Pass read_only here so the subsection grades can be persisted in bulk at the end.
return self._subsection_grade_factory.create(subsection, read_only=True)
def _get_saved_grade(self, student, course, course_structure):
@staticmethod
def _compute_percent(grader_result):
"""
Returns the saved grade for the given course and student.
Computes and returns the grade percentage from the given
result from the grader.
"""
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return None
return CourseGrade.load_persisted_grade(
student,
course,
course_structure
)
return round(grader_result['percent'] * 100 + 0.05) / 100
def _compute_and_update_grade(self, student, course, course_structure, read_only=False):
@staticmethod
def _compute_letter_grade(grade_cutoffs, percent):
"""
Freshly computes and updates the grade for the student and course.
If read_only is True, doesn't save any updates to the grades.
Computes and returns the course letter grade given the
inputs, as defined in the grading_policy (e.g. 'A' 'B' 'C')
or None if not passed.
"""
course_grade = CourseGrade(student, course, course_structure)
course_grade.compute_and_update(read_only)
return course_grade
letter_grade = None
# Possible grades, sorted in descending order of score
descending_grades = sorted(grade_cutoffs, key=lambda x: grade_cutoffs[x], reverse=True)
for possible_grade in descending_grades:
if percent >= grade_cutoffs[possible_grade]:
letter_grade = possible_grade
break
def _user_has_access_to_course(self, course_structure):
return letter_grade
@staticmethod
def _compute_passed(grade_cutoffs, percent):
"""
Given a course structure, returns whether the user
for whom that course structure was retrieved
has access to the course.
Computes and returns whether the given percent value
is a passing grade according to the given grade cutoffs.
"""
return len(course_structure) > 0
nonzero_cutoffs = [cutoff for cutoff in grade_cutoffs.values() if cutoff > 0]
success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None
return success_cutoff and percent >= success_cutoff
from collections import namedtuple
import dogstats_wrapper as dog_stats_api
from logging import getLogger
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
from ..config import assume_zero_if_absent, should_persist_grades
from ..config.waffle import waffle, WRITE_ONLY_IF_ENGAGED
from ..models import PersistentCourseGrade
from .course_data import CourseData
from .course_grade import CourseGrade, ZeroCourseGrade
log = getLogger(__name__)
class CourseGradeFactory(object):
"""
Factory class to create Course Grade objects.
"""
GradeResult = namedtuple('GradeResult', ['student', 'course_grade', 'err_msg'])
def create(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
"""
Returns the CourseGrade for the given user in the course.
Reads the value from storage and validates that the grading
policy hasn't changed since the grade was last computed.
If not in storage, returns a ZeroGrade if ASSUME_ZERO_GRADE_IF_ABSENT.
Else, if changed or not in storage, computes and returns a new value.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
try:
course_grade, read_policy_hash = self._read(user, course_data)
if read_policy_hash == course_data.grading_policy_hash:
return course_grade
read_only = False # update the persisted grade since the policy changed; TODO(TNL-6786) remove soon
except PersistentCourseGrade.DoesNotExist:
if assume_zero_if_absent(course_data.course_key):
return self._create_zero(user, course_data)
read_only = True # keep the grade un-persisted; TODO(TNL-6786) remove once all grades are backfilled
return self._update(user, course_data, read_only)
def read(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
"""
Returns the CourseGrade for the given user in the course as
persisted in storage. Does NOT verify whether the grading
policy is still valid since the grade was last computed.
If not in storage, returns a ZeroGrade if ASSUME_ZERO_GRADE_IF_ABSENT
else returns None.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
try:
course_grade, _ = self._read(user, course_data)
return course_grade
except PersistentCourseGrade.DoesNotExist:
if assume_zero_if_absent(course_data.course_key):
return self._create_zero(user, course_data)
else:
return None
def update(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
"""
Computes, updates, and returns the CourseGrade for the given
user in the course.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
return self._update(user, course_data, read_only=False)
def iter(self, course, students, force_update=False):
"""
Given a course and an iterable of students (User), yield a GradeResult
for every student enrolled in the course. GradeResult is a named tuple of:
(student, course_grade, err_msg)
If an error occurred, course_grade will be None and err_msg will be an
exception message. If there was no error, err_msg is an empty string.
"""
# Pre-fetch the collected course_structure so:
# 1. Correctness: the same version of the course is used to
# compute the grade for all students.
# 2. Optimization: the collected course_structure is not
# retrieved from the data store multiple times.
collected_block_structure = get_block_structure_manager(course.id).get_collected()
for student in students:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]):
try:
operation = CourseGradeFactory().update if force_update else CourseGradeFactory().create
course_grade = operation(student, course, collected_block_structure)
yield self.GradeResult(student, course_grade, "")
except Exception as exc: # pylint: disable=broad-except
# Keep marching on even if this student couldn't be graded for
# some reason, but log it for future reference.
log.exception(
'Cannot grade student %s (%s) in course %s because of exception: %s',
student.username,
student.id,
course.id,
exc.message
)
yield self.GradeResult(student, None, exc.message)
@staticmethod
def _create_zero(user, course_data):
"""
Returns a ZeroCourseGrade object for the given user and course.
"""
log.info(u'Grades: CreateZero, %s, User: %s', unicode(course_data), user.id)
return ZeroCourseGrade(user, course_data)
@staticmethod
def _read(user, course_data):
"""
Returns a CourseGrade object based on stored grade information
for the given user and course.
"""
if not should_persist_grades(course_data.course_key):
raise PersistentCourseGrade.DoesNotExist
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course_data.course_key)
course_grade = CourseGrade(
user,
course_data,
persistent_grade.percent_grade,
persistent_grade.letter_grade,
persistent_grade.passed_timestamp is not None,
)
log.info(u'Grades: Read, %s, User: %s, %s', unicode(course_data), user.id, persistent_grade)
return course_grade, persistent_grade.grading_policy_hash
@staticmethod
def _update(user, course_data, read_only):
"""
Computes, saves, and returns a CourseGrade object for the
given user and course.
Sends a COURSE_GRADE_CHANGED signal to listeners.
"""
course_grade = CourseGrade(user, course_data)
course_grade.update()
should_persist = (
not read_only and # TODO(TNL-6786) Remove the read_only boolean once all grades are back-filled.
should_persist_grades(course_data.course_key) and
not waffle().is_enabled(WRITE_ONLY_IF_ENGAGED) or course_grade.attempted
)
if should_persist:
course_grade._subsection_grade_factory.bulk_create_unsaved()
PersistentCourseGrade.update_or_create_course_grade(
user_id=user.id,
course_id=course_data.course_key,
course_version=course_data.version,
course_edited_timestamp=course_data.edited_on,
grading_policy_hash=course_data.grading_policy_hash,
percent_grade=course_grade.percent,
letter_grade=course_grade.letter_grade or "",
passed=course_grade.passed,
)
COURSE_GRADE_CHANGED.send_robust(
sender=None,
user=user,
course_grade=course_grade,
course_key=course_data.course_key,
deadline=course_data.course.end,
)
log.info(
u'Grades: Update, %s, User: %s, %s, persisted: %s',
course_data.full_string(), user.id, course_grade, should_persist,
)
return course_grade
......@@ -4,21 +4,18 @@ SubsectionGrade Class
from collections import OrderedDict
from lazy import lazy
from logging import getLogger
from courseware.model_data import ScoresClient
from lms.djangoapps.grades.scores import get_score, possibly_scored
from lms.djangoapps.grades.models import BlockRecord, PersistentSubsectionGrade
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.lib.grade_utils import is_score_higher_or_equal
from student.models import anonymous_id_for_user
from submissions import api as submissions_api
from xmodule import block_metadata_utils, graders
from xmodule.graders import AggregatedScore
from ..config.waffle import waffle, WRITE_ONLY_IF_ENGAGED
log = getLogger(__name__)
class SubsectionGrade(object):
class SubsectionGradeBase(object):
"""
Class for Subsection Grades.
"""
......@@ -36,7 +33,6 @@ class SubsectionGrade(object):
self.graded_total = None # aggregated grade for all graded problems
self.all_total = None # aggregated grade for all problems, regardless of whether they are graded
self.locations_to_scores = OrderedDict() # dict of problem locations to ProblemScore
@property
def scores(self):
......@@ -58,6 +54,45 @@ class SubsectionGrade(object):
)
return self.all_total.attempted
class ZeroSubsectionGrade(SubsectionGradeBase):
"""
Class for Subsection Grades with Zero values.
"""
def __init__(self, subsection, course_data):
super(ZeroSubsectionGrade, self).__init__(subsection)
self.graded_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=False, attempted=False)
self.all_total = AggregatedScore(tw_earned=0, tw_possible=None, graded=self.graded, attempted=False)
self.course_data = course_data
@lazy
def locations_to_scores(self):
"""
Overrides the locations_to_scores member variable in order
to return empty scores for all scorable problems in the
course.
"""
locations = OrderedDict() # dict of problem locations to ProblemScore
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):
locations[block_key] = get_score(
submissions_scores={}, csm_scores={}, persisted_block=None, block=block,
)
return locations
class SubsectionGrade(SubsectionGradeBase):
"""
Class for Subsection Grades.
"""
def __init__(self, subsection):
super(SubsectionGrade, self).__init__(subsection)
self.locations_to_scores = OrderedDict() # dict of problem locations to ProblemScore
def init_from_structure(self, student, course_structure, submissions_scores, csm_scores):
"""
Compute the grade of this subsection for the given student and course.
......@@ -99,6 +134,7 @@ class SubsectionGrade(object):
"""
Saves the subsection grade in a persisted model.
"""
subsection_grades = filter(lambda subs_grade: subs_grade._should_persist_per_attempted, subsection_grades)
return PersistentSubsectionGrade.bulk_create_grades(
[subsection_grade._persisted_model_params(student) for subsection_grade in subsection_grades], # pylint: disable=protected-access
course_key,
......@@ -108,6 +144,7 @@ class SubsectionGrade(object):
"""
Saves the subsection grade in a persisted model.
"""
if self._should_persist_per_attempted:
self._log_event(log.debug, u"create_model", student)
return PersistentSubsectionGrade.create_grade(**self._persisted_model_params(student))
......@@ -115,9 +152,18 @@ class SubsectionGrade(object):
"""
Saves or updates the subsection grade in a persisted model.
"""
if self._should_persist_per_attempted:
self._log_event(log.debug, u"update_or_create_model", student)
return PersistentSubsectionGrade.update_or_create_grade(**self._persisted_model_params(student))
@property
def _should_persist_per_attempted(self):
"""
Returns whether the SubsectionGrade's model should be
persisted based on settings and attempted status.
"""
return not waffle().is_enabled(WRITE_ONLY_IF_ENGAGED) or self.attempted
def _compute_block_score(
self,
block_key,
......@@ -182,7 +228,7 @@ class SubsectionGrade(object):
Logs the given statement, for this instance.
"""
log_func(
u"Persistent Grades: SG.{}, subsection: {}, course: {}, "
u"Grades: SG.{}, subsection: {}, course: {}, "
u"version: {}, edit: {}, user: {},"
u"total: {}/{}, graded: {}/{}".format(
log_statement,
......@@ -197,149 +243,3 @@ class SubsectionGrade(object):
self.graded_total.possible,
)
)
class SubsectionGradeFactory(object):
"""
Factory for Subsection Grades.
"""
def __init__(self, student, course, course_structure):
self.student = student
self.course = course
self.course_structure = course_structure
self._cached_subsection_grades = None
self._unsaved_subsection_grades = []
def create(self, subsection, read_only=False):
"""
Returns the SubsectionGrade object for the student and subsection.
If read_only is True, doesn't save any updates to the grades.
"""
self._log_event(
log.debug, u"create, read_only: {0}, subsection: {1}".format(read_only, subsection.location), subsection,
)
subsection_grade = self._get_bulk_cached_grade(subsection)
if not subsection_grade:
subsection_grade = SubsectionGrade(subsection).init_from_structure(
self.student, self.course_structure, self._submissions_scores, self._csm_scores,
)
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
if read_only:
self._unsaved_subsection_grades.append(subsection_grade)
else:
grade_model = subsection_grade.create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return subsection_grade
def bulk_create_unsaved(self):
"""
Bulk creates all the unsaved subsection_grades to this point.
"""
SubsectionGrade.bulk_create_models(self.student, self._unsaved_subsection_grades, self.course.id)
self._unsaved_subsection_grades = []
def update(self, subsection, only_if_higher=None):
"""
Updates the SubsectionGrade object for the student and subsection.
"""
# Save ourselves the extra queries if the course does not persist
# subsection grades.
self._log_event(log.warning, u"update, subsection: {}".format(subsection.location), subsection)
calculated_grade = SubsectionGrade(subsection).init_from_structure(
self.student, self.course_structure, self._submissions_scores, self._csm_scores,
)
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
if only_if_higher:
try:
grade_model = PersistentSubsectionGrade.read_grade(self.student.id, subsection.location)
except PersistentSubsectionGrade.DoesNotExist:
pass
else:
orig_subsection_grade = SubsectionGrade(subsection).init_from_model(
self.student, grade_model, self.course_structure, self._submissions_scores, self._csm_scores,
)
if not is_score_higher_or_equal(
orig_subsection_grade.graded_total.earned,
orig_subsection_grade.graded_total.possible,
calculated_grade.graded_total.earned,
calculated_grade.graded_total.possible,
):
return orig_subsection_grade
grade_model = calculated_grade.update_or_create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return calculated_grade
@lazy
def _csm_scores(self):
"""
Lazily queries and returns all the scores stored in the user
state (in CSM) for the course, while caching the result.
"""
scorable_locations = [block_key for block_key in self.course_structure if possibly_scored(block_key)]
return ScoresClient.create_for_locations(self.course.id, self.student.id, scorable_locations)
@lazy
def _submissions_scores(self):
"""
Lazily queries and returns the scores stored by the
Submissions API for the course, while caching the result.
"""
anonymous_user_id = anonymous_id_for_user(self.student, self.course.id)
return submissions_api.get_scores(unicode(self.course.id), anonymous_user_id)
def _get_bulk_cached_grade(self, subsection):
"""
Returns the student's SubsectionGrade for the subsection,
while caching the results of a bulk retrieval for the
course, for future access of other subsections.
Returns None if not found.
"""
if not PersistentGradesEnabledFlag.feature_enabled(self.course.id):
return
saved_subsection_grades = self._get_bulk_cached_subsection_grades()
subsection_grade = saved_subsection_grades.get(subsection.location)
if subsection_grade:
return SubsectionGrade(subsection).init_from_model(
self.student, subsection_grade, self.course_structure, self._submissions_scores, self._csm_scores,
)
def _get_bulk_cached_subsection_grades(self):
"""
Returns and caches (for future access) the results of
a bulk retrieval of all subsection grades in the course.
"""
if self._cached_subsection_grades is None:
self._cached_subsection_grades = {
record.full_usage_key: record
for record in PersistentSubsectionGrade.bulk_read_grades(self.student.id, self.course.id)
}
return self._cached_subsection_grades
def _update_saved_subsection_grade(self, subsection_usage_key, subsection_model):
"""
Updates (or adds) the subsection grade for the given
subsection usage key in the local cache, iff the cache
is populated.
"""
if self._cached_subsection_grades is not None:
self._cached_subsection_grades[subsection_usage_key] = subsection_model
def _log_event(self, log_func, log_statement, subsection):
"""
Logs the given statement, for this instance.
"""
log_func(u"Persistent Grades: SGF.{}, course: {}, version: {}, edit: {}, user: {}".format(
log_statement,
self.course.id,
getattr(subsection, 'course_version', None),
getattr(subsection, 'subtree_edited_on', None),
self.student.id,
))
from lazy import lazy
from logging import getLogger
from courseware.model_data import ScoresClient
from openedx.core.lib.grade_utils import is_score_higher_or_equal
from student.models import anonymous_id_for_user
from submissions import api as submissions_api
from lms.djangoapps.grades.config import should_persist_grades, assume_zero_if_absent
from lms.djangoapps.grades.models import PersistentSubsectionGrade
from lms.djangoapps.grades.scores import possibly_scored
from .course_data import CourseData
from .subsection_grade import SubsectionGrade, ZeroSubsectionGrade
log = getLogger(__name__)
class SubsectionGradeFactory(object):
"""
Factory for Subsection Grades.
"""
def __init__(self, student, course=None, course_structure=None, course_data=None):
self.student = student
self.course_data = course_data or CourseData(student, course=course, structure=course_structure)
self._cached_subsection_grades = None
self._unsaved_subsection_grades = []
def create(self, subsection, read_only=False):
"""
Returns the SubsectionGrade object for the student and subsection.
If read_only is True, doesn't save any updates to the grades.
"""
self._log_event(
log.debug, u"create, read_only: {0}, subsection: {1}".format(read_only, subsection.location), subsection,
)
subsection_grade = self._get_bulk_cached_grade(subsection)
if not subsection_grade:
if assume_zero_if_absent(self.course_data.course_key):
subsection_grade = ZeroSubsectionGrade(subsection, self.course_data)
else:
subsection_grade = SubsectionGrade(subsection).init_from_structure(
self.student, self.course_data.structure, self._submissions_scores, self._csm_scores,
)
if should_persist_grades(self.course_data.course_key):
if read_only:
self._unsaved_subsection_grades.append(subsection_grade)
else:
grade_model = subsection_grade.create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return subsection_grade
def bulk_create_unsaved(self):
"""
Bulk creates all the unsaved subsection_grades to this point.
"""
SubsectionGrade.bulk_create_models(self.student, self._unsaved_subsection_grades, self.course_data.course_key)
self._unsaved_subsection_grades = []
def update(self, subsection, only_if_higher=None):
"""
Updates the SubsectionGrade object for the student and subsection.
"""
# Save ourselves the extra queries if the course does not persist
# subsection grades.
self._log_event(log.warning, u"update, subsection: {}".format(subsection.location), subsection)
calculated_grade = SubsectionGrade(subsection).init_from_structure(
self.student, self.course_data.structure, self._submissions_scores, self._csm_scores,
)
if should_persist_grades(self.course_data.course_key):
if only_if_higher:
try:
grade_model = PersistentSubsectionGrade.read_grade(self.student.id, subsection.location)
except PersistentSubsectionGrade.DoesNotExist:
pass
else:
orig_subsection_grade = SubsectionGrade(subsection).init_from_model(
self.student, grade_model, self.course_data.structure, self._submissions_scores, self._csm_scores,
)
if not is_score_higher_or_equal(
orig_subsection_grade.graded_total.earned,
orig_subsection_grade.graded_total.possible,
calculated_grade.graded_total.earned,
calculated_grade.graded_total.possible,
):
return orig_subsection_grade
grade_model = calculated_grade.update_or_create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return calculated_grade
@lazy
def _csm_scores(self):
"""
Lazily queries and returns all the scores stored in the user
state (in CSM) for the course, while caching the result.
"""
scorable_locations = [block_key for block_key in self.course_data.structure if possibly_scored(block_key)]
return ScoresClient.create_for_locations(self.course_data.course_key, self.student.id, scorable_locations)
@lazy
def _submissions_scores(self):
"""
Lazily queries and returns the scores stored by the
Submissions API for the course, while caching the result.
"""
anonymous_user_id = anonymous_id_for_user(self.student, self.course_data.course_key)
return submissions_api.get_scores(str(self.course_data.course_key), anonymous_user_id)
def _get_bulk_cached_grade(self, subsection):
"""
Returns the student's SubsectionGrade for the subsection,
while caching the results of a bulk retrieval for the
course, for future access of other subsections.
Returns None if not found.
"""
if should_persist_grades(self.course_data.course_key):
saved_subsection_grades = self._get_bulk_cached_subsection_grades()
subsection_grade = saved_subsection_grades.get(subsection.location)
if subsection_grade:
return SubsectionGrade(subsection).init_from_model(
self.student, subsection_grade, self.course_data.structure, self._submissions_scores, self._csm_scores,
)
def _get_bulk_cached_subsection_grades(self):
"""
Returns and caches (for future access) the results of
a bulk retrieval of all subsection grades in the course.
"""
if self._cached_subsection_grades is None:
self._cached_subsection_grades = {
record.full_usage_key: record
for record in PersistentSubsectionGrade.bulk_read_grades(self.student.id, self.course_data.course_key)
}
return self._cached_subsection_grades
def _update_saved_subsection_grade(self, subsection_usage_key, subsection_model):
"""
Updates (or adds) the subsection grade for the given
subsection usage key in the local cache, iff the cache
is populated.
"""
if self._cached_subsection_grades is not None:
self._cached_subsection_grades[subsection_usage_key] = subsection_model
def _log_event(self, log_func, log_statement, subsection):
"""
Logs the given statement, for this instance.
"""
log_func(u"Grades: SGF.{}, course: {}, version: {}, edit: {}, user: {}".format(
log_statement,
self.course_data.course_key,
getattr(subsection, 'course_version', None),
getattr(subsection, 'subtree_edited_on', None),
self.student.id,
))
......@@ -5,7 +5,6 @@ from logging import getLogger
from openedx.core.lib.cache_utils import memoized
from xblock.core import XBlock
from xmodule.block_metadata_utils import display_name_with_default_escaped
from xmodule.graders import ProblemScore
from .transformer import GradesTransformer
......
......@@ -26,7 +26,7 @@ from .signals import (
SCORE_PUBLISHED,
)
from ..constants import ScoreDatabaseTableEnum
from ..new.course_grade import CourseGradeFactory
from ..new.course_grade_factory import CourseGradeFactory
from ..scores import weighted_score
from ..tasks import recalculate_subsection_grade_v3, RECALCULATE_GRADE_DELAY
......@@ -238,7 +238,7 @@ def recalculate_course_grade(sender, course, course_structure, user, **kwargs):
"""
Updates a saved course grade.
"""
CourseGradeFactory().update(user, course, course_structure)
CourseGradeFactory().update(user, course=course, course_structure=course_structure)
def _emit_problem_submitted_event(kwargs):
......
......@@ -31,8 +31,8 @@ from util.date_utils import from_timestamp
from xmodule.modulestore.django import modulestore
from .constants import ScoreDatabaseTableEnum
from .new.subsection_grade import SubsectionGradeFactory
from .new.course_grade import CourseGradeFactory
from .new.subsection_grade_factory import SubsectionGradeFactory
from .new.course_grade_factory import CourseGradeFactory
from .signals.signals import SUBSECTION_SCORE_CHANGED
from .transformer import GradesTransformer
......@@ -73,11 +73,7 @@ def compute_grades_for_course(course_key, offset, batch_size):
course = courses.get_course_by_id(CourseKey.from_string(course_key))
enrollments = CourseEnrollment.objects.filter(course_id=course.id).order_by('created')
student_iter = (enrollment.user for enrollment in enrollments[offset:offset + batch_size])
list(CourseGradeFactory().iter(
course,
students=student_iter,
read_only=False,
))
list(CourseGradeFactory().iter(course, students=student_iter, force_update=True))
@task(bind=True, base=_BaseTask, default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
......@@ -182,7 +178,7 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs):
if not db_is_updated:
log.info(
u"Persistent Grades: tasks._has_database_updated_with_new_score is False. Task ID: {}. Kwargs: {}. Found "
u"Grades: tasks._has_database_updated_with_new_score is False. Task ID: {}. Kwargs: {}. Found "
u"modified time: {}".format(
self.request.id,
kwargs,
......
......@@ -15,7 +15,7 @@ from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore import ModuleStoreEnum
from ...new.subsection_grade import SubsectionGradeFactory
from ...new.subsection_grade_factory import SubsectionGradeFactory
class GradesAccessIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreTestCase):
......
"""
Tests for CourseData utility class.
"""
from lms.djangoapps.course_blocks.api import get_course_blocks
from mock import patch
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..new.course_data import CourseData
class CourseDataTest(ModuleStoreTestCase):
"""
Simple tests to ensure CourseData works as advertised.
"""
def setUp(self):
super(CourseDataTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
self.one_true_structure = get_course_blocks(self.user, self.course.location)
self.expected_results = {
'course': self.course,
'collected_block_structure': self.one_true_structure,
'structure': self.one_true_structure,
'course_key': self.course.id,
'location': self.course.location,
}
@patch('lms.djangoapps.grades.new.course_data.get_course_blocks')
def test_fill_course_data(self, mock_get_blocks):
"""
Tests to ensure that course data is fully filled with just a single input.
"""
mock_get_blocks.return_value = self.one_true_structure
for kwarg in self.expected_results: # We iterate instead of ddt due to dependence on 'self'
if kwarg == 'location':
continue # This property is purely output; it's never able to be used as input
kwargs = {kwarg: self.expected_results[kwarg]}
course_data = CourseData(self.user, **kwargs)
for arg in self.expected_results:
# No point validating the data we used as input, and c_b_s is input-only
if arg != kwarg and arg != "collected_block_structure":
expected = self.expected_results[arg]
actual = getattr(course_data, arg)
self.assertEqual(expected, actual)
def test_no_data(self):
"""
Tests to ensure ??? happens when none of the data are provided.
Maybe a dict pairing asked-for properties to resulting exceptions? Or an exception on init?
"""
with self.assertRaises(ValueError):
_ = CourseData(self.user)
......@@ -19,8 +19,8 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from .utils import answer_problem
from ..new.course_grade import CourseGradeFactory
from ..new.subsection_grade import SubsectionGradeFactory
from ..new.course_grade_factory import CourseGradeFactory
from ..new.subsection_grade_factory import SubsectionGradeFactory
@attr(shard=1)
......@@ -78,7 +78,7 @@ class TestGradeIteration(SharedModuleStoreTestCase):
self.assertIsNone(course_grade.letter_grade)
self.assertEqual(course_grade.percent, 0.0)
@patch('lms.djangoapps.grades.new.course_grade.CourseGradeFactory.create')
@patch('lms.djangoapps.grades.new.course_grade_factory.CourseGradeFactory.create')
def test_grading_exception(self, mock_course_grade):
"""Test that we correctly capture exception messages that bubble up from
grading. Note that we only see errors at this level if the grading
......
......@@ -7,13 +7,13 @@ import datetime
import ddt
from django.conf import settings
from django.db.utils import DatabaseError
import itertools
from mock import patch
import pytz
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from courseware.access import has_access
from courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags
......@@ -25,9 +25,12 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.utils import TEST_DATA_DIR
from xmodule.modulestore.xml_importer import import_course_from_xml
from ..config.waffle import waffle, ASSUME_ZERO_GRADE_IF_ABSENT
from ..models import PersistentSubsectionGrade
from ..new.course_grade import CourseGradeFactory
from ..new.subsection_grade import SubsectionGrade, SubsectionGradeFactory
from ..new.course_data import CourseData
from ..new.course_grade_factory import CourseGradeFactory
from ..new.course_grade import ZeroCourseGrade, CourseGrade
from ..new.subsection_grade_factory import SubsectionGrade, SubsectionGradeFactory
from .utils import mock_get_score, mock_get_submissions_score
......@@ -100,6 +103,19 @@ class TestCourseGradeFactory(GradeTestBase):
}
self.course.set_grading_policy(grading_policy)
def test_course_grade_no_access(self):
"""
Test to ensure a grade can ba calculated for a student in a course, even if they themselves do not have access.
"""
invisible_course = CourseFactory.create(visible_to_staff_only=True)
access = has_access(self.request.user, 'load', invisible_course)
self.assertEqual(access.has_access, False)
self.assertEqual(access.error_code, 'not_visible_to_user')
# with self.assertNoExceptionRaised: <- this isn't a real method, it's an implicit assumption
grade = CourseGradeFactory().create(self.request.user, invisible_course)
self.assertEqual(grade.percent, 0)
@patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False})
@ddt.data(
(True, True),
......@@ -118,9 +134,9 @@ class TestCourseGradeFactory(GradeTestBase):
course_id=self.course.id,
enabled_for_course=course_setting
):
with patch('lms.djangoapps.grades.new.course_grade.CourseGrade.load_persisted_grade') as mock_save_grades:
with patch('lms.djangoapps.grades.models.PersistentCourseGrade.read_course_grade') as mock_read_grade:
grade_factory.create(self.request.user, self.course)
self.assertEqual(mock_save_grades.called, feature_flag and course_setting)
self.assertEqual(mock_read_grade.called, feature_flag and course_setting)
def test_course_grade_creation(self):
grade_factory = CourseGradeFactory()
......@@ -129,22 +145,27 @@ class TestCourseGradeFactory(GradeTestBase):
self.assertEqual(course_grade.letter_grade, u'Pass')
self.assertEqual(course_grade.percent, 0.5)
def test_zero_course_grade(self):
@ddt.data(True, False)
def test_zero_course_grade(self, assume_zero_enabled):
with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=assume_zero_enabled):
grade_factory = CourseGradeFactory()
with mock_get_score(0, 2):
course_grade = grade_factory.create(self.request.user, self.course)
self.assertIsInstance(course_grade, ZeroCourseGrade if assume_zero_enabled else CourseGrade)
self.assertIsNone(course_grade.letter_grade)
self.assertEqual(course_grade.percent, 0.0)
self.assertIsNotNone(course_grade.chapter_grades)
def test_get_persisted(self):
grade_factory = CourseGradeFactory()
# first, create a grade in the database
with mock_get_score(1, 2):
grade_factory.create(self.request.user, self.course, read_only=False)
grade_factory.update(self.request.user, self.course)
# retrieve the grade, ensuring it is as expected and take just one query
with self.assertNumQueries(1):
course_grade = grade_factory.get_persisted(self.request.user, self.course)
course_grade = grade_factory.read(self.request.user, self.course)
self.assertEqual(course_grade.letter_grade, u'Pass')
self.assertEqual(course_grade.percent, 0.5)
......@@ -165,10 +186,10 @@ class TestCourseGradeFactory(GradeTestBase):
}
self.course.set_grading_policy(new_grading_policy)
# ensure the grade can still be retrieved via get_persisted
# ensure the grade can still be retrieved via read
# despite its outdated grading policy
with self.assertNumQueries(1):
course_grade = grade_factory.get_persisted(self.request.user, self.course)
course_grade = grade_factory.read(self.request.user, self.course)
self.assertEqual(course_grade.letter_grade, u'Pass')
self.assertEqual(course_grade.percent, 0.5)
......@@ -211,10 +232,10 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
wraps=PersistentSubsectionGrade.create_grade
) as mock_create_grade:
with patch(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_bulk_cached_grade',
'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory._get_bulk_cached_grade',
wraps=self.subsection_grade_factory._get_bulk_cached_grade
) as mock_get_bulk_cached_grade:
with self.assertNumQueries(12):
with self.assertNumQueries(14):
grade_a = self.subsection_grade_factory.create(self.sequence)
self.assertTrue(mock_get_bulk_cached_grade.called)
self.assertTrue(mock_create_grade.called)
......@@ -279,6 +300,26 @@ class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)
class ZeroGradeTest(GradeTestBase):
"""
Tests ZeroCourseGrade (and, implicitly, ZeroSubsectionGrade)
functionality.
"""
def test_zero(self):
"""
Creates a ZeroCourseGrade and ensures it's empty.
"""
course_data = CourseData(self.request.user, structure=self.course_structure)
chapter_grades = ZeroCourseGrade(self.request.user, course_data).chapter_grades
for chapter in chapter_grades:
for section in chapter_grades[chapter]['sections']:
for score in section.locations_to_scores.itervalues():
self.assertEqual(score.earned, 0)
self.assertEqual(score.attempted, False)
self.assertEqual(section.all_total.earned, 0)
class SubsectionGradeTest(GradeTestBase):
"""
Tests SubsectionGrade functionality.
......@@ -579,22 +620,18 @@ class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCa
def _create_course_grade_and_check_logging(
self,
factory,
factory_method,
log_mock,
log_statement
log_statement,
):
"""
Creates a course grade and asserts that the associated logging
matches the expected totals passed in to the function.
"""
factory.create(self.request.user, self.course, read_only=False)
log_mock.assert_called_with(
u"Persistent Grades: CourseGrade.{0}, course: {1}, user: {2}".format(
log_statement,
unicode(self.course.id),
unicode(self.request.user.id),
)
)
factory_method(self.request.user, self.course)
self.assertIn(log_statement, log_mock.info.call_args[0][0])
self.assertIn(unicode(self.course.id), log_mock.info.call_args[0][1])
self.assertEquals(self.request.user.id, log_mock.info.call_args[0][2])
def test_course_grade_logging(self):
grade_factory = CourseGradeFactory()
......@@ -604,37 +641,19 @@ class TestCourseGradeLogging(ProblemSubmissionTestMixin, SharedModuleStoreTestCa
course_id=self.course.id,
enabled_for_course=True
):
with patch('lms.djangoapps.grades.new.course_grade.log') as log_mock:
# the course grade has not been created, so we expect each grade to be created
log_statement = u''.join((
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks ",
u"accessed: {3}, total graded subsections: {4}"
)).format(False, 0, 3, 3, 2)
self._create_course_grade_and_check_logging(
grade_factory,
log_mock.warning,
log_statement
)
log_mock.reset_mock()
# the course grade has been created, so we expect to read it from the db
log_statement = u"load_persisted_grade"
self._create_course_grade_and_check_logging(
grade_factory,
log_mock.info,
log_statement
)
log_mock.reset_mock()
# only problem submission, a subsection grade update triggers
# a course grade update
self.submit_question_answer(u'test_problem_1', {u'2_1': u'choice_choice_2'})
log_statement = u''.join((
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks ",
u"accessed: {3}, total graded subsections: {4}"
)).format(False, 3, 0, 3, 2)
self._create_course_grade_and_check_logging(
grade_factory,
log_mock.warning,
log_statement
)
with patch('lms.djangoapps.grades.new.course_grade_factory.log') as log_mock:
# returns Zero when no grade, with ASSUME_ZERO_GRADE_IF_ABSENT
with waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=True):
self._create_course_grade_and_check_logging(grade_factory.create, log_mock, u'CreateZero')
# read, but not persisted
self._create_course_grade_and_check_logging(grade_factory.create, log_mock, u'Update')
# update and persist
self._create_course_grade_and_check_logging(grade_factory.update, log_mock, u'Update')
# read from persistence, using create
self._create_course_grade_and_check_logging(grade_factory.create, log_mock, u'Read')
# read from persistence, using read
self._create_course_grade_and_check_logging(grade_factory.read, log_mock, u'Read')
......@@ -154,10 +154,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 26, True),
(ModuleStoreEnum.Type.mongo, 1, 23, False),
(ModuleStoreEnum.Type.split, 3, 25, True),
(ModuleStoreEnum.Type.split, 3, 22, False),
(ModuleStoreEnum.Type.mongo, 1, 28, True),
(ModuleStoreEnum.Type.mongo, 1, 24, False),
(ModuleStoreEnum.Type.split, 3, 27, True),
(ModuleStoreEnum.Type.split, 3, 23, False),
)
@ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
......@@ -169,8 +169,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade()
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 26),
(ModuleStoreEnum.Type.split, 3, 25),
(ModuleStoreEnum.Type.mongo, 1, 28),
(ModuleStoreEnum.Type.split, 3, 27),
)
@ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
......@@ -230,8 +230,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 24),
(ModuleStoreEnum.Type.split, 3, 23),
(ModuleStoreEnum.Type.mongo, 1, 25),
(ModuleStoreEnum.Type.split, 3, 24),
)
@ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
......@@ -244,7 +244,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertGreater(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@patch('lms.djangoapps.grades.signals.signals.SUBSECTION_SCORE_CHANGED.send')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
@patch('lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.update')
def test_retry_first_time_only(self, mock_update, mock_course_signal):
"""
Ensures that a task retry completes after a one-time failure.
......@@ -255,7 +255,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_course_signal.call_count, 1)
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
@patch('lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.update')
def test_retry_on_integrity_error(self, mock_update, mock_retry):
"""
Ensures that tasks will be retried if IntegrityErrors are encountered.
......@@ -287,7 +287,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._assert_retry_called(mock_retry)
self.assertIn(
u"Persistent Grades: tasks._has_database_updated_with_new_score is False.",
u"Grades: tasks._has_database_updated_with_new_score is False.",
mock_log.info.call_args_list[0][0][0]
)
......@@ -319,13 +319,13 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
else:
self._assert_retry_called(mock_retry)
self.assertIn(
u"Persistent Grades: tasks._has_database_updated_with_new_score is False.",
u"Grades: tasks._has_database_updated_with_new_score is False.",
mock_log.info.call_args_list[0][0][0]
)
@patch('lms.djangoapps.grades.tasks.log')
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
@patch('lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.update')
def test_log_unknown_error(self, mock_update, mock_retry, mock_log):
"""
Ensures that unknown errors are logged before a retry.
......@@ -338,7 +338,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
@patch('lms.djangoapps.grades.tasks.log')
@patch('lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.retry')
@patch('lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory.update')
@patch('lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.update')
def test_no_log_known_error(self, mock_update, mock_retry, mock_log):
"""
Ensures that known errors are not logged before a retry.
......@@ -395,7 +395,7 @@ class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase
result = compute_grades_for_course.delay(
course_key=six.text_type(self.course.id),
batch_size=batch_size,
offset=4
offset=4,
)
self.assertTrue(result.successful)
self.assertEqual(
......@@ -409,8 +409,8 @@ class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase
@ddt.data(*xrange(1, 12, 3))
def test_database_calls(self, batch_size):
per_user_queries = 18 * min(batch_size, 6) # No more than 6 due to offset
with self.assertNumQueries(3 + per_user_queries):
per_user_queries = 17 * min(batch_size, 6) # No more than 6 due to offset
with self.assertNumQueries(5 + per_user_queries):
with check_mongo_calls(1):
compute_grades_for_course.delay(
course_key=six.text_type(self.course.id),
......
......@@ -14,7 +14,7 @@ def mock_passing_grade(grade_pass='Pass', percent=0.75, ):
Mock the grading function to always return a passing grade.
"""
with patch('lms.djangoapps.grades.new.course_grade.CourseGrade._compute_letter_grade') as mock_letter_grade:
with patch('lms.djangoapps.grades.new.course_grade.CourseGrade._calc_percent') as mock_percent_grade:
with patch('lms.djangoapps.grades.new.course_grade.CourseGrade._compute_percent') as mock_percent_grade:
mock_letter_grade.return_value = grade_pass
mock_percent_grade.return_value = percent
yield
......
......@@ -73,6 +73,18 @@ class GradesTransformer(BlockStructureTransformer):
pass
@classmethod
def grading_policy_hash(cls, course):
"""
Returns the grading policy hash for the given course.
"""
ordered_policy = json.dumps(
course.grading_policy,
separators=(',', ':'), # Remove spaces from separators for more compact representation
sort_keys=True,
)
return b64encode(sha1(ordered_policy).digest())
@classmethod
def _collect_explicit_graded(cls, block_structure):
"""
Collect the 'explicit_graded' field for every block.
......@@ -137,27 +149,13 @@ class GradesTransformer(BlockStructureTransformer):
Collect a hash of the course's grading policy, storing it as a
`transformer_block_field` associated with the `GradesTransformer`.
"""
def _hash_grading_policy(policy):
"""
Creates a hash from the course grading policy.
The keys are sorted in order to make the hash
agnostic to the ordering of the policy coming in.
"""
ordered_policy = json.dumps(
policy,
separators=(',', ':'), # Remove spaces from separators for more compact representation
sort_keys=True,
)
return b64encode(sha1(ordered_policy).digest())
course_location = block_structure.root_block_usage_key
course_block = block_structure.get_xblock(course_location)
grading_policy = course_block.grading_policy
block_structure.set_transformer_block_field(
course_block.location,
cls,
"grading_policy_hash",
_hash_grading_policy(grading_policy)
cls.grading_policy_hash(course_block),
)
@staticmethod
......
......@@ -17,7 +17,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from ccx_keys.locator import CCXLocator
from courseware.models import StudentModule
from grades.new.subsection_grade import SubsectionGradeFactory
from grades.new.subsection_grade_factory import SubsectionGradeFactory
from grades.tests.utils import answer_problem
from lms.djangoapps.ccx.tests.factories import CcxFactory
from lms.djangoapps.course_blocks.api import get_course_blocks
......
......@@ -14,7 +14,7 @@ from opaque_keys.edx.keys import CourseKey
from edxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access
from lms.djangoapps.instructor.views.api import require_level
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from xmodule.modulestore.django import modulestore
......
......@@ -42,7 +42,7 @@ from certificates.models import (
)
from courseware.courses import get_course_by_id, get_problems_in_section
from lms.djangoapps.grades.context import grading_context_for_course
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
from courseware.models import StudentModule
from courseware.module_render import get_module_for_descriptor_internal
......@@ -835,7 +835,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
student,
course_id,
course_grade.percent,
course_grade.course.grade_cutoffs,
course.grade_cutoffs,
student.profile.allow_certificate,
student.id in whitelisted_user_ids
)
......@@ -855,7 +855,9 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
else:
grade_results.append([u'Not Attempted'])
if assignment_info['use_subsection_headers']:
assignment_average = course_grade.grade_value['grade_breakdown'].get(assignment_type, {}).get('percent')
assignment_average = course_grade.grader_result['grade_breakdown'].get(assignment_type, {}).get(
'percent'
)
grade_results.append([assignment_average])
grade_results = list(chain.from_iterable(grade_results))
......
......@@ -40,7 +40,7 @@ from lms.djangoapps.instructor_task.tests.test_base import (
OPTION_2,
)
from capa.responsetypes import StudentInputError
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from openedx.core.lib.url_utils import quote_slashes
......
......@@ -117,7 +117,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
self.assertDictContainsSubset({'attempted': num_students, 'succeeded': num_students, 'failed': 0}, result)
@patch('lms.djangoapps.instructor_task.tasks_helper._get_current_task')
@patch('lms.djangoapps.grades.new.course_grade.CourseGradeFactory.iter')
@patch('lms.djangoapps.grades.new.course_grade_factory.CourseGradeFactory.iter')
def test_grading_failure(self, mock_grades_iter, _mock_current_task):
"""
Test that any grading errors are properly reported in the
......@@ -294,7 +294,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
)
@patch('lms.djangoapps.instructor_task.tasks_helper._get_current_task')
@patch('lms.djangoapps.grades.new.course_grade.CourseGradeFactory.iter')
@patch('lms.djangoapps.grades.new.course_grade_factory.CourseGradeFactory.iter')
def test_unicode_in_csv_header(self, mock_grades_iter, _mock_current_task):
"""
Tests that CSV grade report works if unicode in headers.
......@@ -650,7 +650,7 @@ class TestProblemGradeReport(TestReportMixin, InstructorTaskModuleTestCase):
])
@patch('lms.djangoapps.instructor_task.tasks_helper._get_current_task')
@patch('lms.djangoapps.grades.new.course_grade.CourseGradeFactory.iter')
@patch('lms.djangoapps.grades.new.course_grade_factory.CourseGradeFactory.iter')
@ddt.data(u'Cannot grade student', '')
def test_grading_failure(self, error_message, mock_grades_iter, _mock_current_task):
"""
......@@ -1775,7 +1775,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(184):
with self.assertNumQueries(186):
self.assertCertificatesGenerated(task_input, expected_results)
expected_results = {
......
......@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
from django.dispatch import receiver
import logging
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED
from lms import CELERY_APP
from lti_provider.models import GradedAssignment
......
......@@ -5,8 +5,7 @@ from abc import ABCMeta
from contextlib import contextmanager
import logging
from waffle.models import Switch
from waffle.utils import get_setting as waffle_setting
from waffle.testutils import override_switch as waffle_override_switch
from waffle import switch_is_active
from request_cache import get_cache as get_request_cache
......@@ -81,6 +80,18 @@ class WaffleSwitchPlus(WafflePlus):
self._cached_switches[namespaced_switch_name] = active
log.info(u"%sSwitch '%s' set to %s for request.", self.log_prefix, namespaced_switch_name, active)
@contextmanager
def override_in_model(self, switch_name, active=True):
"""
Overrides the active value for the given switch for the duration of this
contextmanager.
Note: The value is overridden in the request cache AND in the model.
"""
with self.override(switch_name, active):
namespaced_switch_name = self._namespaced_setting_name(switch_name)
with waffle_override_switch(namespaced_switch_name, active):
yield
@property
def _cached_switches(self):
"""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment