def test_parse_error_msg_success(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) attempt.status = 'denied' attempt.error_msg = '[{"userPhotoReasons": ["Face out of view"]}, {"photoIdReasons": ["Photo hidden/No photo", "ID name not provided"]}]' parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, ['id_image_missing_name', 'user_image_not_clear', 'id_image_not_clear'])
def test_parse_error_msg_success(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) attempt.status = 'denied' attempt.error_msg = '[{"photoIdReasons": ["Not provided"]}]' parsed_error_msg = attempt.parsed_error_msg() self.assertEquals("No photo ID was provided.", parsed_error_msg)
def _submit_attempt(self, user, face_image, photo_id_image=None, initial_verification=None): """ Submit a verification attempt. Arguments: user (User): The user making the attempt. face_image (str): Decoded face image data. Keyword Arguments: photo_id_image (str or None): Decoded photo ID image data. initial_verification (SoftwareSecurePhotoVerification): The initial verification attempt. """ attempt = SoftwareSecurePhotoVerification(user=user) # We will always have face image data, so upload the face image attempt.upload_face_image(face_image) # If an ID photo wasn't submitted, re-use the ID photo from the initial attempt. # Earlier validation rules ensure that at least one of these is available. if photo_id_image is not None: attempt.upload_photo_id_image(photo_id_image) elif initial_verification is None: # Earlier validation should ensure that we never get here. log.error( "Neither a photo ID image or initial verification attempt provided. " "Parameter validation in the view should prevent this from happening!" ) # Submit the attempt attempt.mark_ready() attempt.submit(copy_id_photo_from=initial_verification) return attempt
def post(self, request): """ POST /api/user/v1/accounts/retire/ { 'username': '******' } Retires the user with the given username. This includes retiring this username, the associates email address, and any other PII associated with this user. """ username = request.data['username'] if is_username_retired(username): return Response(status=status.HTTP_404_NOT_FOUND) try: retirement_status = UserRetirementStatus.get_retirement_for_retirement_action(username) user = retirement_status.user retired_username = retirement_status.retired_username or get_retired_username_by_username(username) retired_email = retirement_status.retired_email or get_retired_email_by_email(user.email) original_email = retirement_status.original_email # Retire core user/profile information self.clear_pii_from_userprofile(user) self.delete_users_profile_images(user) self.delete_users_country_cache(user) # Retire data from Enterprise models self.retire_users_data_sharing_consent(username, retired_username) self.retire_sapsf_data_transmission(user) self.retire_user_from_pending_enterprise_customer_user(user, retired_email) self.retire_entitlement_support_detail(user) # Retire misc. models that may contain PII of this user SoftwareSecurePhotoVerification.retire_user(user.id) PendingEmailChange.delete_by_user_value(user, field='user') UserOrgTag.delete_by_user_value(user, field='user') # Retire any objects linked to the user via their original email CourseEnrollmentAllowed.delete_by_user_value(original_email, field='email') UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field='email') # TODO: Password Reset links - https://openedx.atlassian.net/browse/PLAT-2104 # TODO: Delete OAuth2 records - https://openedx.atlassian.net/browse/EDUCATOR-2703 user.first_name = '' user.last_name = '' user.is_active = False user.username = retired_username user.save() except UserRetirementStatus.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) except RetirementStateError as exc: return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST) except Exception as exc: # pylint: disable=broad-except return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response(status=status.HTTP_204_NO_CONTENT)
def create_and_submit(self, user): """ Helper method that lets us create new SoftwareSecurePhotoVerifications """ attempt = SoftwareSecurePhotoVerification(user=user) attempt.upload_face_image("Fake Data") attempt.upload_photo_id_image("More Fake Data") attempt.mark_ready() attempt.submit() return attempt
def test_no_approved_verification(self): """Test that method 'get_recent_verification' of model 'SoftwareSecurePhotoVerification' returns None if no 'approved' verification are found """ user = UserFactory.create() SoftwareSecurePhotoVerification(user=user) result = SoftwareSecurePhotoVerification.get_recent_verification(user=user) self.assertIs(result, None)
def test_retire_nonuser(self): """ Attempt to Retire User with no records in table """ user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) # User with no records in table assert not attempt.retire_user(user_id=user.id) # No user assert not attempt.retire_user(user_id=47)
def create_and_submit(self): """Helper method to create a generic submission and send it.""" user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) user.profile.name = u"Rust\u01B4" attempt.upload_face_image("Just pretend this is image data") attempt.upload_photo_id_image("Hey, we're a photo ID") attempt.mark_ready() attempt.submit() return attempt
def test_retire_nonuser(self): """ Attempt to Retire User with no records in table """ user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) # User with no records in table self.assertFalse(attempt.retire_user(user_id=user.id)) # No user self.assertFalse(attempt.retire_user(user_id=47))
def test_name_preset(self): """ If a name was set when creating the photo verification (from name affirmation / verified name flow) it should not be overwritten by the profile name """ user = UserFactory.create() user.profile.name = "Profile" preset_attempt = SoftwareSecurePhotoVerification(user=user) preset_attempt.name = "Preset" preset_attempt.mark_ready() assert "Preset" == preset_attempt.name
def test_expiration_date_null(self): """ Test if the `expiration_date` field is null, `expiration_datetime` returns a default expiration date based on the time the entry was created. """ user = UserFactory.create() verification = SoftwareSecurePhotoVerification(user=user) verification.expiration_date = None verification.save() assert verification.expiration_datetime == ( verification.created_at + timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR']))
def test_deprecated_expiry_date(self): """ Test `expiration_datetime` returns `expiry_date` if it is not null. """ user = UserFactory.create() with freeze_time(now()): verification = SoftwareSecurePhotoVerification(user=user) # First, assert that expiration_date is set correctly assert verification.expiration_datetime == ( now() + timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR'])) verification.expiry_date = now() + timedelta(days=10) # Then, assert that expiration_datetime favors expiry_date's value if set assert verification.expiration_datetime == (now() + timedelta(days=10))
def _verify_user(): if not SoftwareSecurePhotoVerification.user_is_verified(user): obj = SoftwareSecurePhotoVerification( user=user, photo_id_key="dummy_photo_id_key") obj.status = 'approved' obj.submitted_at = datetime.datetime.now() obj.reviewing_user = User.objects.get(username='******') obj.save()
def test_parse_error_msg_failure(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) attempt.status = 'denied' # when we can't parse into json bad_messages = { 'Not Provided', '[{"IdReasons": ["Not provided"]}]', '{"IdReasons": ["Not provided"]}', u'[{"ïḋṚëäṡöṅṡ": ["Ⓝⓞⓣ ⓟⓡⓞⓥⓘⓓⓔⓓ "]}]', } for msg in bad_messages: attempt.error_msg = msg parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.")
def generate_certificate(self, **kwargs): """ Generates a certificate for a single user. kwargs: - student: The student for whom to generate a certificate. - course_key: The course key for the course that the student is receiving a certificate in. - expected_verification_status: The expected verification status for the user. When the status has changed, we double check that the actual verification status is as expected before generating a certificate, in the off chance that the database has not yet updated with the user's new verification status. """ original_kwargs = kwargs.copy() student = User.objects.get(id=kwargs.pop('student')) course_key = CourseKey.from_string(kwargs.pop('course_key')) expected_verification_status = kwargs.pop('expected_verification_status', None) if expected_verification_status: actual_verification_status, _ = SoftwareSecurePhotoVerification.user_status( student) if expected_verification_status != actual_verification_status: raise self.retry(kwargs=original_kwargs) generate_user_certificates(student=student, course_key=course_key, **kwargs)
def get(self, request, receipt_id): """ Endpoint for retrieving photo urls for IDV GET /verify_student/photo_urls/{receipt_id} Returns: 200 OK { "EdX-ID": receipt_id, "ExpectedName": user profile name, "PhotoID": id photo S3 url, "PhotoIDKey": encrypted photo id key, "UserPhoto": face photo S3 url, "UserPhotoKey": encrypted user photo key, } """ verification = SoftwareSecurePhotoVerification.get_verification_from_receipt( receipt_id) if verification: _, body = verification.create_request() # remove this key, as it isn't needed body.pop('SendResponseTo') return Response(body) log.warning(u"Could not find verification with receipt ID %s.", receipt_id) raise Http404
def test_verification_for_datetime(self): user = UserFactory.create() now = datetime.now(pytz.UTC) # No attempts in the query set, so should return None query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(now, query) self.assertIs(result, None) # Should also return None if no deadline specified query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) self.assertIs(result, None) # Make an attempt attempt = SoftwareSecurePhotoVerification.objects.create(user=user) # Before the created date, should get no results before = attempt.created_at - timedelta(seconds=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(before, query) self.assertIs(result, None) # Immediately after the created date, should get the attempt after_created = attempt.created_at + timedelta(seconds=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(after_created, query) self.assertEqual(result, attempt) # If no deadline specified, should return first available query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(None, query) self.assertEqual(result, attempt) # Immediately before the expiration date, should get the attempt expiration = attempt.created_at + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) before_expiration = expiration - timedelta(seconds=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(before_expiration, query) self.assertEqual(result, attempt) # Immediately after the expiration date, should not get the attempt attempt.created_at = attempt.created_at - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) attempt.save() after = datetime.now(pytz.UTC) + timedelta(days=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(after, query) self.assertIs(result, None) # Create a second attempt in the same window second_attempt = SoftwareSecurePhotoVerification.objects.create(user=user) # Now we should get the newer attempt deadline = second_attempt.created_at + timedelta(days=1) query = SoftwareSecurePhotoVerification.objects.filter(user=user) result = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, query) self.assertEqual(result, second_attempt)
def test_user_is_verified(self): """ Test to make sure we correctly answer whether a user has been verified. """ user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) attempt.save() # If it's any of these, they're not verified... for status in ["created", "ready", "denied", "submitted", "must_retry"]: attempt.status = status attempt.save() assert_false(IDVerificationService.user_is_verified(user), status) attempt.status = "approved" attempt.save() assert_true(IDVerificationService.user_is_verified(user), attempt.status)
def test_name_freezing(self): """ You can change your name prior to marking a verification attempt ready, but changing your name afterwards should not affect the value in the in the attempt record. Basically, we want to always know what your name was when you submitted it. """ user = UserFactory.create() user.profile.name = u"Jack \u01B4" # gratuious non-ASCII char to test encodings attempt = SoftwareSecurePhotoVerification(user=user) user.profile.name = u"Clyde \u01B4" attempt.mark_ready() user.profile.name = u"Rusty \u01B4" self.assertEqual(u"Clyde \u01B4", attempt.name)
def test_name_freezing(self): """ You can change your name prior to marking a verification attempt ready, but changing your name afterwards should not affect the value in the in the attempt record. Basically, we want to always know what your name was when you submitted it. """ user = UserFactory.create() user.profile.name = "Jack \u01B4" # gratuious non-ASCII char to test encodings attempt = SoftwareSecurePhotoVerification(user=user) user.profile.name = "Clyde \u01B4" attempt.mark_ready() user.profile.name = "Rusty \u01B4" assert 'Clyde ƴ' == attempt.name
def post(self, request): """ Submit photos for verification. This end-point is used for the following cases: * Initial verification through the pay-and-verify flow. * Initial verification initiated from a checkpoint within a course. * Re-verification initiated from a checkpoint within a course. POST Parameters: face_image (str): base64-encoded image data of the user's face. photo_id_image (str): base64-encoded image data of the user's photo ID. full_name (str): The user's full name, if the user is requesting a name change as well. course_key (str): Identifier for the course, if initiated from a checkpoint. checkpoint (str): Location of the checkpoint in the course. """ # If the user already has an initial verification attempt, we can re-use the photo ID # the user submitted with the initial attempt. initial_verification = SoftwareSecurePhotoVerification.get_initial_verification( request.user) # Validate the POST parameters params, response = self._validate_parameters( request, bool(initial_verification)) if response is not None: return response # If necessary, update the user's full name if "full_name" in params: response = self._update_full_name(request.user, params["full_name"]) if response is not None: return response # Retrieve the image data # Validation ensures that we'll have a face image, but we may not have # a photo ID image if this is a reverification. face_image, photo_id_image, response = self._decode_image_data( params["face_image"], params.get("photo_id_image")) # If we have a photo_id we do not want use the initial verification image. if photo_id_image is not None: initial_verification = None if response is not None: return response # Submit the attempt attempt = self._submit_attempt(request.user, face_image, photo_id_image, initial_verification) self._fire_event(request.user, "edx.bi.verify.submitted", {"category": "verification"}) self._send_confirmation_email(request.user) return JsonResponse({})
def _user_verification_mode(self, user, context): """ Returns a list of enrollment-mode and verification-status for the given user. """ enrollment_mode = CourseEnrollment.enrollment_mode_for_user( user, context.course_id)[0] verification_status = SoftwareSecurePhotoVerification.verification_status_for_user( user, context.course_id, enrollment_mode) return [enrollment_mode, verification_status]
def get(self, request): """ Render the reverification flow. Most of the work is done client-side by composing the same Backbone views used in the initial verification flow. """ status, _ = SoftwareSecurePhotoVerification.user_status(request.user) expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime( request.user) can_reverify = False if expiration_datetime: if SoftwareSecurePhotoVerification.is_verification_expiring_soon( expiration_datetime): # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # In this case user can resubmit photos for reverification. can_reverify = True # If the user has no initial verification or if the verification # process is still ongoing 'pending' or expired then allow the user to # submit the photo verification. # A photo verification is marked as 'pending' if its status is either # 'submitted' or 'must_retry'. if status in ["none", "must_reverify", "expired", "pending" ] or can_reverify: context = { "user_full_name": request.user.profile.name, "platform_name": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), "capture_sound": staticfiles_storage.url("audio/camera_capture.wav"), } return render_to_response("verify_student/reverify.html", context) else: context = {"status": status} return render_to_response( "verify_student/reverify_not_allowed.html", context)
def test_user_has_valid_or_pending(self): """ Determine whether we have to prompt this user to verify, or if they've already at least initiated a verification submission. """ user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) # If it's any of these statuses, they don't have anything outstanding for status in ["created", "ready", "denied"]: attempt.status = status attempt.save() assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status) # Any of these, and we are. Note the benefit of the doubt we're giving # -- must_retry, and submitted both count until we hear otherwise for status in ["submitted", "must_retry", "approved"]: attempt.status = status attempt.save() assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user), status)
def checkout_receipt(request): """ Receipt view. """ page_title = _('Receipt') is_payment_complete = True payment_support_email = configuration_helpers.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) payment_support_link = '<a href=\"mailto:{email}\">{email}</a>'.format(email=payment_support_email) is_cybersource = all(k in request.POST for k in ('signed_field_names', 'decision', 'reason_code')) if is_cybersource and request.POST['decision'] != 'ACCEPT': # Cybersource may redirect users to this view if it couldn't recover # from an error while capturing payment info. is_payment_complete = False page_title = _('Payment Failed') reason_code = request.POST['reason_code'] # if the problem was with the info submitted by the user, we present more detailed messages. if is_user_payment_error(reason_code): error_summary = _("There was a problem with this transaction. You have not been charged.") error_text = _( "Make sure your information is correct, or try again with a different card or another form of payment." ) else: error_summary = _("A system error occurred while processing your payment. You have not been charged.") error_text = _("Please wait a few minutes and then try again.") for_help_text = _("For help, contact {payment_support_link}.").format(payment_support_link=payment_support_link) else: # if anything goes wrong rendering the receipt, it indicates a problem fetching order data. error_summary = _("An error occurred while creating your receipt.") error_text = None # nothing particularly helpful to say if this happens. for_help_text = _( "If your course does not appear on your dashboard, contact {payment_support_link}." ).format(payment_support_link=payment_support_link) commerce_configuration = CommerceConfiguration.current() # user order cache should be cleared when a new order is placed # so user can see new order in their order history. if is_payment_complete and commerce_configuration.enabled and commerce_configuration.is_cache_enabled: cache_key = commerce_configuration.CACHE_KEY + '.' + str(request.user.id) cache.delete(cache_key) context = { 'page_title': page_title, 'is_payment_complete': is_payment_complete, 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), 'verified': SoftwareSecurePhotoVerification.verification_valid_or_pending(request.user).exists(), 'error_summary': error_summary, 'error_text': error_text, 'for_help_text': for_help_text, 'payment_support_email': payment_support_email, 'username': request.user.username, 'nav_hidden': True, 'is_request_in_themed_site': is_request_in_themed_site() } return render_to_response('commerce/checkout_receipt.html', context)
def extract_student(student, features): """ convert student to dictionary """ student_features = [x for x in STUDENT_FEATURES if x in features] profile_features = [x for x in PROFILE_FEATURES if x in features] # For data extractions on the 'meta' field # the feature name should be in the format of 'meta.foo' where # 'foo' is the keyname in the meta dictionary meta_features = [] for feature in features: if 'meta.' in feature: meta_key = feature.split('.')[1] meta_features.append((feature, meta_key)) student_dict = dict((feature, extract_attr(student, feature)) for feature in student_features) profile = student.profile if profile is not None: profile_dict = dict((feature, extract_attr(profile, feature)) for feature in profile_features) student_dict.update(profile_dict) # now fetch the requested meta fields meta_dict = json.loads(profile.meta) if profile.meta else {} for meta_feature, meta_key in meta_features: student_dict[meta_feature] = meta_dict.get(meta_key) if include_cohort_column: # Note that we use student.course_groups.all() here instead of # student.course_groups.filter(). The latter creates a fresh query, # therefore negating the performance gain from prefetch_related(). student_dict['cohort'] = next( (cohort.name for cohort in student.course_groups.all() if cohort.course_id == course_key), "[unassigned]" ) if include_team_column: student_dict['team'] = next( (team.name for team in student.teams.all() if team.course_id == course_key), UNAVAILABLE ) if include_enrollment_mode or include_verification_status: enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_key)[0] if include_verification_status: student_dict['verification_status'] = SoftwareSecurePhotoVerification.verification_status_for_user( student, course_key, enrollment_mode ) if include_enrollment_mode: student_dict['enrollment_mode'] = enrollment_mode return student_dict
def post(self, request): """ Submit photos for verification. This end-point is used for the following cases: * Initial verification through the pay-and-verify flow. * Initial verification initiated from a checkpoint within a course. * Re-verification initiated from a checkpoint within a course. POST Parameters: face_image (str): base64-encoded image data of the user's face. photo_id_image (str): base64-encoded image data of the user's photo ID. full_name (str): The user's full name, if the user is requesting a name change as well. course_key (str): Identifier for the course, if initiated from a checkpoint. checkpoint (str): Location of the checkpoint in the course. """ # If the user already has an initial verification attempt, we can re-use the photo ID # the user submitted with the initial attempt. initial_verification = SoftwareSecurePhotoVerification.get_initial_verification(request.user) # Validate the POST parameters params, response = self._validate_parameters(request, bool(initial_verification)) if response is not None: return response # If necessary, update the user's full name if "full_name" in params: response = self._update_full_name(request.user, params["full_name"]) if response is not None: return response # Retrieve the image data # Validation ensures that we'll have a face image, but we may not have # a photo ID image if this is a reverification. face_image, photo_id_image, response = self._decode_image_data( params["face_image"], params.get("photo_id_image") ) # If we have a photo_id we do not want use the initial verification image. if photo_id_image is not None: initial_verification = None if response is not None: return response # Submit the attempt attempt = self._submit_attempt(request.user, face_image, photo_id_image, initial_verification) self._fire_event(request.user, "edx.bi.verify.submitted", {"category": "verification"}) self._send_confirmation_email(request.user) return JsonResponse({})
def _check_already_verified(self, user): """Check whether the user has a valid or pending verification. Note that this includes cases in which the user's verification has not been accepted (either because it hasn't been processed, or there was an error). This should return True if the user has done their part: submitted photos within the expiration period. """ return SoftwareSecurePhotoVerification.user_has_valid_or_pending(user)
def create_upload_and_submit_attempt_for_user(self, user=None): """ Helper method to create a generic submission with photos for a user and send it. """ if not user: user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) user.profile.name = u"Rust\u01B4" attempt.upload_face_image("Just pretend this is image data") attempt.upload_photo_id_image("Hey, we're a photo ID") attempt.mark_ready() return self.submit_attempt(attempt)
def test_submission_while_testing_flag_is_true(self): """ Test that a fake value is set for field 'photo_id_key' of user's initial verification when the feature flag 'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING' is enabled. """ user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) user.profile.name = "test-user" attempt.upload_photo_id_image("Image data") attempt.mark_ready() attempt.submit() self.assertEqual(attempt.photo_id_key, "fake-photo-id-key")
def test_update_expiry_email_date_for_user(self): """Test that method update_expiry_email_date_for_user of model 'SoftwareSecurePhotoVerification' set expiry_email_date if the most recent approved verification is expired. """ email_config = getattr(settings, 'VERIFICATION_EXPIRY_EMAIL', {'DAYS_RANGE': 1, 'RESEND_DAYS': 15}) user = UserFactory.create() verification = SoftwareSecurePhotoVerification(user=user) verification.expiry_date = now() - timedelta(days=FAKE_SETTINGS['DAYS_GOOD_FOR']) verification.status = 'approved' verification.save() self.assertIsNone(verification.expiry_email_date) SoftwareSecurePhotoVerification.update_expiry_email_date_for_user(user, email_config) result = SoftwareSecurePhotoVerification.get_recent_verification(user=user) self.assertIsNotNone(result.expiry_email_date)
def _user_verification_mode(self, user, context, bulk_enrollments): """ Returns a list of enrollment-mode and verification-status for the given user. """ enrollment_mode = CourseEnrollment.enrollment_mode_for_user(user, context.course_id)[0] verification_status = SoftwareSecurePhotoVerification.verification_status_for_user( user, context.course_id, enrollment_mode, user_is_verified=user.id in bulk_enrollments.verified_users, ) return [enrollment_mode, verification_status]
def test_get_verification_from_receipt(self): result = SoftwareSecurePhotoVerification.get_verification_from_receipt( '') assert result is None user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) attempt.status = PhotoVerification.STATUS.submitted attempt.save() receipt_id = attempt.receipt_id result = SoftwareSecurePhotoVerification.get_verification_from_receipt( receipt_id) assert result is not None
def test_user_status(self): # test for correct status when no error returned user = UserFactory.create() status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('none', '')) # test for when one has been created attempt = SoftwareSecurePhotoVerification.objects.create(user=user, status='approved') status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('approved', '')) # create another one for the same user, make sure the right one is # returned SoftwareSecurePhotoVerification.objects.create( user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]' ) status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('approved', '')) # now delete the first one and verify that the denial is being handled # properly attempt.delete() status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('must_reverify', ['id_image_missing']))
def test_verification_status_for_user(self, enrollment_mode, status, output): """ Verify verification_status_for_user returns correct status. """ user = UserFactory.create() course = CourseFactory.create() with patch( 'lms.djangoapps.verify_student.models.SoftwareSecurePhotoVerification.user_is_verified' ) as mock_verification: mock_verification.return_value = status status = SoftwareSecurePhotoVerification.verification_status_for_user(user, course.id, enrollment_mode) self.assertEqual(status, output)
def get(self, request): """ Render the reverification flow. Most of the work is done client-side by composing the same Backbone views used in the initial verification flow. """ status, __ = SoftwareSecurePhotoVerification.user_status(request.user) expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(request.user) can_reverify = False if expiration_datetime: if SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime): # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # In this case user can resubmit photos for reverification. can_reverify = True # If the user has no initial verification or if the verification # process is still ongoing 'pending' or expired then allow the user to # submit the photo verification. # A photo verification is marked as 'pending' if its status is either # 'submitted' or 'must_retry'. if status in ["none", "must_reverify", "expired", "pending"] or can_reverify: context = { "user_full_name": request.user.profile.name, "platform_name": configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), "capture_sound": staticfiles_storage.url("audio/camera_capture.wav"), } return render_to_response("verify_student/reverify.html", context) else: context = { "status": status } return render_to_response("verify_student/reverify_not_allowed.html", context)
def _verification_valid_until(self, user, date_format="%m/%d/%Y"): """ Check whether the user has a valid or pending verification. Arguments: user: date_format: optional parameter for formatting datetime object to string in response Returns: datetime object in string format """ photo_verifications = SoftwareSecurePhotoVerification.verification_valid_or_pending(user) # return 'expiration_datetime' of latest photo verification if found, # otherwise implicitly return '' if photo_verifications: return photo_verifications[0].expiration_datetime.strftime(date_format) return ''
def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument """ Catches a track change signal, determines user status, calls fire_ungenerated_certificate_task for passing grades """ if not auto_certificate_generation_enabled(): return user_enrollments = CourseEnrollment.enrollments_for_user(user=user) grade_factory = CourseGradeFactory() expected_verification_status, _ = SoftwareSecurePhotoVerification.user_status(user) for enrollment in user_enrollments: if grade_factory.read(user=user, course=enrollment.course_overview).passed: if fire_ungenerated_certificate_task(user, enrollment.course_id, expected_verification_status): message = ( u'Certificate generation task initiated for {user} : {course} via track change ' + u'with verification status of {status}' ) log.info(message.format( user=user.id, course=enrollment.course_id, status=expected_verification_status ))
def generate_certificate(self, **kwargs): """ Generates a certificate for a single user. kwargs: - student: The student for whom to generate a certificate. - course_key: The course key for the course that the student is receiving a certificate in. - expected_verification_status: The expected verification status for the user. When the status has changed, we double check that the actual verification status is as expected before generating a certificate, in the off chance that the database has not yet updated with the user's new verification status. """ original_kwargs = kwargs.copy() student = User.objects.get(id=kwargs.pop('student')) course_key = CourseKey.from_string(kwargs.pop('course_key')) expected_verification_status = kwargs.pop('expected_verification_status', None) if expected_verification_status: actual_verification_status, _ = SoftwareSecurePhotoVerification.user_status(student) if expected_verification_status != actual_verification_status: raise self.retry(kwargs=original_kwargs) generate_user_certificates(student=student, course_key=course_key, **kwargs)
def test_initial_verification_for_user(self): """Test that method 'get_initial_verification' of model 'SoftwareSecurePhotoVerification' always returns the initial verification with field 'photo_id_key' set against a user. """ user = UserFactory.create() # No initial verification for the user result = SoftwareSecurePhotoVerification.get_initial_verification(user=user) self.assertIs(result, None) # Make an initial verification with 'photo_id_key' attempt = SoftwareSecurePhotoVerification(user=user, photo_id_key="dummy_photo_id_key") attempt.status = 'approved' attempt.save() # Check that method 'get_initial_verification' returns the correct # initial verification attempt first_result = SoftwareSecurePhotoVerification.get_initial_verification(user=user) self.assertIsNotNone(first_result) # Now create a second verification without 'photo_id_key' attempt = SoftwareSecurePhotoVerification(user=user) attempt.status = 'submitted' attempt.save() # Test method 'get_initial_verification' still returns the correct # initial verification attempt which have 'photo_id_key' set second_result = SoftwareSecurePhotoVerification.get_initial_verification(user=user) self.assertIsNotNone(second_result) self.assertEqual(second_result, first_result) # Test method 'get_initial_verification' returns None after expiration expired_future = datetime.utcnow() + timedelta(days=(FAKE_SETTINGS['DAYS_GOOD_FOR'] + 1)) with freeze_time(expired_future): third_result = SoftwareSecurePhotoVerification.get_initial_verification(user) self.assertIsNone(third_result) # Test method 'get_initial_verification' returns correct attempt after system expiration, # but within earliest allowed override. expired_future = datetime.utcnow() + timedelta(days=(FAKE_SETTINGS['DAYS_GOOD_FOR'] + 1)) earliest_allowed = datetime.utcnow() - timedelta(days=1) with freeze_time(expired_future): fourth_result = SoftwareSecurePhotoVerification.get_initial_verification(user, earliest_allowed) self.assertIsNotNone(fourth_result) self.assertEqual(fourth_result, first_result)
def test_retire_user(self): """ Retire user with record(s) in table """ user = UserFactory.create() user.profile.name = u"Enrique" attempt = SoftwareSecurePhotoVerification(user=user) # Populate Record attempt.mark_ready() attempt.status = "submitted" attempt.photo_id_image_url = "https://example.com/test/image/img.jpg" attempt.face_image_url = "https://example.com/test/face/img.jpg" attempt.photo_id_key = 'there_was_an_attempt' attempt.approve() # Validate data before retirement self.assertEqual(attempt.name, user.profile.name) self.assertEqual(attempt.photo_id_image_url, 'https://example.com/test/image/img.jpg') self.assertEqual(attempt.face_image_url, 'https://example.com/test/face/img.jpg') self.assertEqual(attempt.photo_id_key, 'there_was_an_attempt') # Retire User attempt_again = SoftwareSecurePhotoVerification(user=user) self.assertTrue(attempt_again.retire_user(user_id=user.id)) # Validate data after retirement self.assertEqual(attempt_again.name, '') self.assertEqual(attempt_again.face_image_url, '') self.assertEqual(attempt_again.photo_id_image_url, '') self.assertEqual(attempt_again.photo_id_key, '')
def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None, generate_pdf=True): """ Request a new certificate for a student. Arguments: student - User.object course_id - courseenrollment.course_id (CourseKey) forced_grade - a string indicating a grade parameter to pass with the certificate request. If this is given, grading will be skipped. generate_pdf - Boolean should a message be sent in queue to generate certificate PDF Will change the certificate status to 'generating' or `downloadable` in case of web view certificates. The course must not be a CCX. Certificate must be in the 'unavailable', 'error', 'deleted' or 'generating' state. If a student has a passing grade or is in the whitelist table for the course a request will be made for a new cert. If a student has allow_certificate set to False in the userprofile table the status will change to 'restricted' If a student does not have a passing grade the status will change to status.notpassing Returns the newly created certificate instance """ if hasattr(course_id, 'ccx'): LOGGER.warning( ( u"Cannot create certificate generation task for user %s " u"in the course '%s'; " u"certificates are not allowed for CCX courses." ), student.id, unicode(course_id) ) return None valid_statuses = [ status.generating, status.unavailable, status.deleted, status.error, status.notpassing, status.downloadable, status.auditing, status.audit_passing, status.audit_notpassing, ] cert_status = certificate_status_for_student(student, course_id)['status'] cert = None if cert_status not in valid_statuses: LOGGER.warning( ( u"Cannot create certificate generation task for user %s " u"in the course '%s'; " u"the certificate status '%s' is not one of %s." ), student.id, unicode(course_id), cert_status, unicode(valid_statuses) ) return None # The caller can optionally pass a course in to avoid # re-fetching it from Mongo. If they have not provided one, # get it from the modulestore. if course is None: course = modulestore().get_course(course_id, depth=0) profile = UserProfile.objects.get(user=student) profile_name = profile.name # Needed for access control in grading. self.request.user = student self.request.session = {} is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists() 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) cert_mode = enrollment_mode is_eligible_for_certificate = is_whitelisted or CourseMode.is_eligible_for_certificate(enrollment_mode) unverified = False # For credit mode generate verified certificate if cert_mode == CourseMode.CREDIT_MODE: cert_mode = CourseMode.VERIFIED if template_file is not None: template_pdf = template_file elif mode_is_verified and user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format(id=course_id) elif mode_is_verified and not user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id) if CourseMode.mode_for_course(course_id, CourseMode.HONOR): cert_mode = GeneratedCertificate.MODES.honor else: unverified = True else: # honor code and audit students template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id) LOGGER.info( ( u"Certificate generated for student %s in the course: %s with template: %s. " u"given template: %s, " u"user is verified: %s, " u"mode is verified: %s" ), student.username, unicode(course_id), template_pdf, template_file, user_is_verified, mode_is_verified ) cert, created = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id) # pylint: disable=no-member cert.mode = cert_mode cert.user = student 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 = forced_grade or course_grade.letter_grade try: grade_contents = lxml.html.fromstring(grade_contents).text_content() passing = True except (TypeError, XMLSyntaxError, ParserError) as exc: LOGGER.info( ( u"Could not retrieve grade for student %s " u"in the course '%s' " u"because an exception occurred while parsing the " u"grade contents '%s' as HTML. " u"The exception was: '%s'" ), student.id, unicode(course_id), grade_contents, unicode(exc) ) # Log if the student is whitelisted if is_whitelisted: LOGGER.info( u"Student %s is whitelisted in '%s'", student.id, unicode(course_id) ) passing = True else: passing = False # If this user's enrollment is not eligible to receive a # certificate, mark it as such for reporting and # analytics. Only do this if the certificate is new, or # already marked as ineligible -- we don't want to mark # existing audit certs as ineligible. cutoff = settings.AUDIT_CERT_CUTOFF_DATE if (cutoff and cert.created_date >= cutoff) and not is_eligible_for_certificate: cert.status = CertificateStatuses.audit_passing if passing else CertificateStatuses.audit_notpassing cert.save() LOGGER.info( u"Student %s with enrollment mode %s is not eligible for a certificate.", student.id, enrollment_mode ) return cert # If they are not passing, short-circuit and don't generate cert elif not passing: cert.status = status.notpassing cert.save() LOGGER.info( ( u"Student %s does not have a grade for '%s', " u"so their certificate status has been set to '%s'. " u"No certificate generation task was sent to the XQueue." ), student.id, unicode(course_id), cert.status ) return cert # Check to see whether the student is on the the embargoed # country restricted list. If so, they should not receive a # certificate -- set their status to restricted and log it. if self.restricted.filter(user=student).exists(): cert.status = status.restricted cert.save() LOGGER.info( ( u"Student %s is in the embargoed country restricted " u"list, so their certificate status has been set to '%s' " u"for the course '%s'. " u"No certificate generation task was sent to the XQueue." ), student.id, cert.status, unicode(course_id) ) return cert if unverified: cert.status = status.unverified cert.save() LOGGER.info( ( u"User %s has a verified enrollment in course %s " u"but is missing ID verification. " u"Certificate status has been set to unverified" ), student.id, unicode(course_id), ) return cert # Finally, generate the certificate and send it off. return self._generate_cert(cert, course, student, grade_contents, template_pdf, generate_pdf)
def verification_status(self): """Return the verification status for this user.""" return SoftwareSecurePhotoVerification.user_status(self.user)[0]
def check_verify_status_by_course(user, course_enrollments): """ Determine the per-course verification statuses for a given user. The possible statuses are: * VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification. * VERIFY_STATUS_SUBMITTED: The student has submitted photos for verification, but has have not yet been approved. * VERIFY_STATUS_RESUBMITTED: The student has re-submitted photos for re-verification while they still have an active but expiring ID verification * VERIFY_STATUS_APPROVED: The student has been successfully verified. * VERIFY_STATUS_MISSED_DEADLINE: The student did not submit photos within the course's deadline. * VERIFY_STATUS_NEED_TO_REVERIFY: The student has an active verification, but it is set to expire before the verification deadline for the course. It is is also possible that a course does NOT have a verification status if: * The user is not enrolled in a verified mode, meaning that the user didn't pay. * The course does not offer a verified mode. * The user submitted photos but an error occurred while verifying them. * The user submitted photos but the verification was denied. In the last two cases, we rely on messages in the sidebar rather than displaying messages for each course. Arguments: user (User): The currently logged-in user. course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in. Returns: dict: Mapping of course keys verification status dictionaries. If no verification status is applicable to a course, it will not be included in the dictionary. The dictionaries have these keys: * status (str): One of the enumerated status codes. * days_until_deadline (int): Number of days until the verification deadline. * verification_good_until (str): Date string for the verification expiration date. """ status_by_course = {} # Retrieve all verifications for the user, sorted in descending # order by submission datetime verifications = SoftwareSecurePhotoVerification.objects.filter(user=user) # Check whether the user has an active or pending verification attempt # To avoid another database hit, we re-use the queryset we have already retrieved. has_active_or_pending = SoftwareSecurePhotoVerification.user_has_valid_or_pending( user, queryset=verifications ) # Retrieve expiration_datetime of most recent approved verification # To avoid another database hit, we re-use the queryset we have already retrieved. expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(user, verifications) verification_expiring_soon = SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime) # Retrieve verification deadlines for the enrolled courses enrolled_course_keys = [enrollment.course_id for enrollment in course_enrollments] course_deadlines = VerificationDeadline.deadlines_for_courses(enrolled_course_keys) recent_verification_datetime = None for enrollment in course_enrollments: # If the user hasn't enrolled as verified, then the course # won't display state related to its verification status. if enrollment.mode in CourseMode.VERIFIED_MODES: # Retrieve the verification deadline associated with the course. # This could be None if the course doesn't have a deadline. deadline = course_deadlines.get(enrollment.course_id) relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications) # Picking the max verification datetime on each iteration only with approved status if relevant_verification is not None and relevant_verification.status == "approved": recent_verification_datetime = max( recent_verification_datetime if recent_verification_datetime is not None else relevant_verification.expiration_datetime, relevant_verification.expiration_datetime ) # By default, don't show any status related to verification status = None # Check whether the user was approved or is awaiting approval if relevant_verification is not None: if relevant_verification.status == "approved": if verification_expiring_soon: status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_APPROVED elif relevant_verification.status == "submitted": if verification_expiring_soon: status = VERIFY_STATUS_RESUBMITTED else: status = VERIFY_STATUS_SUBMITTED # If the user didn't submit at all, then tell them they need to verify # If the deadline has already passed, then tell them they missed it. # If they submitted but something went wrong (error or denied), # then don't show any messaging next to the course, since we already # show messages related to this on the left sidebar. submitted = ( relevant_verification is not None and relevant_verification.status not in ["created", "ready"] ) if status is None and not submitted: if deadline is None or deadline > datetime.now(UTC): if SoftwareSecurePhotoVerification.user_is_verified(user): if verification_expiring_soon: # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # Tell the student to reverify. status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_NEED_TO_VERIFY else: # If a user currently has an active or pending verification, # then they may have submitted an additional attempt after # the verification deadline passed. This can occur, # for example, when the support team asks a student # to reverify after the deadline so they can receive # a verified certificate. # In this case, we still want to show them as "verified" # on the dashboard. if has_active_or_pending: status = VERIFY_STATUS_APPROVED # Otherwise, the student missed the deadline, so show # them as "honor" (the kind of certificate they will receive). else: status = VERIFY_STATUS_MISSED_DEADLINE # Set the status for the course only if we're displaying some kind of message # Otherwise, leave the course out of the dictionary. if status is not None: days_until_deadline = None now = datetime.now(UTC) if deadline is not None and deadline > now: days_until_deadline = (deadline - now).days status_by_course[enrollment.course_id] = { 'status': status, 'days_until_deadline': days_until_deadline } if recent_verification_datetime: for key, value in status_by_course.iteritems(): # pylint: disable=unused-variable status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y") return status_by_course
def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None, title='None', generate_pdf=True): """ Request a new certificate for a student. Arguments: student - User.object course_id - courseenrollment.course_id (CourseKey) forced_grade - a string indicating a grade parameter to pass with the certificate request. If this is given, grading will be skipped. generate_pdf - Boolean should a message be sent in queue to generate certificate PDF Will change the certificate status to 'generating' or `downloadable` in case of web view certificates. Certificate must be in the 'unavailable', 'error', 'deleted' or 'generating' state. If a student has a passing grade or is in the whitelist table for the course a request will be made for a new cert. If a student has allow_certificate set to False in the userprofile table the status will change to 'restricted' If a student does not have a passing grade the status will change to status.notpassing Returns the student's status and newly created certificate instance """ valid_statuses = [ status.generating, status.unavailable, status.deleted, status.error, status.notpassing, status.downloadable ] cert_status = certificate_status_for_student(student, course_id)['status'] new_status = cert_status cert = None if cert_status not in valid_statuses: LOGGER.warning( ( u"Cannot create certificate generation task for user %s " u"in the course '%s'; " u"the certificate status '%s' is not one of %s." ), student.id, unicode(course_id), cert_status, unicode(valid_statuses) ) else: # grade the student # re-use the course passed in optionally so we don't have to re-fetch everything # for every student if course is None: course = modulestore().get_course(course_id, depth=0) profile = UserProfile.objects.get(user=student) profile_name = profile.name # Needed self.request.user = student self.request.session = {} course_name = course.display_name or unicode(course_id) is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists() grade = grades.grade(student, self.request, 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) cert_mode = enrollment_mode # For credit mode generate verified certificate if cert_mode == CourseMode.CREDIT_MODE: cert_mode = CourseMode.VERIFIED if mode_is_verified and user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format(id=course_id) elif mode_is_verified and not user_is_verified: template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id) cert_mode = GeneratedCertificate.MODES.honor 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 cert, __ = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id) cert.mode = cert_mode cert.user = student cert.grade = 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) try: grade_contents = lxml.html.fromstring(grade_contents).text_content() except (TypeError, XMLSyntaxError, ParserError) as exc: LOGGER.info( ( u"Could not retrieve grade for student %s " u"in the course '%s' " u"because an exception occurred while parsing the " u"grade contents '%s' as HTML. " u"The exception was: '%s'" ), student.id, unicode(course_id), grade_contents, unicode(exc) ) # Despite blowing up the xml parser, bad values here are fine grade_contents = None if is_whitelisted or grade_contents is not None: if is_whitelisted: LOGGER.info( u"Student %s is whitelisted in '%s'", student.id, unicode(course_id) ) # check to see whether the student is on the # the embargoed country restricted list # otherwise, put a new certificate request # on the queue if self.restricted.filter(user=student).exists(): new_status = status.restricted cert.status = new_status cert.save() LOGGER.info( ( u"Student %s is in the embargoed country restricted " u"list, so their certificate status has been set to '%s' " u"for the course '%s'. " u"No certificate generation task was sent to the XQueue." ), student.id, new_status, unicode(course_id) ) else: key = make_hashkey(random.random()) cert.key = key contents = { 'action': 'create', 'username': student.username, 'course_id': unicode(course_id), 'course_name': course_name, 'name': profile_name, 'grade': grade_contents, 'template_pdf': template_pdf, } if template_file: contents['template_pdf'] = template_file if generate_pdf: new_status = status.generating else: new_status = status.downloadable cert.verify_uuid = uuid4().hex cert.status = new_status cert.save() if generate_pdf: try: self._send_to_xqueue(contents, key) except XQueueAddToQueueError as exc: new_status = ExampleCertificate.STATUS_ERROR cert.status = new_status cert.error_reason = unicode(exc) cert.save() LOGGER.critical( ( u"Could not add certificate task to XQueue. " u"The course was '%s' and the student was '%s'." u"The certificate task status has been marked as 'error' " u"and can be re-submitted with a management command." ), course_id, student.id ) else: LOGGER.info( ( u"The certificate status has been set to '%s'. " u"Sent a certificate grading task to the XQueue " u"with the key '%s'. " ), new_status, key ) else: new_status = status.notpassing cert.status = new_status cert.save() LOGGER.info( ( u"Student %s does not have a grade for '%s', " u"so their certificate status has been set to '%s'. " u"No certificate generation task was sent to the XQueue." ), student.id, unicode(course_id), new_status ) return new_status, cert