Commit 4662246b by Will Daly

Merge pull request #8648 from edx/will/eligibility-provider-refactor

Update credit eligibility
parents 16807d7f 75d194e2
...@@ -1066,27 +1066,6 @@ def _progress(request, course_key, student_id): ...@@ -1066,27 +1066,6 @@ def _progress(request, course_key, student_id):
# checking certificate generation configuration # checking certificate generation configuration
show_generate_cert_btn = certs_api.cert_generation_enabled(course_key) show_generate_cert_btn = certs_api.cert_generation_enabled(course_key)
credit_course_requirements = None
is_course_credit = settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key)
if is_course_credit:
requirement_statuses = get_credit_requirement_status(course_key, student.username)
if any(requirement['status'] == 'failed' for requirement in requirement_statuses):
eligibility_status = "not_eligible"
elif is_user_eligible_for_credit(student.username, course_key):
eligibility_status = "eligible"
else:
eligibility_status = "partial_eligible"
paired_requirements = {}
for requirement in requirement_statuses:
namespace = requirement.pop("namespace")
paired_requirements.setdefault(namespace, []).append(requirement)
credit_course_requirements = {
'eligibility_status': eligibility_status,
'requirements': OrderedDict(sorted(paired_requirements.items(), reverse=True))
}
context = { context = {
'course': course, 'course': course,
'courseware_summary': courseware_summary, 'courseware_summary': courseware_summary,
...@@ -1096,8 +1075,7 @@ def _progress(request, course_key, student_id): ...@@ -1096,8 +1075,7 @@ def _progress(request, course_key, student_id):
'student': student, 'student': student,
'passed': is_course_passed(course, grade_summary), 'passed': is_course_passed(course, grade_summary),
'show_generate_cert_btn': show_generate_cert_btn, 'show_generate_cert_btn': show_generate_cert_btn,
'credit_course_requirements': credit_course_requirements, 'credit_course_requirements': _credit_course_requirements(course_key, student),
'is_credit_course': is_course_credit,
} }
if show_generate_cert_btn: if show_generate_cert_btn:
...@@ -1124,6 +1102,64 @@ def _progress(request, course_key, student_id): ...@@ -1124,6 +1102,64 @@ def _progress(request, course_key, student_id):
return response return response
def _credit_course_requirements(course_key, student):
"""Return information about which credit requirements a user has satisfied.
Arguments:
course_key (CourseKey): Identifier for the course.
student (User): Currently logged in user.
Returns: dict
"""
# If credit eligibility is not enabled or this is not a credit course,
# short-circuit and return `None`. This indicates that credit requirements
# should NOT be displayed on the progress page.
if not (settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key)):
return None
# Retrieve the status of the user for each eligibility requirement in the course.
# For each requirement, the user's status is either "satisfied", "failed", or None.
# In this context, `None` means that we don't know the user's status, either because
# the user hasn't done something (for example, submitting photos for verification)
# or we're waiting on more information (for example, a response from the photo
# verification service).
requirement_statuses = get_credit_requirement_status(course_key, student.username)
# If the user has been marked as "eligible", then they are *always* eligible
# unless someone manually intervenes. This could lead to some strange behavior
# if the requirements change post-launch. For example, if the user was marked as eligible
# for credit, then a new requirement was added, the user will see that they're eligible
# AND that one of the requirements is still pending.
# We're assuming here that (a) we can mitigate this by properly training course teams,
# and (b) it's a better user experience to allow students who were at one time
# marked as eligible to continue to be eligible.
# If we need to, we can always manually move students back to ineligible by
# deleting CreditEligibility records in the database.
if is_user_eligible_for_credit(student.username, course_key):
eligibility_status = "eligible"
# If the user has *failed* any requirements (for example, if a photo verification is denied),
# then the user is NOT eligible for credit.
elif any(requirement['status'] == 'failed' for requirement in requirement_statuses):
eligibility_status = "not_eligible"
# Otherwise, the user may be eligible for credit, but the user has not
# yet completed all the requirements.
else:
eligibility_status = "partial_eligible"
paired_requirements = {}
for requirement in requirement_statuses:
namespace = requirement.pop("namespace")
paired_requirements.setdefault(namespace, []).append(requirement)
return {
'eligibility_status': eligibility_status,
'requirements': OrderedDict(sorted(paired_requirements.items(), reverse=True))
}
@login_required @login_required
@ensure_valid_course_key @ensure_valid_course_key
def submission_history(request, course_id, student_username, location): def submission_history(request, course_id, student_username, location):
......
...@@ -38,7 +38,7 @@ from microsite_configuration import microsite ...@@ -38,7 +38,7 @@ from microsite_configuration import microsite
from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings from openedx.core.djangoapps.user_api.accounts.api import get_account_settings, update_account_settings
from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError
from openedx.core.djangoapps.credit.api import get_credit_requirement, set_credit_requirement_status from openedx.core.djangoapps.credit.api import set_credit_requirement_status
from student.models import CourseEnrollment from student.models import CourseEnrollment
from shoppingcart.models import Order, CertificateItem from shoppingcart.models import Order, CertificateItem
from shoppingcart.processors import ( from shoppingcart.processors import (
...@@ -897,14 +897,14 @@ def _set_user_requirement_status(attempt, namespace, status, reason=None): ...@@ -897,14 +897,14 @@ def _set_user_requirement_status(attempt, namespace, status, reason=None):
log.error("Unable to find checkpoint for user with id %d", attempt.user.id) log.error("Unable to find checkpoint for user with id %d", attempt.user.id)
if checkpoint is not None: if checkpoint is not None:
course_key = checkpoint.course_id
credit_requirement = get_credit_requirement(
course_key, namespace, checkpoint.checkpoint_location
)
if credit_requirement is not None:
try: try:
set_credit_requirement_status( set_credit_requirement_status(
attempt.user.username, credit_requirement, status, reason attempt.user.username,
checkpoint.course_id,
namespace,
checkpoint.checkpoint_location,
status=status,
reason=reason,
) )
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
# Catch exception if unable to add credit requirement # Catch exception if unable to add credit requirement
......
...@@ -103,7 +103,7 @@ from django.utils.http import urlquote_plus ...@@ -103,7 +103,7 @@ from django.utils.http import urlquote_plus
<div class="grade-detail-graph" id="grade-detail-graph" aria-hidden="true"></div> <div class="grade-detail-graph" id="grade-detail-graph" aria-hidden="true"></div>
%endif %endif
% if is_credit_course: % if credit_course_requirements:
<section class="credit-eligibility"> <section class="credit-eligibility">
<div class="credit-eligibility-container"> <div class="credit-eligibility-container">
<div class="eligibility-heading"> <div class="eligibility-heading">
......
...@@ -274,12 +274,12 @@ def create_credit_request(course_key, provider_id, username): ...@@ -274,12 +274,12 @@ def create_credit_request(course_key, provider_id, username):
# Retrieve the final grade from the eligibility table # Retrieve the final grade from the eligibility table
try: try:
final_grade = CreditRequirementStatus.objects.filter( final_grade = CreditRequirementStatus.objects.get(
username=username, username=username,
requirement__namespace="grade", requirement__namespace="grade",
requirement__name="grade", requirement__name="grade",
status="satisfied" status="satisfied"
).latest().reason["final_grade"] ).reason["final_grade"]
except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError): except (CreditRequirementStatus.DoesNotExist, TypeError, KeyError):
log.exception( log.exception(
"Could not retrieve final grade from the credit eligibility table " "Could not retrieve final grade from the credit eligibility table "
...@@ -410,7 +410,7 @@ def get_credit_requests_for_user(username): ...@@ -410,7 +410,7 @@ def get_credit_requests_for_user(username):
return CreditRequest.credit_requests_for_user(username) return CreditRequest.credit_requests_for_user(username)
def get_credit_requirement_status(course_key, username): def get_credit_requirement_status(course_key, username, namespace=None, name=None):
""" Retrieve the user's status for each credit requirement in the course. """ Retrieve the user's status for each credit requirement in the course.
Args: Args:
...@@ -447,7 +447,7 @@ def get_credit_requirement_status(course_key, username): ...@@ -447,7 +447,7 @@ def get_credit_requirement_status(course_key, username):
Returns: Returns:
list of requirement statuses list of requirement statuses
""" """
requirements = CreditRequirement.get_course_requirements(course_key) requirements = CreditRequirement.get_course_requirements(course_key, namespace=namespace, name=name)
requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username) requirement_statuses = CreditRequirementStatus.get_statuses(requirements, username)
requirement_statuses = dict((o.requirement, o) for o in requirement_statuses) requirement_statuses = dict((o.requirement, o) for o in requirement_statuses)
statuses = [] statuses = []
...@@ -511,36 +511,80 @@ def get_credit_requirement(course_key, namespace, name): ...@@ -511,36 +511,80 @@ def get_credit_requirement(course_key, namespace, name):
} if requirement else None } if requirement else None
def set_credit_requirement_status(username, requirement, status="satisfied", reason=None): def set_credit_requirement_status(username, course_key, req_namespace, req_name, status="satisfied", reason=None):
"""Update Credit Requirement Status for given username and requirement """
if exists else add new. Update the user's requirement status.
This will record whether the user satisfied or failed a particular requirement
in a course. If the user has satisfied all requirements, the user will be marked
as eligible for credit in the course.
Args: Args:
username(str): Username of the user username (str): Username of the user
requirement(dict): requirement dict course_key (CourseKey): Identifier for the course associated with the requirement.
status(str): Status of the requirement req_namespace (str): Namespace of the requirement (e.g. "grade" or "reverification")
reason(dict): Reason of the status req_name (str): Name of the requirement (e.g. "grade" or the location of the ICRV XBlock)
Keyword Arguments:
status (str): Status of the requirement (either "satisfied" or "failed")
reason (dict): Reason of the status
Example: Example:
>>> set_credit_requirement_status( >>> set_credit_requirement_status(
"staff", "staff",
{ CourseKey.from_string("course-v1-edX-DemoX-1T2015"),
"course_key": "course-v1-edX-DemoX-1T2015" "reverification",
"namespace": "reverification", "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", status="satisfied",
}, reason={}
"satisfied",
{}
) )
""" """
credit_requirement = CreditRequirement.get_course_requirement( # Check if we're already eligible for credit.
requirement['course_key'], requirement['namespace'], requirement['name'] # If so, short-circuit this process.
if CreditEligibility.is_user_eligible_for_credit(course_key, username):
return
# Retrieve all credit requirements for the course
# We retrieve all of them to avoid making a second query later when
# we need to check whether all requirements have been satisfied.
reqs = CreditRequirement.get_course_requirements(course_key)
# Find the requirement we're trying to set
req_to_update = next((
req for req in reqs
if req.namespace == req_namespace
and req.name == req_name
), None)
# If we can't find the requirement, then the most likely explanation
# is that there was a lag updating the credit requirements after the course
# was published. We *could* attempt to create the requirement here,
# but that could cause serious performance issues if many users attempt to
# lock the row at the same time.
# Instead, we skip updating the requirement and log an error.
if req_to_update is None:
log.error(
(
u'Could not update credit requirement in course "%s" '
u'with namespace "%s" and name "%s" '
u'because the requirement does not exist. '
u'The user "%s" should have had his/her status updated to "%s".'
),
unicode(course_key), req_namespace, req_name, username, status
) )
return
# Update the requirement status
CreditRequirementStatus.add_or_update_requirement_status( CreditRequirementStatus.add_or_update_requirement_status(
username, credit_requirement, status, reason username, req_to_update, status=status, reason=reason
) )
# If we're marking this requirement as "satisfied", there's a chance
# that the user has met all eligibility requirements.
if status == "satisfied":
CreditEligibility.update_eligibility(reqs, username, course_key)
def _get_requirements_to_disable(old_requirements, new_requirements): def _get_requirements_to_disable(old_requirements, new_requirements):
""" """
......
...@@ -6,10 +6,10 @@ Credit courses allow students to receive university credit for ...@@ -6,10 +6,10 @@ Credit courses allow students to receive university credit for
successful completion of a course on EdX successful completion of a course on EdX
""" """
from collections import defaultdict
import logging import logging
from django.db import models from django.db import models, transaction, IntegrityError
from django.db import transaction
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
...@@ -168,8 +168,11 @@ class CreditRequirement(TimeStampedModel): ...@@ -168,8 +168,11 @@ class CreditRequirement(TimeStampedModel):
course=credit_course, course=credit_course,
namespace=requirement["namespace"], namespace=requirement["namespace"],
name=requirement["name"], name=requirement["name"],
display_name=requirement["display_name"], defaults={
defaults={"criteria": requirement["criteria"], "active": True} "display_name": requirement["display_name"],
"criteria": requirement["criteria"],
"active": True
}
) )
if not created: if not created:
credit_requirement.criteria = requirement["criteria"] credit_requirement.criteria = requirement["criteria"]
...@@ -179,20 +182,29 @@ class CreditRequirement(TimeStampedModel): ...@@ -179,20 +182,29 @@ class CreditRequirement(TimeStampedModel):
return credit_requirement, created return credit_requirement, created
@classmethod @classmethod
def get_course_requirements(cls, course_key, namespace=None): def get_course_requirements(cls, course_key, namespace=None, name=None):
""" """
Get credit requirements of a given course. Get credit requirements of a given course.
Args: Args:
course_key(CourseKey): The identifier for a course course_key (CourseKey): The identifier for a course
namespace(str): Namespace of credit course requirements
Keyword Arguments
namespace (str): Optionally filter credit requirements by namespace.
name (str): Optionally filter credit requirements by name.
Returns: Returns:
QuerySet of CreditRequirement model QuerySet of CreditRequirement model
""" """
requirements = CreditRequirement.objects.filter(course__course_key=course_key, active=True) requirements = CreditRequirement.objects.filter(course__course_key=course_key, active=True)
if namespace:
if namespace is not None:
requirements = requirements.filter(namespace=namespace) requirements = requirements.filter(namespace=namespace)
if name is not None:
requirements = requirements.filter(name=name)
return requirements return requirements
@classmethod @classmethod
...@@ -261,8 +273,11 @@ class CreditRequirementStatus(TimeStampedModel): ...@@ -261,8 +273,11 @@ class CreditRequirementStatus(TimeStampedModel):
# the grade to users later and to send the information to credit providers. # the grade to users later and to send the information to credit providers.
reason = JSONField(default={}) reason = JSONField(default={})
# Maintain a history of requirement status updates for auditing purposes
history = HistoricalRecords()
class Meta(object): # pylint: disable=missing-docstring class Meta(object): # pylint: disable=missing-docstring
get_latest_by = "created" unique_together = ('username', 'requirement')
@classmethod @classmethod
def get_statuses(cls, requirements, username): def get_statuses(cls, requirements, username):
...@@ -325,6 +340,40 @@ class CreditEligibility(TimeStampedModel): ...@@ -325,6 +340,40 @@ class CreditEligibility(TimeStampedModel):
return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers') return cls.objects.filter(username=username).select_related('course').prefetch_related('course__providers')
@classmethod @classmethod
def update_eligibility(cls, requirements, username, course_key):
"""
Update the user's credit eligibility for a course.
A user is eligible for credit when the user has satisfied
all requirements for credit in the course.
Arguments:
requirements (Queryset): Queryset of `CreditRequirement`s to check.
username (str): Identifier of the user being updated.
course_key (CourseKey): Identifier of the course.
"""
# Check all requirements for the course to determine if the user
# is eligible. We need to check all the *requirements*
# (not just the *statuses*) in case the user doesn't yet have
# a status for a particular requirement.
status_by_req = defaultdict(lambda: False)
for status in CreditRequirementStatus.get_statuses(requirements, username):
status_by_req[status.requirement.id] = status.status
is_eligible = all(status_by_req[req.id] == "satisfied" for req in requirements)
# If we're eligible, then mark the user as being eligible for credit.
if is_eligible:
try:
CreditEligibility.objects.create(
username=username,
course=CreditCourse.objects.get(course_key=course_key),
)
except IntegrityError:
pass
@classmethod
def is_user_eligible_for_credit(cls, course_key, username): def is_user_eligible_for_credit(cls, course_key, username):
"""Check if the given user is eligible for the provided credit course """Check if the given user is eligible for the provided credit course
......
...@@ -30,11 +30,6 @@ from openedx.core.djangoapps.credit.models import ( ...@@ -30,11 +30,6 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirementStatus, CreditRequirementStatus,
CreditEligibility CreditEligibility
) )
from openedx.core.djangoapps.credit.api import (
set_credit_requirements,
set_credit_requirement_status,
get_credit_requirement
)
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.views import _create_credit_availability_message from student.views import _create_credit_availability_message
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -240,7 +235,7 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -240,7 +235,7 @@ class CreditRequirementApiTests(CreditApiTestBase):
} }
} }
] ]
requirement = get_credit_requirement(self.course_key, "grade", "grade") requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
self.assertIsNone(requirement) self.assertIsNone(requirement)
expected_requirement = { expected_requirement = {
...@@ -252,8 +247,8 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -252,8 +247,8 @@ class CreditRequirementApiTests(CreditApiTestBase):
"min_grade": 0.8 "min_grade": 0.8
} }
} }
set_credit_requirements(self.course_key, requirements) api.set_credit_requirements(self.course_key, requirements)
requirement = get_credit_requirement(self.course_key, "grade", "grade") requirement = api.get_credit_requirement(self.course_key, "grade", "grade")
self.assertIsNotNone(requirement) self.assertIsNotNone(requirement)
self.assertEqual(requirement, expected_requirement) self.assertEqual(requirement, expected_requirement)
...@@ -276,25 +271,128 @@ class CreditRequirementApiTests(CreditApiTestBase): ...@@ -276,25 +271,128 @@ class CreditRequirementApiTests(CreditApiTestBase):
} }
] ]
set_credit_requirements(self.course_key, requirements) api.set_credit_requirements(self.course_key, requirements)
course_requirements = CreditRequirement.get_course_requirements(self.course_key) course_requirements = api.get_credit_requirements(self.course_key)
self.assertEqual(len(course_requirements), 2) self.assertEqual(len(course_requirements), 2)
requirement = get_credit_requirement(self.course_key, "grade", "grade") # Initially, the status should be None
set_credit_requirement_status("staff", requirement, 'satisfied', {}) req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
course_requirement = CreditRequirement.get_course_requirement( self.assertEqual(req_status[0]["status"], None)
requirement['course_key'], requirement['namespace'], requirement['name']
# Set the requirement to "satisfied" and check that it's actually set
api.set_credit_requirement_status("staff", self.course_key, "grade", "grade")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], "satisfied")
# 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")
req_status = api.get_credit_requirement_status(self.course_key, "staff", namespace="grade", name="grade")
self.assertEqual(req_status[0]["status"], "failed")
def test_satisfy_all_requirements(self):
# Configure a course with two credit requirements
self.add_credit_course()
requirements = [
{
"namespace": "grade",
"name": "grade",
"display_name": "Grade",
"criteria": {
"min_grade": 0.8
}
},
{
"namespace": "reverification",
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
"display_name": "Assessment 1",
"criteria": {}
}
]
api.set_credit_requirements(self.course_key, requirements)
# Satisfy one of the requirements, but not the other
with self.assertNumQueries(7):
api.set_credit_requirement_status(
"bob",
self.course_key,
requirements[0]["namespace"],
requirements[0]["name"]
)
# The user should not be eligible (because only one requirement is satisfied)
self.assertFalse(api.is_user_eligible_for_credit("bob", self.course_key))
# Satisfy the other requirement
with self.assertNumQueries(10):
api.set_credit_requirement_status(
"bob",
self.course_key,
requirements[1]["namespace"],
requirements[1]["name"]
) )
status = CreditRequirementStatus.objects.get(username="staff", requirement=course_requirement)
self.assertEqual(status.requirement.namespace, requirement['namespace'])
self.assertEqual(status.status, "satisfied")
set_credit_requirement_status( # Now the user should be eligible
"staff", requirement, 'failed', {'failure_reason': "requirements not satisfied"} self.assertTrue(api.is_user_eligible_for_credit("bob", self.course_key))
# The user should remain eligible even if the requirement status is later changed
api.set_credit_requirement_status(
"bob",
self.course_key,
requirements[0]["namespace"],
requirements[0]["name"],
status="failed"
)
self.assertTrue(api.is_user_eligible_for_credit("bob", self.course_key))
def test_set_credit_requirement_status_req_not_configured(self):
# Configure a credit course with no requirements
self.add_credit_course()
# A user satisfies a requirement. This could potentially
# happen if there's a lag when the requirements are updated
# after the course is published.
api.set_credit_requirement_status("bob", self.course_key, "grade", "grade")
# Since the requirement hasn't been published yet, it won't show
# up in the list of requirements.
req_status = api.get_credit_requirement_status(self.course_key, "bob", namespace="grade", name="grade")
self.assertEqual(req_status, [])
# Now add the requirements, simulating what happens when a course is published.
requirements = [
{
"namespace": "grade",
"name": "grade",
"display_name": "Grade",
"criteria": {
"min_grade": 0.8
}
},
{
"namespace": "reverification",
"name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid",
"display_name": "Assessment 1",
"criteria": {}
}
]
api.set_credit_requirements(self.course_key, requirements)
# The user should not have satisfied the requirements, since they weren't
# in effect when the user completed the requirement
req_status = api.get_credit_requirement_status(self.course_key, "bob")
self.assertEqual(len(req_status), 2)
self.assertEqual(req_status[0]["status"], None)
self.assertEqual(req_status[0]["status"], None)
# The user should *not* have satisfied the reverification requirement
req_status = api.get_credit_requirement_status(
self.course_key,
"bob",
namespace=requirements[1]["namespace"],
name=requirements[1]["name"]
) )
status = CreditRequirementStatus.objects.get(username="staff", requirement=course_requirement) self.assertEqual(len(req_status), 1)
self.assertEqual(status.requirement.namespace, requirement['namespace']) self.assertEqual(req_status[0]["status"], None)
self.assertEqual(status.status, "failed")
@ddt.ddt @ddt.ddt
......
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