diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 7a4afc44f5..967759346d 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -42,6 +42,7 @@ from microsite_configuration import microsite 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.errors import UserNotFound, AccountValidationError +from openedx.core.djangoapps.credit.api import get_credit_requirement, set_credit_requirement_status from student.models import CourseEnrollment from shoppingcart.models import Order, CertificateItem from shoppingcart.processors import ( @@ -921,6 +922,32 @@ def _send_email(user_id, subject, message): user.email_user(subject, message, from_address) +def _set_user_requirement_status(attempt, namespace, status, reason=None): + """Sets the status of a credit requirement for the user, + based on a verification checkpoint. + """ + checkpoint = None + try: + checkpoint = VerificationCheckpoint.objects.get(photo_verification=attempt) + except VerificationCheckpoint.DoesNotExist: + log.error("Unable to find checkpoint for user with id %d", attempt.user.id) + + 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: + set_credit_requirement_status( + attempt.user.username, credit_requirement, status, reason + ) + except Exception: # pylint: disable=broad-except + # Catch exception if unable to add credit requirement + # status for user + log.error("Unable to add Credit requirement status for user with id %d", attempt.user.id) + + @require_POST @csrf_exempt # SS does its own message signing, and their API won't have a cookie value def results_callback(request): @@ -974,15 +1001,19 @@ def results_callback(request): except SoftwareSecurePhotoVerification.DoesNotExist: log.error("Software Secure posted back for receipt_id %s, but not found", receipt_id) return HttpResponseBadRequest("edX ID {} not found".format(receipt_id)) - if result == "PASS": log.debug("Approving verification for %s", receipt_id) attempt.approve() status = "approved" + _set_user_requirement_status(attempt, 'reverification', 'satisfied') + elif result == "FAIL": log.debug("Denying verification for %s", receipt_id) attempt.deny(json.dumps(reason), error_code=error_code) status = "denied" + _set_user_requirement_status( + attempt, 'reverification', 'failed', json.dumps(reason) + ) elif result == "SYSTEM FAIL": log.debug("System failure for %s -- resetting to must_retry", receipt_id) attempt.system_error(json.dumps(reason), error_code=error_code) @@ -993,7 +1024,6 @@ def results_callback(request): return HttpResponseBadRequest( "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result) ) - incourse_reverify_enabled = InCourseReverificationConfiguration.current().enabled if incourse_reverify_enabled: checkpoints = VerificationCheckpoint.objects.filter(photo_verification=attempt).all() diff --git a/openedx/core/djangoapps/credit/api.py b/openedx/core/djangoapps/credit/api.py index b54eb5504c..9658a1c823 100644 --- a/openedx/core/djangoapps/credit/api.py +++ b/openedx/core/djangoapps/credit/api.py @@ -29,7 +29,6 @@ from .models import ( ) from .signature import signature, get_shared_secret_key - log = logging.getLogger(__name__) @@ -488,6 +487,70 @@ def is_credit_course(course_key): return CreditCourse.is_credit_course(course_key) +def get_credit_requirement(course_key, namespace, name): + """Returns the requirement of a given course, namespace and name. + + Args: + course_key(CourseKey): The identifier for course + namespace(str): Namespace of requirement + name(str): Name of the requirement + + Returns: dict + + Example: + >>> get_credit_requirement_status( + "course-v1-edX-DemoX-1T2015", "proctored_exam", "i4x://edX/DemoX/proctoring-block/final_uuid" + ) + { + "course_key": "course-v1-edX-DemoX-1T2015" + "namespace": "reverification", + "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + "display_name": "reverification" + "criteria": {}, + } + + """ + requirement = CreditRequirement.get_course_requirement(course_key, namespace, name) + return { + "course_key": requirement.course.course_key, + "namespace": requirement.namespace, + "name": requirement.name, + "display_name": requirement.display_name, + "criteria": requirement.criteria + } if requirement else None + + +def set_credit_requirement_status(username, requirement, status="satisfied", reason=None): + """Update Credit Requirement Status for given username and requirement + if exists else add new. + + Args: + username(str): Username of the user + requirement(dict): requirement dict + status(str): Status of the requirement + reason(dict): Reason of the status + + Example: + >>> set_credit_requirement_status( + "staff", + { + "course_key": "course-v1-edX-DemoX-1T2015" + "namespace": "reverification", + "name": "i4x://edX/DemoX/edx-reverification-block/assessment_uuid", + }, + "satisfied", + {} + ) + + """ + credit_requirement = CreditRequirement.get_course_requirement( + requirement['course_key'], requirement['namespace'], requirement['name'] + ) + CreditRequirementStatus.add_or_update_requirement_status( + username, credit_requirement, status, reason + ) + + def _get_requirements_to_disable(old_requirements, new_requirements): """ Get the ids of 'CreditRequirement' entries to be disabled that are diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index eb632a5253..2e273be16f 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -9,6 +9,7 @@ successful completion of a course on EdX import logging from django.db import models +from django.db import transaction from django.core.validators import RegexValidator from simple_history.models import HistoricalRecords @@ -208,6 +209,26 @@ class CreditRequirement(TimeStampedModel): """ cls.objects.filter(id__in=requirement_ids).update(active=False) + @classmethod + def get_course_requirement(cls, course_key, namespace, name): + """Get credit requirement of a given course. + + Args: + course_key(CourseKey): The identifier for a course + namespace(str): Namespace of credit course requirements + name(str): Name of credit course requirement + + Returns: + CreditRequirement object if exists + + """ + try: + return cls.objects.get( + course__course_key=course_key, active=True, namespace=namespace, name=name + ) + except cls.DoesNotExist: + return None + class CreditRequirementStatus(TimeStampedModel): """ @@ -257,13 +278,34 @@ class CreditRequirementStatus(TimeStampedModel): """ return cls.objects.filter(requirement__in=requirements, username=username) + @classmethod + @transaction.commit_on_success + def add_or_update_requirement_status(cls, username, requirement, status="satisfied", reason=None): + """Add credit requirement status for given username. + + Args: + username(str): Username of the user + requirement(CreditRequirement): 'CreditRequirement' object + status(str): Status of the requirement + reason(dict): Reason of the status + + """ + requirement_status, created = cls.objects.get_or_create( + username=username, + requirement=requirement, + defaults={"reason": reason, "status": status} + ) + if not created: + requirement_status.status = status + requirement_status.reason = reason if reason else {} + requirement_status.save() + class CreditEligibility(TimeStampedModel): """ A record of a user's eligibility for credit from a specific credit provider for a specific course. """ - username = models.CharField(max_length=255, db_index=True) course = models.ForeignKey(CreditCourse, related_name="eligibilities") provider = models.ForeignKey(CreditProvider, related_name="eligibilities") diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 6e5845fd98..2a8da65ab3 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -27,7 +27,12 @@ from openedx.core.djangoapps.credit.models import ( CreditProvider, CreditRequirement, CreditRequirementStatus, - CreditEligibility, + CreditEligibility +) +from openedx.core.djangoapps.credit.api import ( + set_credit_requirements, + set_credit_requirement_status, + get_credit_requirement ) @@ -215,6 +220,74 @@ class CreditRequirementApiTests(CreditApiTestBase): is_eligible = api.is_user_eligible_for_credit('abc', credit_course.course_key) self.assertFalse(is_eligible) + def test_get_credit_requirement(self): + self.add_credit_course() + requirements = [ + { + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": { + "min_grade": 0.8 + } + } + ] + requirement = get_credit_requirement(self.course_key, "grade", "grade") + self.assertIsNone(requirement) + + expected_requirement = { + "course_key": self.course_key, + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": { + "min_grade": 0.8 + } + } + set_credit_requirements(self.course_key, requirements) + requirement = get_credit_requirement(self.course_key, "grade", "grade") + self.assertIsNotNone(requirement) + self.assertEqual(requirement, expected_requirement) + + def test_set_credit_requirement_status(self): + 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": {} + } + ] + + set_credit_requirements(self.course_key, requirements) + course_requirements = CreditRequirement.get_course_requirements(self.course_key) + self.assertEqual(len(course_requirements), 2) + + requirement = get_credit_requirement(self.course_key, "grade", "grade") + set_credit_requirement_status("staff", requirement, 'satisfied', {}) + course_requirement = CreditRequirement.get_course_requirement( + requirement['course_key'], requirement['namespace'], requirement['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( + "staff", requirement, 'failed', {'failure_reason': "requirements not satisfied"} + ) + status = CreditRequirementStatus.objects.get(username="staff", requirement=course_requirement) + self.assertEqual(status.requirement.namespace, requirement['namespace']) + self.assertEqual(status.status, "failed") + @ddt.ddt class CreditProviderIntegrationApiTests(CreditApiTestBase): @@ -425,6 +498,7 @@ class CreditProviderIntegrationApiTests(CreditApiTestBase): self.assertEqual(requests, []) def _configure_credit(self): + """ Configure a credit course and its requirements.