Commit 07fb1645 by Clinton Blackburn

Merge pull request #12381 from edx/clintonb/credit-fix

Multiple Credit Fixes
parents 16213573 bcd762ef
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
Django admin page for credit eligibility Django admin page for credit eligibility
""" """
from ratelimitbackend import admin from ratelimitbackend import admin
from openedx.core.djangoapps.credit.models import ( from openedx.core.djangoapps.credit.models import (
CreditConfig, CreditCourse, CreditProvider, CreditEligibility, CreditRequest CreditConfig, CreditCourse, CreditProvider, CreditEligibility, CreditRequest, CreditRequirement,
CreditRequirementStatus
) )
...@@ -47,8 +49,26 @@ class CreditRequestAdmin(admin.ModelAdmin): ...@@ -47,8 +49,26 @@ class CreditRequestAdmin(admin.ModelAdmin):
model = CreditRequest model = CreditRequest
class CreditRequirementAdmin(admin.ModelAdmin):
""" Admin for CreditRequirement. """
list_display = ('course', 'namespace', 'name', 'display_name', 'active',)
class Meta(object):
model = CreditRequirement
class CreditRequirementStatusAdmin(admin.ModelAdmin):
""" Admin for CreditRequirementStatus. """
list_display = ('username', 'requirement', 'status',)
class Meta(object):
model = CreditRequirementStatus
admin.site.register(CreditCourse, CreditCourseAdmin) admin.site.register(CreditCourse, CreditCourseAdmin)
admin.site.register(CreditProvider, CreditProviderAdmin) admin.site.register(CreditProvider, CreditProviderAdmin)
admin.site.register(CreditEligibility, CreditEligibilityAdmin) admin.site.register(CreditEligibility, CreditEligibilityAdmin)
admin.site.register(CreditRequest, CreditRequestAdmin) admin.site.register(CreditRequest, CreditRequestAdmin)
admin.site.register(CreditConfig) admin.site.register(CreditConfig)
admin.site.register(CreditRequirement, CreditRequirementAdmin)
admin.site.register(CreditRequirementStatus, CreditRequirementStatusAdmin)
...@@ -7,13 +7,10 @@ import logging ...@@ -7,13 +7,10 @@ import logging
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
from openedx.core.djangoapps.credit.email_utils import send_credit_notifications from openedx.core.djangoapps.credit.email_utils import send_credit_notifications
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements, InvalidCreditCourse
from openedx.core.djangoapps.credit.models import ( from openedx.core.djangoapps.credit.models import (
CreditCourse, CreditCourse, CreditRequirement, CreditRequirementStatus, CreditEligibility, CreditRequest
CreditRequirement,
CreditRequirementStatus,
CreditEligibility,
) )
# TODO: Cleanup this mess! ECOM-2908 # TODO: Cleanup this mess! ECOM-2908
...@@ -228,13 +225,21 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name, ...@@ -228,13 +225,21 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name,
) )
""" """
# Check if we're already eligible for credit. # Do not allow students who have requested credit to change their eligibility
# If so, short-circuit this process. if CreditRequest.get_user_request_status(username, course_key):
if CreditEligibility.is_user_eligible_for_credit(course_key, username): log.info(
u'Refusing to set status of requirement with namespace "%s" and name "%s" because the '
u'user "%s" has already requested credit for the course "%s".',
req_namespace, req_name, username, course_key
)
return
# Do not allow a student who has earned eligibility to un-earn eligibility
eligible_before_update = CreditEligibility.is_user_eligible_for_credit(course_key, username)
if eligible_before_update and status == 'failed':
log.info( log.info(
u'Skipping update of credit requirement with namespace "%s" ' u'Refusing to set status of requirement with namespace "%s" and name "%s" to "failed" because the '
u'and name "%s" because the user "%s" is already eligible for credit ' u'user "%s" is already eligible for credit in the course "%s".',
u'in the course "%s".',
req_namespace, req_name, username, course_key req_namespace, req_name, username, course_key
) )
return return
...@@ -273,9 +278,9 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name, ...@@ -273,9 +278,9 @@ def set_credit_requirement_status(username, course_key, req_namespace, req_name,
username, req_to_update, status=status, reason=reason username, req_to_update, status=status, reason=reason
) )
# If we're marking this requirement as "satisfied", there's a chance # If we're marking this requirement as "satisfied", there's a chance that the user has met all eligibility
# that the user has met all eligibility requirements. # requirements, and should be notified. However, if the user was already eligible, do not send another notification.
if status == "satisfied": if status == "satisfied" and not eligible_before_update:
is_eligible, eligibility_record_created = CreditEligibility.update_eligibility(reqs, username, course_key) is_eligible, eligibility_record_created = CreditEligibility.update_eligibility(reqs, username, course_key)
if eligibility_record_created and is_eligible: if eligibility_record_created and is_eligible:
try: try:
......
...@@ -294,6 +294,9 @@ class CreditRequirement(TimeStampedModel): ...@@ -294,6 +294,9 @@ class CreditRequirement(TimeStampedModel):
unique_together = ('namespace', 'name', 'course') unique_together = ('namespace', 'name', 'course')
ordering = ["order"] ordering = ["order"]
def __unicode__(self):
return self.display_name
@classmethod @classmethod
def add_or_update_course_requirement(cls, credit_course, requirement, order): def add_or_update_course_requirement(cls, credit_course, requirement, order):
""" """
......
...@@ -7,11 +7,10 @@ import logging ...@@ -7,11 +7,10 @@ import logging
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from openedx.core.djangoapps.credit.verification_access import update_verification_partitions
from xmodule.modulestore.django import SignalHandler from xmodule.modulestore.django import SignalHandler
from openedx.core.djangoapps.credit.verification_access import update_verification_partitions
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -55,8 +54,7 @@ def on_pre_publish(sender, course_key, **kwargs): # pylint: disable=unused-argu ...@@ -55,8 +54,7 @@ def on_pre_publish(sender, course_key, **kwargs): # pylint: disable=unused-argu
@receiver(GRADES_UPDATED) @receiver(GRADES_UPDATED)
def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument
"""Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade """Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade requirement status.
requirement status.
Args: Args:
sender: None sender: None
...@@ -81,12 +79,39 @@ def listen_for_grade_calculation(sender, username, grade_summary, course_key, de ...@@ -81,12 +79,39 @@ def listen_for_grade_calculation(sender, username, grade_summary, course_key, de
criteria = requirements[0].get('criteria') criteria = requirements[0].get('criteria')
if criteria: if criteria:
min_grade = criteria.get('min_grade') min_grade = criteria.get('min_grade')
if grade_summary['percent'] >= min_grade: passing_grade = grade_summary['percent'] >= min_grade
reason_dict = {'final_grade': grade_summary['percent']} now = timezone.now()
api.set_credit_requirement_status( status = None
username, course_id, 'grade', 'grade', status="satisfied", reason=reason_dict reason = None
)
elif deadline and deadline < timezone.now(): if (deadline and now < deadline) or not deadline:
# Student completed coursework on-time
if passing_grade:
# Student received a passing grade
status = 'satisfied'
reason = {'final_grade': grade_summary['percent']}
else:
# Submission after deadline
if passing_grade:
# Grade was good, but submission arrived too late
status = 'failed'
reason = {
'current_date': now,
'deadline': deadline
}
else:
# Student failed to receive minimum grade
status = 'failed'
reason = {
'final_grade': grade_summary['percent'],
'minimum_grade': min_grade
}
# We do not record a status if the user has not yet earned the minimum grade, but still has
# time to do so.
if status and reason:
api.set_credit_requirement_status( api.set_credit_requirement_status(
username, course_id, 'grade', 'grade', status="failed", reason={} username, course_id, 'grade', 'grade', status=status, reason=reason
) )
...@@ -33,7 +33,8 @@ from openedx.core.djangoapps.credit.models import ( ...@@ -33,7 +33,8 @@ from openedx.core.djangoapps.credit.models import (
CreditProvider, CreditProvider,
CreditRequirement, CreditRequirement,
CreditRequirementStatus, CreditRequirementStatus,
CreditEligibility CreditEligibility,
CreditRequest
) )
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from util.date_utils import from_timestamp from util.date_utils import from_timestamp
...@@ -371,8 +372,16 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -371,8 +372,16 @@ class CreditRequirementApiTests(CreditApiTestBase):
eligibilities = api.get_eligibilities_for_user("staff") eligibilities = api.get_eligibilities_for_user("staff")
self.assertEqual(eligibilities, []) self.assertEqual(eligibilities, [])
def assert_grade_requirement_status(self, expected_status, expected_order):
""" Assert the status and order of the grade requirement. """
req_status = api.get_credit_requirement_status(self.course_key, 'staff', namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], expected_status)
self.assertEqual(req_status[0]["order"], expected_order)
return req_status
def test_set_credit_requirement_status(self): def test_set_credit_requirement_status(self):
self.add_credit_course() username = "staff"
credit_course = self.add_credit_course()
requirements = [ requirements = [
{ {
"namespace": "grade", "namespace": "grade",
...@@ -395,40 +404,44 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -395,40 +404,44 @@ class CreditRequirementApiTests(CreditApiTestBase):
self.assertEqual(len(course_requirements), 2) self.assertEqual(len(course_requirements), 2)
# Initially, the status should be None # Initially, the status should be None
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") self.assert_grade_requirement_status(None, 0)
self.assertEqual(req_status[0]["status"], None)
self.assertEqual(req_status[0]["order"], 0) # Requirement statuses cannot be changed if a CreditRequest exists
credit_request = CreditRequest.objects.create(
course=credit_course,
provider=CreditProvider.objects.first(),
username=username,
)
api.set_credit_requirement_status(username, self.course_key, "grade", "grade")
self.assert_grade_requirement_status(None, 0)
credit_request.delete()
# Set the requirement to "satisfied" and check that it's actually set # Set the requirement to "satisfied" and check that it's actually set
api.set_credit_requirement_status("staff", self.course_key, "grade", "grade") api.set_credit_requirement_status(username, self.course_key, "grade", "grade")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") self.assert_grade_requirement_status('satisfied', 0)
self.assertEqual(req_status[0]["status"], "satisfied")
self.assertEqual(req_status[0]["order"], 0)
# Set the requirement to "failed" and check that it's actually set # Set the requirement to "failed" and check that it's actually set
api.set_credit_requirement_status("staff", self.course_key, "grade", "grade", status="failed") api.set_credit_requirement_status(username, self.course_key, "grade", "grade", status="failed")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade") self.assert_grade_requirement_status('failed', 0)
self.assertEqual(req_status[0]["status"], "failed")
self.assertEqual(req_status[0]["order"], 0)
req_status = api.get_credit_requirement_status(self.course_key, "staff") req_status = api.get_credit_requirement_status(self.course_key, "staff")
self.assertEqual(req_status[0]["status"], "failed") self.assertEqual(req_status[0]["status"], "failed")
self.assertEqual(req_status[0]["order"], 0) self.assertEqual(req_status[0]["order"], 0)
# make sure the 'order' on the 2nd requiemtn is set correctly (aka 1) # make sure the 'order' on the 2nd requirement is set correctly (aka 1)
self.assertEqual(req_status[1]["status"], None) self.assertEqual(req_status[1]["status"], None)
self.assertEqual(req_status[1]["order"], 1) self.assertEqual(req_status[1]["order"], 1)
# Set the requirement to "declined" and check that it's actually set # Set the requirement to "declined" and check that it's actually set
api.set_credit_requirement_status( api.set_credit_requirement_status(
"staff", self.course_key, username, self.course_key,
"reverification", "reverification",
"i4x://edX/DemoX/edx-reverification-block/assessment_uuid", "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
status="declined" status="declined"
) )
req_status = api.get_credit_requirement_status( req_status = api.get_credit_requirement_status(
self.course_key, self.course_key,
"staff", username,
namespace="reverification", namespace="reverification",
name="i4x://edX/DemoX/edx-reverification-block/assessment_uuid" name="i4x://edX/DemoX/edx-reverification-block/assessment_uuid"
) )
...@@ -530,7 +543,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -530,7 +543,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
user = UserFactory.create(username=self.USER_INFO['username'], password=self.USER_INFO['password']) user = UserFactory.create(username=self.USER_INFO['username'], password=self.USER_INFO['password'])
# Satisfy one of the requirements, but not the other # Satisfy one of the requirements, but not the other
with self.assertNumQueries(11): with self.assertNumQueries(12):
api.set_credit_requirement_status( api.set_credit_requirement_status(
user.username, user.username,
self.course_key, self.course_key,
...@@ -542,7 +555,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -542,7 +555,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
self.assertFalse(api.is_user_eligible_for_credit("bob", self.course_key)) self.assertFalse(api.is_user_eligible_for_credit("bob", self.course_key))
# Satisfy the other requirement # Satisfy the other requirement
with self.assertNumQueries(19): with self.assertNumQueries(20):
api.set_credit_requirement_status( api.set_credit_requirement_status(
"bob", "bob",
self.course_key, self.course_key,
...@@ -596,7 +609,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -596,7 +609,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
# Delete the eligibility entries and satisfy the user's eligibility # Delete the eligibility entries and satisfy the user's eligibility
# requirement again to trigger eligibility notification # requirement again to trigger eligibility notification
CreditEligibility.objects.all().delete() CreditEligibility.objects.all().delete()
with self.assertNumQueries(15): with self.assertNumQueries(16):
api.set_credit_requirement_status( api.set_credit_requirement_status(
"bob", "bob",
self.course_key, self.course_key,
......
...@@ -2,24 +2,23 @@ ...@@ -2,24 +2,23 @@
Tests for minimum grade requirement status Tests for minimum grade requirement status
""" """
import pytz
import ddt import ddt
import pytz
from datetime import timedelta, datetime from datetime import timedelta, datetime
from nose.plugins.attrib import attr from unittest import skipUnless
from django.conf import settings from django.conf import settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from unittest import skipUnless from nose.plugins.attrib import attr
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from openedx.core.djangoapps.credit.api import ( from openedx.core.djangoapps.credit.api import (
set_credit_requirements, get_credit_requirement_status set_credit_requirements, get_credit_requirement_status
) )
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider
from openedx.core.djangoapps.credit.signals import listen_for_grade_calculation from openedx.core.djangoapps.credit.signals import listen_for_grade_calculation
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@attr('shard_2') @attr('shard_2')
...@@ -67,20 +66,35 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase): ...@@ -67,20 +66,35 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
# Add a single credit requirement (final grade) # Add a single credit requirement (final grade)
set_credit_requirements(self.course.id, requirements) set_credit_requirements(self.course.id, requirements)
def assert_requirement_status(self, grade, due_date, expected_status):
""" Verify the user's credit requirement status is as expected after simulating a grading calculation. """
listen_for_grade_calculation(None, self.user.username, {'percent': grade}, self.course.id, due_date)
req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade')
self.assertEqual(req_status[0]['status'], expected_status)
if expected_status == 'satisfied':
expected_reason = {'final_grade': grade}
self.assertEqual(req_status[0]['reason'], expected_reason)
@ddt.data( @ddt.data(
(0.6, VALID_DUE_DATE), (0.6, VALID_DUE_DATE),
(0.52, VALID_DUE_DATE), (0.52, None),
(0.70, EXPIRED_DUE_DATE),
) )
@ddt.unpack @ddt.unpack
def test_min_grade_requirement_with_valid_grade(self, grade_achieved, due_date): def test_min_grade_requirement_with_valid_grade(self, grade, due_date):
"""Test with valid grades. Deadline date does not effect in case """Test with valid grades submitted before deadline"""
of valid grade. self.assert_requirement_status(grade, due_date, 'satisfied')
"""
listen_for_grade_calculation(None, self.user.username, {'percent': grade_achieved}, self.course.id, due_date) def test_grade_changed(self):
req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade') """ Verify successive calls to update a satisfied grade requirement are recorded. """
self.assertEqual(req_status[0]["status"], 'satisfied') self.assert_requirement_status(0.6, self.VALID_DUE_DATE, 'satisfied')
self.assert_requirement_status(0.75, self.VALID_DUE_DATE, 'satisfied')
self.assert_requirement_status(0.70, self.VALID_DUE_DATE, 'satisfied')
def test_min_grade_requirement_with_valid_grade_and_expired_deadline(self):
""" Verify the status is set to failure if a passing grade is received past the submission deadline. """
self.assert_requirement_status(0.70, self.EXPIRED_DUE_DATE, 'failed')
@ddt.data( @ddt.data(
(0.50, None), (0.50, None),
...@@ -88,16 +102,10 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase): ...@@ -88,16 +102,10 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase):
(0.40, VALID_DUE_DATE), (0.40, VALID_DUE_DATE),
) )
@ddt.unpack @ddt.unpack
def test_min_grade_requirement_failed_grade_valid_deadline(self, grade_achieved, due_date): def test_min_grade_requirement_failed_grade_valid_deadline(self, grade, due_date):
"""Test with failed grades and deadline is still open or not defined.""" """Test with failed grades and deadline is still open or not defined."""
self.assert_requirement_status(grade, due_date, None)
listen_for_grade_calculation(None, self.user.username, {'percent': grade_achieved}, self.course.id, due_date)
req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade')
self.assertEqual(req_status[0]["status"], None)
def test_min_grade_requirement_failed_grade_expired_deadline(self): def test_min_grade_requirement_failed_grade_expired_deadline(self):
"""Test with failed grades and deadline expire""" """Test with failed grades and deadline expire"""
self.assert_requirement_status(0.22, self.EXPIRED_DUE_DATE, 'failed')
listen_for_grade_calculation(None, self.user.username, {'percent': 0.22}, self.course.id, self.EXPIRED_DUE_DATE)
req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade')
self.assertEqual(req_status[0]["status"], 'failed')
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