Merge pull request #18337 from edx/iahmad/ENT-1017-Verified-certificates-for-bank-learners

incorporated manual verification
This commit is contained in:
irfanuddinahmad
2018-06-25 12:52:58 +05:00
committed by GitHub
14 changed files with 382 additions and 46 deletions

View File

@@ -409,7 +409,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
RequestCache.clear_request_cache()
expected_query_count = 42
expected_query_count = 43
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with check_mongo_calls(mongo_count):
with self.assertNumQueries(expected_query_count):
@@ -2151,7 +2151,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(114):
with self.assertNumQueries(122):
self.assertCertificatesGenerated(task_input, expected_results)
expected_results = {

View File

@@ -5,7 +5,7 @@ Admin site configurations for verify_student.
from django.contrib import admin
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
@admin.register(SoftwareSecurePhotoVerification)
@@ -27,3 +27,13 @@ class SSOVerificationAdmin(admin.ModelAdmin):
readonly_fields = ('user', 'identity_provider_slug', 'identity_provider_type',)
raw_id_fields = ('user',)
search_fields = ('user__username', 'identity_provider_slug',)
@admin.register(ManualVerification)
class ManualVerificationAdmin(admin.ModelAdmin):
"""
Admin for the ManualVerification table.
"""
list_display = ('id', 'user', 'status', 'reason', 'created_at', 'updated_at',)
raw_id_fields = ('user',)
search_fields = ('user__username', 'reason',)

View File

@@ -0,0 +1,86 @@
"""
Django admin commands related to verify_student
"""
import logging
import os
from pprint import pformat
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User
from lms.djangoapps.verify_student.models import ManualVerification
from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date
log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
This method attempts to manually verify users.
Example usage:
$ ./manage.py lms manual_verifications --email-ids-file <absolute path of file with email ids (one per line)>
"""
help = 'Manually verifies one or more users passed as an argument list.'
def add_arguments(self, parser):
parser.add_argument(
'--email-ids-file',
action='store',
dest='email_ids_file',
default=None,
help='Path of the file to read email id from.',
type=str,
required=True
)
def handle(self, *args, **options):
email_ids_file = options['email_ids_file']
if email_ids_file:
if not os.path.exists(email_ids_file):
raise CommandError(u'Pass the correct absolute path to email ids file as --email-ids-file argument.')
total_emails, failed_emails = self._generate_manual_verification_from_file(email_ids_file)
if failed_emails:
log.error(u'Completed manual verification. {} of {} failed.'.format(
len(failed_emails),
total_emails
))
log.error('Failed emails:{}'.format(pformat(failed_emails)))
else:
log.info('Successfully generated manual verification for {} emails.'.format(total_emails))
def _generate_manual_verification_from_file(self, email_ids_file):
"""
Generate manual verification for the emails provided in the email ids file.
Arguments:
email_ids_file (str): path of the file containing email ids.
Returns:
(total_emails, failed_emails): a tuple containing count of emails processed and a list containing
emails whose verifications could not be processed.
"""
failed_emails = []
with open(email_ids_file, 'r') as file_handler:
email_ids = file_handler.readlines()
total_emails = len(email_ids)
log.info(u'Creating manual verification for {} emails.'.format(total_emails))
for email_id in email_ids:
try:
email_id = email_id.strip()
user = User.objects.get(email=email_id)
ManualVerification.objects.get_or_create(
user=user,
status='approved',
created_at__gte=earliest_allowed_verification_date(),
defaults={'name': user.profile.name},
)
except User.DoesNotExist:
failed_emails.append(email_id)
err_msg = u'Tried to verify email {}, but user not found'
log.error(err_msg.format(email_id))
return total_emails, failed_emails

View File

@@ -0,0 +1,113 @@
"""
Tests for django admin commands in the verify_student module
"""
import logging
import os
import tempfile
from django.core.management import call_command, CommandError
from django.test import TestCase
from lms.djangoapps.verify_student.models import ManualVerification
from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date
from student.tests.factories import UserFactory
from testfixtures import LogCapture
LOGGER_NAME = 'lms.djangoapps.verify_student.management.commands.manual_verifications'
class TestVerifyStudentCommand(TestCase):
"""
Tests for django admin commands in the verify_student module
"""
tmp_file_path = os.path.join(tempfile.gettempdir(), 'tmp-emails.txt')
def setUp(self):
super(TestVerifyStudentCommand, self).setUp()
self.user1 = UserFactory.create()
self.user2 = UserFactory.create()
self.user3 = UserFactory.create()
self.invalid_email = unicode('unknown@unknown.com')
self.create_email_ids_file(
self.tmp_file_path,
[self.user1.email, self.user2.email, self.user3.email, self.invalid_email]
)
@staticmethod
def create_email_ids_file(file_path, email_ids):
"""
Write the email_ids list to the temp file.
"""
with open(file_path, 'w') as temp_file:
temp_file.write(str("\n".join(email_ids)))
def test_manual_verifications(self):
"""
Tests that the manual_verifications management command executes successfully
"""
self.assertEquals(ManualVerification.objects.filter(status='approved').count(), 0)
call_command('manual_verifications', '--email-ids-file', self.tmp_file_path)
self.assertEquals(ManualVerification.objects.filter(status='approved').count(), 3)
def test_manual_verifications_created_date(self):
"""
Tests that the manual_verifications management command does not create a new verification
if a previous non-expired verification exists
"""
call_command('manual_verifications', '--email-ids-file', self.tmp_file_path)
verification1 = ManualVerification.objects.filter(
user=self.user1,
status='approved',
created_at__gte=earliest_allowed_verification_date()
)
call_command('manual_verifications', '--email-ids-file', self.tmp_file_path)
verification2 = ManualVerification.objects.filter(
user=self.user1,
status='approved',
created_at__gte=earliest_allowed_verification_date()
)
self.assertQuerysetEqual(verification1, [repr(r) for r in verification2])
def test_user_doesnot_exist_log(self):
"""
Tests that the manual_verifications management command logs an error when an invalid email is
provided as input
"""
expected_log = (
(LOGGER_NAME,
'INFO',
u'Creating manual verification for 4 emails.'
),
(LOGGER_NAME,
'ERROR',
u'Tried to verify email unknown@unknown.com, but user not found'
),
(LOGGER_NAME,
'ERROR',
u'Completed manual verification. 1 of 4 failed.'
),
(LOGGER_NAME,
'ERROR',
"Failed emails:['unknown@unknown.com']"
)
)
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
call_command('manual_verifications', '--email-ids-file', self.tmp_file_path)
logger.check(
*expected_log
)
def test_invalid_file_path(self):
"""
Verify command raises the CommandError for invalid file path.
"""
with self.assertRaises(CommandError):
call_command('manual_verifications', '--email-ids-file', u'invalid/email_id/file/path')

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-07 10:51
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('verify_student', '0009_remove_id_verification_aggregate'),
]
operations = [
migrations.CreateModel(
name='ManualVerification',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', model_utils.fields.StatusField(choices=[(b'created', b'created'), (b'ready', b'ready'), (b'submitted', b'submitted'), (b'must_retry', b'must_retry'), (b'approved', b'approved'), (b'denied', b'denied')], default=b'created', max_length=100, no_check_for_status=True, verbose_name='status')),
('status_changed', model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='status', verbose_name='status changed')),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('reason', models.CharField(blank=True, help_text=b'Specifies the reason for manual verification of the user.', max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -141,6 +141,36 @@ class IDVerificationAttempt(StatusModel):
)
class ManualVerification(IDVerificationAttempt):
"""
Each ManualVerification represents a user's verification that bypasses the need for
any other verification.
"""
reason = models.CharField(
max_length=255,
blank=True,
help_text=(
'Specifies the reason for manual verification of the user.'
)
)
class Meta(object):
app_label = 'verify_student'
def __unicode__(self):
return 'ManualIDVerification for {name}, status: {status}'.format(
name=self.name,
status=self.status,
)
def should_display_status_to_user(self):
"""
Whether or not the status should be displayed to the user.
"""
return False
class SSOVerification(IDVerificationAttempt):
"""
Each SSOVerification represents a Student's attempt to establish their identity

View File

@@ -13,7 +13,7 @@ from course_modes.models import CourseMode
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from student.models import User
from .models import SoftwareSecurePhotoVerification, SSOVerification
from .models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
from .utils import earliest_allowed_verification_date, most_recent_verification
log = logging.getLogger(__name__)
@@ -69,7 +69,8 @@ class IDVerificationService(object):
}
return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
SSOVerification.objects.filter(**filter_kwargs).exists())
SSOVerification.objects.filter(**filter_kwargs).exists() or
ManualVerification.objects.filter(**filter_kwargs).exists())
@classmethod
def verifications_for_user(cls, user):
@@ -78,7 +79,8 @@ class IDVerificationService(object):
"""
verifications = []
for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user),
SSOVerification.objects.filter(user=user)):
SSOVerification.objects.filter(user=user),
ManualVerification.objects.filter(user=user)):
verifications.append(verification)
return verifications
@@ -95,7 +97,8 @@ class IDVerificationService(object):
}
return chain(
SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).select_related('user'),
SSOVerification.objects.filter(**filter_kwargs).select_related('user')
SSOVerification.objects.filter(**filter_kwargs).select_related('user'),
ManualVerification.objects.filter(**filter_kwargs).select_related('user')
)
@classmethod
@@ -120,8 +123,14 @@ class IDVerificationService(object):
photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs)
sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs)
manual_id_verifications = ManualVerification.objects.filter(**filter_kwargs)
attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at')
attempt = most_recent_verification(
photo_id_verifications,
sso_id_verifications,
manual_id_verifications,
'updated_at'
)
return attempt and attempt.expiration_datetime
@classmethod
@@ -139,7 +148,8 @@ class IDVerificationService(object):
}
return (SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).exists() or
SSOVerification.objects.filter(**filter_kwargs).exists())
SSOVerification.objects.filter(**filter_kwargs).exists() or
ManualVerification.objects.filter(**filter_kwargs).exists())
@classmethod
def user_status(cls, user):
@@ -166,8 +176,14 @@ class IDVerificationService(object):
try:
photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-updated_at')
sso_id_verifications = SSOVerification.objects.filter(user=user).order_by('-updated_at')
manual_id_verifications = ManualVerification.objects.filter(user=user).order_by('-updated_at')
attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at')
attempt = most_recent_verification(
photo_id_verifications,
sso_id_verifications,
manual_id_verifications,
'updated_at'
)
except IndexError:
# The user has no verification attempts, return the default set of data.
return user_status

View File

@@ -23,6 +23,7 @@ from common.test.utils import MockS3Mixin
from lms.djangoapps.verify_student.models import (
SoftwareSecurePhotoVerification,
SSOVerification,
ManualVerification,
VerificationDeadline,
VerificationException
)
@@ -387,6 +388,16 @@ class SSOVerificationTest(TestVerification):
self.verification_active_at_datetime(attempt)
class ManualVerificationTest(TestVerification):
"""
Tests for the ManualVerification model
"""
def test_active_at_datetime(self):
user = UserFactory.create()
verification = ManualVerification.objects.create(user=user)
self.verification_active_at_datetime(verification)
class VerificationDeadlineTest(CacheIsolationTestCase):
"""
Tests for the VerificationDeadline model.

View File

@@ -16,7 +16,7 @@ from nose.tools import (
)
from common.test.utils import MockS3Mixin
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
from lms.djangoapps.verify_student.services import IDVerificationService
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -78,12 +78,12 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase):
# test for correct status when no error returned
user = UserFactory.create()
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'none', 'error': '', 'should_display': True})
self.assertDictEqual(status, {'status': 'none', 'error': '', 'should_display': True})
# test for when photo verification has been created
SoftwareSecurePhotoVerification.objects.create(user=user, status='approved')
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': True})
self.assertDictEqual(status, {'status': 'approved', 'error': '', 'should_display': True})
# create another photo verification for the same user, make sure the denial
# is handled properly
@@ -91,18 +91,23 @@ class TestIDVerificationService(MockS3Mixin, ModuleStoreTestCase):
user=user, status='denied', error_msg='[{"photoIdReasons": ["Not provided"]}]'
)
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True})
self.assertDictEqual(status, {'status': 'must_reverify', 'error': ['id_image_missing'], 'should_display': True})
# test for when sso verification has been created
SSOVerification.objects.create(user=user, status='approved')
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'approved', 'error': '', 'should_display': False})
self.assertDictEqual(status, {'status': 'approved', 'error': '', 'should_display': False})
# create another sso verification for the same user, make sure the denial
# is handled properly
SSOVerification.objects.create(user=user, status='denied')
status = IDVerificationService.user_status(user)
self.assertEquals(status, {'status': 'must_reverify', 'error': '', 'should_display': False})
self.assertDictEqual(status, {'status': 'must_reverify', 'error': '', 'should_display': False})
# test for when manual verification has been created
ManualVerification.objects.create(user=user, status='approved')
status = IDVerificationService.user_status(user)
self.assertDictEqual(status, {'status': 'approved', 'error': '', 'should_display': False})
@ddt.unpack
@ddt.data(

View File

@@ -11,7 +11,7 @@ import pytz
from mock import patch
from pytest import mark
from django.conf import settings
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
from lms.djangoapps.verify_student.utils import verification_for_datetime, most_recent_verification
from student.tests.factories import UserFactory
@@ -88,38 +88,49 @@ class TestVerifyStudentUtils(unittest.TestCase):
self.assertEqual(result, second_attempt)
@ddt.data(
(False, False, None, None),
(True, False, None, 'photo'),
(False, True, None, 'sso'),
(True, True, 'photo', 'sso'),
(True, True, 'sso', 'photo'),
(False, False, False, None, None),
(True, False, False, None, 'photo'),
(False, True, False, None, 'sso'),
(False, False, True, None, 'manual'),
(True, True, True, 'photo', 'sso'),
(True, True, True, 'sso', 'photo'),
(True, True, True, 'manual', 'photo')
)
@ddt.unpack
def test_most_recent_verification(
self,
create_photo_verification,
create_sso_verification,
create_manual_verification,
first_verification,
expected_verification):
user = UserFactory.create()
photo_verification = None
sso_verification = None
manual_verification = None
if not first_verification:
if create_photo_verification:
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
if create_sso_verification:
sso_verification = SSOVerification.objects.create(user=user)
if create_manual_verification:
manual_verification = ManualVerification.objects.create(user=user)
elif first_verification == 'photo':
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
sso_verification = SSOVerification.objects.create(user=user)
else:
elif first_verification == 'sso':
sso_verification = SSOVerification.objects.create(user=user)
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
else:
manual_verification = ManualVerification.objects.create(user=user)
photo_verification = SoftwareSecurePhotoVerification.objects.create(user=user)
most_recent = most_recent_verification(
SoftwareSecurePhotoVerification.objects.all(),
SSOVerification.objects.all(),
ManualVerification.objects.all(),
'created_at'
)
@@ -127,5 +138,7 @@ class TestVerifyStudentUtils(unittest.TestCase):
self.assertEqual(most_recent, None)
elif expected_verification == 'photo':
self.assertEqual(most_recent, photo_verification)
else:
elif expected_verification == 'sso':
self.assertEqual(most_recent, sso_verification)
else:
self.assertEqual(most_recent, manual_verification)

View File

@@ -93,28 +93,32 @@ def send_verification_status_email(context):
))
def most_recent_verification(photo_id_verifications, sso_id_verifications, most_recent_key):
def most_recent_verification(photo_id_verifications, sso_id_verifications, manual_id_verifications, most_recent_key):
"""
Return the most recent verification given querysets for both photo and sso verifications.
Return the most recent verification given querysets for photo, sso and manual verifications.
This function creates a map of the latest verification of all types and then returns the earliest
verification using the max of the map values.
Arguments:
photo_id_verifications: Queryset containing photo verifications
sso_id_verifications: Queryset containing sso verifications
most_recent_key: Either 'updated_at' or 'created_at'
photo_id_verifications: Queryset containing photo verifications
sso_id_verifications: Queryset containing sso verifications
manual_id_verifications: Queryset containing manual verifications
most_recent_key: Either 'updated_at' or 'created_at'
Returns:
The most recent verification.
"""
photo_id_verification = photo_id_verifications and photo_id_verifications.first()
sso_id_verification = sso_id_verifications and sso_id_verifications.first()
manual_id_verification = manual_id_verifications and manual_id_verifications.first()
if not photo_id_verification and not sso_id_verification:
return None
elif photo_id_verification and not sso_id_verification:
return photo_id_verification
elif sso_id_verification and not photo_id_verification:
return sso_id_verification
elif getattr(photo_id_verification, most_recent_key) > getattr(sso_id_verification, most_recent_key):
return photo_id_verification
else:
return sso_id_verification
verifications = [photo_id_verification, sso_id_verification, manual_id_verification]
verifications_map = {
verification: getattr(verification, most_recent_key)
for verification in verifications
if getattr(verification, most_recent_key, False)
}
return max(verifications_map, key=lambda k: verifications_map[k]) if verifications_map else None

View File

@@ -162,7 +162,7 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
call_command('backpopulate_program_credentials', commit=True)
# The task should be called for both users since professional and no-id-professional are equivalent.
mock_task.assert_has_calls([mock.call(self.alice.username), mock.call(self.bob.username)])
mock_task.assert_has_calls([mock.call(self.alice.username), mock.call(self.bob.username)], any_order=True)
@ddt.data(SEPARATE_PROGRAMS, SEPARATE_COURSES, SAME_COURSE)
def test_handle_flatten(self, hierarchy_type, mock_task, mock_get_programs):

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.utils.timezone import now
from rest_framework import serializers
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
from .models import UserPreference
@@ -119,3 +119,10 @@ class SSOVerificationSerializer(IDVerificationSerializer):
class Meta(object):
fields = ('status', 'expiration_datetime', 'is_verified')
model = SSOVerification
class ManualVerificationSerializer(IDVerificationSerializer):
class Meta(object):
fields = ('status', 'expiration_datetime', 'is_verified')
model = ManualVerification

View File

@@ -5,10 +5,10 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.generics import RetrieveAPIView
from rest_framework_oauth.authentication import OAuth2Authentication
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, ManualVerification
from lms.djangoapps.verify_student.utils import most_recent_verification
from openedx.core.djangoapps.user_api.serializers import (
SoftwareSecurePhotoVerificationSerializer, SSOVerificationSerializer,
SoftwareSecurePhotoVerificationSerializer, SSOVerificationSerializer, ManualVerificationSerializer,
)
from openedx.core.lib.api.permissions import IsStaffOrOwner
@@ -26,17 +26,25 @@ class IDVerificationStatusView(RetrieveAPIView):
kwargs['context'] = self.get_serializer_context()
if isinstance(instance, SoftwareSecurePhotoVerification):
return SoftwareSecurePhotoVerificationSerializer(*args, **kwargs)
else:
elif isinstance(instance, SSOVerification):
return SSOVerificationSerializer(*args, **kwargs)
else:
return ManualVerificationSerializer(*args, **kwargs)
def get_object(self):
username = self.kwargs['username']
photo_verifications = SoftwareSecurePhotoVerification.objects.filter(
user__username=username).order_by('-updated_at')
sso_verifications = SSOVerification.objects.filter(user__username=username).order_by('-updated_at')
manual_verifications = ManualVerification.objects.filter(user__username=username).order_by('-updated_at')
if photo_verifications or sso_verifications:
verification = most_recent_verification(photo_verifications, sso_verifications, 'updated_at')
if photo_verifications or sso_verifications or manual_verifications:
verification = most_recent_verification(
photo_verifications,
sso_verifications,
manual_verifications,
'updated_at'
)
self.check_object_permissions(self.request, verification)
return verification