From a46a28e983d4c6cd66fd0503fe317a0e9376b29d Mon Sep 17 00:00:00 2001
From: irfanuddinahmad <irfan.ahmad@arbisoft.com>
Date: Thu, 7 Jun 2018 15:57:29 +0500
Subject: [PATCH] incorporated manual verification

---
 .../tests/test_tasks_helper.py                |   4 +-
 lms/djangoapps/verify_student/admin.py        |  12 +-
 .../commands/manual_verifications.py          |  86 +++++++++++++
 .../tests/test_manual_verify_student.py       | 113 ++++++++++++++++++
 .../migrations/0010_manualverification.py     |  33 +++++
 lms/djangoapps/verify_student/models.py       |  30 +++++
 lms/djangoapps/verify_student/services.py     |  32 +++--
 .../verify_student/tests/test_models.py       |  11 ++
 .../verify_student/tests/test_services.py     |  17 ++-
 .../verify_student/tests/test_utils.py        |  29 +++--
 lms/djangoapps/verify_student/utils.py        |  34 +++---
 .../test_backpopulate_program_credentials.py  |   2 +-
 .../core/djangoapps/user_api/serializers.py   |   9 +-
 .../user_api/verification_api/views.py        |  18 ++-
 14 files changed, 383 insertions(+), 47 deletions(-)
 create mode 100644 lms/djangoapps/verify_student/management/commands/manual_verifications.py
 create mode 100644 lms/djangoapps/verify_student/management/commands/tests/test_manual_verify_student.py
 create mode 100644 lms/djangoapps/verify_student/migrations/0010_manualverification.py

diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
index bd489d7e6e6..e761465f361 100644
--- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
+++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py
@@ -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 = {
diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py
index 8a2e8315ddc..3db76cda4c4 100644
--- a/lms/djangoapps/verify_student/admin.py
+++ b/lms/djangoapps/verify_student/admin.py
@@ -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',)
diff --git a/lms/djangoapps/verify_student/management/commands/manual_verifications.py b/lms/djangoapps/verify_student/management/commands/manual_verifications.py
new file mode 100644
index 00000000000..37421332fd7
--- /dev/null
+++ b/lms/djangoapps/verify_student/management/commands/manual_verifications.py
@@ -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
diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_manual_verify_student.py b/lms/djangoapps/verify_student/management/commands/tests/test_manual_verify_student.py
new file mode 100644
index 00000000000..134386dbfb3
--- /dev/null
+++ b/lms/djangoapps/verify_student/management/commands/tests/test_manual_verify_student.py
@@ -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')
diff --git a/lms/djangoapps/verify_student/migrations/0010_manualverification.py b/lms/djangoapps/verify_student/migrations/0010_manualverification.py
new file mode 100644
index 00000000000..55bcdbff4b9
--- /dev/null
+++ b/lms/djangoapps/verify_student/migrations/0010_manualverification.py
@@ -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)),
+            ],
+        ),
+    ]
diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py
index 9fc15d1bab5..d21617493b0 100644
--- a/lms/djangoapps/verify_student/models.py
+++ b/lms/djangoapps/verify_student/models.py
@@ -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
diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py
index 8e8cbc026a8..7fa51c474e1 100644
--- a/lms/djangoapps/verify_student/services.py
+++ b/lms/djangoapps/verify_student/services.py
@@ -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')
-
-            attempt = most_recent_verification(photo_id_verifications, sso_id_verifications, 'updated_at')
+            manual_id_verifications = ManualVerification.objects.filter(user=user).order_by('-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
diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py
index 1dbbb126ea7..c55a0972062 100644
--- a/lms/djangoapps/verify_student/tests/test_models.py
+++ b/lms/djangoapps/verify_student/tests/test_models.py
@@ -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.
diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py
index 68410e9d503..43999288bf3 100644
--- a/lms/djangoapps/verify_student/tests/test_services.py
+++ b/lms/djangoapps/verify_student/tests/test_services.py
@@ -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(
diff --git a/lms/djangoapps/verify_student/tests/test_utils.py b/lms/djangoapps/verify_student/tests/test_utils.py
index 75407968d4e..49806b681cc 100644
--- a/lms/djangoapps/verify_student/tests/test_utils.py
+++ b/lms/djangoapps/verify_student/tests/test_utils.py
@@ -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)
diff --git a/lms/djangoapps/verify_student/utils.py b/lms/djangoapps/verify_student/utils.py
index 3193d0055d5..5fb4e11c9cb 100644
--- a/lms/djangoapps/verify_student/utils.py
+++ b/lms/djangoapps/verify_student/utils.py
@@ -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
diff --git a/openedx/core/djangoapps/programs/tests/test_backpopulate_program_credentials.py b/openedx/core/djangoapps/programs/tests/test_backpopulate_program_credentials.py
index fb48035b467..6b93d85e495 100644
--- a/openedx/core/djangoapps/programs/tests/test_backpopulate_program_credentials.py
+++ b/openedx/core/djangoapps/programs/tests/test_backpopulate_program_credentials.py
@@ -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):
diff --git a/openedx/core/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py
index 7b24ab35b50..ae515de0ae2 100644
--- a/openedx/core/djangoapps/user_api/serializers.py
+++ b/openedx/core/djangoapps/user_api/serializers.py
@@ -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
diff --git a/openedx/core/djangoapps/user_api/verification_api/views.py b/openedx/core/djangoapps/user_api/verification_api/views.py
index eae82f74e11..b64714ba46c 100644
--- a/openedx/core/djangoapps/user_api/verification_api/views.py
+++ b/openedx/core/djangoapps/user_api/verification_api/views.py
@@ -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
 
-- 
GitLab