From 45f9e268a7fc2f6e571b5a8b021f82fb270a583d Mon Sep 17 00:00:00 2001 From: annagav Date: Fri, 29 Jan 2021 16:15:44 -0500 Subject: [PATCH 1/9] removing pearson communication code --- exams/api.py | 31 - exams/api_test.py | 34 - .../commands/simulate_sftp_responses.py | 174 ----- .../commands/simulate_sftp_responses_test.py | 91 --- exams/pearson/audit.py | 220 ------- exams/pearson/audit_test.py | 190 ------ exams/pearson/constants.py | 59 -- exams/pearson/download.py | 348 ---------- exams/pearson/download_test.py | 604 ------------------ exams/pearson/exceptions.py | 29 - exams/pearson/factories.py | 68 -- exams/pearson/readers.py | 222 ------- exams/pearson/readers_test.py | 189 ------ exams/pearson/sftp.py | 48 -- exams/pearson/sftp_test.py | 74 --- exams/pearson/test_resources/noshow.dat | 2 - exams/pearson/upload.py | 23 - exams/pearson/upload_test.py | 52 -- exams/pearson/utils.py | 130 ---- exams/pearson/utils_test.py | 82 --- exams/pearson/writers.py | 280 -------- exams/pearson/writers_test.py | 406 ------------ exams/signals.py | 9 - exams/signals_test.py | 18 - exams/urls.py | 16 - exams/utils.py | 22 - exams/utils_test.py | 28 - exams/views.py | 69 -- exams/views_test.py | 151 ----- micromasters/urls.py | 1 - 30 files changed, 3670 deletions(-) delete mode 100644 exams/management/commands/simulate_sftp_responses.py delete mode 100644 exams/management/commands/simulate_sftp_responses_test.py delete mode 100644 exams/pearson/audit.py delete mode 100644 exams/pearson/audit_test.py delete mode 100644 exams/pearson/download.py delete mode 100644 exams/pearson/download_test.py delete mode 100644 exams/pearson/exceptions.py delete mode 100644 exams/pearson/factories.py delete mode 100644 exams/pearson/readers.py delete mode 100644 exams/pearson/readers_test.py delete mode 100644 exams/pearson/sftp.py delete mode 100644 exams/pearson/sftp_test.py delete mode 100644 exams/pearson/test_resources/noshow.dat delete mode 100644 exams/pearson/upload.py delete mode 100644 exams/pearson/upload_test.py delete mode 100644 exams/pearson/utils.py delete mode 100644 exams/pearson/utils_test.py delete mode 100644 exams/pearson/writers.py delete mode 100644 exams/pearson/writers_test.py delete mode 100644 exams/urls.py delete mode 100644 exams/views.py delete mode 100644 exams/views_test.py diff --git a/exams/api.py b/exams/api.py index b582d619f2..3ef8697fca 100644 --- a/exams/api.py +++ b/exams/api.py @@ -2,10 +2,6 @@ API for exams app """ import logging -import hashlib - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from dashboard.utils import get_mmtrack from dashboard.api import has_to_pay_for_exam @@ -35,33 +31,6 @@ log = logging.getLogger(__name__) -def sso_digest(client_candidate_id, timestamp, session_timeout): - """ - Compute the sso_digest value we need to send to pearson - - Args: - client_candidate_id (int|str): id for the user, usually Profile.student_id - timestamp (int): unix timestamp - session_timeout (int): number of seconds the session will last - - Returns: - str: the computed digest value - """ - if settings.EXAMS_SSO_PASSPHRASE is None: - raise ImproperlyConfigured("EXAMS_SSO_PASSPHRASE is not configured") - if settings.EXAMS_SSO_CLIENT_CODE is None: - raise ImproperlyConfigured("EXAMS_SSO_CLIENT_CODE is not configured") - - data = ''.join([ - settings.EXAMS_SSO_PASSPHRASE, - settings.EXAMS_SSO_CLIENT_CODE, - str(timestamp), - str(session_timeout), - str(client_candidate_id), - ]).encode('iso-8859-1') - return hashlib.sha256(data).hexdigest() - - def authorize_for_exam_run(user, course_run, exam_run): """ Authorize user for exam if he has paid for course and passed course. diff --git a/exams/api_test.py b/exams/api_test.py index 1bafb61e0e..d019827b93 100644 --- a/exams/api_test.py +++ b/exams/api_test.py @@ -4,12 +4,9 @@ from unittest.mock import patch import ddt -from django.core.exceptions import ImproperlyConfigured from django.db.models.signals import post_save from django.test import ( - SimpleTestCase, TestCase, - override_settings, ) from factory.django import mute_signals @@ -23,7 +20,6 @@ from exams.api import ( authorize_for_exam_run, authorize_for_latest_passed_course, - sso_digest, MESSAGE_NOT_ELIGIBLE_TEMPLATE, MESSAGE_NOT_PASSED_OR_EXIST_TEMPLATE, ) @@ -53,36 +49,6 @@ def create_order(user, course_run): ).order -class SSODigestTests(SimpleTestCase): - """ - Tests for the sso_digest helper function - """ - - @override_settings( - EXAMS_SSO_PASSPHRASE="C is for cookie", - EXAMS_SSO_CLIENT_CODE="and that's good enough for me", - ) - def test_that_sso_digest_computes_correctly(self): - """Verifies sso_digest computes correctly""" - - # computed "by hand" - assert sso_digest(123, 1486069731, 1800) == ( - 'a64ea7218e4a67d863e03ec43ac40240af39f5924af46e02b2199e3f7974b8d3' - ) - - @override_settings(EXAMS_SSO_PASSPHRASE=None) - def test_that_no_passphrase_raises(self): - """Verifies that if we don't set the passphrase we raise an exception""" - with self.assertRaises(ImproperlyConfigured): - sso_digest(123, 1486069731, 1800) - - @override_settings(EXAMS_SSO_CLIENT_CODE=None) - def test_that_no_client_code_raises(self): - """Verifies that if we don't set the passphrase we raise an exception""" - with self.assertRaises(ImproperlyConfigured): - sso_digest(123, 1486069731, 1800) - - @ddt.ddt class ExamAuthorizationApiTests(TestCase): """Tests for exam api""" diff --git a/exams/management/commands/simulate_sftp_responses.py b/exams/management/commands/simulate_sftp_responses.py deleted file mode 100644 index 5ce31e5245..0000000000 --- a/exams/management/commands/simulate_sftp_responses.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Command to simulate Pearson responses""" -import csv -import io -import os -import random -import time -import zipfile - -from django.conf import settings -from django.core.management.base import BaseCommand - -from exams.pearson.constants import ( - PEARSON_DEFAULT_DATETIME_FORMAT, - PEARSON_FILE_TYPES, -) -from exams.pearson import sftp -from micromasters.utils import now_in_utc - - -class Command(BaseCommand): - """Simulates Pearson responses""" - help = 'Simulates the SFTP backend by generating random responses' - - def add_arguments(self, parser): # pylint: disable=no-self-use - """Configure command args""" - parser.add_argument( - '--ratio-success', - dest='ratio', - type=float, - default=0.5, - ) - parser.add_argument( - '--interval', - dest='interval', - type=int, - default=5, - ) - parser.add_argument( - '--poll', - action='store_true', - dest='poll', - default=False, - ) - parser.add_argument( - '--keep-files', - action='store_true', - dest='keep', - default=False, - ) - - def handle(self, *args, **options): - """Handle the command""" - ratio = options['ratio'] - poll = options['poll'] - interval = options['interval'] - keep = options['keep'] - - if poll: - while True: - self.handle_poll(ratio, keep) - - self.stdout.write('Next poll in {} seconds'.format(interval)) - time.sleep(interval) - else: - self.handle_poll(ratio, keep) - - def handle_poll(self, ratio, keep): - """Handle a poll interval""" - self.stdout.write('Checking sftp server') - - with sftp.get_connection() as sftp_conn: - with sftp_conn.cd(settings.EXAMS_SFTP_UPLOAD_DIR): - paths = [path for path in sftp_conn.listdir() if sftp_conn.isfile(path)] - - self.stdout.write('Found {} file(s)'.format(len(paths))) - - for path in paths: - time.sleep(2) # ensures unique filename timestamps - self.stdout.write('Found a file: {}/{}'.format(sftp_conn.pwd, path)) - - if path.startswith('ead'): - self.handle_ead(sftp_conn, path, ratio) - - elif path.startswith('cdd'): - self.handle_cdd(sftp_conn, path, ratio) - else: - continue - - if not keep: - sftp_conn.remove(path) - - def handle_ead(self, sftp_conn, remote_path, ratio): - """Handle an EAD file""" - now = now_in_utc() - result_file = io.StringIO() - writer = csv.DictWriter(result_file, [ - 'ClientAuthorizationID', - 'ClientCandidateID', - 'Status', - 'Date', - 'Message', - ], delimiter='\t') - writer.writeheader() - with sftp_conn.open(remote_path, mode='r') as eac_file: - for row in csv.DictReader(eac_file, delimiter='\t'): - cid = row['ClientCandidateID'] - aid = row['ClientAuthorizationID'] - error = random.random() > ratio - status = 'Error' if error else 'Accepted' - self.stdout.write('Marking authorization {aid} for profile {cid} as {status}'.format( - aid=aid, - cid=cid, - status=status, - )) - writer.writerow({ - 'ClientAuthorizationID': aid, - 'ClientCandidateID': cid, - 'Status': status, - 'Date': now.strftime(PEARSON_DEFAULT_DATETIME_FORMAT), - 'Message': 'Invalid ExamSeriesCode' if error else '', - }) - - self.write_zip( - sftp_conn, - result_file.getvalue(), - now.strftime('{}-%Y-%m-%d.dat'.format(PEARSON_FILE_TYPES.EAC)), - now - ) - - def handle_cdd(self, sftp_conn, remote_path, ratio): - """Handle a CDD file""" - now = now_in_utc() - result_file = io.StringIO() - writer = csv.DictWriter(result_file, [ - 'ClientCandidateID', - 'Status', - 'Date', - 'Message', - ], delimiter='\t') - writer.writeheader() - with sftp_conn.open(remote_path, mode='r') as cdd_file: - for row in csv.DictReader(cdd_file, delimiter='\t'): - cid = row['ClientCandidateID'] - error = random.random() > ratio - status = 'Error' if error else 'Accepted' - self.stdout.write('Marking profile {cid} as {status}'.format( - cid=cid, - status=status, - )) - writer.writerow({ - 'ClientCandidateID': cid, - 'Status': 'Error' if error else 'Accepted', - 'Date': now.strftime(PEARSON_DEFAULT_DATETIME_FORMAT), - 'Message': 'Invalid Address' if error else '', - }) - - self.write_zip( - sftp_conn, - result_file.getvalue(), - now.strftime('{}-%Y-%m-%d.dat'.format(PEARSON_FILE_TYPES.VCDC)), - now - ) - - def write_zip(self, sftp_conn, data, filename, now): # pylint: disable=no-self-use - """Write a zip file to the sftp server""" - zip_path = os.path.join( - settings.EXAMS_SFTP_TEMP_DIR, - now.strftime('ORGNAME-NS-%Y-%m-%d-%H%M%S.zip'), - ) - with zipfile.ZipFile(zip_path, 'w') as zf: - zf.writestr(filename, data) - - with sftp_conn.cd(settings.EXAMS_SFTP_RESULTS_DIR): - sftp_conn.put(zip_path) diff --git a/exams/management/commands/simulate_sftp_responses_test.py b/exams/management/commands/simulate_sftp_responses_test.py deleted file mode 100644 index 0d0075f606..0000000000 --- a/exams/management/commands/simulate_sftp_responses_test.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for simulate_sftp_responses""" -from unittest.mock import ( - ANY, - MagicMock, - patch, -) - -from django.test import ( - TestCase, - override_settings, -) - -from exams.management.commands import simulate_sftp_responses -from micromasters.utils import now_in_utc - - -class SimulateSftpResponsesTest(TestCase): - """Tests for simulate_sftp_responses""" - - @patch('exams.pearson.sftp.get_connection') - @patch('exams.management.commands.simulate_sftp_responses.Command.handle_ead') - @patch('exams.management.commands.simulate_sftp_responses.Command.handle_cdd') - def test_handle_poll(self, handle_cdd_mock, handle_ead_mock, get_connection_mock): - """Test that handle_poll handles ead and cdd files""" - sftp_mock = get_connection_mock.return_value.__enter__.return_value - sftp_mock.listdir.return_value = ['ead', 'cdd', 'zzz'] - sftp_mock.isfile.return_value = [True, True, True] - - cmd = simulate_sftp_responses.Command() - cmd.handle_poll(0.5, False) - - get_connection_mock.assert_called_once_with() - - sftp_mock.cd.assert_called_once_with(ANY) - sftp_mock.listdir.assert_called_once_with() - - handle_ead_mock.assert_called_once_with(sftp_mock, 'ead', 0.5) - handle_cdd_mock.assert_called_once_with(sftp_mock, 'cdd', 0.5) - - @patch('csv.DictReader') - @patch('exams.management.commands.simulate_sftp_responses.Command.write_zip') - def test_handle_cdd(self, write_zip_mock, dict_reader_mock): - """Test that handle_cdd writes a zip file""" - sftp_mock = MagicMock() - - dict_reader_mock.return_value = [{ - 'ClientCandidateID': 1, - }] - - cmd = simulate_sftp_responses.Command() - with patch('time.sleep'): # don't slow down tests - cmd.handle_cdd(sftp_mock, 'file', False) - - sftp_mock.open.assert_called_once_with('file', mode='r') - write_zip_mock.assert_called_once_with(sftp_mock, ANY, ANY, ANY) - - @patch('csv.DictReader') - @patch('exams.management.commands.simulate_sftp_responses.Command.write_zip') - def test_handle_ead(self, write_zip_mock, dict_reader_mock): - """Test that handle_ead writes a zip file""" - sftp_mock = MagicMock() - - dict_reader_mock.return_value = [{ - 'ClientAuthorizationID': 2, - 'ClientCandidateID': 1, - }] - - cmd = simulate_sftp_responses.Command() - with patch('time.sleep'): # don't slow down tests - cmd.handle_ead(sftp_mock, 'file', False) - - sftp_mock.open.assert_called_once_with('file', mode='r') - write_zip_mock.assert_called_once_with(sftp_mock, ANY, ANY, ANY) - - @override_settings( - EXAMS_SFTP_RESULTS_DIR='/results', - ) - @patch('zipfile.ZipFile') - def test_write_zip(self, zip_file_mock): - """Test that write_zip writes a zip file""" - sftp_mock = MagicMock() - - cmd = simulate_sftp_responses.Command() - cmd.write_zip(sftp_mock, 'data string', 'file.dat', now_in_utc()) - - zip_file_mock.assert_called_once_with(ANY, 'w') - zf_mock = zip_file_mock.return_value.__enter__.return_value - zf_mock.writestr.assert_called_once_with('file.dat', 'data string') - - sftp_mock.cd.assert_called_once_with('/results') - sftp_mock.put.assert_called_once_with(ANY) diff --git a/exams/pearson/audit.py b/exams/pearson/audit.py deleted file mode 100644 index 65c466b2fa..0000000000 --- a/exams/pearson/audit.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Exam auditing""" -import logging -import os - -from boto.s3 import ( - connection, - key, -) -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from nacl.encoding import Base64Encoder -from nacl.public import PublicKey, SealedBox - -from micromasters import utils - -SSE_ENCRYPTION_ALGORITHM = 'AES256' - -log = logging.getLogger(__name__) - - -class S3AuditStorage: - """Audit storage mechanism for S3""" - - def _validate(self): - """ - Configures / validates the storage configuration - - Raises: - ImproperlyConfigured: if a required setting is not set - """ - missing_settings = [key for key in ( - 'EXAMS_AUDIT_S3_BUCKET', - 'EXAMS_AUDIT_AWS_ACCESS_KEY_ID', - 'EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY', - ) if not getattr(settings, key)] - - if missing_settings: - raise ImproperlyConfigured( - 'The following setting(s) are required but not set: {}'.format(missing_settings) - ) - - def get_connection(self): - """ - Creates a connection to S3 - - Returns: - boto.s3.connection.S3Connection: a connection to S3 - """ - self._validate() - return connection.S3Connection( - settings.EXAMS_AUDIT_AWS_ACCESS_KEY_ID, - settings.EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY - ) - - def get_bucket(self): - """ - Gets the configured bucket - - Raises: - ImproperlyConfigured: if a required setting is not set - - Returns: - boto.s3.bucket.Bucket: the S3 bucket for storage - """ - return self.get_connection().get_bucket(settings.EXAMS_AUDIT_S3_BUCKET) - - def get_s3_key(self, filename, file_type): - """ - Determines the S3 key to store the file under - - Args: - filename (str): filename of the encrypted file - file_type (str): type of the encrypted file - - Returns: - boto.s3.key.Key: the key to store the file in - """ - basename = os.path.basename(filename) - return key.Key( - self.get_bucket(), - 'exam_audits/{file_type}/{filename}'.format( - filename=basename, - file_type=file_type, - ) - ) - - def upload(self, filename, data, file_type): - """ - Uploads the file to S3 - - Args: - filename (str): filename of the encrypted file - data (str): the encrypted data - file_type (str): type of the encrypted file - - Returns: - str: the key path in S3 where the file was stored - """ - s3_key = self.get_s3_key(filename, file_type) - s3_key.set_contents_from_string( - data, - headers={ - 'x-amz-server-side-encryption': SSE_ENCRYPTION_ALGORITHM, - } - ) - return s3_key.key - - -def _get_public_key(): - """ - Get the configured PublicKey instance - - Returns: - PublicKey: - the public key as configured in settings - """ - if not settings.EXAMS_AUDIT_NACL_PUBLIC_KEY: - raise ImproperlyConfigured( - "EXAMS_AUDIT_NACL_PUBLIC_KEY is required but not set" - ) - - return PublicKey(settings.EXAMS_AUDIT_NACL_PUBLIC_KEY, encoder=Base64Encoder) - - -def _get_sealed_box(): - """ - Get a NaCl SealedBox configured with the public key - - Returns: - SealedBox: - the configured SealedBox - """ - return SealedBox(_get_public_key()) - - -class ExamDataAuditor: - """ - Encrypted file auditor for exam requests/responses - """ - REQUEST = 'request' - RESPONSE = 'response' - - def __init__(self, store=None): - self.store = store or S3AuditStorage() - - def encrypt(self, filename, encrypted_filename): - """ - Encrypts the local file - - Args: - filename (str): absolute path to the local file - encrypted_filename (str): absolute path to the local encrypted file - - Returns: - str: - the encrypted data - """ - log.debug('Encrypting file %s to %s', filename, encrypted_filename) - - with open(filename, 'rb') as source_file: - return _get_sealed_box().encrypt(source_file.read()) - - def upload_encrypted_file(self, filename, file_type): - """ - Uploads the file to S3 - - Args: - filename (str): absolute path to the local file - file_type (str): the type of file, either RESPONSE or REQUEST - - Returns: - str: path to the stored file - """ - encrypted_filename = '{}.nacl'.format(filename) - try: - encrypted_data = self.encrypt(filename, encrypted_filename) - - return self.store.upload(encrypted_filename, encrypted_data, file_type) - finally: - utils.safely_remove_file(encrypted_filename) - - def audit_file(self, filename, file_type): - """ - Audits the given file - this means we encrypt it and store it on S3 - - Args: - filename (str): path to the unencrypted file - file_type (str): the type of file, either RESPONSE or REQUEST - - Returns: - str: the path where the file was stored - """ - if not settings.EXAMS_AUDIT_ENABLED: - return None - - return self.upload_encrypted_file(filename, file_type) - - def audit_response_file(self, filename): - """ - Audits the given response file, see audit() for details - - Args: - filename (str): path to the unencrypted file - - Returns: : - str: the path where the file was stored - """ - return self.audit_file(filename, self.RESPONSE) - - def audit_request_file(self, filename): - """ - Audits the given request file, see audit() for details - - Args: - filename (str): path to the unencrypted file - - Returns: - str: the path where the file was stored - """ - return self.audit_file(filename, self.REQUEST) diff --git a/exams/pearson/audit_test.py b/exams/pearson/audit_test.py deleted file mode 100644 index 336c7b9458..0000000000 --- a/exams/pearson/audit_test.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for auditing""" -from unittest.mock import ( - call, - DEFAULT, - Mock -) -import copy -import tempfile - -from django.core.exceptions import ImproperlyConfigured -from django.test import override_settings -from nacl.public import PrivateKey, SealedBox -from nacl.encoding import Base64Encoder -import pytest - -from exams.pearson import audit - -# pylint: disable=missing-docstring,redefined-outer-name - - -DEFAULT_SETTINGS = { - 'EXAMS_AUDIT_ENABLED': True, - 'EXAMS_AUDIT_S3_BUCKET': '.test.bucket.name.', - 'EXAMS_AUDIT_AWS_ACCESS_KEY_ID': 'test.id', - 'EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY': 'test.access.key', -} - - -@pytest.fixture() -def private_key(): - """Creates a new NaCl private key""" - return PrivateKey.generate() - - -@pytest.fixture() -def valid_settings(private_key): - """ - Fixture that provides valid (passes checks in configure()) configuration - """ - settings = copy.copy(DEFAULT_SETTINGS) - settings.update({ - 'EXAMS_AUDIT_NACL_PUBLIC_KEY': Base64Encoder.encode(bytes(private_key.public_key)), - }) - - with override_settings(**settings): - yield DEFAULT_SETTINGS - - -@pytest.fixture() -def invalid_settings(): - """ - Fixture that runs a test against a set of invalid configurations - """ - settings = copy.copy(DEFAULT_SETTINGS) - settings.update({ - 'EXAMS_AUDIT_NACL_PUBLIC_KEY': Base64Encoder.encode('bad'), - }) - - with override_settings(**settings): - yield settings - - -@pytest.fixture() -def missing_settings(): - """ - Fixture that runs a test with each of the specified settings keys set to None - """ - settings = copy.copy(DEFAULT_SETTINGS) - settings["EXAMS_AUDIT_NACL_PUBLIC_KEY"] = None - - with override_settings(**settings): - yield settings - - -@pytest.fixture() -def s3_store(valid_settings): # pylint: disable=unused-argument - """S3 storage for tests""" - return audit.S3AuditStorage() - - -@pytest.fixture -def auditor(valid_settings): # pylint: disable=unused-argument - """ - ExamDataAuditor that cleans up the GPG keys after itself - """ - yield audit.ExamDataAuditor(store=Mock(spec=audit.S3AuditStorage)) - - -def test_s3_store_get_connection_valid(s3_store, mocker): - """Test get_connection given valid settings""" - mock = mocker.patch('boto.s3.connection.S3Connection') - s3_store.get_bucket() - mock.assert_called_once_with('test.id', 'test.access.key') - - -@pytest.mark.parametrize('key', [ - 'EXAMS_AUDIT_S3_BUCKET', - 'EXAMS_AUDIT_AWS_ACCESS_KEY_ID', - 'EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY', -]) -def test_s3_store_missing_settings(s3_store, key): - """Test configure() against missing settings""" - with override_settings(**{ - key: None, - }): - with pytest.raises(ImproperlyConfigured): - s3_store.upload('filename', 'data', 'filetype') - - -def test_s3_store_get_s3_key(s3_store, mocker): - """Test that the S3 store generates a key object witht he correct bucket and key""" - mocker.patch.object(s3_store, 'get_bucket', return_value='.the.bucket.') - key = s3_store.get_s3_key('filename', 'filetype') - s3_store.get_bucket.assert_called_once_with() - assert key.bucket == s3_store.get_bucket() - assert key.key == 'exam_audits/filetype/filename' - - -def test_exam_data_s3_store_upload(s3_store, mocker): - """Test that the S3 store uploads a file with the correct headers""" - mocker.patch.object(s3_store, 'get_s3_key') - - s3_store.upload('filename', 'data', 'filetype') - mock_key = s3_store.get_s3_key.return_value - - assert mock_key.set_contents_from_string.call_count == 1 - assert mock_key.set_contents_from_string.call_args == call( - 'data', - headers={ - 'x-amz-server-side-encryption': 'AES256' - } - ) - - -@pytest.mark.usefixtures('missing_settings') -@pytest.mark.parametrize('is_enabled', [True, False]) -def test_exam_data_auditor_enabled(auditor, mocker, is_enabled): - """Test that audit_file() respected the enabled flag""" - mocker.patch.multiple(auditor, encrypt=DEFAULT, upload_encrypted_file=DEFAULT) - with tempfile.NamedTemporaryFile() as audit_file: - with override_settings(**{ - 'EXAMS_AUDIT_ENABLED': is_enabled, - }): - auditor.audit_file(audit_file.name, 'filetype') - assert auditor.upload_encrypted_file.call_count == (1 if is_enabled else 0) - - -@pytest.mark.usefixtures('missing_settings') -def test_exam_data_auditor_configure_missing_settings(auditor): - """Test that configure() fails with missing settings""" - with tempfile.NamedTemporaryFile() as audit_file: - with pytest.raises(ImproperlyConfigured): - auditor.audit_file(audit_file.name, 'filetype') - - -def test_exam_data_auditor_audit_file(auditor, private_key): - """Test that the auditor encrypts and uploads the file""" - with tempfile.NamedTemporaryFile() as audit_file: - # the auditor encrypts the file to a local path - expected_encrypted_filename = '{}.nacl'.format(audit_file.name) - expected_encrypted_keypath = 'keypath' # computed inside _upload_to_s3 - file_contents = b'unencrypted file contents' - - audit_file.write(file_contents) - audit_file.flush() - - def upload_side_effect(encrypted_filename, encrypted_data, file_type): - """Verify the encrypted file exists at this point""" - # verify the upload was triggered with the expected encrypted filename and that file exists - assert SealedBox(private_key).decrypt(encrypted_data) == file_contents - assert encrypted_filename == expected_encrypted_filename - assert file_type == 'sometype' - return expected_encrypted_keypath - auditor.store.upload.side_effect = upload_side_effect - - assert auditor.audit_file(audit_file.name, 'sometype') == expected_encrypted_keypath - - -def test_exam_data_auditor_audit_request_file(auditor, mocker): - """Test that request files are uploaded with the correct type""" - mocker.patch.object(auditor, 'audit_file', return_value=True) - assert auditor.audit_request_file('test.file') is True - auditor.audit_file.assert_called_once_with('test.file', auditor.REQUEST) - - -def test_exam_data_auditor_audit_response_file(auditor, mocker): - """Test that response files are uploaded with the correct type""" - mocker.patch.object(auditor, 'audit_file', return_value=True) - assert auditor.audit_response_file('test.file') is True - auditor.audit_file.assert_called_once_with('test.file', auditor.RESPONSE) diff --git a/exams/pearson/constants.py b/exams/pearson/constants.py index 6d85e004fb..8e461be917 100644 --- a/exams/pearson/constants.py +++ b/exams/pearson/constants.py @@ -1,63 +1,4 @@ """Pearson-related constants""" -from types import SimpleNamespace - -# Pearson TSV constants -PEARSON_DATETIME_FORMATS = [ - "%Y/%m/%d %H:%M:%S", - "%m/%d/%Y %H:%M:%S" -] -PEARSON_DEFAULT_DATETIME_FORMAT = PEARSON_DATETIME_FORMATS[0] -PEARSON_DEFAULT_DATE_FORMAT = PEARSON_DEFAULT_DATETIME_FORMAT.split(' ')[0] - -PEARSON_FILE_TYPES = SimpleNamespace( - EAC='eac', - VCDC='vcdc', - EXAM='exam', - CAND='cand', - SURV='surv', - CMNT='cmnt', - SECT='sect', - RESP='resp', - ITEM='item', -) - -# these are files we intentionally skip, but treat as if we processed them -PEARSON_INTENDED_SKIP_FILE_TYPES = ( - PEARSON_FILE_TYPES.CAND, - PEARSON_FILE_TYPES.SURV, - PEARSON_FILE_TYPES.CMNT, - PEARSON_FILE_TYPES.SECT, - PEARSON_FILE_TYPES.ITEM, - PEARSON_FILE_TYPES.RESP, -) - -# SFTP Upload constants -PEARSON_UPLOAD_REQUIRED_SETTINGS = [ - "EXAMS_SFTP_HOST", - "EXAMS_SFTP_PORT", - "EXAMS_SFTP_USERNAME", - "EXAMS_SFTP_PASSWORD", - "EXAMS_SFTP_UPLOAD_DIR", -] - -# Common options for Pearson TSV readers/writers -PEARSON_DIALECT_OPTIONS = { - 'delimiter': '\t', -} - -# Only for these countries does Pearson require/support state/zip -PEARSON_STATE_SUPPORTED_COUNTRIES = ( - "US", - "CA", -) - -# Vue Candidate Data Confirmation (VCDC) file statuses -VCDC_SUCCESS_STATUS = "Accepted" -VCDC_FAILURE_STATUS = "Error" - -# Exam Authorization Confirmation (EAC) file statuses -EAC_SUCCESS_STATUS = "Accepted" -EAC_FAILURE_STATUS = "Error" # EXAM constants EXAM_GRADE_PASS = 'pass' diff --git a/exams/pearson/download.py b/exams/pearson/download.py deleted file mode 100644 index 855b0994e0..0000000000 --- a/exams/pearson/download.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Pearson SFTP download implementation""" -import logging -import os -import zipfile -from contextlib import contextmanager - -from django.conf import settings -from paramiko import SSHException - -from exams.pearson import audit -from exams.pearson.constants import ( - EXAM_GRADES, - EXAM_GRADE_PASS, - EAC_SUCCESS_STATUS, - VCDC_SUCCESS_STATUS, - PEARSON_INTENDED_SKIP_FILE_TYPES, - PEARSON_FILE_TYPES, -) -from exams.pearson.exceptions import RetryableSFTPException -from exams.pearson.readers import ( - EACReader, - EXAMReader, - VCDCReader, -) -from exams.pearson import utils -from exams.models import ( - ExamAuthorization, - ExamProfile, -) -from grades.models import ProctoredExamGrade - -log = logging.getLogger(__name__) - - -@contextmanager -def locally_extracted(zip_file, member): - """ - Context manager for temporarily extracting a zip file member to the local filesystem - - Args: - zip_file (zipfile.ZipFile): the zip file to extract from - member (str): the name of the file to extract - - Yields: - file: the extracted file object - """ - extracted_file_path = zip_file.extract(member, path=settings.EXAMS_SFTP_TEMP_DIR) - try: - # csv.reader requires files to be opened in text mode, not binary - with open(extracted_file_path, 'r') as extracted_file: - yield extracted_file - finally: - if os.path.exists(extracted_file_path): - os.remove(extracted_file_path) - - -def format_and_log_error(message, **kwargs): - """ - Formats and logs an error messages - - Args: - error: error message - - Returns: - str: formatted error message - """ - formatted_values = ' '.join("{}='{}'".format(k, v) for k, v in kwargs.items() if v) - formatted_message = '{}: {}'.format(message, formatted_values) - log.error(formatted_message) - return formatted_message - - -class ArchivedResponseProcessor: - """ - Handles fetching and processing of files stored in a ZIP archive on Pearson SFTP - """ - def __init__(self, sftp): - self.sftp = sftp - self.auditor = audit.ExamDataAuditor() - - def fetch_file(self, remote_path): - """ - Fetches a remote file and returns the local path - - Args: - remote_path (str): the remote path of the file to fetch - - Returns: - str: the local path of the file - """ - local_path = os.path.join(settings.EXAMS_SFTP_TEMP_DIR, remote_path) - - self.sftp.get(remote_path, localpath=local_path) - - return local_path - - def filtered_files(self): - """ - Walks a directory and yields files that match the pattern - - Yields: - (str, str): a tuple of (remote_path, local_path) - """ - for remote_path in self.sftp.listdir(): - if self.sftp.isfile(remote_path) and utils.is_zip_file(remote_path): - yield remote_path, self.fetch_file(remote_path) - - def process(self): - """ - Process response files - """ - try: - with self.sftp.cd(settings.EXAMS_SFTP_RESULTS_DIR): - for remote_path, local_path in self.filtered_files(): - try: - if self.process_zip(local_path): - self.sftp.remove(remote_path) - - log.debug("Processed remote file: %s", remote_path) - except (EOFError, SSHException,): - raise - except: # pylint: disable=bare-except - log.exception("Error processing file: %s", remote_path) - finally: - if os.path.exists(local_path): - os.remove(local_path) - except (EOFError, SSHException,) as exc: - raise RetryableSFTPException("Exception processing response files") from exc - - def process_zip(self, local_path): - """ - Process a single zip file - - Args: - local_path (str): path to the zip file on the local filesystem - - Returns: - bool: True if all files processed successfully - """ - processed = True - - # audit before we process in case the process dies - self.auditor.audit_response_file(local_path) - - log.debug('Processing Pearson zip file: %s', local_path) - - # extract the zip and walk the files - with zipfile.ZipFile(local_path) as zip_file: - for extracted_filename in zip_file.namelist(): - log.debug('Processing file %s extracted from %s', extracted_filename, local_path) - with locally_extracted(zip_file, extracted_filename) as extracted_file: - result, errors = self.process_extracted_file(extracted_file, extracted_filename) - - processed = result and processed - - if len(errors) > 0: - utils.email_processing_failures(extracted_filename, local_path, errors) - - return processed - - def process_extracted_file(self, extracted_file, extracted_filename): - """ - Processes an individual file extracted from the zip - - Args: - extracted_file (zipfile.ZipExtFile): the extracted file-like or iterable object - extracted_filename (str): the filename of the extracted file - - Returns: - (bool, list(str)): bool is True if file processed successfuly, error messages are returned in the list - """ - - if extracted_filename.startswith(PEARSON_FILE_TYPES.VCDC): - # We send Pearson CDD files and get the results as VCDC files - return self.process_vcdc_file(extracted_file) - elif extracted_filename.startswith(PEARSON_FILE_TYPES.EAC): - # We send Pearson EAD files and get the results as EAC files - return self.process_eac_file(extracted_file) - elif extracted_filename.startswith(PEARSON_FILE_TYPES.EXAM): - return self.process_exam_file(extracted_file) - elif any(extracted_filename.startswith(file_type) for file_type in PEARSON_INTENDED_SKIP_FILE_TYPES): - # for files we don't care about, act like we processed them - # so they don't cause us to leave the zip file on the server - # this would cause us to reprocess these zip files forever - return True, [] - - return False, [] - - def get_invalid_row_messages(self, rows): # pylint: disable=no-self-use - """ - Converts a list of failed rows to a list of error messages - - Args: - rows (iterable): iterable of rows - - Returns: - list(str): list of error messages - """ - return [ - "Unable to parse row '{row}'".format( - row=row, - ) for row in rows - ] - - def process_vcdc_file(self, extracted_file): - """ - Processes a VCDC file extracted from the zip - - Args: - extracted_file (zipfile.ZipExtFile): the extracted file-like object - - Returns: - (bool, list(str)): bool is True if file processed successfuly, error messages are returned in the list - """ - log.debug('Found VCDC file: %s', extracted_file) - results, invalid_rows = VCDCReader().read(extracted_file) - messages = self.get_invalid_row_messages(invalid_rows) - for result in results: - try: - exam_profile = ExamProfile.objects.get(profile__student_id=result.client_candidate_id) - except ExamProfile.DoesNotExist: - messages.append(format_and_log_error( - 'Unable to find an ExamProfile record:', - client_candidate_id=result.client_candidate_id, - error=result.message, - )) - continue - - if result.status == EAC_SUCCESS_STATUS and 'WARNING' not in result.message: - exam_profile.status = ExamProfile.PROFILE_SUCCESS - else: - exam_profile.status = ExamProfile.PROFILE_FAILED - messages.append(format_and_log_error( - 'ExamProfile sync failed:', - client_candidate_id=result.client_candidate_id, - username=exam_profile.profile.user.username, - error=result.message, - )) - - exam_profile.save() - - return True, messages - - def process_eac_file(self, extracted_file): - """ - Processes a EAC file extracted from the zip - - Args: - extracted_file (zipfile.ZipExtFile): the extracted file-like object - - Returns: - (bool, list(str)): bool is True if file processed successfuly, error messages are returned in the list - """ - log.debug('Found EAC file: %s', extracted_file) - results, invalid_rows = EACReader().read(extracted_file) - messages = self.get_invalid_row_messages(invalid_rows) - for result in results: - try: - exam_authorization = ExamAuthorization.objects.get(id=result.client_authorization_id) - except ExamAuthorization.DoesNotExist: - messages.append(format_and_log_error( - 'Unable to find a matching ExamAuthorization record:', - client_candidate_id=result.client_candidate_id, - client_authorization_id=result.client_authorization_id, - error=result.message, - )) - continue - - if result.status == VCDC_SUCCESS_STATUS: - exam_authorization.status = ExamAuthorization.STATUS_SUCCESS - else: - exam_authorization.status = ExamAuthorization.STATUS_FAILED - messages.append(format_and_log_error( - 'ExamAuthorization sync failed:', - username=exam_authorization.user.username, - client_authorization_id=result.client_authorization_id, - error=result.message, - )) - - exam_authorization.save() - - return True, messages - - def process_exam_file(self, extracted_file): - """ - Processes a EXAM file extracted from the zip - - Args: - extracted_file (zipfile.ZipExtFile): the extracted file-like object - - Returns: - (bool, list(str)): bool is True if file processed successfuly, error messages are returned in the list - """ - log.debug('Found EXAM file: %s', extracted_file) - results, invalid_rows = EXAMReader().read(extracted_file) - messages = self.get_invalid_row_messages(invalid_rows) - - for result in results: - try: - exam_authorization = ExamAuthorization.objects.get(id=result.client_authorization_id) - except ExamAuthorization.DoesNotExist: - messages.append(format_and_log_error( - 'Unable to find a matching ExamAuthorization record:', - client_candidate_id=result.client_candidate_id, - client_authorization_id=result.client_authorization_id, - )) - continue - - if not result.no_show and result.grade.lower() not in EXAM_GRADES: - messages.append(format_and_log_error( - 'Unexpected grade value:', - client_authorization_id=result.client_authorization_id, - client_candidate_id=result.client_candidate_id, - username=exam_authorization.user.username, - grade=result.grade, - )) - continue - - row_data = dict(result._asdict()) # OrderedDict -> dict - row_data['exam_date'] = row_data['exam_date'].isoformat() # datetime doesn't serialize - - # extract certain keys to store directly in row columns - defaults = { - 'exam_date': result.exam_date, - 'passing_score': result.passing_score, - 'score': result.score, - 'grade': result.grade, - 'percentage_grade': result.score / 100.0 if result.score else 0, - 'client_authorization_id': result.client_authorization_id, - 'passed': result.grade.lower() == EXAM_GRADE_PASS, - 'row_data': row_data, - } - - if not result.no_show: - # create the grade or update it - ProctoredExamGrade.objects.update_or_create( - user=exam_authorization.user, - course=exam_authorization.course, - client_authorization_id=result.client_authorization_id, - exam_run=exam_authorization.exam_run, - defaults=defaults - ) - - exam_authorization.exam_no_show = result.no_show or False - exam_authorization.exam_taken = True - exam_authorization.save() - - return True, messages diff --git a/exams/pearson/download_test.py b/exams/pearson/download_test.py deleted file mode 100644 index 9b17f842a8..0000000000 --- a/exams/pearson/download_test.py +++ /dev/null @@ -1,604 +0,0 @@ -"""Pearson SFTP download tests""" -from datetime import datetime -from unittest.mock import ( - MagicMock, - Mock, - call, - mock_open, - patch -) - -import ddt -import pytest -import pytz -from factory.django import mute_signals -from django.db.models.signals import post_save -from django.test import ( - override_settings, - SimpleTestCase, -) -from paramiko import SSHException - -from courses.factories import CourseFactory -from exams.factories import ( - ExamAuthorizationFactory, - ExamProfileFactory, -) -from exams.models import ( - ExamAuthorization, - ExamProfile, -) -from exams.pearson.constants import ( - EAC_SUCCESS_STATUS, - EAC_FAILURE_STATUS, - VCDC_SUCCESS_STATUS, - VCDC_FAILURE_STATUS, - EXAM_GRADE_PASS, -) -from exams.pearson import download -from exams.pearson.exceptions import RetryableSFTPException -from exams.pearson.factories import EXAMResultFactory -from exams.pearson.readers import ( - EACResult, - VCDCResult, -) -from exams.pearson.sftp_test import EXAMS_SFTP_SETTINGS -from grades.models import ProctoredExamGrade -from micromasters.utils import now_in_utc -from search.base import MockedESTestCase - -FIXED_DATETIME = datetime(2016, 5, 15, 15, 2, 55, tzinfo=pytz.UTC) - - -# pylint: disable=too-many-arguments -@ddt.ddt -@pytest.mark.usefixtures('auditor') -@override_settings(**EXAMS_SFTP_SETTINGS) -class PearsonDownloadTest(SimpleTestCase): - """ - Tests for non-connection Pearson download code - """ - def setUp(self): - self.sftp = Mock() - - def test_fetch_file(self): - """ - Tests that fetch_file works as expected - """ - remote_path = 'file.ext' - expected_local_path = '/tmp/file.ext' - processor = download.ArchivedResponseProcessor(self.sftp) - - local_path = processor.fetch_file(remote_path) - - assert local_path == expected_local_path - self.sftp.get.assert_called_once_with(remote_path, localpath=expected_local_path) - - def test_filtered_files(self): - """ - Test that filtered_files filters on the regex - """ - listdir_values = ['a.zip', 'b.zip', 'b'] - isfile_values = [True, False, True] - self.sftp.listdir.return_value = listdir_values - self.sftp.isfile.side_effect = isfile_values - processor = download.ArchivedResponseProcessor(self.sftp) - - result = list(processor.filtered_files()) - - assert result == [('a.zip', '/tmp/a.zip')] - - self.sftp.listdir.assert_called_once_with() - assert self.sftp.isfile.call_args_list == [call(arg) for arg in listdir_values] - - @patch('exams.pearson.download.ArchivedResponseProcessor.process_eac_file') - @patch('exams.pearson.download.ArchivedResponseProcessor.process_vcdc_file') - @patch('exams.pearson.download.ArchivedResponseProcessor.process_exam_file') - def test_process_extracted_file(self, process_exam_file_mock, process_vcdc_file_mock, process_eac_file_mock): - """ - Test that process_extracted_file handles file types correctly - """ - extracted_file = Mock() - process_eac_file_mock.return_value = (True, ['EAC']) - process_vcdc_file_mock.return_value = (True, ['VCDC']) - process_exam_file_mock.return_value = (True, ['EXAM']) - processor = download.ArchivedResponseProcessor(self.sftp) - - assert processor.process_extracted_file(extracted_file, 'eac-07-04-2016.dat') == (True, ['EAC']) - processor.process_eac_file.assert_called_once_with(extracted_file) - - assert processor.process_extracted_file(extracted_file, 'vcdc-07-04-2016.dat') == (True, ['VCDC']) - processor.process_vcdc_file.assert_called_once_with(extracted_file) - - assert processor.process_extracted_file(extracted_file, 'exam-07-04-2016.dat') == (True, ['EXAM']) - processor.process_exam_file.assert_called_once_with(extracted_file) - - assert processor.process_extracted_file(extracted_file, 'notatype-07-04-2016.dat') == (False, []) - - @ddt.data( - (['a.file', 'b.file'], [(True, []), (True, [])], True), - (['a.file', 'b.file'], [(True, []), (False, [])], False), - (['a.file', 'b.file'], [(False, []), (True, [])], False), - (['a.file', 'b.file'], [(False, []), (False, [])], False), - ) - @ddt.unpack - @patch('zipfile.ZipFile', spec=True) - @patch('exams.pearson.download.ArchivedResponseProcessor.process_extracted_file') - def test_process_zip(self, files, results, expected_result, process_extracted_file_mock, zip_file_mock): - """Tests that process_zip behaves correctly""" - process_extracted_file_mock.side_effect = results - zip_file_mock.return_value.__enter__.return_value.namelist.return_value = files - - with patch( - 'exams.pearson.utils.email_processing_failures' - ) as email_processing_failures_mock, patch( - 'exams.pearson.download.locally_extracted' - ) as locally_extracted_mock: - locally_extracted_mock.__enter__.return_value = [] - processor = download.ArchivedResponseProcessor(self.sftp) - assert processor.process_zip('local.zip') == expected_result - - self.auditor.return_value.audit_response_file.assert_called_once_with('local.zip') - email_processing_failures_mock.assert_not_called() - - @patch('zipfile.ZipFile', spec=True) - @patch('exams.pearson.download.ArchivedResponseProcessor.process_extracted_file') - def test_process_zip_email(self, process_extracted_file_mock, zip_file_mock): - """Tests that an email is sent if errors returned""" - process_extracted_file_mock.return_value = (True, ['ERROR']) - zip_file_mock.return_value.__enter__.return_value.namelist.return_value = ['a.dat'] - - with patch( - 'exams.pearson.utils.email_processing_failures' - ) as email_processing_failures_mock: - processor = download.ArchivedResponseProcessor(self.sftp) - processor.process_zip('local.zip') - - self.auditor.return_value.audit_response_file.assert_called_once_with('local.zip') - email_processing_failures_mock.assert_called_once_with('a.dat', 'local.zip', ['ERROR']) - - @ddt.data( - (True, 1), - (False, 0), - ) - @ddt.unpack - @patch('os.path.exists') - @patch('os.remove') - def test_locally_extracted(self, exists, remove_count, remove_mock, exists_mock): - """ - Tests that the local file gets removed on error if it exists - """ - zip_mock = Mock(return_value='path') - exists_mock.return_value = exists - with patch('exams.pearson.download.open', mock_open(), create=True) as open_mock: - open_mock.side_effect = Exception('exception') - with self.assertRaises(Exception): - with download.locally_extracted(zip_mock, 'file'): - pass - open_mock.assert_called_once_with(zip_mock.extract.return_value, 'r') - - exists_mock.assert_called_once_with(zip_mock.extract.return_value) - - assert remove_mock.call_count == remove_count - - def test_get_invalid_row_messages(self): - """Test generation of error messages""" - processor = download.ArchivedResponseProcessor(self.sftp) - - messages = processor.get_invalid_row_messages([{ - 'Prop1': 'str', - 'Prop2': 'bad_int', - }]) - - for msg in messages: - assert msg.startswith('Unable to parse row') - - -@override_settings(**EXAMS_SFTP_SETTINGS) -@ddt.ddt -@patch('os.remove') -@patch('os.path.exists', return_value=True) -@patch( - 'exams.pearson.download.ArchivedResponseProcessor.filtered_files', - return_value=[ - ('a.zip', '/tmp/a.zip'), - ] -) -@patch('exams.pearson.download.ArchivedResponseProcessor.process_zip', return_value=True) -class ArchivedResponseProcessorProcessTest(SimpleTestCase): - """Tests around ArchivedResponseProcessor.process""" - def setUp(self): - self.sftp = MagicMock() - - def test_process_success(self, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test the happy path""" - processor = download.ArchivedResponseProcessor(self.sftp) - processor.process() - - filtered_files_mock.assert_called_once_with() - self.sftp.remove.assert_called_once_with('a.zip') - process_zip_mock.assert_called_once_with('/tmp/a.zip') - os_path_exists_mock.assert_called_once_with('/tmp/a.zip') - os_remove_mock.assert_called_once_with('/tmp/a.zip') - - def test_process_failure(self, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test the unhappy path""" - process_zip_mock.return_value = False - processor = download.ArchivedResponseProcessor(self.sftp) - processor.process() - - filtered_files_mock.assert_called_once_with() - self.sftp.remove.assert_not_called() - process_zip_mock.assert_called_once_with('/tmp/a.zip') - os_path_exists_mock.assert_called_once_with('/tmp/a.zip') - os_remove_mock.assert_called_once_with('/tmp/a.zip') - - def test_process_exception(self, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test that process() cleans up the local but not the remote on any processing exception""" - process_zip_mock.side_effect = Exception('exception') - - processor = download.ArchivedResponseProcessor(self.sftp) - processor.process() - - filtered_files_mock.assert_called_once_with() - self.sftp.remove.assert_not_called() - process_zip_mock.assert_called_once_with('/tmp/a.zip') - os_path_exists_mock.assert_called_once_with('/tmp/a.zip') - os_remove_mock.assert_called_once_with('/tmp/a.zip') - - @ddt.data( - SSHException('exception'), - EOFError(), - ) - def test_process_ssh_exception_remove( - self, exc, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test that SSH exceptions bubble up""" - self.sftp.remove.side_effect = exc - - processor = download.ArchivedResponseProcessor(self.sftp) - with self.assertRaises(RetryableSFTPException): - processor.process() - - filtered_files_mock.assert_called_once_with() - self.sftp.remove.assert_called_once_with('a.zip') - process_zip_mock.assert_called_once_with('/tmp/a.zip') - os_path_exists_mock.assert_called_once_with('/tmp/a.zip') - os_remove_mock.assert_called_once_with('/tmp/a.zip') - - def test_process_ssh_exception_cd( - self, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test that SSH exceptions bubble up""" - self.sftp.cd.side_effect = SSHException('exception') - - processor = download.ArchivedResponseProcessor(self.sftp) - with self.assertRaises(RetryableSFTPException): - processor.process() - - filtered_files_mock.assert_not_called() - self.sftp.remove.assert_not_called() - process_zip_mock.assert_not_called() - os_path_exists_mock.assert_not_called() - os_remove_mock.assert_not_called() - - def test_process_missing_local(self, process_zip_mock, filtered_files_mock, os_path_exists_mock, os_remove_mock): - """Test that a missing local file doesn't fail""" - os_path_exists_mock.return_value = False - - processor = download.ArchivedResponseProcessor(self.sftp) - processor.process() - - filtered_files_mock.assert_called_once_with() - self.sftp.remove.assert_called_once_with('a.zip') - process_zip_mock.assert_called_once_with('/tmp/a.zip') - os_path_exists_mock.assert_called_once_with('/tmp/a.zip') - os_remove_mock.assert_not_called() - - -@override_settings(**EXAMS_SFTP_SETTINGS) -@ddt.ddt -class VCDCDownloadTest(MockedESTestCase): - """ - Test for Vue Candidate Data Confirmation files (VCDC) files processing. - """ - @classmethod - def setUpTestData(cls): - sftp = Mock() - cls.now = now_in_utc() - cls.processor = download.ArchivedResponseProcessor(sftp) - with mute_signals(post_save): - cls.success_profiles = ExamProfileFactory.create_batch(2) + [ - ExamProfileFactory.create(profile__id=999, profile__student_id=1000), # disjoint id and student_id - ] - cls.failure_profiles = ExamProfileFactory.create_batch(2) - - cls.success_results = ([ - VCDCResult( - client_candidate_id=exam_profile.profile.student_id, - status=VCDC_SUCCESS_STATUS, - date=cls.now, - message='', - ) for exam_profile in cls.success_profiles - ], []) - cls.failed_results = ([ - VCDCResult( - client_candidate_id=cls.failure_profiles[0].profile.student_id, - status=VCDC_FAILURE_STATUS, - date=cls.now, - message='', - ), - VCDCResult( - client_candidate_id=cls.failure_profiles[1].profile.student_id, - status=VCDC_FAILURE_STATUS, - date=cls.now, - message='Bad address', - ), - ], []) - - cls.all_results = ( - cls.success_results[0] + cls.failed_results[0], - cls.success_results[1] + cls.failed_results[1], - ) - - def test_process_result_vcdc(self): - """ - Test file processing, happy case. - """ - - with patch('exams.pearson.download.VCDCReader.read', return_value=self.success_results): - assert self.processor.process_vcdc_file("/tmp/file.ext") == (True, []) - - for profile in self.success_profiles: - profile.refresh_from_db() - assert profile.status == ExamProfile.PROFILE_SUCCESS - - def test_process_result_vcdc_when_error(self): - """ - Test situation where we get failure results back - """ - - with patch('exams.pearson.download.VCDCReader.read', return_value=self.all_results): - result, errors = self.processor.process_vcdc_file("/tmp/file.ext") - - assert result is True - assert all(error.startswith('ExamProfile sync failed:') for error in errors) - assert "error='Bad address'" in errors[1] - - for profile in self.success_profiles: - profile.refresh_from_db() - assert profile.status == ExamProfile.PROFILE_SUCCESS - - for profile in self.failure_profiles: - profile.refresh_from_db() - assert profile.status == ExamProfile.PROFILE_FAILED - - def test_process_result_vcdc_when_invalid_data_in_file(self): - """Tests for the situation where we don't have a matching record""" - results = ([ - VCDCResult( - client_candidate_id=10, - status=VCDC_SUCCESS_STATUS, - date=self.now, - message='' - ), - VCDCResult( - client_candidate_id=11, - status=VCDC_FAILURE_STATUS, - date=self.now, - message='Invalid address' - ) - ], []) - - with patch('exams.pearson.download.VCDCReader.read', return_value=results): - result, errors = self.processor.process_vcdc_file("/tmp/file.ext") - - assert result is True - assert len(errors) == 2 - assert all(error.startswith('Unable to find an ExamProfile record:') for error in errors) - - def test_process_result_vcdc_successful_warning(self): - """Tests for the situation where we get a success with a warning""" - message = "WARNING: success doesn't come that easy" - profile = self.failure_profiles[1].profile - results = ([ - VCDCResult( - client_candidate_id=profile.student_id, - status=VCDC_SUCCESS_STATUS, - date=self.now, - message=message, - ), - ], []) - - with patch('exams.pearson.download.VCDCReader.read', return_value=results): - result, errors = self.processor.process_vcdc_file("/tmp/file.ext") - - assert result is True - assert errors[0].startswith('ExamProfile sync failed:') - assert "error='{}'".format(message) in errors[0] - - -@override_settings(**EXAMS_SFTP_SETTINGS) -@ddt.ddt -class EACDownloadTest(MockedESTestCase): - """ - Test for Exam Authorization Confirmation files (EAC) files processing. - """ - @classmethod - def setUpTestData(cls): - sftp = Mock() - cls.processor = download.ArchivedResponseProcessor(sftp) - cls.course = course = CourseFactory.create() - cls.success_auths = ExamAuthorizationFactory.create_batch(2, course=course) - cls.failure_auths = ExamAuthorizationFactory.create_batch(2, course=course) - - cls.success_results = ([ - EACResult( - client_authorization_id=auth.id, - client_candidate_id=auth.user.profile.student_id, - date=FIXED_DATETIME, - status=EAC_SUCCESS_STATUS, - message='', - ) for auth in cls.success_auths - ], []) - cls.failed_results = ([ - EACResult( - client_authorization_id=cls.failure_auths[0].id, - client_candidate_id=cls.failure_auths[0].user.profile.student_id, - date=FIXED_DATETIME, - status=EAC_FAILURE_STATUS, - message='', - ), - EACResult( - client_authorization_id=cls.failure_auths[1].id, - client_candidate_id=cls.failure_auths[1].user.profile.student_id, - date=FIXED_DATETIME, - status=EAC_FAILURE_STATUS, - message='wrong username', - ), - ], []) - - cls.all_results = ( - cls.success_results[0] + cls.failed_results[0], - cls.success_results[1] + cls.failed_results[1], - ) - - def test_process_result_eac(self): - """ - Test Exam Authorization Confirmation files (EAC) file processing, happy case. - """ - - with patch('exams.pearson.download.EACReader.read', return_value=self.success_results): - assert self.processor.process_eac_file("/tmp/file.ext") == (True, []) - - for auth in self.success_auths: - auth.refresh_from_db() - assert auth.status == ExamAuthorization.STATUS_SUCCESS - - def test_process_result_eac_when_error(self): - """ - Test Exam Authorization Confirmation files (EAC) file processing, failure case. - """ - - with patch('exams.pearson.download.EACReader.read', return_value=self.all_results): - result, errors = self.processor.process_eac_file("/tmp/file.ext") - - assert result is True - assert all(error.startswith('ExamAuthorization sync failed:') for error in errors) - assert "error='wrong username'" in errors[1] - - for auth in self.success_auths: - auth.refresh_from_db() - assert auth.status == ExamAuthorization.STATUS_SUCCESS - - for auth in self.failure_auths: - auth.refresh_from_db() - assert auth.status == ExamAuthorization.STATUS_FAILED - - def test_process_result_eac_when_invalid_data_in_file(self): - """ - Test Exam Authorization Confirmation files (EAC) file processing, when this is - record in EAC corresponding to which there in no record in ExamAuthorization model. - """ - results = ([ - EACResult( - client_authorization_id=10, - client_candidate_id=10, - date=FIXED_DATETIME, - status=EAC_SUCCESS_STATUS, - message='' - ), - EACResult( - client_authorization_id=11, - client_candidate_id=11, - date=FIXED_DATETIME, - status=EAC_FAILURE_STATUS, - message='wrong user name' - ) - ], []) - - with patch('exams.pearson.download.EACReader.read', return_value=results): - result, errors = self.processor.process_eac_file("/tmp/file.ext") - - assert result is True - assert len(errors) == 2 - assert all(error.startswith('Unable to find a matching ExamAuthorization record:') for error in errors) - assert "error='wrong user name'" in errors[1] - - -@override_settings(**EXAMS_SFTP_SETTINGS) -@ddt.ddt -class EXAMDownloadTest(MockedESTestCase): - """ - Test for Exam result files (EXAM) files processing. - """ - @classmethod - def setUpTestData(cls): - cls.processor = download.ArchivedResponseProcessor(Mock()) - cls.course = CourseFactory.create() - - def test_process_result_exam(self): - """Test that the authorization is marked as taken and a ProctoredExamGrade created""" - exam_results = [] - auths = [] - - # create a bunch of results that are passes - for auth in ExamAuthorizationFactory.create_batch(5, course=self.course): - exam_results.append(EXAMResultFactory.create( - passed=True, - client_candidate_id=auth.user.profile.student_id, - client_authorization_id=auth.id, - )) - auths.append(auth) - - # create a bunch of results that are failed - for auth in ExamAuthorizationFactory.create_batch(5, course=self.course): - exam_results.append(EXAMResultFactory.create( - failed=True, - client_candidate_id=auth.user.profile.student_id, - client_authorization_id=auth.id, - )) - auths.append(auth) - - grades = ProctoredExamGrade.objects.filter(course=self.course) - assert grades.count() == 0 - - with patch('exams.pearson.download.EXAMReader.read', return_value=(exam_results, [])): - assert self.processor.process_exam_file("/tmp/file.ext") == (True, []) - - for auth in auths: - auth.refresh_from_db() - assert auth.exam_no_show is False - assert auth.exam_taken is True - - sorted_exam_results = sorted(exam_results, key=lambda result: result.client_authorization_id) - sorted_grades = list(grades.order_by('client_authorization_id')) - - assert len(sorted_grades) == len(sorted_exam_results) - - for grade, exam_result in zip(sorted_grades, sorted_exam_results): - assert grade.exam_date == exam_result.exam_date - assert grade.passing_score == exam_result.passing_score - assert grade.grade == exam_result.grade - assert grade.score == exam_result.score - assert grade.passed is (exam_result.grade == EXAM_GRADE_PASS) - assert grade.percentage_grade == float(exam_result.score / 100.0) - expected_data = dict(exam_result._asdict()) # _asdict() returns an OrderedDict - expected_data['exam_date'] = expected_data['exam_date'].isoformat() - assert grade.row_data == expected_data - - def test_process_result_exam_no_show(self): - """Test process_exam_file against no-show rows""" - exam_auth = ExamAuthorizationFactory.create(course=self.course) - exam_result = EXAMResultFactory.create( - noshow=True, - client_candidate_id=exam_auth.user.profile.student_id, - client_authorization_id=exam_auth.id, - ) - - with patch('exams.pearson.download.EXAMReader.read', return_value=([exam_result], [])): - assert self.processor.process_exam_file("/tmp/file.ext") == (True, []) - - exam_auth.refresh_from_db() - assert exam_auth.exam_no_show is True - assert exam_auth.exam_taken is True - - assert not ProctoredExamGrade.objects.filter(course=self.course).exists() diff --git a/exams/pearson/exceptions.py b/exams/pearson/exceptions.py deleted file mode 100644 index 603fd2268a..0000000000 --- a/exams/pearson/exceptions.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Exceptions for exams -""" - - -class InvalidTsvRowException(Exception): - """ - A row for a tsv is invalid - """ - - -class InvalidProfileDataException(InvalidTsvRowException): - """ - Profile contains invalid data to sync - """ - - -class UnparsableRowException(InvalidTsvRowException): - """ - Row from a TSV was unparsable - """ - - -class RetryableSFTPException(Exception): - """ - A retryable exception during SFTP upload - - Usually this is a transient connection or SSH error. - """ diff --git a/exams/pearson/factories.py b/exams/pearson/factories.py deleted file mode 100644 index 00026e9fdf..0000000000 --- a/exams/pearson/factories.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Factories for pearson module""" -import faker -import pytz -import factory -from factory import fuzzy - -from exams.pearson.readers import EXAMResult - - -FAKE = faker.Factory.create() - - -class EXAMResultFactory(factory.Factory): - """Factory for EXAMResult""" - registration_id = factory.Faker('random_int') - client_candidate_id = factory.Faker('random_int') - tc_id = factory.Faker('random_int') - exam_series_code = factory.Faker('numerify', text="##.##x") - exam_name = factory.Faker('lexify', text="MicroMasters in ????") - exam_revision = '' # always an empty string - form = factory.LazyFunction(lambda: '{}{}'.format(FAKE.year(), FAKE.random_letter().upper())) - exam_language = factory.Faker('language_code') - attempt = factory.Faker('random_digit') - exam_date = factory.Faker('date_time_this_year', tzinfo=pytz.utc) - time_used = factory.Faker('time') - passing_score = 60.0 # fixed passing score - score = factory.LazyAttribute(lambda result: float(result.correct)) - grade = fuzzy.FuzzyChoice(choices=['pass', 'fail']) - no_show = factory.Faker('boolean') - nda_refused = factory.Faker('boolean') - incorrect = factory.LazyAttribute(lambda result: 100 - result.correct) - skipped = factory.Faker('random_int') - unscored = factory.Faker('random_int') - client_authorization_id = factory.Faker('random_int') - voucher = factory.Faker('word') - - @factory.lazy_attribute - def correct(self): - """Number of correct answers based on pass/fail""" - passing_score = int(self.passing_score) - if self.grade == 'pass': - return FAKE.random_int(min=passing_score, max=100) - else: - return FAKE.random_int(min=0, max=passing_score - 1) - - class Meta: # pylint: disable=missing-docstring - model = EXAMResult - - class Params: # pylint: disable=missing-docstring - passed = factory.Trait( - grade='pass', - no_show=False, - ) - failed = factory.Trait( - grade='fail', - no_show=False, - ) - noshow = factory.Trait( - no_show=True, - attempt=None, - passing_score=None, - score=None, - nda_refused=None, - correct=None, - incorrect=None, - skipped=None, - unscored=None, - ) diff --git a/exams/pearson/readers.py b/exams/pearson/readers.py deleted file mode 100644 index bb4cb7b50c..0000000000 --- a/exams/pearson/readers.py +++ /dev/null @@ -1,222 +0,0 @@ -""" -Readers for data Pearson exports -""" -import csv -from collections import namedtuple - -from exams.pearson.constants import PEARSON_DIALECT_OPTIONS - -from exams.pearson.exceptions import ( - InvalidTsvRowException, - UnparsableRowException, -) -from exams.pearson.utils import ( - parse_bool, - parse_datetime, - parse_or_default, - parse_int_or_none, - parse_float_or_none, -) - - -class BaseTSVReader: - """ - Base class for TSV file readers - - It handles the high-level mapping and reading of the data. - Subclasses specify how the fields map. - """ - def __init__(self, field_mappers, read_as_cls): - """ - Initializes a new TSV writer - - The first value of each fields tuple is the destination field name. - The second value is a str property path (e.g. "one.two.three") or - a callable that when passed a row returns a computed field value - - Usage: - - # maps column 'ABC' to field 'abc' and casts it to an int - # maps column 'DEF' to field 'def' and leaves it as a str - # Initializes each row into the namedtuple - Record = namedtupxe('Record', ['ab Exception as escc', 'def']) - raise UnparsableRowException('Row is unparsable "{}"'.format(row)) from exc - - BaseTSVReader([ - ('ABC', 'abc', int), - ('DEF', 'def'), - ], Record) - - Arguments: - field_mappers (list(tuple)): list of tuple field mappers - read_as_cls (cls): class to instantiate row data - """ - self.field_mappers = field_mappers - self.read_as_cls = read_as_cls - - @classmethod - def map_cell(cls, row, source, target, transformer=None): - """ - Maps an individual cell of a row - - Args: - row (dict): row data to map - source (str): source key to pull data from row - target (str): target property to put data into - transformer (callable): optional transformer callable, used to parse values - """ - if source not in row: - keys = ', '.join(str(key) for key in row.keys()) - raise InvalidTsvRowException( - "Column '{}' missing from row. Available columns: {}".format(source, keys)) - - value = row[source] - - if transformer is not None and callable(transformer): - value = transformer(value) - - return (target, value) - - def map_row(self, row): - """ - Maps a row object to a row dict - - Args: - row: the row to map to a dict - - Returns: - object: - row mapped to an object using the field mappers and read_as_cls - """ - try: - kwargs = dict(self.map_cell(row, *mapper) for mapper in self.field_mappers) - except Exception as exc: - raise UnparsableRowException('Row is unparsable') from exc - - return self.read_as_cls(**kwargs) - - def read(self, tsv_file): - """ - Reads the rows from the designated file using the configured fields. - - Arguments: - tsv_file: a file-like object to read the data from - - Returns: - records(list): - a list of the records cat to read_as_cls - """ - file_reader = csv.DictReader( - tsv_file, - **PEARSON_DIALECT_OPTIONS - ) - valid_rows, invalid_rows = [], [] - - for row in file_reader: - try: - valid_rows.append(self.map_row(row)) - except InvalidTsvRowException: - invalid_rows.append(row) - - return (valid_rows, invalid_rows) - - -VCDCResult = namedtuple('VCDCResult', [ - 'client_candidate_id', - 'status', - 'date', - 'message', -]) - - -class VCDCReader(BaseTSVReader): - """ - Reader for Pearson VUE Candidate Data Confirmation (VCDC) files. - """ - def __init__(self): - super().__init__([ - ('ClientCandidateID', 'client_candidate_id', int), - ('Status', 'status'), - ('Date', 'date', parse_datetime), - ('Message', 'message'), - ], VCDCResult) - - -EACResult = namedtuple('EACResult', [ - 'client_authorization_id', - 'client_candidate_id', - 'date', - 'status', - 'message' -]) - - -class EACReader(BaseTSVReader): - """ - Reader for Pearson VUE Exam Authorization Confirmation files (EAC) files. - """ - def __init__(self): - super().__init__([ - ('ClientAuthorizationID', 'client_authorization_id', int), - ('ClientCandidateID', 'client_candidate_id', int), - ('Date', 'date', parse_datetime), - ('Status', 'status'), - ('Message', 'message') - ], EACResult) - - -EXAMResult = namedtuple('EXAMResult', [ - 'registration_id', - 'client_candidate_id', - 'tc_id', - 'exam_series_code', - 'exam_name', - 'exam_revision', - 'form', - 'exam_language', - 'attempt', - 'exam_date', - 'time_used', - 'passing_score', - 'score', - 'grade', - 'no_show', - 'nda_refused', - 'correct', - 'incorrect', - 'skipped', - 'unscored', - 'client_authorization_id', - 'voucher', -]) - - -class EXAMReader(BaseTSVReader): - """ - Reader for Pearson VUE Exam result files (EXAM) files. - """ - def __init__(self): - super().__init__([ - ('RegistrationID', 'registration_id', int), - ('ClientCandidateID', 'client_candidate_id', int), - ('TCID', 'tc_id', int), - ('ExamSeriesCode', 'exam_series_code'), - ('ExamName', 'exam_name'), - ('ExamRevision', 'exam_revision'), - ('Form', 'form'), - ('ExamLanguage', 'exam_language'), - ('Attempt', 'attempt', parse_int_or_none), - ('ExamDate', 'exam_date', parse_datetime), - ('TimeUsed', 'time_used'), - ('PassingScore', 'passing_score', parse_float_or_none), - ('Score', 'score', parse_float_or_none), - ('Grade', 'grade'), - ('NoShow', 'no_show', parse_bool), - ('NDARefused', 'nda_refused', parse_or_default(parse_bool, None)), - ('Correct', 'correct', parse_int_or_none), - ('Incorrect', 'incorrect', parse_int_or_none), - ('Skipped', 'skipped', parse_int_or_none), - ('Unscored', 'unscored', parse_int_or_none), - ('ClientAuthorizationID', 'client_authorization_id', int), - ('Voucher', 'voucher'), - ], EXAMResult) diff --git a/exams/pearson/readers_test.py b/exams/pearson/readers_test.py deleted file mode 100644 index aa3ae45429..0000000000 --- a/exams/pearson/readers_test.py +++ /dev/null @@ -1,189 +0,0 @@ - -""" -Tests for TSV readers -""" -import io -from collections import namedtuple -from datetime import datetime -from unittest import TestCase as UnitTestCase - -import pytz - -from django.conf import settings -from exams.pearson.readers import ( - BaseTSVReader, - EACReader, - EACResult, - VCDCReader, - VCDCResult, - EXAMReader, -) -from exams.pearson.exceptions import InvalidTsvRowException - -FIXED_DATETIME = datetime(2016, 5, 15, 15, 2, 55, tzinfo=pytz.UTC) - - -class BaseTSVReaderTest(UnitTestCase): - """ - Tests for Pearson reader code - """ - - def test_reader_init(self): - """ - Tests that the reader initializes correctly - """ - - PropTuple = namedtuple('PropTuple', ['prop2']) - fields = { - ('prop2', 'Prop2', int), - } - - reader = BaseTSVReader(fields, PropTuple) - - assert reader.field_mappers == fields - assert reader.read_as_cls == PropTuple - - def test_map_row(self): - """ - Tests map_row with a prefix set - """ - PropTuple = namedtuple('PropTuple', ['prop2']) - reader = BaseTSVReader({ - ('Prop2', 'prop2'), - }, PropTuple) - - row = { - 'Prop1': '12', - 'Prop2': '145', - } - - result = reader.map_row(row) - assert result == PropTuple( - prop2='145', - ) - assert isinstance(result.prop2, str) - - reader = BaseTSVReader({ - ('Prop2', 'prop2', int), - }, PropTuple) - - row = { - 'Prop1': 12, - 'Prop2': 145, - } - - result = reader.map_row(row) - assert result == PropTuple( - prop2=145, - ) - assert isinstance(result.prop2, int) - - with self.assertRaises(InvalidTsvRowException): - reader.map_row({}) - - def test_read(self): - """ - Tests the read method outputs correctly - """ - PropTuple = namedtuple('PropTuple', ['prop1', 'prop2']) - tsv_file = io.StringIO( - "Prop1\tProp2\r\n" - "137\t145\r\n" - "\tnot_an_int\r\n" - ) - reader = BaseTSVReader([ - ('Prop1', 'prop1'), - ('Prop2', 'prop2', int), - ], PropTuple) - - valid_row = PropTuple( - prop1='137', - prop2=145, - ) - - results = reader.read(tsv_file) - - assert results == ([valid_row], [{ - 'Prop1': '', - 'Prop2': 'not_an_int' - }]) - - -class VCDCReaderTest(UnitTestCase): - """Tests for VCDCReader""" - def test_vcdc_read(self): # pylint: disable=no-self-use - """Test that read() correctly parses a VCDC file""" - sample_data = io.StringIO( - "ClientCandidateID\tStatus\tDate\tMessage\r\n" - "1\tAccepted\t2016/05/15 15:02:55\t\r\n" - "145\tAccepted\t2016/05/15 15:02:55\tWARNING: There be dragons\r\n" - "345\tError\t2016/05/15 15:02:55\tEmpty Address\r\n" - ) - - reader = VCDCReader() - results = reader.read(sample_data) - - assert results == ([ - VCDCResult( - client_candidate_id=1, status='Accepted', date=FIXED_DATETIME, message='' - ), - VCDCResult( - client_candidate_id=145, status='Accepted', date=FIXED_DATETIME, message='WARNING: There be dragons' - ), - VCDCResult( - client_candidate_id=345, status='Error', date=FIXED_DATETIME, message='Empty Address' - ), - ], []) - - -class EACReaderTest(UnitTestCase): - """Tests for EACReader""" - def test_eac_read(self): # pylint: disable=no-self-use - """Test that read() correctly parses a EAC file""" - sample_data = io.StringIO( - "ClientAuthorizationID\tClientCandidateID\tStatus\tDate\tMessage\r\n" - "4\t1\tAccepted\t2016/05/15 15:02:55\t\r\n" - "5\t2\tAccepted\t2016/05/15 15:02:55\tWARNING: There be dragons\r\n" - "6\t3\tError\t2016/05/15 15:02:55\tInvalid profile\r\n" - ) - - reader = EACReader() - results = reader.read(sample_data) - - assert results == ([ - EACResult( - client_authorization_id=4, - client_candidate_id=1, - date=FIXED_DATETIME, - status='Accepted', - message='' - ), - EACResult( - client_authorization_id=5, - client_candidate_id=2, - date=FIXED_DATETIME, - status='Accepted', - message='WARNING: There be dragons' - ), - EACResult( - client_authorization_id=6, - client_candidate_id=3, - date=FIXED_DATETIME, - status='Error', - message='Invalid profile' - ) - ], []) - - -class EXAMReaderTest(UnitTestCase): - """Tests for EXAMReader""" - def test_exam_read_no_shows(self): - """Test that a typical no-show result from Perason does not result in any errors""" - test_file_path = '{}/exams/pearson/test_resources/noshow.dat'.format(settings.BASE_DIR) - - reader = EXAMReader() - with open(test_file_path, 'r') as test_file: - results = reader.read(test_file) - - # Assert that there are no error messages in the results tuple - assert len(results[1]) == 0 diff --git a/exams/pearson/sftp.py b/exams/pearson/sftp.py deleted file mode 100644 index b3dc2abaae..0000000000 --- a/exams/pearson/sftp.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Pearson SFTP upload implementation -""" -import logging - -import pysftp -from pysftp.exceptions import ConnectionException -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from paramiko import SSHException - -from exams.pearson.exceptions import RetryableSFTPException -from exams.pearson.constants import PEARSON_UPLOAD_REQUIRED_SETTINGS - -log = logging.getLogger(__name__) - - -def get_connection(): - """ - Creates a new SFTP connection - - Returns: - connection(pysftp.Connection): - the configured connection - """ - missing_settings = [] - for key in PEARSON_UPLOAD_REQUIRED_SETTINGS: - if getattr(settings, key) is None: - missing_settings.append(key) - - if missing_settings: - raise ImproperlyConfigured( - "The setting(s) {} are required".format(', '.join(missing_settings)) - ) - - cnopts = pysftp.CnOpts() - cnopts.hostkeys = None # ignore knownhosts - - try: - return pysftp.Connection( - host=str(settings.EXAMS_SFTP_HOST), - port=int(settings.EXAMS_SFTP_PORT), - username=str(settings.EXAMS_SFTP_USERNAME), - password=str(settings.EXAMS_SFTP_PASSWORD), - cnopts=cnopts, - ) - except (ConnectionException, SSHException) as ex: - raise RetryableSFTPException() from ex diff --git a/exams/pearson/sftp_test.py b/exams/pearson/sftp_test.py deleted file mode 100644 index 6b295b2d35..0000000000 --- a/exams/pearson/sftp_test.py +++ /dev/null @@ -1,74 +0,0 @@ - -""" -Tests for Pearson SFTP -""" -from unittest.mock import ( - ANY, - patch, -) - -from django.core.exceptions import ImproperlyConfigured -from django.test import SimpleTestCase, override_settings -from ddt import ddt, data - -from exams.pearson.constants import PEARSON_UPLOAD_REQUIRED_SETTINGS -from exams.pearson.sftp import get_connection - - -EXAMS_SFTP_FILENAME = 'FILENAME' -EXAMS_SFTP_HOST = 'l0calh0st' -EXAMS_SFTP_PORT = '345' -EXAMS_SFTP_USERNAME = 'username' -EXAMS_SFTP_PASSWORD = 'password' -EXAMS_SFTP_UPLOAD_DIR = 'tmp' -EXAMS_SFTP_RESULTS_DIR = '/tmp' -EXAMS_SFTP_SETTINGS = { - 'EXAMS_SFTP_HOST': EXAMS_SFTP_HOST, - 'EXAMS_SFTP_PORT': EXAMS_SFTP_PORT, - 'EXAMS_SFTP_USERNAME': EXAMS_SFTP_USERNAME, - 'EXAMS_SFTP_PASSWORD': EXAMS_SFTP_PASSWORD, - 'EXAMS_SFTP_UPLOAD_DIR': EXAMS_SFTP_UPLOAD_DIR, - 'EXAMS_SFTP_RESULTS_DIR': EXAMS_SFTP_RESULTS_DIR, - # ensure auditing is disabled - 'EXAMS_AUDIT_S3_BUCKET': None, - 'EXAMS_AUDIT_ENCRYPTION_PUBLIC_KEY': None, - 'EXAMS_AUDIT_ENCRYPTION_FINGERPRINT': None, -} - - -@ddt -@override_settings(**EXAMS_SFTP_SETTINGS) -@patch('pysftp.Connection') -class PeasonSFTPTest(SimpleTestCase): - """ - Tests for Pearson upload_tsv - """ - - def test_get_connection_settings(self, connection_mock): # pylint: disable=no-self-use - """ - Tests that get_connection calls psftp.Connection with the correct values - """ - connection = get_connection() - connection_mock.assert_called_once_with( - host=EXAMS_SFTP_HOST, - port=int(EXAMS_SFTP_PORT), - username=EXAMS_SFTP_USERNAME, - password=EXAMS_SFTP_PASSWORD, - cnopts=ANY, - ) - - assert connection == connection_mock.return_value - - @data(*PEARSON_UPLOAD_REQUIRED_SETTINGS) - def test_get_connection_missing_settings(self, settings_key, connection_mock): - """ - Tests that get_connection ImproperlyConfigured if settings.{0} is not set - """ - kwargs = {settings_key: None} - - with self.settings(**kwargs): - with self.assertRaises(ImproperlyConfigured) as cm: - get_connection() - - connection_mock.assert_not_called() - assert settings_key in cm.exception.args[0] diff --git a/exams/pearson/test_resources/noshow.dat b/exams/pearson/test_resources/noshow.dat deleted file mode 100644 index e899a04c9e..0000000000 --- a/exams/pearson/test_resources/noshow.dat +++ /dev/null @@ -1,2 +0,0 @@ -Incorrect ExamLanguage NDARefused Score PassingScore Correct NoShow Skipped RegistrationID Unscored Form ExamName ExamDate Attempt ClientCandidateID ClientAuthorizationID ExamSeriesCode TCID Grade TimeUsed Voucher ExamRevision - ENU true 12345 2017/03/01 12:30:00 1234567890 123 MicroMasters 12345 \ No newline at end of file diff --git a/exams/pearson/upload.py b/exams/pearson/upload.py deleted file mode 100644 index c9b490a760..0000000000 --- a/exams/pearson/upload.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Pearson SFTP upload implementation -""" -from django.conf import settings -from paramiko import SSHException - -from exams.pearson.exceptions import RetryableSFTPException -from exams.pearson.sftp import get_connection - - -def upload_tsv(file_path): - """ - Upload the given TSV files to the remote - - Args: - file_path (str): absolute path to the file to be uploaded - """ - try: - with get_connection() as sftp: - with sftp.cd(settings.EXAMS_SFTP_UPLOAD_DIR): - sftp.put(file_path) - except (EOFError, SSHException,) as exc: - raise RetryableSFTPException() from exc diff --git a/exams/pearson/upload_test.py b/exams/pearson/upload_test.py deleted file mode 100644 index ae75901bf2..0000000000 --- a/exams/pearson/upload_test.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Tests for Pearson SFTP -""" -from unittest.mock import patch - -from django.test import SimpleTestCase, override_settings -from ddt import ddt, data -from paramiko import SSHException -from pysftp.exceptions import ConnectionException - -from exams.pearson import upload -from exams.pearson.exceptions import RetryableSFTPException -from exams.pearson.sftp_test import ( - EXAMS_SFTP_FILENAME, - EXAMS_SFTP_UPLOAD_DIR, - EXAMS_SFTP_SETTINGS, -) - - -@ddt -@override_settings(**EXAMS_SFTP_SETTINGS) -@patch('pysftp.Connection') -class PeasonUploadTest(SimpleTestCase): - """ - Tests for Pearson upload_tsv - """ - - def test_upload_tsv(self, connection_mock): - """ - Tests that upload uses the correct settings values - """ - upload.upload_tsv(EXAMS_SFTP_FILENAME) - - ftp_mock = connection_mock.return_value.__enter__.return_value - ftp_mock.cd.assert_called_once_with(EXAMS_SFTP_UPLOAD_DIR) - ftp_mock.put.assert_called_once_with(EXAMS_SFTP_FILENAME) - - @data( - SSHException(), - EOFError(), - ConnectionException('localhost', 22), - ) - def test_retryable_exceptions(self, expected_exc, connection_mock): - """ - Test that if {exc_cls} is raised that it results in a RetryableSFTPException - """ - connection_mock.side_effect = expected_exc - with self.assertRaises(RetryableSFTPException) as cm: - upload.upload_tsv(EXAMS_SFTP_FILENAME) - - assert isinstance(cm.exception, RetryableSFTPException) - assert cm.exception.__cause__ == expected_exc diff --git a/exams/pearson/utils.py b/exams/pearson/utils.py deleted file mode 100644 index 92da64c216..0000000000 --- a/exams/pearson/utils.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Utilities for Pearson-specific code""" -from datetime import datetime -import re - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -import pytz - -from exams.pearson.exceptions import UnparsableRowException -from exams.pearson.constants import PEARSON_DATETIME_FORMATS - -from mail import api as mail_api - -ZIP_FILE_RE = re.compile(r'^.+\.zip$') - - -def is_zip_file(filename): - """ - Checks if a filename looks like a zip file - - Args: - filename (str): filename to check - Returns: - bool: True if the file is a zip file - """ - return bool(ZIP_FILE_RE.match(filename)) - - -def parse_or_default(parser, default): - """ - Generate a function that safely parses a value or returns the default - - Args: - parser (callable): callable to parse the value - default (Any): default value if parser fails - - Returns: - callable: function that returns the parser's return value if it succeeds, otherwise it returns the default - """ - def inner(value): - """Inner function that performs the safe parsing""" - try: - return parser(value) - except (ValueError, TypeError, UnparsableRowException): - return default - return inner - - -parse_int_or_none = parse_or_default(int, None) - - -parse_float_or_none = parse_or_default(float, None) - - -def parse_datetime(dt_string): - """ - Attempts to parse a datetime string with any one of the datetime formats that we - expect from Pearson - - Args: - dt_string (str): datetime string to be parsed - - Returns: - datetime.datetime: parsed datetime - - Raises: - UnparsableRowException: - Thrown if the datetime string cannot be parsed with any of the accepted formats - """ - for dt_format in PEARSON_DATETIME_FORMATS: - try: - return datetime.strptime(dt_string, dt_format).replace(tzinfo=pytz.UTC) - except ValueError: - pass - raise UnparsableRowException('Unparsable datetime: {}'.format(dt_string)) - - -def parse_bool(value): - """ - Parses boolean values as formatted by Pearson - - Args: - value (str): boolean string representation - - Returns: - bool: parsed boolean value - - Raises: - UnparsableRowException: - Thrown if the value cannot be parsed as a boolean - """ - value = value.lower() - if value == 'true': - return True - elif value == 'false': - return False - else: - raise UnparsableRowException('Unexpected boolean value: {}'.format(value)) - - -def email_processing_failures(filename, zipfile, messages): - """ - Email summary of failures to mm admin - - Args: - filename(str): Path of file on local machine. - zipfile(str): Filename of the zip file - messages(list): List of error messages compiled in processing - Exam Authorization Confirmation files (EAC) file. - """ - if getattr(settings, 'ADMIN_EMAIL', None) is None: - raise ImproperlyConfigured('Setting ADMIN_EMAIL is not set') - - error_messages = '\n'.join('- {}'.format(message) for message in messages) - subject = "Summary of failures of Pearson file='{file}'".format(file=filename) - body = ( - "Hi,\n" - "The following errors were found in the file {filename} in {zipfile}:\n\n" - "{messages}" - ).format( - messages=error_messages, - filename=filename, - zipfile=zipfile - ) - - mail_api.MailgunClient().send_individual_email( - subject, - body, - settings.ADMIN_EMAIL - ) diff --git a/exams/pearson/utils_test.py b/exams/pearson/utils_test.py deleted file mode 100644 index 96bb9d398e..0000000000 --- a/exams/pearson/utils_test.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for Pearson utils""" -from datetime import datetime -from unittest.mock import patch - -from django.test import SimpleTestCase -import ddt -import pytz - -from exams.pearson import utils -from exams.pearson.exceptions import UnparsableRowException - -FIXED_DATETIME = datetime(2016, 5, 15, 15, 2, 55, tzinfo=pytz.UTC) - - -@ddt.ddt -class PearsonUtilsTest(SimpleTestCase): - """Tests for Pearson utils""" - def test_is_zip_file(self): # pylint: disable=no-self-use - """Tests is_zip_file""" - assert utils.is_zip_file('file.zip') is True - assert utils.is_zip_file('file.not') is False - assert utils.is_zip_file('file') is False - - def test_parse_datetime_valid(self): - """ - Tests that datetimes format correctly according to Pearson spec - """ - parsed_datetimes = map( - utils.parse_datetime, - ['2016/05/15 15:02:55', '05/15/2016 15:02:55'] - ) - assert all(parsed_datetime == FIXED_DATETIME for parsed_datetime in parsed_datetimes) - - def test_parse_datetime_invalid(self): - """ - Tests that an improperly-formatted datetime will result in an exception - """ - with self.assertRaises(UnparsableRowException): - utils.parse_datetime('bad/date/format') - - @ddt.data( - ('true', True), - ('True', True), - ('TRUE', True), - ('false', False), - ('False', False), - ('FALSE', False), - ) - @ddt.unpack - def test_parse_bool_valid(self, value, expected): - """Tests that it parses bools correctly""" - assert utils.parse_bool(value) == expected - - def test_parse_bool_invalid(self): - """Tests that it fails invalid bool values""" - with self.assertRaises(UnparsableRowException): - utils.parse_bool('Truerer') - - def test_parse_or_default(self): - """Tests parse_or_default uses parsed value or default""" - assert utils.parse_or_default(int, None)('5') == 5 - assert utils.parse_or_default(int, None)('') is None - assert utils.parse_or_default(int, 4)('') == 4 - - @patch('mail.api.MailgunClient') - def test_email_processing_failures(self, mailgun_client_mock): # pylint: disable=no-self-use - """Test email_processing_failures for correct calls and formatting""" - - with self.settings(ADMIN_EMAIL='admin@example.com'): - utils.email_processing_failures('b.dat', 'a.zip', [ - 'ERROR', - 'ERROR2', - ]) - - client_instance = mailgun_client_mock.return_value - client_instance.send_individual_email.assert_called_once_with( - "Summary of failures of Pearson file='b.dat'", - "Hi,\nThe following errors were found in the " - "file b.dat in a.zip:\n\n" - "- ERROR\n- ERROR2", - "admin@example.com" - ) diff --git a/exams/pearson/writers.py b/exams/pearson/writers.py deleted file mode 100644 index e3f2f02a24..0000000000 --- a/exams/pearson/writers.py +++ /dev/null @@ -1,280 +0,0 @@ -""" -Pearson TSV writers -""" -import csv -import logging -from collections import OrderedDict -from operator import attrgetter - -import phonenumbers -import pycountry - -from exams.pearson.constants import ( - PEARSON_DEFAULT_DATE_FORMAT, - PEARSON_DEFAULT_DATETIME_FORMAT, - PEARSON_DIALECT_OPTIONS, - PEARSON_STATE_SUPPORTED_COUNTRIES, -) -from exams.pearson.exceptions import ( - InvalidProfileDataException, - InvalidTsvRowException, -) - -log = logging.getLogger(__name__) - - -class BaseTSVWriter: - """ - Base class for TSV file writers. - - It handles the high-level mapping and writing of the data. - Subclasses specify how the fields map. - """ - def __init__(self, fields, field_prefix=None): - """ - Initializes a new TSV writer - - The first value of each fields tuple is the destination field name. - The second value is a str property path (e.g. "one.two.three") or - a callable that when passed a row returns a computed field value - - Arguments: - fields (List): list of (str, str|callable) tuples - field_prefix (str): path prefix to prefix field lookups with - """ - self.fields = OrderedDict(fields) - self.columns = self.fields.keys() - self.field_mappers = {column: self.get_field_mapper(field) for (column, field) in fields} - self.prefix_mapper = attrgetter(field_prefix) if field_prefix is not None else None - - @classmethod - def format_date(cls, date): - """ - Formats a date to Pearson's required format - """ - return date.strftime(PEARSON_DEFAULT_DATE_FORMAT) - - @classmethod - def format_datetime(cls, dt): - """ - Formats a datetime to Pearson's required format - """ - return dt.strftime(PEARSON_DEFAULT_DATETIME_FORMAT) - - @classmethod - def get_field_mapper(cls, field): - """ - Returns a field mapper, accepts either a property path in str form or a callable - """ - if isinstance(field, str): - return attrgetter(field) - elif callable(field): - return field - else: - raise TypeError("field_mapper must be a str or a callable") - - def map_row(self, row): - """ - Maps a row object to a row dict - - Args: - row: the row to map to a dict - - Returns: - dict: - row mapped to a dict using the field mappers - """ - if self.prefix_mapper is not None: - row = self.prefix_mapper(row) - return {column: field_mapper(row) for column, field_mapper in self.field_mappers.items()} - - def write(self, tsv_file, rows): - """ - Writes the rows to the designated file using the configured fields. - - Invalid records are not written. - - Arguments: - tsv_file: a file-like object to write the data to - rows: list of records to write to the tsv file - - Returns: - (valid_record, invalid_records): - a tuple of which records were valid or invalid - """ - file_writer = csv.DictWriter( - tsv_file, - self.columns, - restval='', # ensure we don't print 'None' into the file for optional fields - **PEARSON_DIALECT_OPTIONS - ) - - file_writer.writeheader() - - valid_rows, invalid_rows = [], [] - - for row in rows: - try: - file_writer.writerow(self.map_row(row)) - valid_rows.append(row) - except InvalidTsvRowException: - log.exception("Invalid tsv row") - invalid_rows.append(row) - - return (valid_rows, invalid_rows) - - -class CDDWriter(BaseTSVWriter): - """ - A writer for Pearson Candidate Demographic Data (CDD) files - """ - - def __init__(self): - """ - Initializes a new CDD writer - """ - super().__init__([ - ('ClientCandidateID', 'profile.student_id'), - ('FirstName', self.first_name), - ('LastName', self.last_name), - ('Email', 'profile.user.email'), - ('Address1', 'profile.address1'), - ('Address2', 'profile.address2'), - ('Address3', 'profile.address3'), - ('City', 'profile.city'), - ('State', self.profile_state), - ('PostalCode', 'profile.postal_code'), - ('Country', self.profile_country_to_alpha3), - ('Phone', self.profile_phone_number_to_raw_number), - ('PhoneCountryCode', self.profile_phone_number_to_country_code), - ('LastUpdate', lambda exam_profile: self.format_datetime(exam_profile.updated_on)), - ]) - - @classmethod - def first_name(cls, exam_profile): - """ - Determines which first_name to use - - Args: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: romanized_first_name if we have it, first_name otherwise - """ - return exam_profile.profile.romanized_first_name or exam_profile.profile.first_name - - @classmethod - def last_name(cls, exam_profile): - """ - Determines which last_name to use - - Args: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: romanized_last_name if we have it, last_name otherwise - """ - return exam_profile.profile.romanized_last_name or exam_profile.profile.last_name - - @classmethod - def profile_state(cls, exam_profile): - """ - Transforms the state into the format accepted by PEARSON - - Args: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: 2-character state representation if country is US/CA otherwise None - """ - country, state = exam_profile.profile.country_subdivision - - return state if country in PEARSON_STATE_SUPPORTED_COUNTRIES else '' - - @classmethod - def _parse_phone_number(cls, phone_number_string): - """ - Parses a phone number string and raises proper exceptions in case it is invalid - - Args: - phone_number_string (str): a string representing a phone number - - Returns: - phonenumbers.phonenumber.PhoneNumber: a PhoneNumber object - """ - try: - phone_number = phonenumbers.parse(phone_number_string) - except phonenumbers.phonenumberutil.NumberParseException: - raise InvalidProfileDataException('Stored phone number is in an invalid string') - if not phonenumbers.is_valid_number(phone_number): - raise InvalidProfileDataException('Stored phone number is in an invalid phone number') - return phone_number - - @classmethod - def profile_phone_number_to_country_code(cls, exam_profile): - """ - Get the country code for a profile's phone number - - Args: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: the country code - """ - phone_number = cls._parse_phone_number(exam_profile.profile.phone_number) - return str(phone_number.country_code) - - @classmethod - def profile_phone_number_to_raw_number(cls, exam_profile): - """ - Get just the number for a profile's phone number - - Args: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: full phone number minus the country code - """ - phone_number = cls._parse_phone_number(exam_profile.profile.phone_number) - return phonenumbers.national_significant_number(phone_number) - - @classmethod - def profile_country_to_alpha3(cls, exam_profile): - """ - Returns the alpha3 code of a profile's country - - Arguments: - exam_profile (exams.models.ExamProfile): the ExamProfile being written - - Returns: - str: - the alpha3 country code - """ - # Pearson requires ISO-3166 alpha3 codes, but we store as alpha2 - try: - country = pycountry.countries.get(alpha_2=exam_profile.profile.country) - except KeyError as exc: - raise InvalidProfileDataException() from exc - return country.alpha_3 - - -class EADWriter(BaseTSVWriter): - """ - A writer for Pearson Exam Authorization Data (EAD) files - """ - - def __init__(self): - """ - Initializes a new EAD writer - """ - super().__init__([ - ('AuthorizationTransactionType', 'operation'), - ('ClientAuthorizationID', 'id'), - ('ClientCandidateID', 'user.profile.student_id'), - ('ExamSeriesCode', 'exam_run.exam_series_code'), - ('Modules', lambda _: ''), - ('Accommodations', lambda _: ''), - ('EligibilityApptDateFirst', lambda exam_auth: self.format_date(exam_auth.exam_run.date_first_eligible)), - ('EligibilityApptDateLast', lambda exam_auth: self.format_date(exam_auth.exam_run.date_last_eligible)), - ('LastUpdate', lambda exam_auth: self.format_datetime(exam_auth.updated_on)), - ]) diff --git a/exams/pearson/writers_test.py b/exams/pearson/writers_test.py deleted file mode 100644 index e0f739912c..0000000000 --- a/exams/pearson/writers_test.py +++ /dev/null @@ -1,406 +0,0 @@ -""" -Tests for TSV writers -""" -import io -from datetime import date, datetime -from unittest import TestCase as UnitTestCase -from unittest.mock import ( - Mock, - NonCallableMock, -) - -import ddt -import pytz -from django.db.models.signals import post_save -from django.test import TestCase -from factory.django import mute_signals - -from exams.pearson.exceptions import ( - InvalidProfileDataException, - InvalidTsvRowException, -) -from exams.factories import ( - ExamAuthorizationFactory, - ExamProfileFactory, -) -from exams.pearson.writers import ( - CDDWriter, - EADWriter, - BaseTSVWriter, -) -from profiles.factories import ProfileFactory -from profiles.models import Profile - -FIXED_DATETIME = datetime(2016, 5, 15, 15, 2, 55, tzinfo=pytz.UTC) -FIXED_DATE = date(2016, 5, 15) - - -class TSVWriterTestCase(UnitTestCase): - """ - Base class for tests around TSVWriter implementations - """ - def setUp(self): - self.tsv_file = io.StringIO() - - @property - def tsv_value(self): - """Extracts the contents of the tsv file""" - return self.tsv_file.getvalue() - - @property - def tsv_lines(self): - """Extracts the lines of the tsv file""" - return self.tsv_value.splitlines() - - @property - def tsv_header(self): - """Extracts the header line of the tsv file""" - return self.tsv_lines[0] - - @property - def tsv_rows(self): - """Extracts the non-header lines of the tsv file""" - return self.tsv_lines[1:] - - -class BaseTSVWriterTest(TSVWriterTestCase): - """ - Tests for Pearson writer code - """ - - def test_get_field_mapper(self): - """ - Tests that _get_field_mapper handles input correctly - """ - profile = Profile(address='1 Main St') - - assert BaseTSVWriter.get_field_mapper('address')(profile) == profile.address - - def get_addr1(profile): # pylint: disable=missing-docstring - return profile.address - - addr1_field_mapper = BaseTSVWriter.get_field_mapper(get_addr1) - - assert addr1_field_mapper is get_addr1 - assert addr1_field_mapper(profile) == profile.address1 - - with self.assertRaises(TypeError): - BaseTSVWriter.get_field_mapper([]) - - def test_format_datetime(self): - """ - Tests that datetimes format correctly according to Pearson spec - """ - assert BaseTSVWriter.format_datetime(FIXED_DATETIME) == '2016/05/15 15:02:55' - - def test_format_date(self): - """ - Tests that datetimes format correctly according to Pearson spec - """ - assert BaseTSVWriter.format_date(FIXED_DATE) == '2016/05/15' - - def test_writer_init(self): - """ - Tests that the writer initializes correctly - """ - - fields = [ - ('Prop2', 'prop2'), - ] - - writer = BaseTSVWriter(fields, field_prefix='prop1') - - assert list(writer.columns) == ['Prop2'] # writer.columns is of odict_keys type so cast to list - assert len(writer.fields) == len(fields) - assert len(writer.field_mappers) == len(fields) - assert callable(writer.prefix_mapper) - - assert BaseTSVWriter([]).prefix_mapper is None - - def test_map_row_with_prefix(self): - """ - Tests map_row with a prefix set - """ - writer = BaseTSVWriter([ - ('Prop2', 'prop2'), - ], field_prefix='prop1') - - row = NonCallableMock() - row.prop1 = NonCallableMock(prop2=145) - - assert writer.map_row(row) == { - 'Prop2': 145, - } - - def test_map_row_without_prefix(self): - """ - Tests map_row with a prefix set - """ - writer = BaseTSVWriter([ - ('Prop2', 'prop1'), - ]) - - row = NonCallableMock(prop1=145) - - assert writer.map_row(row) == { - 'Prop2': 145, - } - - def test_write(self): - """ - Tests the write method outputs correctly - """ - writer = BaseTSVWriter([ - ('Prop1', 'prop1'), - ('Prop2', 'prop2'), - ]) - - row = NonCallableMock( - prop1=145, - prop2=None, - ) - - valid, invalid = writer.write(self.tsv_file, [row]) - - assert valid == [row] - assert invalid == [] - assert self.tsv_value == ( - "Prop1\tProp2\r\n" - "145\t\r\n" # None should convert to an empty string - ) - - def test_write_skips_invalid_rows(self): - """ - Tests write_cdd_file against a profile with invalid state - """ - writer = BaseTSVWriter([ - ('Prop1', Mock(side_effect=InvalidTsvRowException)), - ]) - - row = NonCallableMock() - - valid, invalid = writer.write(self.tsv_file, [row]) - - assert valid == [] - assert invalid == [row] - assert self.tsv_value == "Prop1\r\n" - - -@ddt.ddt -class CDDWriterTest(TSVWriterTestCase, TestCase): - """ - Tests for CDDWriter - """ - def setUp(self): - self.cdd_writer = CDDWriter() - super().setUp() - - @ddt.data( - ("Jekyll", None, "Jekyll"), - (None, "Hyde", "Hyde"), - ("Jekyll", "Hyde", "Hyde"), - ) - @ddt.unpack - def test_first_name(self, unromanized, romanized, expected): - """ - Test that the `first_name` method prefers the `romanized_first_name` - field, and falls back on `first_name` field. - """ - with mute_signals(post_save): - profile = ExamProfileFactory( - profile__first_name=unromanized, - profile__romanized_first_name=romanized, - ) - assert CDDWriter.first_name(profile) == expected - - @ddt.data( - ("Jekyll", None, "Jekyll"), - (None, "Hyde", "Hyde"), - ("Jekyll", "Hyde", "Hyde"), - ) - @ddt.unpack - def test_last_name(self, unromanized, romanized, expected): - """ - Test that the `last_name` method prefers the `romanized_last_name` - field, and falls back on `last_name` field. - """ - with mute_signals(post_save): - profile = ExamProfileFactory( - profile__last_name=unromanized, - profile__romanized_last_name=romanized, - ) - assert CDDWriter.last_name(profile) == expected - - @ddt.data( - ("US", "US-MA", "MA"), - ("CA", "CA-NB", "NB"), - ("UK", "GB-ABD", ""), - ) - @ddt.unpack - def test_profile_state(self, country, state, expected): - """Test that profile_state returns expected values""" - with mute_signals(post_save): - profile = ExamProfileFactory( - profile__country=country, - profile__state_or_territory=state - ) - assert CDDWriter.profile_state(profile) == expected - - def test_profile_country_to_alpha3_invalid_country(self): - """ - A profile with an invalid country code should raise an InvalidProfileDataException - """ - with mute_signals(post_save): - profile = ExamProfileFactory(profile__country='XXXX') - with self.assertRaises(InvalidProfileDataException): - CDDWriter.profile_country_to_alpha3(profile) - - @ddt.data( - ("+1 617 293-3423", "1", "6172933423", ), - ("+39 345 9999999", "39", "3459999999", ), - ("+393459999999", "39", "3459999999", ), - ("+39 0827 99999", "39", "082799999", ), - ("+91 020-30303030", "91", "2030303030", ), - ("+17874061234", "1", "7874061234", ), - ("+52-55-60-521234", "52", "5560521234", ), - ("+229-97-09-1234", "229", "97091234", ) - ) - @ddt.unpack - def test_profile_phone_number_functions(self, input_number, expected_country_code, expected_number): - """ - A profile with a valid phone number should be parsed correctly - """ - with mute_signals(post_save): - profile = ExamProfileFactory(profile__phone_number=input_number) - assert CDDWriter.profile_phone_number_to_raw_number(profile) == expected_number - assert CDDWriter.profile_phone_number_to_country_code(profile) == expected_country_code - - @ddt.data( - '', - None, - 'bad string', - '120272727', # nonsense number - '+1234567', # number that resembles a real number - "+1 899 293-3423", # invalid number even if it looks fine - ) - def test_profile_phone_number_exceptions(self, bad_number): - """ - It should raise exceptions for bad data - """ - with mute_signals(post_save): - profile = ExamProfileFactory(profile__phone_number=bad_number) - with self.assertRaises(InvalidProfileDataException): - CDDWriter.profile_phone_number_to_raw_number(profile) - with self.assertRaises(InvalidProfileDataException): - CDDWriter.profile_phone_number_to_country_code(profile) - - def test_write_profiles_cdd_header(self): - """ - Tests write_cdd_file writes the correct header - """ - self.cdd_writer.write(self.tsv_file, []) - - assert self.tsv_value == ( - "ClientCandidateID\tFirstName\tLastName\t" - "Email\tAddress1\tAddress2\tAddress3\t" - "City\tState\tPostalCode\tCountry\t" - "Phone\tPhoneCountryCode\tLastUpdate\r\n" - ) - - def test_write_cdd_file(self): - """ - Tests cdd_writer against a set of profiles - """ - kwargs = { - 'profile__id': 14879, - 'profile__romanized_first_name': 'Jane', - 'profile__romanized_last_name': 'Smith', - 'profile__user__email': 'jane@example.com', - 'profile__address': '1 Main St, Room B345', - 'profile__city': 'Boston', - 'profile__state_or_territory': 'US-MA', - 'profile__country': 'US', - 'profile__postal_code': '02115', - 'profile__phone_number': '+1 617 293-3423', - } - - with mute_signals(post_save): - exam_profiles = [ExamProfileFactory.create(**kwargs)] - exam_profiles[0].updated_on = FIXED_DATETIME - - self.cdd_writer.write(self.tsv_file, exam_profiles) - - assert self.tsv_rows[0] == ( - "14879\tJane\tSmith\tjane@example.com\t" - "1 Main St, Room B345\t\t\t" # triple tab is for blank address2 and address3 - "Boston\tMA\t02115\tUSA\t" - "6172933423\t1\t2016/05/15 15:02:55" - ) - - def test_write_cdd_file_with_blank_romanized_name(self): - """ - Tests cdd_writer against a profile without romanized name fields - """ - kwargs = { - 'profile__id': 9876, - 'profile__first_name': 'Jane', - 'profile__last_name': 'Smith', - 'profile__romanized_first_name': None, - 'profile__romanized_last_name': None, - 'profile__phone_number': '+1 617 293-3423', - } - - with mute_signals(post_save): - exam_profiles = [ExamProfileFactory.create(**kwargs)] - exam_profiles[0].profile.updated_on = FIXED_DATETIME - self.cdd_writer.write(self.tsv_file, exam_profiles) - - assert self.tsv_rows[0].startswith("9876\tJane\tSmith\t") - - -class EADWriterTest(TSVWriterTestCase, TestCase): - """ - Tests for EADWriter - """ - def setUp(self): - self.ead_writer = EADWriter() - super().setUp() - - def test_write_ead_header(self): - """ - Tests EADWriter writes the correct header - """ - self.ead_writer.write(self.tsv_file, []) - - assert self.tsv_header == ( - "AuthorizationTransactionType\tClientAuthorizationID\t" - "ClientCandidateID\tExamSeriesCode\tModules\t" - "Accommodations\tEligibilityApptDateFirst\tEligibilityApptDateLast\t" - "LastUpdate" - ) - - def test_write_ead_file(self): - """ - Tests that write_ead_file outputs correctly - """ - kwargs = { - 'id': 143, - 'operation': 'add', - 'exam_run__exam_series_code': 'MM-DEDP', - 'exam_run__date_first_eligible': date(2016, 5, 15), - 'exam_run__date_last_eligible': date(2016, 10, 15), - } - - with mute_signals(post_save): - profile = ProfileFactory(id=14879) - exam_auths = [ExamAuthorizationFactory.create(user=profile.user, **kwargs)] - exam_auths[0].updated_on = FIXED_DATETIME - - self.ead_writer.write(self.tsv_file, exam_auths) - - assert self.tsv_rows[0] == ( - "add\t143\t" - "14879\tMM-DEDP\t\t" - "\t2016/05/15\t2016/10/15\t" # accommodation blank intentionally - "2016/05/15 15:02:55" - ) diff --git a/exams/signals.py b/exams/signals.py index db62b2f0a2..1c402c625b 100644 --- a/exams/signals.py +++ b/exams/signals.py @@ -21,20 +21,11 @@ from grades.api import update_existing_combined_final_grade_for_exam_run from grades.models import FinalGrade -from profiles.models import Profile log = logging.getLogger(__name__) -@receiver(post_save, sender=Profile, dispatch_uid="update_exam_profile") -def update_exam_profile(sender, instance, **kwargs): # pylint: disable=unused-argument - """ - Signal handler to trigger a sync of the profile if an ExamProfile record exists for it. - """ - ExamProfile.objects.filter(profile_id=instance.id).update(status=ExamProfile.PROFILE_PENDING) - - @receiver(post_save, sender=ExamRun, dispatch_uid="update_exam_run") def update_exam_run(sender, instance, created, **kwargs): # pylint: disable=unused-argument """If we update an ExamRun, update ExamAuthorizations accordingly""" diff --git a/exams/signals_test.py b/exams/signals_test.py index 5eba93c4e4..9e5f5693f4 100644 --- a/exams/signals_test.py +++ b/exams/signals_test.py @@ -3,7 +3,6 @@ """ from datetime import timedelta -from django.contrib.auth.models import User from django.db.models.signals import post_save from factory.django import mute_signals import ddt @@ -66,23 +65,6 @@ def setUpTestData(cls): date_first_schedulable=now_in_utc() - timedelta(days=1), ) - def test_update_exam_profile_called(self): - """ - Verify that update_exam_profile is called when a profile saves - """ - user = User.objects.create(username='test') - profile = user.profile - profile_exam = ExamProfile.objects.create( - profile=profile, - status=ExamProfile.PROFILE_SUCCESS, - ) - profile.first_name = 'NewName' - profile.save() - - profile_exam.refresh_from_db() - - assert profile_exam.status == ExamProfile.PROFILE_PENDING - def test_update_exam_authorization_final_grade(self): """ Verify that update_exam_authorization_final_grade is called when a FinalGrade saves diff --git a/exams/urls.py b/exams/urls.py deleted file mode 100644 index d4d44d1277..0000000000 --- a/exams/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -"""URLs for exams app""" -from django.conf.urls import url - -from exams.views import ( - PearsonCallbackRedirectView, - PearsonSSO, -) - -urlpatterns = [ - url( - r'^pearson/(?Psuccess|error|timeout|logout)/?$', - PearsonCallbackRedirectView.as_view(), - name='pearson_callback' - ), - url(r'^api/v0/pearson/sso/$', PearsonSSO.as_view(), name='pearson_sso_api'), -] diff --git a/exams/utils.py b/exams/utils.py index 56d1f669c8..80358c4f52 100644 --- a/exams/utils.py +++ b/exams/utils.py @@ -1,28 +1,6 @@ """Exam related helpers""" import re -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - - -def exponential_backoff(retries): - """ - Exponential backoff for retried tasks - - Args: - retries (int): cumulative number of retries - - Raises: - ImproperlyConfigured: if settings.EXAMS_SFTP_BACKOFF_BASE is not a parsable int - - Returns: - int: seconds to wait until next attempt - """ - try: - return int(settings.EXAMS_SFTP_BACKOFF_BASE) ** retries - except ValueError as ex: - raise ImproperlyConfigured('EXAMS_SFTP_BACKOFF_BASE must be an integer') from ex - def is_eligible_for_exam(mmtrack, course_run): """ diff --git a/exams/utils_test.py b/exams/utils_test.py index 62943e8c97..b0db29ab30 100644 --- a/exams/utils_test.py +++ b/exams/utils_test.py @@ -1,43 +1,15 @@ """Test cases for the exam util""" from ddt import ddt, data, unpack -from django.core.exceptions import ImproperlyConfigured from django.db.models.signals import post_save -from django.test import SimpleTestCase from factory.django import mute_signals from exams.utils import ( - exponential_backoff, validate_profile ) from profiles.factories import ProfileFactory from search.base import MockedESTestCase -@ddt -class ExamBackoffUtilsTest(SimpleTestCase): - """Tests for exam tasks""" - @data( - (5, 1, 5), - (5, 2, 25), - (5, 3, 125), - ) - @unpack - def test_exponential_backoff_values(self, base, retries, expected): # pylint: disable=no-self-use - """ - Test that exponential_backoff returns a power of settings.EXAMS_SFTP_BACKOFF_BASE - """ - with self.settings(EXAMS_SFTP_BACKOFF_BASE=base): - assert exponential_backoff(retries) == expected - - def test_exponential_backoff_invalid(self): # pylint: disable=no-self-use - """ - Test that exponential_backoff raises a configuration error if it gets an invalid value - """ - with self.settings(EXAMS_SFTP_BACKOFF_BASE='NOT_AN_INT'): - with self.assertRaises(ImproperlyConfigured): - exponential_backoff(1) - - @ddt class ExamProfileValidationTests(MockedESTestCase): """Tests for exam utils validate_profile""" diff --git a/exams/views.py b/exams/views.py deleted file mode 100644 index 6239ff3556..0000000000 --- a/exams/views.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -Views for exams app -""" - -from urllib.parse import quote_plus -from django.views.generic.base import RedirectView -from django.core.exceptions import ImproperlyConfigured -from rest_framework import ( - authentication, - permissions, - status, -) -from rest_framework.response import Response -from rest_framework.views import APIView - -from exams.api import sso_digest -from exams.models import ExamProfile -from micromasters.utils import now_in_utc - - -class PearsonCallbackRedirectView(RedirectView): - """ - Redirect from Pearson callbacks to dashboard - """ - def get_redirect_url(self, status_code): # pylint: disable=arguments-differ - return "/dashboard?exam={status_code}".format(status_code=quote_plus(status_code)) - - -class PearsonSSO(APIView): - """ - Views for the Pearson SSO API - """ - authentication_classes = ( - authentication.SessionAuthentication, - authentication.TokenAuthentication, - ) - permission_classes = (permissions.IsAuthenticated, ) - - def get(self, request, *args, **kargs): # pylint: disable=unused-argument, no-self-use - """ - Request for exam SSO parameters - """ - profile = request.user.profile - student_id = profile.student_id - - if not ExamProfile.objects.filter( - profile=profile, - status=ExamProfile.PROFILE_SUCCESS, - ).exists(): - # UI should in theory not send a user here in this state, - # but it's not impossible so let's handle it politely - return Response(data={ - 'error': 'You are not ready to schedule an exam at this time', - }, status=status.HTTP_403_FORBIDDEN) - - timestamp = int(now_in_utc().timestamp()) - session_timeout = request.session.get_expiry_age() - - try: - digest = sso_digest(student_id, timestamp, session_timeout) - except ImproperlyConfigured: - return Response(status=500) - - return Response(data={ - 'sso_digest': digest, - 'timestamp': timestamp, - 'session_timeout': session_timeout, - 'sso_redirect_url': request.build_absolute_uri('/'), - }) diff --git a/exams/views_test.py b/exams/views_test.py deleted file mode 100644 index 1554e6e4a5..0000000000 --- a/exams/views_test.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -Tests for exam views -""" -from unittest.mock import patch - -import ddt -from rest_framework import status -from rest_framework.test import APITestCase -from django.core.exceptions import ImproperlyConfigured -from django.urls import reverse - -from exams.models import ExamProfile -from micromasters.factories import UserFactory -from micromasters.test import SimpleTestCase -from micromasters.utils import now_in_utc -from search.base import MockedESTestCase - - -class PearsonSSOCallbackTests(SimpleTestCase): - """ - Tests for Pearson callback URLs - """ - databases = '__all__' - - def test_success(self): - """ - Test /pearson/success URL - """ - response = self.client.get('/pearson/success/') - assert response.status_code == status.HTTP_302_FOUND - assert response.url == "/dashboard?exam=success" - - def test_error(self): - """ - Test /pearson/error URL - """ - response = self.client.get('/pearson/error/') - assert response.status_code == status.HTTP_302_FOUND - assert response.url == "/dashboard?exam=error" - - def test_timeout(self): - """ - Test /pearson/error URL - """ - response = self.client.get('/pearson/timeout/') - assert response.status_code == status.HTTP_302_FOUND - assert response.url == "/dashboard?exam=timeout" - - def test_logout(self): - """ - Test /pearson/logout URL - """ - response = self.client.get('/pearson/logout/') - assert response.status_code == status.HTTP_302_FOUND - assert response.url == "/dashboard?exam=logout" - - def test_not_found(self): - """ - Test a URL under /pearson that doesn't exist - """ - response = self.client.get('/pearson/other/') - assert response.status_code == status.HTTP_404_NOT_FOUND - - -@ddt.ddt -class PearsonSSOViewTests(MockedESTestCase, APITestCase): - """ - Tests for the SSO views - """ - @classmethod - def setUpTestData(cls): - super().setUpTestData() - # create an user - cls.user = UserFactory() - - def setUp(self): - super().setUp() - self.client.force_login(self.user) - - def test_sso_get_no_exam_profile(self): - """ - Test issuing a GET request when user has no ExamProfile - """ - with patch('exams.views.sso_digest', return_value='test value'): - response = self.client.get(reverse("pearson_sso_api")) - assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == { - 'error': 'You are not ready to schedule an exam at this time', - } - - @ddt.data( - (ExamProfile.PROFILE_INVALID, ), - (ExamProfile.PROFILE_IN_PROGRESS, ), - (ExamProfile.PROFILE_PENDING, ), - ) - @ddt.unpack - def test_sso_get_with_exam_profile_not_success(self, profile_status): - """ - Test issuing a GET request when user has an ExamProfile in non-success status - """ - ExamProfile.objects.create( - profile=self.user.profile, - status=profile_status, - ) - - with patch('exams.views.sso_digest', return_value='test value'): - response = self.client.get(reverse("pearson_sso_api")) - assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.json() == { - 'error': 'You are not ready to schedule an exam at this time', - } - - def test_sso_get_with_exam_profile_success(self): - """ - Test issuing a GET request when user has an ExamProfile in PROFILE_SUCCESS status - """ - ExamProfile.objects.create( - profile=self.user.profile, - status=ExamProfile.PROFILE_SUCCESS, - ) - - with patch('exams.views.sso_digest', return_value='test value'): - response = self.client.get(reverse("pearson_sso_api")) - result = response.json() - assert response.status_code == status.HTTP_200_OK - - timestamp = result['timestamp'] - assert isinstance(timestamp, int) - - now = int(now_in_utc().timestamp()) - assert now - timestamp < 5 - - assert result['sso_digest'] == 'test value' - - # best we can assert is that this is an integer - assert isinstance(result['session_timeout'], int) - - assert result['sso_redirect_url'] == 'http://testserver/' - - def test_sso_improperly_configured_response(self): - """ - Test issuing a GET request when user has an ExamProfile in PROFILE_SUCCESS status - """ - ExamProfile.objects.create( - profile=self.user.profile, - status=ExamProfile.PROFILE_SUCCESS, - ) - - with patch('exams.views.sso_digest', side_effect=ImproperlyConfigured): - response = self.client.get(reverse("pearson_sso_api")) - assert response.status_code == 500 diff --git a/micromasters/urls.py b/micromasters/urls.py index 18b0ee9a9a..5dccf49c41 100644 --- a/micromasters/urls.py +++ b/micromasters/urls.py @@ -31,7 +31,6 @@ url('', include('search.urls')), url('', include('mail.urls')), url('', include('profiles.urls')), - url('', include('exams.urls')), url('', include('discussions.urls')), url(r'^status/', include('server_status.urls')), url('', include('ui.urls')), From 2f4e1c1c26ed9fa728d8dbeffb8d5f8ff6f83b39 Mon Sep 17 00:00:00 2001 From: annagav Date: Mon, 1 Feb 2021 15:15:24 -0500 Subject: [PATCH 2/9] more code --- exams/conftest.py | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 exams/conftest.py diff --git a/exams/conftest.py b/exams/conftest.py deleted file mode 100644 index bd75b03b05..0000000000 --- a/exams/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Configure pytest""" -import pytest - - -@pytest.fixture(autouse=True) -def auditor(request, mocker): - """Default mock for auditor""" - mock = mocker.patch('exams.pearson.audit.ExamDataAuditor', autospec=True) - - # compatability for unitest tests - if request.instance is not None: - request.instance.auditor = mock - - # yield for vanilla pytest usages - yield mock From 99a10e6b62604dc52f3f21299c31c603dd9ff0f0 Mon Sep 17 00:00:00 2001 From: annagav Date: Wed, 3 Feb 2021 12:42:23 -0500 Subject: [PATCH 3/9] removing more --- app.json | 24 ------------------- exams/{pearson => }/constants.py | 0 .../commands/import_edx_exam_grades.py | 2 +- exams/pearson/__init__.py | 0 exams/tasks.py | 7 ------ grades/factories.py | 2 +- grades/models.py | 2 +- grades/models_test.py | 2 +- micromasters/settings.py | 22 ----------------- 9 files changed, 4 insertions(+), 57 deletions(-) rename exams/{pearson => }/constants.py (100%) delete mode 100644 exams/pearson/__init__.py diff --git a/app.json b/app.json index baefaf6151..569efc9589 100644 --- a/app.json +++ b/app.json @@ -84,30 +84,6 @@ "ELASTICSEARCH_URL": { "description": "URL for connecting to Elasticsearch cluster" }, - "EXAMS_AUDIT_NACL_PUBLIC_KEY": { - "description": "NaCl public key for encrypting audit files", - "required": false - }, - "EXAMS_SFTP_HOST": { - "description": "Hostname for Pearson SFTP server", - "required": false - }, - "EXAMS_SFTP_PORT": { - "description": "Port for Pearson SFTP server", - "required": false - }, - "EXAMS_SFTP_USERNAME": { - "description": "Username for Pearson SFTP server authentication", - "required": false - }, - "EXAMS_SFTP_PASSWORD": { - "description": "Password for Pearson SFTP server authentication", - "required": false - }, - "EXAMS_SFTP_UPLOAD_DIR": { - "description": "Upload directory for files we send to Pearson", - "required": false - }, "FEATURE_OPEN_DISCUSSIONS_USER_SYNC": { "description": "Enables creation and syncing of open-discussions user data", "required": false diff --git a/exams/pearson/constants.py b/exams/constants.py similarity index 100% rename from exams/pearson/constants.py rename to exams/constants.py diff --git a/exams/management/commands/import_edx_exam_grades.py b/exams/management/commands/import_edx_exam_grades.py index dd45c0d08b..4136280e6e 100644 --- a/exams/management/commands/import_edx_exam_grades.py +++ b/exams/management/commands/import_edx_exam_grades.py @@ -9,7 +9,7 @@ from courses.models import Course from exams.models import ExamRun, ExamAuthorization -from exams.pearson.constants import EXAM_GRADE_PASS +from exams.constants import EXAM_GRADE_PASS from grades.models import ProctoredExamGrade from micromasters.utils import now_in_utc diff --git a/exams/pearson/__init__.py b/exams/pearson/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/exams/tasks.py b/exams/tasks.py index 30930e620d..1db17487fd 100644 --- a/exams/tasks.py +++ b/exams/tasks.py @@ -11,13 +11,6 @@ from micromasters.celery import app from micromasters.utils import now_in_utc, chunks -PEARSON_CDD_FILE_PREFIX = "cdd-%Y%m%d%H_" -PEARSON_EAD_FILE_PREFIX = "ead-%Y%m%d%H_" - -PEARSON_FILE_EXTENSION = ".dat" - -PEARSON_FILE_ENCODING = "utf-8" - log = logging.getLogger(__name__) diff --git a/grades/factories.py b/grades/factories.py index 78cbdeae7f..d16fb1b581 100644 --- a/grades/factories.py +++ b/grades/factories.py @@ -19,7 +19,7 @@ ProgramFactory, ) from exams.factories import ExamRunFactory -from exams.pearson.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL +from exams.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL from grades.constants import FinalGradeStatus from grades.models import ( FinalGrade, diff --git a/grades/models.py b/grades/models.py index 8eae44443f..fd4e9a12c7 100644 --- a/grades/models.py +++ b/grades/models.py @@ -14,7 +14,7 @@ Program, ) from exams.models import ExamRun -from exams.pearson.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL +from exams.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL from grades.constants import FinalGradeStatus from micromasters.models import ( AuditableModel, diff --git a/grades/models_test.py b/grades/models_test.py index 095aff2783..5b9f73d7d4 100644 --- a/grades/models_test.py +++ b/grades/models_test.py @@ -9,7 +9,7 @@ CourseRunFactory, ProgramFactory, ) -from exams.pearson.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL +from exams.constants import EXAM_GRADE_PASS, EXAM_GRADE_FAIL from grades.models import ( CourseRunGradingAlreadyCompleteError, CourseRunGradingStatus, diff --git a/micromasters/settings.py b/micromasters/settings.py index e11a1fd604..5bc824c0df 100644 --- a/micromasters/settings.py +++ b/micromasters/settings.py @@ -555,28 +555,6 @@ OPEN_EXCHANGE_RATES_URL = get_string("OPEN_EXCHANGE_RATES_URL", "https://openexchangerates.org/api/") OPEN_EXCHANGE_RATES_APP_ID = get_string("OPEN_EXCHANGE_RATES_APP_ID", "") -# Exams SFTP -EXAMS_SFTP_TEMP_DIR = get_string('EXAMS_SFTP_TEMP_DIR', '/tmp') -EXAMS_SFTP_HOST = get_string('EXAMS_SFTP_HOST', 'localhost') -EXAMS_SFTP_PORT = get_int('EXAMS_SFTP_PORT', 22) -EXAMS_SFTP_USERNAME = get_string('EXAMS_SFTP_USERNAME', None) -EXAMS_SFTP_PASSWORD = get_string('EXAMS_SFTP_PASSWORD', None) -EXAMS_SFTP_UPLOAD_DIR = get_string('EXAMS_SFTP_UPLOAD_DIR', '/results/topvue') -EXAMS_SFTP_RESULTS_DIR = get_string('EXAMS_SFTP_RESULTS_DIR', '/results') -EXAMS_SFTP_BACKOFF_BASE = get_string('EXAMS_SFTP_BACKOFF_BASE', '5') - -# Pearson SSO -EXAMS_SSO_PASSPHRASE = get_string('EXAMS_SSO_PASSPHRASE', None) -EXAMS_SSO_CLIENT_CODE = get_string('EXAMS_SSO_CLIENT_CODE', None) -EXAMS_SSO_URL = get_string('EXAMS_SSO_URL', None) - -# Exam request/response auditing -EXAMS_AUDIT_ENABLED = get_bool('EXAMS_AUDIT_ENABLED', False) -EXAMS_AUDIT_NACL_PUBLIC_KEY = get_string('EXAMS_AUDIT_NACL_PUBLIC_KEY', None) -EXAMS_AUDIT_S3_BUCKET = get_string('EXAMS_AUDIT_S3_BUCKET', None) -EXAMS_AUDIT_AWS_ACCESS_KEY_ID = get_string('EXAMS_AUDIT_AWS_ACCESS_KEY_ID', None) -EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY = get_string('EXAMS_AUDIT_AWS_SECRET_ACCESS_KEY', None) - # Open Discussions OPEN_DISCUSSIONS_API_USERNAME = get_string('OPEN_DISCUSSIONS_API_USERNAME', None) OPEN_DISCUSSIONS_BASE_URL = get_string('OPEN_DISCUSSIONS_BASE_URL', None) From 2a97fac8dce816f752c7fea529e1be2c50dc8b76 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 13:12:51 -0500 Subject: [PATCH 4/9] ui stuff --- static/js/actions/pearson.js | 45 --- static/js/actions/pearson_test.js | 23 -- .../js/components/dashboard/FinalExamCard.js | 324 ++---------------- static/js/containers/DashboardPage.js | 54 --- static/js/global_init.js | 2 - static/js/lib/pearson.js | 64 ---- static/js/lib/pearson_test.js | 65 ---- static/js/reducers/pearson.js | 43 --- static/js/reducers/pearson_test.js | 70 ---- ui/views.py | 2 - ui/views_test.py | 12 - 11 files changed, 24 insertions(+), 680 deletions(-) delete mode 100644 static/js/actions/pearson.js delete mode 100644 static/js/actions/pearson_test.js delete mode 100644 static/js/lib/pearson.js delete mode 100644 static/js/lib/pearson_test.js delete mode 100644 static/js/reducers/pearson.js delete mode 100644 static/js/reducers/pearson_test.js diff --git a/static/js/actions/pearson.js b/static/js/actions/pearson.js deleted file mode 100644 index 6d2e55d576..0000000000 --- a/static/js/actions/pearson.js +++ /dev/null @@ -1,45 +0,0 @@ -// @flow -import { createAction } from "redux-actions" -import type { Dispatch } from "redux" - -import { getPearsonSSO } from "../lib/api" - -export const REQUEST_GET_PEARSON_SSO_DIGEST = "REQUEST_GET_PEARSON_SSO_DIGEST" -export const requestGetPearsonSSODigest = createAction( - REQUEST_GET_PEARSON_SSO_DIGEST -) - -export const RECEIVE_GET_PEARSON_SSO_FAILURE = "RECEIVE_GET_PEARSON_SSO_FAILURE" -export const receiveGetPearsonSSOFailure = createAction( - RECEIVE_GET_PEARSON_SSO_FAILURE -) - -export const RECEIVE_GET_PEARSON_SSO_SUCCESS = "RECEIVE_GET_PEARSON_SSO_SUCCESS" -export const receiveGetPearsonSSOSuccess = createAction( - RECEIVE_GET_PEARSON_SSO_SUCCESS -) - -export function getPearsonSSODigest() { - return (dispatch: Dispatch) => { - dispatch(requestGetPearsonSSODigest()) - return getPearsonSSO().then( - ok => { - dispatch(receiveGetPearsonSSOSuccess()) - return Promise.resolve(ok) - }, - err => { - dispatch(receiveGetPearsonSSOFailure()) - return Promise.reject(err) - } - ) - } -} - -export const PEARSON_SSO_IN_PROGRESS = "PEARSON_SSO_IN_PROGRESS" -export const pearsonSSOInProgress = createAction(PEARSON_SSO_IN_PROGRESS) - -export const PEARSON_SSO_FAILURE = "PEARSON_SSO_FAILURE" -export const pearsonSSOFailure = createAction(PEARSON_SSO_FAILURE) - -export const SET_PEARSON_ERROR = "SET_PEARSON_ERROR" -export const setPearsonError = createAction(SET_PEARSON_ERROR) diff --git a/static/js/actions/pearson_test.js b/static/js/actions/pearson_test.js deleted file mode 100644 index 7f18666fde..0000000000 --- a/static/js/actions/pearson_test.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow -import { assertCreatedActionHelper } from "./test_util" -import { - REQUEST_GET_PEARSON_SSO_DIGEST, - requestGetPearsonSSODigest, - RECEIVE_GET_PEARSON_SSO_FAILURE, - receiveGetPearsonSSOFailure, - RECEIVE_GET_PEARSON_SSO_SUCCESS, - receiveGetPearsonSSOSuccess, - SET_PEARSON_ERROR, - setPearsonError -} from "./pearson" - -describe("generated pearson action helpers", () => { - it("should create all action helpers", () => { - [ - [requestGetPearsonSSODigest, REQUEST_GET_PEARSON_SSO_DIGEST], - [receiveGetPearsonSSOFailure, RECEIVE_GET_PEARSON_SSO_FAILURE], - [receiveGetPearsonSSOSuccess, RECEIVE_GET_PEARSON_SSO_SUCCESS], - [setPearsonError, SET_PEARSON_ERROR] - ].forEach(assertCreatedActionHelper) - }) -}) diff --git a/static/js/components/dashboard/FinalExamCard.js b/static/js/components/dashboard/FinalExamCard.js index 152a68162f..b001080d63 100644 --- a/static/js/components/dashboard/FinalExamCard.js +++ b/static/js/components/dashboard/FinalExamCard.js @@ -3,319 +3,43 @@ import React from "react" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" -import IconButton from "@material-ui/core/IconButton" -import _ from "lodash" -import R from "ramda" -import Dialog from "@material-ui/core/Dialog" -import DialogContent from "@material-ui/core/DialogContent" -import { dialogActions } from "../inputs/util" import type { Profile } from "../../flow/profileTypes" import type { Program } from "../../flow/programTypes" import type { UIState } from "../../reducers/ui" -import { - PEARSON_PROFILE_ABSENT, - PEARSON_PROFILE_SUCCESS, - PEARSON_PROFILE_IN_PROGRESS, - PEARSON_PROFILE_INVALID, - PEARSON_PROFILE_SCHEDULABLE -} from "../../constants" -import { FETCH_PROCESSING } from "../../actions" -import { getRomanizedName, getLocation } from "../../util/util" -import type { PearsonAPIState } from "../../reducers/pearson" -import DialogActions from "@material-ui/core/DialogActions" -import Icon from "@material-ui/core/Icon" - -const cardWrapper = (...children) => ( - - -
-
- -
-
-

Final Proctored Exam

-

- {`You must take a proctored exam for each course. Exams may be taken - at any `} - - authorized Pearson test center - - {`. Before you can take an exam, you have to pay for the course and - pass the online work.`} -

-
-
- - {children} -
-
-) - -const getPostalCode = profile => - profile.postal_code !== null ? {profile.postal_code} : null - -const accountCreated = (profile, navigateToProfile) => ( -
-
-
- Your Pearson Testing account has been created. Your information should - match the ID you bring to the test center. -
-
-
- {getRomanizedName(profile)} - {_.get(profile, ["address"])} - {getLocation(profile)} - {getPostalCode(profile)} - Phone: {_.get(profile, ["phone_number"])} -
- {editProfileButton(navigateToProfile)} -
-
-
-) - -const editProfileButton = fn => ( - - edit - -) - -const absentCard = () => - cardWrapper( -

- We will notify you when you become eligible to schedule course exams. -

- ) - -const successCard = (profile, navigateToProfile) => - cardWrapper( - accountCreated(profile, navigateToProfile), -
- We will notify you when you become eligible to schedule course exams. -
- ) - -const pendingCard = () => - cardWrapper( -
- Your updated information has been submitted to Pearson. Please check back - later. -
- ) - -const invalidCard = navigateToProfile => - cardWrapper( -
- {editProfileButton(navigateToProfile)} -
- You need to update your profile in - order to take a test at a Pearson Test center. -
-
- ) - -const isProcessing = R.compose( - R.any(R.equals(FETCH_PROCESSING)), - R.props(["getStatus", "postStatus"]), - R.defaultTo({}) -) - -const errorDisplay = pearson => - R.isNil(pearson.error) ? null : ( -
- {pearson.error} -
- ) - -const listItem = (text, index) =>
  • {text}
  • - -const schedulableCourseList = R.compose( - R.addIndex(R.map)(listItem), - R.map(R.prop("title")), - R.filter(R.propEq("can_schedule_exam", true)), - R.propOr([], "courses"), - R.defaultTo({}) -) - -const renderPearsonTOSDialog = (open, show, submitPearsonSSO, pearson) => ( - show(false)} - > - - -

    - You are being redirected to Pearson VUE’s website. -

    -
    -

    - You acknowledge that by clicking Continue, you will be leaving the - MITx MicroMasters website and going to a third-party website over - which MIT’s MITx does not have control, and that you accept the - Pearson VUE Business Group’s{" "} - - Terms of Service - - . MIT is not responsible for the content of third-party sites - hyper-linked from the Pearson VUE website, nor does MIT guarantee or - endorse the information, recommendations, products or services offered - on third-party sites. -

    -

    - By clicking Continue, you further acknowledge, understand, and agree - that: -

    -

    - - MIT makes no representations or warranties of any kind regarding the - facilities or services provided by Pearson VUE, including, but not - limited to, at any Pearson VUE authorized testing center. MIT hereby - disclaims all representations and warranties, express or implied, - including, without limitation, accuracy and fitness for a particular - purpose. To the extent permissible by law, you assume the risk of - injury or loss or damage to property while visiting any Pearson VUE - testing center for in-person testing for any MITx MicroMasters - course (the “Purpose”). You hereby release MIT and all of its - officers, directors, members, employees, volunteers, agents, - administrators, assigns, and contractors (collectively “Releasees”), - from any and all claims, demands, suits, judgments, damages, actions - and liabilities of every kind and nature whatsoever, that you may - suffer at any time as a result of the Purpose. - -

    -
    -

    - By clicking Continue, I agree to above Terms and Conditions. -

    -
    - - {dialogActions( - () => show(false), - submitPearsonSSO, - isProcessing(pearson), - "CONTINUE" - )} - -
    -) - -const schedulableCard = ( - profile, - program, - navigateToProfile, - pearson, - showPearsonTOSDialog, - open, - submitPearsonSSO -) => - cardWrapper( - renderPearsonTOSDialog( - open, - showPearsonTOSDialog, - submitPearsonSSO, - pearson - ), - accountCreated(profile, navigateToProfile), -
    - -
    - You are ready to schedule an exam for: -
      {schedulableCourseList(program)}
    -
    -
    , - errorDisplay(pearson) - ) type Props = { profile: Profile, program: Program, - navigateToProfile: () => void, - submitPearsonSSO: () => void, - pearson: PearsonAPIState, ui: UIState, - showPearsonTOSDialog: (open: boolean) => void } export default class FinalExamCard extends React.Component { render() { - const { - profile, - program, - navigateToProfile, - submitPearsonSSO, - pearson, - ui: { - dialogVisibility: { pearsonTOSDialogVisible = false } - }, - showPearsonTOSDialog - } = this.props - if (!SETTINGS.FEATURES.ENABLE_EDX_EXAMS) { - switch (program.pearson_exam_status) { - case PEARSON_PROFILE_ABSENT: - return absentCard() - case PEARSON_PROFILE_SUCCESS: - return successCard(profile, navigateToProfile) - case PEARSON_PROFILE_IN_PROGRESS: - return pendingCard() - case PEARSON_PROFILE_INVALID: - return invalidCard(navigateToProfile) - case PEARSON_PROFILE_SCHEDULABLE: - return schedulableCard( - profile, - program, - navigateToProfile, - pearson, - showPearsonTOSDialog, - pearsonTOSDialogVisible, - submitPearsonSSO - ) - default: - return null - } - } else { - return ( - - -
    -
    - -
    -
    -

    Final Proctored Exam

    -

    - To earn a certificate, you must take an online proctored exam - for each course. Before you can take a proctored exam, you - have to pay for the course and pass the online work. -

    -

    - Exams will be available online on edX.org. You may take the - exam at any time during the exam period. No advance scheduling - is required, but you should verify your account and complete - the exam onboarding during the one week onboarding period. -

    -
    + return ( + + +
    +
    + +
    +
    +

    Final Proctored Exam

    +

    + To earn a certificate, you must take an online proctored exam + for each course. Before you can take a proctored exam, you + have to pay for the course and pass the online work. +

    +

    + Exams will be available online on edX.org. You may take the + exam at any time during the exam period. No advance scheduling + is required, but you should verify your account and complete + the exam onboarding during the one week onboarding period. +

    - - - ) - } +
    +
    +
    + ) } } diff --git a/static/js/containers/DashboardPage.js b/static/js/containers/DashboardPage.js index e4ab7534f4..fee377c07e 100644 --- a/static/js/containers/DashboardPage.js +++ b/static/js/containers/DashboardPage.js @@ -93,15 +93,8 @@ import { currencyForCountry } from "../lib/currency" import DocsInstructionsDialog from "../components/DocsInstructionsDialog" import CouponNotificationDialog from "../components/CouponNotificationDialog" import CourseEnrollmentDialog from "../components/CourseEnrollmentDialog" -import { - getPearsonSSODigest, - pearsonSSOInProgress, - pearsonSSOFailure, - setPearsonError -} from "../actions/pearson" import { INCOME_DIALOG } from "./FinancialAidCalculator" import { processCheckout } from "./OrderSummaryPage" -import { generateSSOForm } from "../lib/pearson" import { getOwnDashboard, getOwnCoursePrices } from "../reducers/util" import { actions } from "../lib/redux_rest" import { wait } from "../util/util" @@ -126,13 +119,11 @@ import type { CouponsState } from "../reducers/coupons" import type { ProfileGetResult } from "../flow/profileTypes" import type { Course, CourseRun, Program } from "../flow/programTypes" import type { Coupon } from "../flow/couponTypes" -import type { PearsonAPIState } from "../reducers/pearson" import type { RestState } from "../flow/restTypes" import type { Post } from "../flow/discussionTypes" import PersonalCoursePriceDialog from "../components/dashboard/PersonalCoursePriceDialog" const isFinishedProcessing = R.contains(R.__, [FETCH_SUCCESS, FETCH_FAILURE]) -const PEARSON_TOS_DIALOG = "pearsonTOSDialogVisible" export type GradeType = "EDX_GRADE" | "EXAM_GRADE" export const EDX_GRADE: GradeType = "EDX_GRADE" @@ -158,7 +149,6 @@ class DashboardPage extends React.Component { orderReceipt: OrderReceiptState, financialAid: FinancialAidState, location: Object, - pearson: PearsonAPIState, openEmailComposer: (emailType: string, emailOpenParams: any) => void, discussionsFrontpage: RestState> } @@ -187,36 +177,6 @@ class DashboardPage extends React.Component { dispatch(clearCoupons()) } - submitPearsonSSO = () => { - const { - dispatch, - profile: { profile } - } = this.props - - dispatch(getPearsonSSODigest()) - .then(res => { - dispatch(pearsonSSOInProgress()) - const { session_timeout, sso_digest, timestamp, sso_redirect_url } = res - - const form = generateSSOForm( - profile.student_id, - timestamp, - session_timeout, - sso_digest, - sso_redirect_url - ) - form.submit() - }) - .catch(() => { - dispatch(pearsonSSOFailure()) - dispatch( - setPearsonError( - "It looks like we're experiencing an issue with scheduling, try again later." - ) - ) - }) - } - openCourseContactDialog = (course: Course, canContactCourseTeam: boolean) => { const { dispatch, openEmailComposer } = this.props if (canContactCourseTeam) { @@ -622,15 +582,6 @@ class DashboardPage extends React.Component { )(this.props) } - showPearsonTOSDialog = (open: boolean) => { - const { dispatch } = this.props - if (open) { - dispatch(showDialog(PEARSON_TOS_DIALOG)) - } else { - dispatch(hideDialog(PEARSON_TOS_DIALOG)) - } - } - setShowGradeDetailDialog = ( open: boolean, gradeType: GradeType, @@ -892,7 +843,6 @@ class DashboardPage extends React.Component { ui, financialAid, coupons, - pearson, discussionsFrontpage } = this.props const program = this.getCurrentlyEnrolledProgram() @@ -946,11 +896,8 @@ class DashboardPage extends React.Component { {financialAidCard} { orderReceipt: state.orderReceipt, financialAid: state.financialAid, coupons: state.coupons, - pearson: state.pearson, discussionsFrontpage: state.discussionsFrontpage } } diff --git a/static/js/global_init.js b/static/js/global_init.js index b5d24b44ac..a604babcb9 100644 --- a/static/js/global_init.js +++ b/static/js/global_init.js @@ -20,8 +20,6 @@ const _createSettings = () => ({ roles: [], support_email: "a_real_email@example.com", es_page_size: 40, - EXAMS_SSO_CLIENT_CODE: "foobarcode", - EXAMS_SSO_URL: "http://foo.bar/baz", FEATURES: { PROGRAM_LEARNERS: true, DISCUSSIONS_POST_UI: true, diff --git a/static/js/lib/pearson.js b/static/js/lib/pearson.js deleted file mode 100644 index 4bd86dae0d..0000000000 --- a/static/js/lib/pearson.js +++ /dev/null @@ -1,64 +0,0 @@ -// @flow -/* global SETTINGS:false */ -import R from "ramda" - -export const staticFormEntries: Array<[string, string]> = [] - -const ssoFormEntries = ( - studentId, - timestamp, - timeout, - ssoDigest, - ssoRedirectURL -) => { - const baseURL = ssoRedirectURL.replace(/\/$/, "") - if (R.isNil(SETTINGS.EXAMS_SSO_CLIENT_CODE)) { - throw new Error("EXAMS_SSO_CLIENT_CODE not configured") - } - return [ - ["ACTION", "scheduleExam"], - ["CLIENT_CODE", SETTINGS.EXAMS_SSO_CLIENT_CODE], - ["EXTERNAL_ERROR_URL", `${baseURL}/pearson/error`], - ["EXTERNAL_LOGOUT_URL", `${baseURL}/pearson/logout`], - ["EXTERNAL_RETURN_URL", `${baseURL}/pearson/success`], - ["EXTERNAL_TIMEOUT_URL", `${baseURL}/pearson/timeout`], - ["CLIENT_CANDIDATE_ID", String(studentId)], - ["EXTERNAL_PAGE_TIMESTAMP", String(timestamp)], - ["EXTERNAL_SESSION_TIMEOUT", String(timeout)], - ["EXTERNAL_AUTH_HASH", ssoDigest] - ] -} - -export const createFormInput = R.curry((form, [name, value]) => { - const node = document.createElement("input") - node.type = "hidden" - node.name = name - node.value = value - form.appendChild(node) -}) - -const createForm = () => { - const form = document.createElement("form") - // $FlowFixMe: flow disagrees - document.body.appendChild(form) - if (R.isNil(SETTINGS.EXAMS_SSO_URL)) { - throw new Error("EXAMS_SSO_URL not configured") - } - form.action = SETTINGS.EXAMS_SSO_URL - return form -} - -export const generateSSOForm = ( - studentId: number, - timestamp: number, - timeout: number, - ssoDigest: string, - ssoRedirectURL: string -) => { - const form = createForm() - R.map( - createFormInput(form), - ssoFormEntries(studentId, timestamp, timeout, ssoDigest, ssoRedirectURL) - ) - return form -} diff --git a/static/js/lib/pearson_test.js b/static/js/lib/pearson_test.js deleted file mode 100644 index a5b9a5fa49..0000000000 --- a/static/js/lib/pearson_test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { assert } from "chai" - -import { generateSSOForm, staticFormEntries, createFormInput } from "./pearson" - -describe("pearson library", () => { - describe("createFormInput", () => { - it("returns an input, given a form, name, and value", () => { - const form = document.createElement("form") - document.body.appendChild(form) - createFormInput(form, ["foo", "bar"]) - const input = form.querySelector("input") - assert.equal(input.name, "foo") - assert.equal(input.type, "hidden") - assert.equal(input.value, "bar") - }) - }) - - describe("generateSSOForm", () => { - const timestamp = Math.round(new Date().getTime() / 1000) - const hex = - "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2" - let form, inputs - - beforeEach(() => { - form = generateSSOForm( - 12, - timestamp, - timestamp + 1000, - hex, - "http://foo.bar/" - ) - inputs = [...form.querySelectorAll("input")].map(el => [ - el.name, - el.value - ]) - }) - - const checkInputs = entriesToFind => { - entriesToFind.forEach(([k1, v1]) => { - assert.notEqual(-1, inputs.find(([k2, v2]) => k1 === k2 && v1 === v2)) - }) - } - - it("should accept arguments and return a form", () => { - assert(form.nodeName === "FORM") - }) - - it("should contain all the static form entries", () => { - checkInputs(staticFormEntries) - }) - - it("should contain all the values it was passed", () => { - checkInputs( - ["CLIENT_CANDIDATE_ID", String(12)], - ["EXTERNAL_PAGE_TIMESTAMP", String(timestamp)], - ["EXTERNAL_SESSION_TIMEOUT", String(timestamp + 1000)], - ["EXTERNAL_AUTH_HASH", hex], - ["EXTERNAL_ERROR_URL", "http://foo.bar/pearson/error"], - ["EXTERNAL_LOGOUT_URL", "http://foo.bar/pearson/logout"], - ["EXTERNAL_RETURN_URL", "http://foo.bar/pearson/success"], - ["EXTERNAL_TIMEOUT_URL", "http://foo.bar/pearson/timeout"] - ) - }) - }) -}) diff --git a/static/js/reducers/pearson.js b/static/js/reducers/pearson.js deleted file mode 100644 index feb2c4f48a..0000000000 --- a/static/js/reducers/pearson.js +++ /dev/null @@ -1,43 +0,0 @@ -// @flow -import { - REQUEST_GET_PEARSON_SSO_DIGEST, - RECEIVE_GET_PEARSON_SSO_FAILURE, - RECEIVE_GET_PEARSON_SSO_SUCCESS, - PEARSON_SSO_IN_PROGRESS, - PEARSON_SSO_FAILURE, - SET_PEARSON_ERROR -} from "../actions/pearson" -import { FETCH_FAILURE, FETCH_PROCESSING, FETCH_SUCCESS } from "../actions" -import type { Action } from "../flow/reduxTypes" - -export const INITIAL_PEARSON_STATE = { - getStatus: null, - error: null -} - -export type PearsonAPIState = { - getStatus: ?string, - error: ?string -} - -export const pearson = ( - state: PearsonAPIState = INITIAL_PEARSON_STATE, - action: Action -) => { - switch (action.type) { - case REQUEST_GET_PEARSON_SSO_DIGEST: - return { ...state, getStatus: FETCH_PROCESSING } - case RECEIVE_GET_PEARSON_SSO_FAILURE: - return { ...state, getStatus: FETCH_FAILURE } - case RECEIVE_GET_PEARSON_SSO_SUCCESS: - return { ...state, getStatus: FETCH_SUCCESS } - case PEARSON_SSO_IN_PROGRESS: - return { ...state, postStatus: FETCH_PROCESSING } - case PEARSON_SSO_FAILURE: - return { ...state, postStatus: FETCH_FAILURE } - case SET_PEARSON_ERROR: - return { ...state, error: action.payload } - default: - return state - } -} diff --git a/static/js/reducers/pearson_test.js b/static/js/reducers/pearson_test.js deleted file mode 100644 index 22e6756848..0000000000 --- a/static/js/reducers/pearson_test.js +++ /dev/null @@ -1,70 +0,0 @@ -// @flow -import configureTestStore from "redux-asserts" -import sinon from "sinon" -import { assert } from "chai" - -import { FETCH_FAILURE, FETCH_PROCESSING, FETCH_SUCCESS } from "../actions" -import { - requestGetPearsonSSODigest, - receiveGetPearsonSSOFailure, - receiveGetPearsonSSOSuccess, - setPearsonError, - REQUEST_GET_PEARSON_SSO_DIGEST, - RECEIVE_GET_PEARSON_SSO_FAILURE, - RECEIVE_GET_PEARSON_SSO_SUCCESS, - SET_PEARSON_ERROR -} from "../actions/pearson" -import { INITIAL_PEARSON_STATE } from "./pearson" -import rootReducer from "../reducers" - -describe("pearson reducer", () => { - let sandbox, store, dispatchThen - - beforeEach(() => { - sandbox = sinon.sandbox.create() - store = configureTestStore(rootReducer) - dispatchThen = store.createDispatchThen(state => state.pearson) - }) - - afterEach(() => { - sandbox.restore() - }) - - it("should have some initial state", () => { - return dispatchThen({ type: "unknown" }, ["unknown"]).then(state => { - assert.deepEqual(state, INITIAL_PEARSON_STATE) - }) - }) - - it("should let you mark a request in flight", () => { - return dispatchThen(requestGetPearsonSSODigest(), [ - REQUEST_GET_PEARSON_SSO_DIGEST - ]).then(state => { - assert.deepEqual(state, { getStatus: FETCH_PROCESSING, error: null }) - }) - }) - - it("should let you mark a fetch error", () => { - return dispatchThen(receiveGetPearsonSSOFailure(), [ - RECEIVE_GET_PEARSON_SSO_FAILURE - ]).then(state => { - assert.deepEqual(state, { getStatus: FETCH_FAILURE, error: null }) - }) - }) - - it("should let you mark fetch success", () => { - return dispatchThen(receiveGetPearsonSSOSuccess(), [ - RECEIVE_GET_PEARSON_SSO_SUCCESS - ]).then(state => { - assert.deepEqual(state, { getStatus: FETCH_SUCCESS, error: null }) - }) - }) - - it("should let you set an error", () => { - return dispatchThen(setPearsonError("AN ERROR OH NO"), [ - SET_PEARSON_ERROR - ]).then(state => { - assert.deepEqual(state, { getStatus: null, error: "AN ERROR OH NO" }) - }) - }) -}) diff --git a/ui/views.py b/ui/views.py index 3f421d74ba..c957b4727f 100644 --- a/ui/views.py +++ b/ui/views.py @@ -58,8 +58,6 @@ def get(self, request, *args, **kwargs): "user": serialize_maybe_user(request.user), "es_page_size": settings.ELASTICSEARCH_DEFAULT_PAGE_SIZE, "public_path": public_path(request), - "EXAMS_SSO_CLIENT_CODE": settings.EXAMS_SSO_CLIENT_CODE, - "EXAMS_SSO_URL": settings.EXAMS_SSO_URL, "FEATURES": { "PROGRAM_LEARNERS": settings.FEATURES.get('PROGRAM_LEARNERS_ENABLED', False), "DISCUSSIONS_POST_UI": settings.FEATURES.get('OPEN_DISCUSSIONS_POST_UI', False), diff --git a/ui/views_test.py b/ui/views_test.py index 49b13b5dc6..6281e29bb6 100644 --- a/ui/views_test.py +++ b/ui/views_test.py @@ -294,8 +294,6 @@ def test_dashboard_settings(self): VERSION='0.0.1', RAVEN_CONFIG={'dsn': ''}, ELASTICSEARCH_DEFAULT_PAGE_SIZE=10, - EXAMS_SSO_CLIENT_CODE='itsacode', - EXAMS_SSO_URL='url', OPEN_DISCUSSIONS_REDIRECT_URL=open_discussions_redirect_url, ), patch('ui.templatetags.render_bundle._get_bundle') as get_bundle: resp = self.client.get(DASHBOARD_URL) @@ -329,8 +327,6 @@ def test_dashboard_settings(self): 'sentry_dsn': "", 'es_page_size': 10, 'public_path': '/static/bundles/', - 'EXAMS_SSO_CLIENT_CODE': 'itsacode', - 'EXAMS_SSO_URL': 'url', 'FEATURES': { 'PROGRAM_LEARNERS': False, 'DISCUSSIONS_POST_UI': False, @@ -748,8 +744,6 @@ def test_users_logged_in(self): VERSION='0.0.1', RAVEN_CONFIG={'dsn': ''}, ELASTICSEARCH_DEFAULT_PAGE_SIZE=10, - EXAMS_SSO_CLIENT_CODE='itsacode', - EXAMS_SSO_URL='url', OPEN_DISCUSSIONS_REDIRECT_URL=open_discussions_redirect_url ): # Mock has_permission so we don't worry about testing permissions here @@ -784,8 +778,6 @@ def test_users_logged_in(self): 'sentry_dsn': "", 'es_page_size': 10, 'public_path': '/static/bundles/', - 'EXAMS_SSO_CLIENT_CODE': 'itsacode', - 'EXAMS_SSO_URL': 'url', 'FEATURES': { 'PROGRAM_LEARNERS': False, 'DISCUSSIONS_POST_UI': False, @@ -830,8 +822,6 @@ def test_users_anonymous(self): VERSION='0.0.1', RAVEN_CONFIG={'dsn': ''}, ELASTICSEARCH_DEFAULT_PAGE_SIZE=10, - EXAMS_SSO_CLIENT_CODE='itsacode', - EXAMS_SSO_URL='url', OPEN_DISCUSSIONS_REDIRECT_URL=open_discussions_redirect_url ): # Mock has_permission so we don't worry about testing permissions here @@ -860,8 +850,6 @@ def test_users_anonymous(self): 'sentry_dsn': "", 'es_page_size': 10, 'public_path': '/static/bundles/', - 'EXAMS_SSO_CLIENT_CODE': 'itsacode', - 'EXAMS_SSO_URL': 'url', 'FEATURES': { 'PROGRAM_LEARNERS': False, 'DISCUSSIONS_POST_UI': False, From 29a36e3845ab477a59180b67b3d45666556e5e22 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 13:32:48 -0500 Subject: [PATCH 5/9] tests --- .../dashboard/FinalExamCard_test.js | 127 ------------------ 1 file changed, 127 deletions(-) diff --git a/static/js/components/dashboard/FinalExamCard_test.js b/static/js/components/dashboard/FinalExamCard_test.js index 24717d5c26..4e3f91d16b 100644 --- a/static/js/components/dashboard/FinalExamCard_test.js +++ b/static/js/components/dashboard/FinalExamCard_test.js @@ -7,35 +7,24 @@ import { assert } from "chai" import sinon from "sinon" import IconButton from "@material-ui/core/IconButton" import { MuiThemeProvider, createMuiTheme } from "@material-ui/core/styles" -import ReactTestUtils from "react-dom/test-utils" import FinalExamCard from "./FinalExamCard" import { DASHBOARD_RESPONSE, USER_PROFILE_RESPONSE } from "../../test_constants" import { PEARSON_PROFILE_ABSENT, - PEARSON_PROFILE_SUCCESS, - PEARSON_PROFILE_IN_PROGRESS, - PEARSON_PROFILE_INVALID, - PEARSON_PROFILE_SCHEDULABLE } from "../../constants" -import { INITIAL_PEARSON_STATE } from "../../reducers/pearson" import { INITIAL_UI_STATE } from "../../reducers/ui" import { stringStrip, getEl } from "../../util/test_utils" import type { Program } from "../../flow/programTypes" describe("FinalExamCard", () => { let sandbox - let navigateToProfileStub, submitPearsonSSOStub, showPearsonTOSDialogStub let props const profile = { ...USER_PROFILE_RESPONSE, preferred_name: "Preferred Name" } beforeEach(() => { sandbox = sinon.sandbox.create() - navigateToProfileStub = sandbox.stub() - submitPearsonSSOStub = sandbox.stub() - showPearsonTOSDialogStub = sandbox.stub() - SETTINGS.FEATURES.ENABLE_EDX_EXAMS = false const program: Program = (_.cloneDeep( DASHBOARD_RESPONSE.programs.find( program => program.pearson_exam_status !== undefined @@ -44,11 +33,7 @@ describe("FinalExamCard", () => { props = { profile: profile, program: program, - navigateToProfile: navigateToProfileStub, - submitPearsonSSO: submitPearsonSSOStub, - pearson: { ...INITIAL_PEARSON_STATE }, ui: { ...INITIAL_UI_STATE }, - showPearsonTOSDialog: showPearsonTOSDialogStub } }) @@ -78,116 +63,4 @@ pay for the course and pass the online work.` "Your Pearson Testing account has been created" ) }) - ;[PEARSON_PROFILE_SUCCESS, PEARSON_PROFILE_SCHEDULABLE].forEach(status => { - it(`should let the user know when the profile is ready when the status is ${status}`, () => { - props.program.pearson_exam_status = status - const cardText = stringStrip(renderCard(props).text()) - assert.include(cardText, "Your Pearson Testing account has been created") - }) - - it(`should include profile info if the profile is ${status}`, () => { - props.program.pearson_exam_status = status - const cardText = stringStrip(renderCard(props).text()) - assert.include(cardText, profile.address) - assert.include(cardText, profile.romanized_first_name) - assert.include(cardText, profile.romanized_last_name) - assert.notInclude(cardText, profile.preferred_name) - assert.include(cardText, stringStrip(profile.phone_number)) - assert.include(cardText, profile.state_or_territory) - }) - - it(`should show a button to edit if the profile is ${status}`, () => { - props.program.pearson_exam_status = status - const card = renderCard(props) - card.find(IconButton).simulate("click") - assert(navigateToProfileStub.called) - }) - }) - - it("should let the user know if the profile is in progress", () => { - props.program.pearson_exam_status = PEARSON_PROFILE_IN_PROGRESS - const card = renderCard(props) - assert.include( - stringStrip(card.text()), - "Your updated information has been submitted to Pearson Please check back later" - ) - }) - - it("should let the user know if the profile is invalid", () => { - props.program.pearson_exam_status = PEARSON_PROFILE_INVALID - const card = renderCard(props) - assert.include( - stringStrip(card.text()), - "You need to update your profile in order to take a test at a Pearson Test center" - ) - }) - - it("should show a schedule button when an exam is schedulable", () => { - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - const card = renderCard(props) - const button = card.find(".exam-button") - assert.equal(button.text(), "Schedule an exam") - button.simulate("click") - assert(showPearsonTOSDialogStub.called) - }) - - it("should show the titles of schedulable exams", () => { - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - const course = props.program.courses[0] - course.can_schedule_exam = true - const card = renderCard(props) - assert.include( - stringStrip(card.text()), - `You are ready to schedule an exam for ${stringStrip(course.title)}` - ) - }) - - it("should show a scheduling error, when there is one", () => { - props.pearson.error = "ERROR ERROR" - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - const card = renderCard(props) - assert.include(stringStrip(card.text()), "ERROR ERROR") - }) - - it("renders confirm pearson TOS dialog", () => { - props.ui.dialogVisibility = { pearsonTOSDialogVisible: true } - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - renderCard(props) - assert.include( - getEl(document, ".dialog-title").textContent, - "You are being redirected to Pearson VUE’s website." - ) - assert.include( - getEl(document, ".tos-container").textContent, - "You acknowledge that by clicking Continue, you will be leaving the MITx MicroMasters " + - "website and going to a third-party website over which MIT’s MITx does not have control, " - ) - assert.include( - getEl(document, ".tos-container").textContent, - " from any and all claims, demands, suits, judgments, damages, actions and liabilities " + - "of every kind and nature whatsoever, that you may suffer at any time as a result of the Purpose." - ) - assert.include( - getEl(document, ".attention").textContent, - "By clicking Continue, I agree to above Terms and Conditions." - ) - }) - - it("showToPearsonSiteDialog called in cancel", () => { - props.ui.dialogVisibility = { pearsonTOSDialogVisible: true } - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - renderCard(props) - ReactTestUtils.Simulate.click(getEl(getDialog(), ".cancel-button")) - assert.equal(showPearsonTOSDialogStub.callCount, 1) - }) - - it("submitPearsonSSO called in continue", () => { - props.ui.dialogVisibility = { pearsonTOSDialogVisible: true } - props.program.pearson_exam_status = PEARSON_PROFILE_SCHEDULABLE - renderCard(props) - const btnContinue = getEl(getDialog(), ".save-button") - assert.equal(btnContinue.textContent, "CONTINUE") - ReactTestUtils.Simulate.click(btnContinue) - assert.equal(submitPearsonSSOStub.callCount, 1) - }) }) From 566059fa1cb2d7f19af78f776a7ff2a347216098 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 16:01:18 -0500 Subject: [PATCH 6/9] exam card fixes --- .../js/components/dashboard/FinalExamCard.js | 21 ++++++++------- .../dashboard/FinalExamCard_test.js | 27 +++++++------------ static/js/factories/dashboard.js | 2 +- static/js/flow/programTypes.js | 2 +- static/js/global_init.js | 12 ++++----- static/js/test_constants.js | 8 +++--- 6 files changed, 32 insertions(+), 40 deletions(-) diff --git a/static/js/components/dashboard/FinalExamCard.js b/static/js/components/dashboard/FinalExamCard.js index b001080d63..7ccdae51ca 100644 --- a/static/js/components/dashboard/FinalExamCard.js +++ b/static/js/components/dashboard/FinalExamCard.js @@ -9,32 +9,33 @@ import type { Program } from "../../flow/programTypes" import type { UIState } from "../../reducers/ui" type Props = { - profile: Profile, - program: Program, - ui: UIState, + program: Program } export default class FinalExamCard extends React.Component { render() { + const { program } = this.props + if (program.exam_card_status === "") return null + return (
    - +

    Final Proctored Exam

    To earn a certificate, you must take an online proctored exam - for each course. Before you can take a proctored exam, you - have to pay for the course and pass the online work. + for each course. Before you can take a proctored exam, you have + to pay for the course and pass the online work.

    - Exams will be available online on edX.org. You may take the - exam at any time during the exam period. No advance scheduling - is required, but you should verify your account and complete - the exam onboarding during the one week onboarding period. + Exams will be available online on edX.org. You may take the exam + at any time during the exam period. No advance scheduling is + required, but you should verify your account and complete the + exam onboarding during the one week onboarding period.

    diff --git a/static/js/components/dashboard/FinalExamCard_test.js b/static/js/components/dashboard/FinalExamCard_test.js index 4e3f91d16b..cc4bb014e2 100644 --- a/static/js/components/dashboard/FinalExamCard_test.js +++ b/static/js/components/dashboard/FinalExamCard_test.js @@ -5,14 +5,11 @@ import React from "react" import { mount } from "enzyme" import { assert } from "chai" import sinon from "sinon" -import IconButton from "@material-ui/core/IconButton" import { MuiThemeProvider, createMuiTheme } from "@material-ui/core/styles" import FinalExamCard from "./FinalExamCard" import { DASHBOARD_RESPONSE, USER_PROFILE_RESPONSE } from "../../test_constants" -import { - PEARSON_PROFILE_ABSENT, -} from "../../constants" +import { PEARSON_PROFILE_ABSENT } from "../../constants" import { INITIAL_UI_STATE } from "../../reducers/ui" import { stringStrip, getEl } from "../../util/test_utils" import type { Program } from "../../flow/programTypes" @@ -27,21 +24,19 @@ describe("FinalExamCard", () => { sandbox = sinon.sandbox.create() const program: Program = (_.cloneDeep( DASHBOARD_RESPONSE.programs.find( - program => program.pearson_exam_status !== undefined + program => program.exam_card_status !== undefined ) ): any) props = { - profile: profile, - program: program, - ui: { ...INITIAL_UI_STATE }, + profile: profile, + program: program, + ui: { ...INITIAL_UI_STATE } } }) - const commonText = `You must take a proctored exam for each course. Exams may -be taken at any authorized Pearson test center. Before you can take an exam, you have to -pay for the course and pass the online work.` + const commonText = `To earn a certificate, you must take an online proctored exam for each + course. Before you can take a proctored exam, you have to pay for the course and pass the online work.` - const getDialog = () => document.querySelector(".dialog-to-pearson-site") const renderCard = props => mount( @@ -49,18 +44,14 @@ pay for the course and pass the online work.` ) - it("should not render when pearson_exam_status is empty", () => { + it("should not render when exam_card_status is empty", () => { const card = renderCard(props) assert.equal(card.html(), "") }) it("should just show a basic message if the profile is absent", () => { - props.program.pearson_exam_status = PEARSON_PROFILE_ABSENT + props.program.exam_card_status = PEARSON_PROFILE_ABSENT const card = renderCard(props) assert.include(stringStrip(card.text()), stringStrip(commonText)) - assert.notInclude( - stringStrip(card.text()), - "Your Pearson Testing account has been created" - ) }) }) diff --git a/static/js/factories/dashboard.js b/static/js/factories/dashboard.js index 7565ce1df4..4dd60277e7 100644 --- a/static/js/factories/dashboard.js +++ b/static/js/factories/dashboard.js @@ -129,7 +129,7 @@ export const makeProgram = (): Program => { min_possible_cost: 1000, id: newFinancialAidId() }, - pearson_exam_status: + exam_card_status: PEARSON_STATUSES[Math.floor(Math.random() * PEARSON_STATUSES.length)], grade_average: Math.floor(Math.random() * 100), certificate: "", diff --git a/static/js/flow/programTypes.js b/static/js/flow/programTypes.js index 6a92a94c91..0a4e9037d8 100644 --- a/static/js/flow/programTypes.js +++ b/static/js/flow/programTypes.js @@ -34,7 +34,7 @@ export type Program = { title: string, financial_aid_availability: boolean, financial_aid_user_info: FinancialAidUserInfo, - pearson_exam_status: string, + exam_card_status: string, grade_average: ?number, certificate: string, grade_records_url: string, diff --git a/static/js/global_init.js b/static/js/global_init.js index a604babcb9..d74fae2f5a 100644 --- a/static/js/global_init.js +++ b/static/js/global_init.js @@ -15,12 +15,12 @@ const _createSettings = () => ({ last_name: "Doe", preferred_name: "JD" }, - edx_base_url: "/edx/", - search_url: "/", - roles: [], - support_email: "a_real_email@example.com", - es_page_size: 40, - FEATURES: { + edx_base_url: "/edx/", + search_url: "/", + roles: [], + support_email: "a_real_email@example.com", + es_page_size: 40, + FEATURES: { PROGRAM_LEARNERS: true, DISCUSSIONS_POST_UI: true, DISCUSSIONS_CREATE_CHANNEL_UI: true, diff --git a/static/js/test_constants.js b/static/js/test_constants.js index 74bec1dd69..b183b7a968 100644 --- a/static/js/test_constants.js +++ b/static/js/test_constants.js @@ -1280,10 +1280,10 @@ export const DASHBOARD_RESPONSE: Dashboard = deepFreeze({ id: 2 }, { - title: "Last program", - description: "The last program", - pearson_exam_status: "", - courses: [ + title: "Last program", + description: "The last program", + exam_card_status: "", + courses: [ { id: 13, position_in_program: 0, From 4f90ea7b37b3e18c786bdea640337a43f671ff06 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 16:30:34 -0500 Subject: [PATCH 7/9] lint --- static/js/components/dashboard/FinalExamCard.js | 2 -- static/js/components/dashboard/FinalExamCard_test.js | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/static/js/components/dashboard/FinalExamCard.js b/static/js/components/dashboard/FinalExamCard.js index 7ccdae51ca..fecae47ab5 100644 --- a/static/js/components/dashboard/FinalExamCard.js +++ b/static/js/components/dashboard/FinalExamCard.js @@ -4,9 +4,7 @@ import React from "react" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" -import type { Profile } from "../../flow/profileTypes" import type { Program } from "../../flow/programTypes" -import type { UIState } from "../../reducers/ui" type Props = { program: Program diff --git a/static/js/components/dashboard/FinalExamCard_test.js b/static/js/components/dashboard/FinalExamCard_test.js index cc4bb014e2..61ea9a5f3e 100644 --- a/static/js/components/dashboard/FinalExamCard_test.js +++ b/static/js/components/dashboard/FinalExamCard_test.js @@ -11,17 +11,15 @@ import FinalExamCard from "./FinalExamCard" import { DASHBOARD_RESPONSE, USER_PROFILE_RESPONSE } from "../../test_constants" import { PEARSON_PROFILE_ABSENT } from "../../constants" import { INITIAL_UI_STATE } from "../../reducers/ui" -import { stringStrip, getEl } from "../../util/test_utils" +import { stringStrip } from "../../util/test_utils" import type { Program } from "../../flow/programTypes" describe("FinalExamCard", () => { - let sandbox let props const profile = { ...USER_PROFILE_RESPONSE, preferred_name: "Preferred Name" } beforeEach(() => { - sandbox = sinon.sandbox.create() const program: Program = (_.cloneDeep( DASHBOARD_RESPONSE.programs.find( program => program.exam_card_status !== undefined From 19622837d2fe6a826f298f9b59199fbd9413d394 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 17:09:42 -0500 Subject: [PATCH 8/9] import --- static/js/components/dashboard/FinalExamCard_test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/static/js/components/dashboard/FinalExamCard_test.js b/static/js/components/dashboard/FinalExamCard_test.js index 61ea9a5f3e..349b28a2ff 100644 --- a/static/js/components/dashboard/FinalExamCard_test.js +++ b/static/js/components/dashboard/FinalExamCard_test.js @@ -4,7 +4,6 @@ import _ from "lodash" import React from "react" import { mount } from "enzyme" import { assert } from "chai" -import sinon from "sinon" import { MuiThemeProvider, createMuiTheme } from "@material-ui/core/styles" import FinalExamCard from "./FinalExamCard" From daeea6b5931d2c244cc0d3a36a960a52fe6be5c9 Mon Sep 17 00:00:00 2001 From: annagav Date: Thu, 4 Feb 2021 17:30:05 -0500 Subject: [PATCH 9/9] flow --- static/js/reducers/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/js/reducers/index.js b/static/js/reducers/index.js index 3213a795bc..9c4b4321f3 100644 --- a/static/js/reducers/index.js +++ b/static/js/reducers/index.js @@ -37,7 +37,6 @@ import { financialAid } from "./financial_aid" import { documents } from "./documents" import { orderReceipt } from "./order_receipt" import { coupons } from "./coupons" -import { pearson } from "./pearson" import { channelDialog } from "./channel_dialog" import { dashboard } from "./dashboard" import { ALL_ERRORS_VISIBLE } from "../constants" @@ -217,7 +216,6 @@ export default combineReducers({ documents, orderReceipt, coupons, - pearson, channelDialog, ...reducers })