# -*- coding: utf-8 -*- """ Unit tests for instructor.api methods. """ import datetime import functools import io import json import random import shutil import tempfile import ddt import pytest import six from boto.exception import BotoServerError from django.conf import settings from django.contrib.auth.models import User from django.core import mail from django.core.files.uploadedfile import SimpleUploadedFile from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, TestCase from django.test.utils import override_settings from django.urls import reverse as django_reverse from django.utils.translation import ugettext as _ from edx_when.api import get_overrides_for_user from mock import Mock, NonCallableMock, patch from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import UsageKey from pytz import UTC from six import text_type, unichr # pylint: disable=redefined-builtin from six.moves import range, zip from testfixtures import LogCapture from bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.tests.factories import ( BetaTesterFactory, GlobalStaffFactory, InstructorFactory, StaffFactory, UserProfileFactory ) from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.certificates.api import generate_user_certificates from lms.djangoapps.certificates.models import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo from lms.djangoapps.instructor.views.api import ( _split_input_list, common_exceptions_400, generate_unique_password, require_finance_admin ) from lms.djangoapps.instructor_task.api_helper import ( AlreadyRunningError, QueueConnectionError, generate_already_running_error_message ) from openedx.core.djangoapps.course_date_signals.handlers import extract_dates from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.lib.teams_config import TeamsConfig from openedx.core.lib.xblock_utils import grade_histogram from shoppingcart.models import ( Coupon, CouponRedemption, CourseRegistrationCode, CourseRegistrationCodeInvoiceItem, Invoice, InvoiceTransaction, Order, PaidCourseRegistration, RegistrationCodeRedemption ) from student.models import ( ALLOWEDTOENROLL_TO_ENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED, ENROLLED_TO_ENROLLED, ENROLLED_TO_UNENROLLED, UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ENROLLED, UNENROLLED_TO_UNENROLLED, CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, NonExistentCourseError, get_retired_email_by_email, get_retired_username_by_username ) from student.roles import ( CourseBetaTesterRole, CourseDataResearcherRole, CourseFinanceAdminRole, CourseInstructorRole, CourseSalesAdminRole ) from student.tests.factories import AdminFactory, UserFactory from xmodule.fields import Date from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from .test_tools import msk_from_problem_urlname DATE_FIELD = Date() EXPECTED_CSV_HEADER = ( '"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",' '"customer_reference_number","internal_reference"' ) EXPECTED_COUPON_CSV_HEADER = u'"Coupon Code","Course Id","% Discount","Description","Expiration Date",' \ u'"Is Active","Code Redeemed Count","Total Discounted Seats","Total Discounted Amount"' # ddt data for test cases involving reports REPORTS_DATA = ( { 'report_type': 'grade', 'instructor_api_endpoint': 'calculate_grades_csv', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_calculate_grades_csv', 'extra_instructor_api_kwargs': {} }, { 'report_type': 'enrolled learner profile', 'instructor_api_endpoint': 'get_students_features', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_calculate_students_features_csv', 'extra_instructor_api_kwargs': {'csv': '/csv'} }, { 'report_type': 'detailed enrollment', 'instructor_api_endpoint': 'get_enrollment_report', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_detailed_enrollment_features_csv', 'extra_instructor_api_kwargs': {} }, { 'report_type': 'enrollment', 'instructor_api_endpoint': 'get_students_who_may_enroll', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_calculate_may_enroll_csv', 'extra_instructor_api_kwargs': {}, }, { 'report_type': 'proctored exam results', 'instructor_api_endpoint': 'get_proctored_exam_results', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_proctored_exam_results_report', 'extra_instructor_api_kwargs': {}, }, { 'report_type': 'problem responses', 'instructor_api_endpoint': 'get_problem_responses', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_calculate_problem_responses_csv', 'extra_instructor_api_kwargs': {}, } ) # ddt data for test cases involving executive summary report EXECUTIVE_SUMMARY_DATA = ( { 'report_type': 'executive summary', 'task_type': 'exec_summary_report', 'instructor_api_endpoint': 'get_exec_summary_report', 'task_api_endpoint': 'lms.djangoapps.instructor_task.api.submit_executive_summary_report', 'extra_instructor_api_kwargs': {} }, ) INSTRUCTOR_GET_ENDPOINTS = set([ 'get_anon_ids', 'get_coupon_codes', 'get_issued_certificates', 'get_sale_order_records', 'get_sale_records', ]) INSTRUCTOR_POST_ENDPOINTS = set([ 'active_registration_codes', 'add_users_to_cohorts', 'bulk_beta_modify_access', 'calculate_grades_csv', 'change_due_date', 'export_ora2_data', 'generate_registration_codes', 'get_enrollment_report', 'get_exec_summary_report', 'get_grading_config', 'get_problem_responses', 'get_proctored_exam_results', 'get_registration_codes', 'get_student_enrollment_status', 'get_student_progress_url', 'get_students_features', 'get_students_who_may_enroll', 'get_user_invoice_preference', 'list_background_email_tasks', 'list_course_role_members', 'list_email_content', 'list_entrance_exam_instructor_tasks', 'list_financial_report_downloads', 'list_forum_members', 'list_instructor_tasks', 'list_report_downloads', 'mark_student_can_skip_entrance_exam', 'modify_access', 'register_and_enroll_students', 'rescore_entrance_exam', 'rescore_problem', 'reset_due_date', 'reset_student_attempts', 'reset_student_attempts_for_entrance_exam', 'sale_validation', 'show_student_extensions', 'show_unit_extensions', 'send_email', 'spent_registration_codes', 'students_update_enrollment', 'update_forum_role_membership', 'override_problem_score', ]) def reverse(endpoint, args=None, kwargs=None, is_dashboard_endpoint=True): """ Simple wrapper of Django's reverse that first ensures that we have declared each endpoint under test. Arguments: args: The args to be passed through to reverse. endpoint: The endpoint to be passed through to reverse. kwargs: The kwargs to be passed through to reverse. is_dashboard_endpoint: True if this is an instructor dashboard endpoint that must be declared in the INSTRUCTOR_GET_ENDPOINTS or INSTRUCTOR_GET_ENDPOINTS sets, or false otherwise. Returns: The return of Django's reverse function """ is_endpoint_declared = endpoint in INSTRUCTOR_GET_ENDPOINTS or endpoint in INSTRUCTOR_POST_ENDPOINTS if is_dashboard_endpoint and is_endpoint_declared is False: # Verify that all endpoints are declared so we can ensure they are # properly validated elsewhere. raise ValueError(u"The endpoint {} must be declared in ENDPOINTS before use.".format(endpoint)) return django_reverse(endpoint, args=args, kwargs=kwargs) @common_exceptions_400 def view_success(request): # pylint: disable=unused-argument "A dummy view for testing that returns a simple HTTP response" return HttpResponse('success') @common_exceptions_400 def view_user_doesnotexist(request): # pylint: disable=unused-argument "A dummy view that raises a User.DoesNotExist exception" raise User.DoesNotExist() @common_exceptions_400 def view_alreadyrunningerror(request): # pylint: disable=unused-argument "A dummy view that raises an AlreadyRunningError exception" raise AlreadyRunningError() @common_exceptions_400 def view_alreadyrunningerror_unicode(request): # pylint: disable=unused-argument """ A dummy view that raises an AlreadyRunningError exception with unicode message """ raise AlreadyRunningError(u'Text with unicode chárácters') @common_exceptions_400 def view_queue_connection_error(request): # pylint: disable=unused-argument """ A dummy view that raises a QueueConnectionError exception. """ raise QueueConnectionError() @ddt.ddt class TestCommonExceptions400(TestCase): """ Testing the common_exceptions_400 decorator. """ def setUp(self): super(TestCommonExceptions400, self).setUp() self.request = Mock(spec=HttpRequest) self.request.META = {} def test_happy_path(self): resp = view_success(self.request) self.assertEqual(resp.status_code, 200) def test_user_doesnotexist(self): self.request.is_ajax.return_value = False resp = view_user_doesnotexist(self.request) # pylint: disable=assignment-from-no-return self.assertContains(resp, "User does not exist", status_code=400) def test_user_doesnotexist_ajax(self): self.request.is_ajax.return_value = True resp = view_user_doesnotexist(self.request) # pylint: disable=assignment-from-no-return self.assertContains(resp, "User does not exist", status_code=400) @ddt.data(True, False) def test_alreadyrunningerror(self, is_ajax): self.request.is_ajax.return_value = is_ajax resp = view_alreadyrunningerror(self.request) # pylint: disable=assignment-from-no-return self.assertContains(resp, "Requested task is already running", status_code=400) @ddt.data(True, False) def test_alreadyrunningerror_with_unicode(self, is_ajax): self.request.is_ajax.return_value = is_ajax resp = view_alreadyrunningerror_unicode(self.request) # pylint: disable=assignment-from-no-return self.assertContains( resp, u'Text with unicode chárácters', status_code=400, ) @ddt.data(True, False) def test_queue_connection_error(self, is_ajax): """ Tests that QueueConnectionError exception is handled in common_exception_400. """ self.request.is_ajax.return_value = is_ajax resp = view_queue_connection_error(self.request) # pylint: disable=assignment-from-no-return self.assertContains( resp, 'Error occured. Please try again later', status_code=400, ) @ddt.ddt class TestEndpointHttpMethods(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Ensure that users can make GET requests against endpoints that allow GET, and not against those that don't allow GET. """ @classmethod def setUpClass(cls): """ Set up test course. """ super(TestEndpointHttpMethods, cls).setUpClass() cls.course = CourseFactory.create() def setUp(self): """ Set up global staff role so authorization will not fail. """ super(TestEndpointHttpMethods, self).setUp() global_user = GlobalStaffFactory() self.client.login(username=global_user.username, password='test') @ddt.data(*INSTRUCTOR_POST_ENDPOINTS) def test_endpoints_reject_get(self, data): """ Tests that POST endpoints are rejected with 405 when using GET. """ url = reverse(data, kwargs={'course_id': text_type(self.course.id)}) response = self.client.get(url) self.assertEqual( response.status_code, 405, u"Endpoint {} returned status code {} instead of a 405. It should not allow GET.".format( data, response.status_code ) ) @ddt.data(*INSTRUCTOR_GET_ENDPOINTS) def test_endpoints_accept_get(self, data): """ Tests that GET endpoints are not rejected with 405 when using GET. """ url = reverse(data, kwargs={'course_id': text_type(self.course.id)}) response = self.client.get(url) self.assertNotEqual( response.status_code, 405, u"Endpoint {} returned status code 405 where it shouldn't, since it should allow GET.".format( data ) ) @patch('bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Ensure that users cannot access endpoints they shouldn't be able to. """ @classmethod def setUpClass(cls): super(TestInstructorAPIDenyLevels, cls).setUpClass() cls.course = CourseFactory.create() cls.chapter = ItemFactory.create( parent=cls.course, category='chapter', display_name="Chapter", publish_item=True, start=datetime.datetime(2018, 3, 10, tzinfo=UTC), ) cls.sequential = ItemFactory.create( parent=cls.chapter, category='sequential', display_name="Lesson", publish_item=True, start=datetime.datetime(2018, 3, 10, tzinfo=UTC), metadata={'graded': True, 'format': 'Homework'}, ) cls.vertical = ItemFactory.create( parent=cls.sequential, category='vertical', display_name='Subsection', publish_item=True, start=datetime.datetime(2018, 3, 10, tzinfo=UTC), ) cls.problem = ItemFactory.create( category="problem", parent=cls.vertical, display_name="A Problem Block", weight=1, publish_item=True, ) cls.problem_urlname = text_type(cls.problem.location) BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False) @classmethod def tearDownClass(cls): super(TestInstructorAPIDenyLevels, cls).tearDownClass() BulkEmailFlag.objects.all().delete() def setUp(self): super(TestInstructorAPIDenyLevels, self).setUp() self.user = UserFactory.create() CourseEnrollment.enroll(self.user, self.course.id) _module = StudentModule.objects.create( student=self.user, course_id=self.course.id, module_state_key=self.problem.location, state=json.dumps({'attempts': 10}), ) # Endpoints that only Staff or Instructors can access self.staff_level_endpoints = [ ('students_update_enrollment', {'identifiers': 'foo@example.org', 'action': 'enroll'}), ('get_grading_config', {}), ('get_students_features', {}), ('get_student_progress_url', {'unique_student_identifier': self.user.username}), ('update_forum_role_membership', {'unique_student_identifier': self.user.email, 'rolename': 'Moderator', 'action': 'allow'}), ('list_forum_members', {'rolename': FORUM_ROLE_COMMUNITY_TA}), ('send_email', {'send_to': '["staff"]', 'subject': 'test', 'message': 'asdf'}), ('list_instructor_tasks', {}), ('list_background_email_tasks', {}), ('list_report_downloads', {}), ('list_financial_report_downloads', {}), ('calculate_grades_csv', {}), ('get_students_features', {}), ('get_enrollment_report', {}), ('get_students_who_may_enroll', {}), ('get_exec_summary_report', {}), ('get_proctored_exam_results', {}), ('get_problem_responses', {}), ('export_ora2_data', {}), ('rescore_problem', {'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}), ('override_problem_score', {'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email, 'score': 0}), ('reset_student_attempts', {'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}), ( 'reset_student_attempts', { 'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email, 'delete_module': True } ), ] # Endpoints that only Instructors can access self.instructor_level_endpoints = [ ('bulk_beta_modify_access', {'identifiers': 'foo@example.org', 'action': 'add'}), ('modify_access', {'unique_student_identifier': self.user.email, 'rolename': 'beta', 'action': 'allow'}), ('list_course_role_members', {'rolename': 'beta'}), ('rescore_problem', {'problem_to_reset': self.problem_urlname, 'all_students': True}), ('reset_student_attempts', {'problem_to_reset': self.problem_urlname, 'all_students': True}), ] def _access_endpoint(self, endpoint, args, status_code, msg): """ Asserts that accessing the given `endpoint` gets a response of `status_code`. endpoint: string, endpoint for instructor dash API args: dict, kwargs for `reverse` call status_code: expected HTTP status code response msg: message to display if assertion fails. """ url = reverse(endpoint, kwargs={'course_id': text_type(self.course.id)}) if endpoint in INSTRUCTOR_GET_ENDPOINTS: response = self.client.get(url, args) else: response = self.client.post(url, args) self.assertEqual( response.status_code, status_code, msg=msg ) def test_student_level(self): """ Ensure that an enrolled student can't access staff or instructor endpoints. """ self.client.login(username=self.user.username, password='test') for endpoint, args in self.staff_level_endpoints: self._access_endpoint( endpoint, args, 403, "Student should not be allowed to access endpoint " + endpoint ) for endpoint, args in self.instructor_level_endpoints: self._access_endpoint( endpoint, args, 403, "Student should not be allowed to access endpoint " + endpoint ) def _access_problem_responses_endpoint(self, msg): """ Access endpoint for problem responses report, ensuring that UsageKey.from_string returns a problem key that the endpoint can work with. msg: message to display if assertion fails. """ mock_problem_key = NonCallableMock(return_value=u'') mock_problem_key.course_key = self.course.id with patch.object(UsageKey, 'from_string') as patched_method: patched_method.return_value = mock_problem_key self._access_endpoint('get_problem_responses', {}, 200, msg) def test_staff_level(self): """ Ensure that a staff member can't access instructor endpoints. """ staff_member = StaffFactory(course_key=self.course.id) CourseEnrollment.enroll(staff_member, self.course.id) CourseFinanceAdminRole(self.course.id).add_users(staff_member) CourseDataResearcherRole(self.course.id).add_users(staff_member) self.client.login(username=staff_member.username, password='test') # Try to promote to forums admin - not working # update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow') for endpoint, args in self.staff_level_endpoints: expected_status = 200 # TODO: make these work if endpoint in ['update_forum_role_membership', 'list_forum_members']: continue elif endpoint == 'get_problem_responses': self._access_problem_responses_endpoint( "Staff member should be allowed to access endpoint " + endpoint ) continue self._access_endpoint( endpoint, args, expected_status, "Staff member should be allowed to access endpoint " + endpoint ) for endpoint, args in self.instructor_level_endpoints: self._access_endpoint( endpoint, args, 403, "Staff member should not be allowed to access endpoint " + endpoint ) def test_instructor_level(self): """ Ensure that an instructor member can access all endpoints. """ inst = InstructorFactory(course_key=self.course.id) CourseEnrollment.enroll(inst, self.course.id) CourseFinanceAdminRole(self.course.id).add_users(inst) CourseDataResearcherRole(self.course.id).add_users(inst) self.client.login(username=inst.username, password='test') for endpoint, args in self.staff_level_endpoints: expected_status = 200 # TODO: make these work if endpoint in ['update_forum_role_membership']: continue elif endpoint == 'get_problem_responses': self._access_problem_responses_endpoint( "Instructor should be allowed to access endpoint " + endpoint ) continue self._access_endpoint( endpoint, args, expected_status, "Instructor should be allowed to access endpoint " + endpoint ) for endpoint, args in self.instructor_level_endpoints: expected_status = 200 self._access_endpoint( endpoint, args, expected_status, "Instructor should be allowed to access endpoint " + endpoint ) @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test Bulk account creation and enrollment from csv file """ @classmethod def setUpClass(cls): super(TestInstructorAPIBulkAccountCreationAndEnrollment, cls).setUpClass() cls.course = CourseFactory.create() # Create a course with mode 'audit' cls.audit_course = CourseFactory.create() CourseModeFactory.create(course_id=cls.audit_course.id, mode_slug=CourseMode.AUDIT) cls.url = reverse( 'register_and_enroll_students', kwargs={'course_id': text_type(cls.course.id)} ) cls.audit_course_url = reverse( 'register_and_enroll_students', kwargs={'course_id': text_type(cls.audit_course.id)} ) def setUp(self): super(TestInstructorAPIBulkAccountCreationAndEnrollment, self).setUp() # Create a course with mode 'honor' and with price self.white_label_course = CourseFactory.create() self.white_label_course_mode = CourseModeFactory.create( course_id=self.white_label_course.id, mode_slug=CourseMode.HONOR, min_price=10, suggested_prices='10', ) self.white_label_course_url = reverse( 'register_and_enroll_students', kwargs={'course_id': text_type(self.white_label_course.id)} ) self.request = RequestFactory().request() self.instructor = InstructorFactory(course_key=self.course.id) self.audit_course_instructor = InstructorFactory(course_key=self.audit_course.id) self.white_label_course_instructor = InstructorFactory(course_key=self.white_label_course.id) self.client.login(username=self.instructor.username, password='test') self.not_enrolled_student = UserFactory( username='NotEnrolledStudent', email='nonenrolled@test.com', first_name='NotEnrolled', last_name='Student' ) @patch('lms.djangoapps.instructor.views.api.log.info') def test_account_creation_and_enrollment_with_csv(self, info_log): """ Happy path test to create a single new user """ csv_content = b"test_student@example.com,test_student_1,tester1,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # test the log for email that's send to new created user. info_log.assert_called_with(u'email sent to new created user at %s', 'test_student@example.com') @patch('lms.djangoapps.instructor.views.api.log.info') def test_account_creation_and_enrollment_with_csv_with_blank_lines(self, info_log): """ Happy path test to create a single new user """ csv_content = b"\ntest_student@example.com,test_student_1,tester1,USA\n\n" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # test the log for email that's send to new created user. info_log.assert_called_with(u'email sent to new created user at %s', 'test_student@example.com') @patch('lms.djangoapps.instructor.views.api.log.info') def test_email_and_username_already_exist(self, info_log): """ If the email address and username already exists and the user is enrolled in the course, do nothing (including no email gets sent out) """ csv_content = b"test_student@example.com,test_student_1,tester1,USA\n" \ b"test_student@example.com,test_student_1,tester2,US" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # test the log for email that's send to new created user. info_log.assert_called_with( u"user already exists with username '%s' and email '%s'", 'test_student_1', 'test_student@example.com' ) def test_file_upload_type_not_csv(self): """ Try uploading some non-CSV file and verify that it is rejected """ uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['general_errors']), 0) self.assertEqual( data['general_errors'][0]['response'], 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.' ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) def test_bad_file_upload_type(self): """ Try uploading some non-CSV file and verify that it is rejected """ uploaded_file = SimpleUploadedFile("temp.csv", io.BytesIO(b"some initial binary data: \x00\x01").read()) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['general_errors']), 0) self.assertEqual(data['general_errors'][0]['response'], 'Could not read uploaded file.') manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) def test_insufficient_data(self): """ Try uploading a CSV file which does not have the exact four columns of data """ csv_content = b"test_student@example.com,test_student_1\n" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 1) self.assertEqual(data['general_errors'][0]['response'], 'Data in row #1 must have exactly four columns: email, username, full name, and country') manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) def test_invalid_email_in_csv(self): """ Test failure case of a poorly formatted email field """ csv_content = b"test_student.example.com,test_student_1,tester1,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) data = json.loads(response.content.decode('utf-8')) self.assertEqual(response.status_code, 200) self.assertNotEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) self.assertEqual(data['row_errors'][0]['response'], u'Invalid email {0}.'.format('test_student.example.com')) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) @patch('lms.djangoapps.instructor.views.api.log.info') def test_csv_user_exist_and_not_enrolled(self, info_log): """ If the email address and username already exists and the user is not enrolled in the course, enrolled him/her and iterate to next one. """ csv_content = b"nonenrolled@test.com,NotEnrolledStudent,tester1,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) info_log.assert_called_with( u'user %s enrolled in the course %s', u'NotEnrolledStudent', self.course.id ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertTrue(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) def test_user_with_already_existing_email_in_csv(self): """ If the email address already exists, but the username is different, assume it is the correct user and just register the user in the course. """ csv_content = b"test_student@example.com,test_student_1,tester1,USA\n" \ b"test_student@example.com,test_student_2,tester2,US" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) warning_message = u'An account with email {email} exists but the provided username {username} ' \ u'is different. Enrolling anyway with {email}.'.format(email='test_student@example.com', username='test_student_2') self.assertNotEqual(len(data['warnings']), 0) self.assertEqual(data['warnings'][0]['response'], warning_message) user = User.objects.get(email='test_student@example.com') self.assertTrue(CourseEnrollment.is_enrolled(user, self.course.id)) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertTrue(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) def test_user_with_retired_email_in_csv(self): """ If the CSV contains email addresses which correspond with users which have already been retired, confirm that the attempt returns invalid email errors. """ # This email address is re-used to create a retired account and another account. conflicting_email = 'test_student@example.com' # prep a retired user user = UserFactory.create(username='old_test_student', email=conflicting_email) user.email = get_retired_email_by_email(user.email) user.username = get_retired_username_by_username(user.username) user.is_active = False user.save() csv_content = "{email},{username},tester,USA".format(email=conflicting_email, username='new_test_student') uploaded_file = SimpleUploadedFile("temp.csv", six.b(csv_content)) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['row_errors']), 0) self.assertEqual( data['row_errors'][0]['response'], u'Invalid email {email}.'.format(email=conflicting_email) ) self.assertFalse(User.objects.filter(email=conflicting_email).exists()) def test_user_with_already_existing_username_in_csv(self): """ If the username already exists (but not the email), assume it is a different user and fail to create the new account. """ csv_content = b"test_student1@example.com,test_student_1,tester1,USA\n" \ b"test_student2@example.com,test_student_1,tester2,US" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['row_errors']), 0) self.assertEqual(data['row_errors'][0]['response'], u'Username {user} already exists.'.format(user='test_student_1')) def test_csv_file_not_attached(self): """ Test when the user does not attach a file """ csv_content = b"test_student1@example.com,test_student_1,tester1,USA\n" \ b"test_student2@example.com,test_student_1,tester2,US" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'file_not_found': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['general_errors']), 0) self.assertEqual(data['general_errors'][0]['response'], 'File is not attached.') manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) def test_raising_exception_in_auto_registration_and_enrollment_case(self): """ Test that exceptions are handled well """ csv_content = b"test_student1@example.com,test_student_1,tester1,USA\n" \ b"test_student2@example.com,test_student_1,tester2,US" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) with patch('lms.djangoapps.instructor.views.api.create_manual_course_enrollment') as mock: mock.side_effect = NonExistentCourseError() response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['row_errors']), 0) self.assertEqual(data['row_errors'][0]['response'], 'NonExistentCourseError') manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) def test_generate_unique_password(self): """ generate_unique_password should generate a unique password string that excludes certain characters. """ password = generate_unique_password([], 12) self.assertEqual(len(password), 12) for letter in password: self.assertNotIn(letter, 'aAeEiIoOuU1l') def test_users_created_and_enrolled_successfully_if_others_fail(self): # prep a retired user user = UserFactory.create(username='old_test_student_4', email='test_student4@example.com') user.email = get_retired_email_by_email(user.email) user.username = get_retired_username_by_username(user.username) user.is_active = False user.save() csv_content = b"test_student1@example.com,test_student_1,tester1,USA\n" \ b"test_student3@example.com,test_student_1,tester3,CA\n" \ b"test_student4@example.com,test_student_4,tester4,USA\n" \ b"test_student2@example.com,test_student_2,tester2,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertNotEqual(len(data['row_errors']), 0) self.assertEqual( data['row_errors'][0]['response'], u'Username {user} already exists.'.format(user='test_student_1') ) self.assertEqual( data['row_errors'][1]['response'], u'Invalid email {email}.'.format(email='test_student4@example.com') ) self.assertTrue(User.objects.filter(username='test_student_1', email='test_student1@example.com').exists()) self.assertTrue(User.objects.filter(username='test_student_2', email='test_student2@example.com').exists()) self.assertFalse(User.objects.filter(email='test_student3@example.com').exists()) self.assertFalse(User.objects.filter(email='test_student4@example.com').exists()) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 2) @patch('lms.djangoapps.instructor.views.api', 'generate_random_string', Mock(side_effect=['first', 'first', 'second'])) def test_generate_unique_password_no_reuse(self): """ generate_unique_password should generate a unique password string that hasn't been generated before. """ generated_password = ['first'] password = generate_unique_password(generated_password, 12) self.assertNotEqual(password, 'first') @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': False}) def test_allow_automated_signups_flag_not_set(self): csv_content = b"test_student1@example.com,test_student_1,tester1,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 403) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 0) @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) def test_audit_enrollment_mode(self): """ Test that enrollment mode for audit courses (paid courses) is 'audit'. """ # Login Audit Course instructor self.client.login(username=self.audit_course_instructor.username, password='test') csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.audit_course_url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # Verify enrollment modes to be 'audit' for enrollment in manual_enrollments: self.assertEqual(enrollment.enrollment.mode, CourseMode.AUDIT) @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) def test_honor_enrollment_mode(self): """ Test that enrollment mode for unpaid honor courses is 'honor'. """ # Remove white label course price self.white_label_course_mode.min_price = 0 self.white_label_course_mode.suggested_prices = '' self.white_label_course_mode.save() # Login Audit Course instructor self.client.login(username=self.white_label_course_instructor.username, password='test') csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.white_label_course_url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # Verify enrollment modes to be 'honor' for enrollment in manual_enrollments: self.assertEqual(enrollment.enrollment.mode, CourseMode.HONOR) @patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) def test_default_shopping_cart_enrollment_mode_for_white_label(self): """ Test that enrollment mode for white label courses (paid courses) is DEFAULT_SHOPPINGCART_MODE_SLUG. """ # Login white label course instructor self.client.login(username=self.white_label_course_instructor.username, password='test') csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) response = self.client.post(self.white_label_course_url, {'students_list': uploaded_file}) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode('utf-8')) self.assertEqual(len(data['row_errors']), 0) self.assertEqual(len(data['warnings']), 0) self.assertEqual(len(data['general_errors']), 0) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) # Verify enrollment modes to be CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG for enrollment in manual_enrollments: self.assertEqual(enrollment.enrollment.mode, CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG) @ddt.ddt class TestInstructorAPIEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test enrollment modification endpoint. This test does NOT exhaustively test state changes, that is the job of test_enrollment. This tests the response and action switch. """ @classmethod def setUpClass(cls): super(TestInstructorAPIEnrollment, cls).setUpClass() cls.course = CourseFactory.create() # Email URL values cls.site_name = configuration_helpers.get_value( 'SITE_NAME', settings.SITE_NAME ) cls.about_path = '/courses/{}/about'.format(cls.course.id) cls.course_path = '/courses/{}/'.format(cls.course.id) def setUp(self): super(TestInstructorAPIEnrollment, self).setUp() self.request = RequestFactory().request() self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student') CourseEnrollment.enroll( self.enrolled_student, self.course.id ) self.notenrolled_student = UserFactory(username='NotEnrolledStudent', first_name='NotEnrolled', last_name='Student') # Create invited, but not registered, user cea = CourseEnrollmentAllowed(email='robot-allowed@robot.org', course_id=self.course.id) cea.save() self.allowed_email = 'robot-allowed@robot.org' self.notregistered_email = 'robot-not-an-email-yet@robot.org' self.assertEqual(User.objects.filter(email=self.notregistered_email).count(), 0) # uncomment to enable enable printing of large diffs # from failed assertions in the event of a test failure. # (comment because pylint C0103(invalid-name)) # self.maxDiff = None def test_missing_params(self): """ Test missing all query parameters. """ url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url) self.assertEqual(response.status_code, 400) def test_bad_action(self): """ Test with an invalid action. """ action = 'robot-not-an-action' url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.enrolled_student.email, 'action': action}) self.assertEqual(response.status_code, 400) def test_invalid_email(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': 'percivaloctavius@', 'action': 'enroll', 'email_students': False}) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "enroll", 'auto_enroll': False, "results": [ { "identifier": 'percivaloctavius@', "invalidIdentifier": True, } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_invalid_username(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': 'percivaloctavius', 'action': 'enroll', 'email_students': False}) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "enroll", 'auto_enroll': False, "results": [ { "identifier": 'percivaloctavius', "invalidIdentifier": True, } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_enroll_with_username(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.username, 'action': 'enroll', 'email_students': False}) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "enroll", 'auto_enroll': False, "results": [ { "identifier": self.notenrolled_student.username, "before": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": False, }, "after": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_enroll_without_email(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.email, 'action': 'enroll', 'email_students': False}) print(u"type(self.notenrolled_student.email): {}".format(type(self.notenrolled_student.email))) self.assertEqual(response.status_code, 200) # test that the user is now enrolled user = User.objects.get(email=self.notenrolled_student.email) self.assertTrue(CourseEnrollment.is_enrolled(user, self.course.id)) # test the response data expected = { "action": "enroll", "auto_enroll": False, "results": [ { "identifier": self.notenrolled_student.email, "before": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": False, }, "after": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 0) @ddt.data('http', 'https') def test_enroll_with_email(self, protocol): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notenrolled_student.email, 'action': 'enroll', 'email_students': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) print(u"type(self.notenrolled_student.email): {}".format(type(self.notenrolled_student.email))) self.assertEqual(response.status_code, 200) # test that the user is now enrolled user = User.objects.get(email=self.notenrolled_student.email) self.assertTrue(CourseEnrollment.is_enrolled(user, self.course.id)) # test the response data expected = { "action": "enroll", "auto_enroll": False, "results": [ { "identifier": self.notenrolled_student.email, "before": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": False, }, "after": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": False, } } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been enrolled in {}'.format(self.course.display_name) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith('Dear NotEnrolled Student\n\n') for body in [text_body, html_body]: self.assertIn(u'You have been enrolled in {course_name} at edx.org by a member of the course staff.'.format( course_name=self.course.display_name, ), body) self.assertIn('This course will now appear on your edx.org dashboard.', body) self.assertIn('{proto}://{site}{course_path}'.format( proto=protocol, site=self.site_name, course_path=self.course_path, ), body) self.assertIn("To start accessing course materials, please visit", text_body) self.assertIn("This email was automatically sent from edx.org to NotEnrolled Student\n\n", text_body) @ddt.data('http', 'https') def test_enroll_with_email_not_registered(self, protocol): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ALLOWEDTOENROLL) self.assertEqual(response.status_code, 200) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to register for {}'.format(self.course.display_name) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] register_url = '{proto}://{site}/register'.format(proto=protocol, site=self.site_name) assert text_body.startswith('Dear student,') assert u'To finish your registration, please visit {register_url}'.format( register_url=register_url, ) in text_body assert 'Please finish your registration and fill out' in html_body assert register_url in html_body for body in [text_body, html_body]: assert u'You have been invited to join {course} at edx.org by a member of the course staff.'.format( course=self.course.display_name ) in body assert ('fill out the registration form making sure to use ' 'robot-not-an-email-yet@robot.org in the Email field') in body assert 'Once you have registered and activated your account,' in body assert '{proto}://{site}{about_path}'.format( proto=protocol, site=self.site_name, about_path=self.about_path ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body @ddt.data('http', 'https') @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True}) def test_enroll_email_not_registered_mktgsite(self, protocol): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ALLOWEDTOENROLL) self.assertEqual(response.status_code, 200) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith('Dear student,') assert 'To finish your registration, please visit' in text_body assert 'Please finish your registration and fill' in html_body for body in [text_body, html_body]: assert u'You have been invited to join {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name ) in body assert '{proto}://{site}/register'.format( proto=protocol, site=self.site_name ) in body assert ('fill out the registration form making sure to use ' 'robot-not-an-email-yet@robot.org in the Email field') in body assert u'You can then enroll in {display_name}.'.format( display_name=self.course.display_name ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body @ddt.data('http', 'https') def test_enroll_with_email_not_registered_autoenroll(self, protocol): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True, 'auto_enroll': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) print(u"type(self.notregistered_email): {}".format(type(self.notregistered_email))) self.assertEqual(response.status_code, 200) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to register for {}'.format(self.course.display_name) ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ALLOWEDTOENROLL) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] register_url = '{proto}://{site}/register'.format( proto=protocol, site=self.site_name, ) assert text_body.startswith('Dear student,') assert u'To finish your registration, please visit {register_url}'.format( register_url=register_url, ) in text_body assert 'Please finish your registration and fill out the registration' in html_body assert 'Finish Your Registration' in html_body assert register_url in html_body for body in [text_body, html_body]: assert u'You have been invited to join {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name ) in body assert (' and fill ' 'out the registration form making sure to use robot-not-an-email-yet@robot.org ' 'in the Email field') in body assert (u'Once you have registered and activated your account, ' u'you will see {display_name} listed on your dashboard.').format( display_name=self.course.display_name ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body def test_unenroll_without_email(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.enrolled_student.email, 'action': 'unenroll', 'email_students': False}) print(u"type(self.enrolled_student.email): {}".format(type(self.enrolled_student.email))) self.assertEqual(response.status_code, 200) # test that the user is now unenrolled user = User.objects.get(email=self.enrolled_student.email) self.assertFalse(CourseEnrollment.is_enrolled(user, self.course.id)) # test the response data expected = { "action": "unenroll", "auto_enroll": False, "results": [ { "identifier": self.enrolled_student.email, "before": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": False, }, "after": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, ENROLLED_TO_UNENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 0) def test_unenroll_with_email(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.enrolled_student.email, 'action': 'unenroll', 'email_students': True}) print(u"type(self.enrolled_student.email): {}".format(type(self.enrolled_student.email))) self.assertEqual(response.status_code, 200) # test that the user is now unenrolled user = User.objects.get(email=self.enrolled_student.email) self.assertFalse(CourseEnrollment.is_enrolled(user, self.course.id)) # test the response data expected = { "action": "unenroll", "auto_enroll": False, "results": [ { "identifier": self.enrolled_student.email, "before": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": False, }, "after": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, ENROLLED_TO_UNENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been unenrolled from {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith('Dear Enrolled Student') for body in [text_body, html_body]: assert u'You have been unenrolled from {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name, ) in body assert 'This course will no longer appear on your edx.org dashboard.' in body assert 'Your other courses have not been affected.' in body assert 'This email was automatically sent from edx.org to Enrolled Student' in body def test_unenroll_with_email_allowed_student(self): url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.allowed_email, 'action': 'unenroll', 'email_students': True}) print(u"type(self.allowed_email): {}".format(type(self.allowed_email))) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "unenroll", "auto_enroll": False, "results": [ { "identifier": self.allowed_email, "before": { "enrollment": False, "auto_enroll": False, "user": False, "allowed": True, }, "after": { "enrollment": False, "auto_enroll": False, "user": False, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, ALLOWEDTOENROLL_TO_UNENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been unenrolled from {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith('Dear Student,') for body in [text_body, html_body]: assert u'You have been unenrolled from the course {display_name} by a member of the course staff.'.format( display_name=self.course.display_name, ) in body assert 'Please disregard the invitation previously sent.' in body assert 'This email was automatically sent from edx.org to robot-allowed@robot.org' in body @ddt.data('http', 'https') @patch('lms.djangoapps.instructor.enrollment.uses_shib') def test_enroll_with_email_not_registered_with_shib(self, protocol, mock_uses_shib): mock_uses_shib.return_value = True url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) self.assertEqual(response.status_code, 200) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to register for {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] course_url = '{proto}://{site}{about_path}'.format( proto=protocol, site=self.site_name, about_path=self.about_path, ) assert text_body.startswith('Dear student,') assert u'To access this course visit {course_url} and register for this course.'.format( course_url=course_url, ) in text_body assert 'To access this course visit it and register:' in html_body assert course_url in html_body for body in [text_body, html_body]: assert u'You have been invited to join {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name, ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body @patch('lms.djangoapps.instructor.enrollment.uses_shib') @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True}) def test_enroll_email_not_registered_shib_mktgsite(self, mock_uses_shib): # Try with marketing site enabled and shib on mock_uses_shib.return_value = True url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) # Try with marketing site enabled with patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): response = self.client.post(url, {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True}) self.assertEqual(response.status_code, 200) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith('Dear student,') for body in [text_body, html_body]: assert u'You have been invited to join {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name, ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body @ddt.data('http', 'https') @patch('lms.djangoapps.instructor.enrollment.uses_shib') def test_enroll_with_email_not_registered_with_shib_autoenroll(self, protocol, mock_uses_shib): mock_uses_shib.return_value = True url = reverse('students_update_enrollment', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': True, 'auto_enroll': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) print(u"type(self.notregistered_email): {}".format(type(self.notregistered_email))) self.assertEqual(response.status_code, 200) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to register for {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] course_url = '{proto}://{site}{course_path}'.format( proto=protocol, site=self.site_name, course_path=self.course_path, ) assert text_body.startswith('Dear student,') assert course_url in html_body assert u'To access this course visit {course_url} and login.'.format(course_url=course_url) in text_body assert 'To access this course click on the button below and login:' in html_body for body in [text_body, html_body]: assert u'You have been invited to join {display_name} at edx.org by a member of the course staff.'.format( display_name=self.course.display_name, ) in body assert 'This email was automatically sent from edx.org to robot-not-an-email-yet@robot.org' in body def test_enroll_already_enrolled_student(self): """ Ensure that already enrolled "verified" students cannot be downgraded to "honor" """ course_enrollment = CourseEnrollment.objects.get( user=self.enrolled_student, course_id=self.course.id ) # make this enrollment "verified" course_enrollment.mode = u'verified' course_enrollment.save() self.assertEqual(course_enrollment.mode, u'verified') # now re-enroll the student through the instructor dash self._change_student_enrollment(self.enrolled_student, self.course, 'enroll') # affirm that the student is still in "verified" mode course_enrollment = CourseEnrollment.objects.get( user=self.enrolled_student, course_id=self.course.id ) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, ENROLLED_TO_ENROLLED) self.assertEqual(course_enrollment.mode, u"verified") def create_paid_course(self): """ create paid course mode. """ paid_course = CourseFactory.create() CourseModeFactory.create(course_id=paid_course.id, min_price=50, mode_slug=CourseMode.HONOR) CourseInstructorRole(paid_course.id).add_users(self.instructor) return paid_course def test_unenrolled_allowed_to_enroll_user(self): """ test to unenroll allow to enroll user. """ paid_course = self.create_paid_course() url = reverse('students_update_enrollment', kwargs={'course_id': text_type(paid_course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': False, 'auto_enroll': False, 'reason': 'testing..', 'role': 'Learner'} response = self.client.post(url, params) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_ALLOWEDTOENROLL) self.assertEqual(response.status_code, 200) # now registered the user UserFactory(email=self.notregistered_email) url = reverse('students_update_enrollment', kwargs={'course_id': text_type(paid_course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': False, 'auto_enroll': False, 'reason': 'testing', 'role': 'Learner'} response = self.client.post(url, params) manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 2) self.assertEqual(manual_enrollments[1].state_transition, ALLOWEDTOENROLL_TO_ENROLLED) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "enroll", "auto_enroll": False, "results": [ { "identifier": self.notregistered_email, "before": { "enrollment": False, "auto_enroll": False, "user": True, "allowed": True, }, "after": { "enrollment": True, "auto_enroll": False, "user": True, "allowed": True, } } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_unenrolled_already_not_enrolled_user(self): """ test unenrolled user already not enrolled in a course. """ paid_course = self.create_paid_course() course_enrollment = CourseEnrollment.objects.filter( user__email=self.notregistered_email, course_id=paid_course.id ) self.assertEqual(course_enrollment.count(), 0) url = reverse('students_update_enrollment', kwargs={'course_id': text_type(paid_course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'unenroll', 'email_students': False, 'auto_enroll': False, 'reason': 'testing', 'role': 'Learner'} response = self.client.post(url, params) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "unenroll", "auto_enroll": False, "results": [ { "identifier": self.notregistered_email, "before": { "enrollment": False, "auto_enroll": False, "user": False, "allowed": False, }, "after": { "enrollment": False, "auto_enroll": False, "user": False, "allowed": False, } } ] } manual_enrollments = ManualEnrollmentAudit.objects.all() self.assertEqual(manual_enrollments.count(), 1) self.assertEqual(manual_enrollments[0].state_transition, UNENROLLED_TO_UNENROLLED) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_unenroll_and_enroll_verified(self): """ Test that unenrolling and enrolling a student from a verified track results in that student being in the default track """ course_enrollment = CourseEnrollment.objects.get( user=self.enrolled_student, course_id=self.course.id ) # upgrade enrollment course_enrollment.mode = u'verified' course_enrollment.save() self.assertEqual(course_enrollment.mode, u'verified') self._change_student_enrollment(self.enrolled_student, self.course, 'unenroll') self._change_student_enrollment(self.enrolled_student, self.course, 'enroll') course_enrollment = CourseEnrollment.objects.get( user=self.enrolled_student, course_id=self.course.id ) self.assertEqual(course_enrollment.mode, CourseMode.DEFAULT_MODE_SLUG) def test_role_and_reason_are_persisted(self): """ test that role and reason fields are persisted in the database """ paid_course = self.create_paid_course() url = reverse('students_update_enrollment', kwargs={'course_id': text_type(paid_course.id)}) params = {'identifiers': self.notregistered_email, 'action': 'enroll', 'email_students': False, 'auto_enroll': False, 'reason': 'testing', 'role': 'Learner'} response = self.client.post(url, params) manual_enrollment = ManualEnrollmentAudit.objects.first() self.assertEqual(manual_enrollment.reason, 'testing') self.assertEqual(manual_enrollment.role, 'Learner') self.assertEqual(response.status_code, 200) def _change_student_enrollment(self, user, course, action): """ Helper function that posts to 'students_update_enrollment' to change a student's enrollment """ url = reverse( 'students_update_enrollment', kwargs={'course_id': text_type(course.id)}, ) params = { 'identifiers': user.email, 'action': action, 'email_students': True, 'reason': 'change user enrollment', 'role': 'Learner' } response = self.client.post(url, params) self.assertEqual(response.status_code, 200) return response def test_get_enrollment_status(self): """Check that enrollment states are reported correctly.""" # enrolled, active url = reverse( 'get_student_enrollment_status', kwargs={'course_id': text_type(self.course.id)}, ) params = { 'unique_student_identifier': 'EnrolledStudent' } response = self.client.post(url, params) self.assertEqual(response.status_code, 200) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual( res_json['enrollment_status'], 'Enrollment status for EnrolledStudent: active' ) # unenrolled, inactive CourseEnrollment.unenroll( self.enrolled_student, self.course.id ) response = self.client.post(url, params) self.assertEqual(response.status_code, 200) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual( res_json['enrollment_status'], 'Enrollment status for EnrolledStudent: inactive' ) # invited, not yet registered params = { 'unique_student_identifier': 'robot-allowed@robot.org' } response = self.client.post(url, params) self.assertEqual(response.status_code, 200) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual( res_json['enrollment_status'], 'Enrollment status for robot-allowed@robot.org: pending' ) # never enrolled or invited params = { 'unique_student_identifier': 'nonotever@example.com' } response = self.client.post(url, params) self.assertEqual(response.status_code, 200) res_json = json.loads(response.content.decode('utf-8')) self.assertEqual( res_json['enrollment_status'], 'Enrollment status for nonotever@example.com: never enrolled' ) @ddt.ddt class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test bulk beta modify access endpoint. """ @classmethod def setUpClass(cls): super(TestInstructorAPIBulkBetaEnrollment, cls).setUpClass() cls.course = CourseFactory.create() # Email URL values cls.site_name = configuration_helpers.get_value( 'SITE_NAME', settings.SITE_NAME ) cls.about_path = '/courses/{}/about'.format(cls.course.id) cls.course_path = '/courses/{}/'.format(cls.course.id) def setUp(self): super(TestInstructorAPIBulkBetaEnrollment, self).setUp() self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') self.beta_tester = BetaTesterFactory(course_key=self.course.id) CourseEnrollment.enroll( self.beta_tester, self.course.id ) self.assertTrue(CourseBetaTesterRole(self.course.id).has_user(self.beta_tester)) self.notenrolled_student = UserFactory(username='NotEnrolledStudent') self.notregistered_email = 'robot-not-an-email-yet@robot.org' self.assertEqual(User.objects.filter(email=self.notregistered_email).count(), 0) self.request = RequestFactory().request() # uncomment to enable enable printing of large diffs # from failed assertions in the event of a test failure. # (comment because pylint C0103(invalid-name)) # self.maxDiff = None def test_beta_tester_must_not_earn_cert(self): """ Test to ensure that beta tester must not earn certificate in a course in which he/she is a beta-tester. """ with LogCapture() as capture: message = u'Cancelling course certificate generation for user [{}] against course [{}], ' \ u'user is a Beta Tester.' message = message.format(self.course.id, self.beta_tester.username) generate_user_certificates(self.beta_tester, self.course.id, self.course) capture.check_present(('edx.certificate', 'INFO', message)) def test_missing_params(self): """ Test missing all query parameters. """ url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url) self.assertEqual(response.status_code, 400) def test_bad_action(self): """ Test with an invalid action. """ action = 'robot-not-an-action' url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.beta_tester.email, 'action': action}) self.assertEqual(response.status_code, 400) def add_notenrolled(self, response, identifier): """ Test Helper Method (not a test, called by other tests) Takes a client response from a call to bulk_beta_modify_access with 'email_students': False, and the student identifier (email or username) given as 'identifiers' in the request. Asserts the reponse returns cleanly, that the student was added as a beta tester, and the response properly contains their identifier, 'error': False, and 'userDoesNotExist': False. Additionally asserts no email was sent. """ self.assertEqual(response.status_code, 200) self.assertTrue(CourseBetaTesterRole(self.course.id).has_user(self.notenrolled_student)) # test the response data expected = { "action": "add", "results": [ { "identifier": identifier, "error": False, "userDoesNotExist": False, "is_active": True } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 0) def test_add_notenrolled_email(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.email, 'action': 'add', 'email_students': False}) self.add_notenrolled(response, self.notenrolled_student.email) self.assertFalse(CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id)) def test_add_notenrolled_email_autoenroll(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.email, 'action': 'add', 'email_students': False, 'auto_enroll': True}) self.add_notenrolled(response, self.notenrolled_student.email) self.assertTrue(CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id)) def test_add_notenrolled_username(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.username, 'action': 'add', 'email_students': False}) self.add_notenrolled(response, self.notenrolled_student.username) self.assertFalse(CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id)) def test_add_notenrolled_username_autoenroll(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.username, 'action': 'add', 'email_students': False, 'auto_enroll': True}) self.add_notenrolled(response, self.notenrolled_student.username) self.assertTrue(CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id)) @ddt.data('http', 'https') def test_add_notenrolled_with_email(self, protocol): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notenrolled_student.email, 'action': 'add', 'email_students': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) self.assertEqual(response.status_code, 200) self.assertTrue(CourseBetaTesterRole(self.course.id).has_user(self.notenrolled_student)) # test the response data expected = { "action": "add", "results": [ { "identifier": self.notenrolled_student.email, "error": False, "userDoesNotExist": False, "is_active": True } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to a beta test for {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] student_name = self.notenrolled_student.profile.name assert text_body.startswith(u'Dear {student_name}'.format(student_name=student_name)) assert u'Visit {display_name}'.format(display_name=self.course.display_name) in html_body for body in [text_body, html_body]: assert u'You have been invited to be a beta tester for {display_name} at edx.org'.format( display_name=self.course.display_name, ) in body assert 'by a member of the course staff.' in body assert 'enroll in this course and begin the beta test' in body assert '{proto}://{site}{about_path}'.format( proto=protocol, site=self.site_name, about_path=self.about_path, ) in body assert u'This email was automatically sent from edx.org to {student_email}'.format( student_email=self.notenrolled_student.email, ) in body @ddt.data('http', 'https') def test_add_notenrolled_with_email_autoenroll(self, protocol): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) params = {'identifiers': self.notenrolled_student.email, 'action': 'add', 'email_students': True, 'auto_enroll': True} environ = {'wsgi.url_scheme': protocol} response = self.client.post(url, params, **environ) self.assertEqual(response.status_code, 200) self.assertTrue(CourseBetaTesterRole(self.course.id).has_user(self.notenrolled_student)) # test the response data expected = { "action": "add", "results": [ { "identifier": self.notenrolled_student.email, "error": False, "userDoesNotExist": False, "is_active": True } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been invited to a beta test for {display_name}'.format(display_name=self.course.display_name) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] student_name = self.notenrolled_student.profile.name assert text_body.startswith(u'Dear {student_name}'.format(student_name=student_name)) for body in [text_body, html_body]: assert u'You have been invited to be a beta tester for {display_name} at edx.org'.format( display_name=self.course.display_name, ) in body assert 'by a member of the course staff' in body assert 'To start accessing course materials, please visit' in body assert '{proto}://{site}{course_path}'.format( proto=protocol, site=self.site_name, course_path=self.course_path ) assert u'This email was automatically sent from edx.org to {student_email}'.format( student_email=self.notenrolled_student.email, ) in body @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True}) def test_add_notenrolled_email_mktgsite(self): # Try with marketing site enabled url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notenrolled_student.email, 'action': 'add', 'email_students': True}) self.assertEqual(response.status_code, 200) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] student_name = self.notenrolled_student.profile.name assert text_body.startswith(u'Dear {student_name}'.format(student_name=student_name)) for body in [text_body, html_body]: assert u'You have been invited to be a beta tester for {display_name} at edx.org'.format( display_name=self.course.display_name, ) in body assert 'by a member of the course staff.' in body assert 'Visit edx.org' in body assert 'enroll in this course and begin the beta test' in body assert u'This email was automatically sent from edx.org to {student_email}'.format( student_email=self.notenrolled_student.email, ) in body def test_enroll_with_email_not_registered(self): # User doesn't exist url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.notregistered_email, 'action': 'add', 'email_students': True, 'reason': 'testing'}) self.assertEqual(response.status_code, 200) # test the response data expected = { "action": "add", "results": [ { "identifier": self.notregistered_email, "error": True, "userDoesNotExist": True, "is_active": None } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 0) def test_remove_without_email(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.beta_tester.email, 'action': 'remove', 'email_students': False, 'reason': 'testing'}) self.assertEqual(response.status_code, 200) # Works around a caching bug which supposedly can't happen in prod. The instance here is not == # the instance fetched from the email above which had its cache cleared if hasattr(self.beta_tester, '_roles'): del self.beta_tester._roles self.assertFalse(CourseBetaTesterRole(self.course.id).has_user(self.beta_tester)) # test the response data expected = { "action": "remove", "results": [ { "identifier": self.beta_tester.email, "error": False, "userDoesNotExist": False, "is_active": True } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 0) def test_remove_with_email(self): url = reverse('bulk_beta_modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, {'identifiers': self.beta_tester.email, 'action': 'remove', 'email_students': True, 'reason': 'testing'}) self.assertEqual(response.status_code, 200) # Works around a caching bug which supposedly can't happen in prod. The instance here is not == # the instance fetched from the email above which had its cache cleared if hasattr(self.beta_tester, '_roles'): del self.beta_tester._roles self.assertFalse(CourseBetaTesterRole(self.course.id).has_user(self.beta_tester)) # test the response data expected = { "action": "remove", "results": [ { "identifier": self.beta_tester.email, "error": False, "userDoesNotExist": False, "is_active": True } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) # Check the outbox self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, u'You have been removed from a beta test for {display_name}'.format(display_name=self.course.display_name,) ) text_body = mail.outbox[0].body html_body = mail.outbox[0].alternatives[0][0] assert text_body.startswith(u'Dear {name}'.format(name=self.beta_tester.profile.name)) for body in [text_body, html_body]: assert u'You have been removed as a beta tester for {display_name} at edx.org'.format( display_name=self.course.display_name, ) in body assert ('This course will remain on your dashboard, but you will no longer be ' 'part of the beta testing group.') in body assert 'Your other courses have not been affected.' in body assert u'This email was automatically sent from edx.org to {email_address}'.format( email_address=self.beta_tester.email, ) in body class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test endpoints whereby instructors can change permissions of other users. This test does NOT test whether the actions had an effect on the database, that is the job of test_access. This tests the response and action switch. Actually, modify_access does not have a very meaningful response yet, so only the status code is tested. """ @classmethod def setUpClass(cls): super(TestInstructorAPILevelsAccess, cls).setUpClass() cls.course = CourseFactory.create() def setUp(self): super(TestInstructorAPILevelsAccess, self).setUp() self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') self.other_instructor = InstructorFactory(course_key=self.course.id) self.other_staff = StaffFactory(course_key=self.course.id) self.other_user = UserFactory() def test_modify_access_noparams(self): """ Test missing all query parameters. """ url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url) self.assertEqual(response.status_code, 400) def test_modify_access_bad_action(self): """ Test with an invalid action parameter. """ url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_staff.email, 'rolename': 'staff', 'action': 'robot-not-an-action', }) self.assertEqual(response.status_code, 400) def test_modify_access_bad_role(self): """ Test with an invalid action parameter. """ url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_staff.email, 'rolename': 'robot-not-a-roll', 'action': 'revoke', }) self.assertEqual(response.status_code, 400) def test_modify_access_allow(self): url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_user.email, 'rolename': 'staff', 'action': 'allow', }) self.assertEqual(response.status_code, 200) def test_modify_access_allow_with_uname(self): url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_instructor.username, 'rolename': 'staff', 'action': 'allow', }) self.assertEqual(response.status_code, 200) def test_modify_access_revoke(self): url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_staff.email, 'rolename': 'staff', 'action': 'revoke', }) self.assertEqual(response.status_code, 200) def test_modify_access_revoke_with_username(self): url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_staff.username, 'rolename': 'staff', 'action': 'revoke', }) self.assertEqual(response.status_code, 200) def test_modify_access_with_fake_user(self): url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': 'GandalfTheGrey', 'rolename': 'staff', 'action': 'revoke', }) self.assertEqual(response.status_code, 200) expected = { 'unique_student_identifier': 'GandalfTheGrey', 'userDoesNotExist': True, } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_modify_access_with_inactive_user(self): self.other_user.is_active = False self.other_user.save() url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_user.username, 'rolename': 'beta', 'action': 'allow', }) self.assertEqual(response.status_code, 200) expected = { 'unique_student_identifier': self.other_user.username, 'inactiveUser': True, } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_modify_access_revoke_not_allowed(self): """ Test revoking access that a user does not have. """ url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.other_staff.email, 'rolename': 'instructor', 'action': 'revoke', }) self.assertEqual(response.status_code, 200) def test_modify_access_revoke_self(self): """ Test that an instructor cannot remove instructor privelages from themself. """ url = reverse('modify_access', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.instructor.email, 'rolename': 'instructor', 'action': 'revoke', }) self.assertEqual(response.status_code, 200) # check response content expected = { 'unique_student_identifier': self.instructor.username, 'rolename': 'instructor', 'action': 'revoke', 'removingSelfAsInstructor': True, } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_list_course_role_members_noparams(self): """ Test missing all query parameters. """ url = reverse('list_course_role_members', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url) self.assertEqual(response.status_code, 400) def test_list_course_role_members_bad_rolename(self): """ Test with an invalid rolename parameter. """ url = reverse('list_course_role_members', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'rolename': 'robot-not-a-rolename', }) self.assertEqual(response.status_code, 400) def test_list_course_role_members_staff(self): url = reverse('list_course_role_members', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'rolename': 'staff', }) self.assertEqual(response.status_code, 200) # check response content expected = { 'course_id': text_type(self.course.id), 'staff': [ { 'username': self.other_staff.username, 'email': self.other_staff.email, 'first_name': self.other_staff.first_name, 'last_name': self.other_staff.last_name, } ] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_list_course_role_members_beta(self): url = reverse('list_course_role_members', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(url, { 'rolename': 'beta', }) self.assertEqual(response.status_code, 200) # check response content expected = { 'course_id': text_type(self.course.id), 'beta': [] } res_json = json.loads(response.content.decode('utf-8')) self.assertEqual(res_json, expected) def test_update_forum_role_membership(self): """ Test update forum role membership with user's email and username. """ # Seed forum roles for course. seed_permissions_roles(self.course.id) for user in [self.instructor, self.other_user]: for identifier_attr in [user.email, user.username]: for rolename in ["Administrator", "Moderator", "Community TA"]: for action in ["allow", "revoke"]: self.assert_update_forum_role_membership(user, identifier_attr, rolename, action) def assert_update_forum_role_membership(self, current_user, identifier, rolename, action): """ Test update forum role membership. Get unique_student_identifier, rolename and action and update forum role. """ url = reverse('update_forum_role_membership', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post( url, { 'unique_student_identifier': identifier, 'rolename': rolename, 'action': action, } ) # Status code should be 200. self.assertEqual(response.status_code, 200) user_roles = current_user.roles.filter(course_id=self.course.id).values_list("name", flat=True) if action == 'allow': self.assertIn(rolename, user_roles) elif action == 'revoke': self.assertNotIn(rolename, user_roles) @ddt.ddt @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test endpoints that show data without side effects. """ @classmethod def setUpClass(cls): super(TestInstructorAPILevelsDataDump, cls).setUpClass() cls.course = CourseFactory.create() def setUp(self): super(TestInstructorAPILevelsDataDump, self).setUp() self.course_mode = CourseMode(course_id=self.course.id, mode_slug="honor", mode_display_name="honor cert", min_price=40) self.course_mode.save() self.instructor = InstructorFactory(course_key=self.course.id) CourseDataResearcherRole(self.course.id).add_users(self.instructor) self.client.login(username=self.instructor.username, password='test') self.cart = Order.get_cart_for_user(self.instructor) self.coupon_code = 'abcde' self.coupon = Coupon(code=self.coupon_code, description='testing code', course_id=self.course.id, percentage_discount=10, created_by=self.instructor, is_active=True) self.coupon.save() # Create testing invoice 1 self.sale_invoice_1 = Invoice.objects.create( total_amount=1234.32, company_name='Test1', company_contact_name='TestName', company_contact_email='Test@company.com', recipient_name='Testw', recipient_email='test1@test.com', customer_reference_number='2Fwe23S', internal_reference="A", course_id=self.course.id, is_valid=True ) self.invoice_item = CourseRegistrationCodeInvoiceItem.objects.create( invoice=self.sale_invoice_1, qty=1, unit_price=1234.32, course_id=self.course.id ) self.students = [UserFactory() for _ in range(6)] for student in self.students: CourseEnrollment.enroll(student, self.course.id) self.students_who_may_enroll = self.students + [UserFactory() for _ in range(5)] for student in self.students_who_may_enroll: CourseEnrollmentAllowed.objects.create( email=student.email, course_id=self.course.id ) def register_with_redemption_code(self, user, code): """ enroll user using a registration code """ redeem_url = reverse('register_code_redemption', args=[code], is_dashboard_endpoint=False) self.client.login(username=user.username, password='test') response = self.client.get(redeem_url) self.assertEqual(response.status_code, 200) # check button text self.assertContains(response, 'Activate Course Enrollment') response = self.client.post(redeem_url) self.assertEqual(response.status_code, 200) def test_invalidate_sale_record(self): """ Testing the sale invalidating scenario. """ for i in range(2): course_registration_code = CourseRegistrationCode( code='sale_invoice{}'.format(i), course_id=text_type(self.course.id), created_by=self.instructor, invoice=self.sale_invoice_1, invoice_item=self.invoice_item, mode_slug='honor' ) course_registration_code.save() data = {'invoice_number': self.sale_invoice_1.id, 'event_type': "invalidate"} url = reverse('sale_validation', kwargs={'course_id': text_type(self.course.id)}) self.assert_request_status_code(200, url, method="POST", data=data) #Now try to fetch data against not existing invoice number test_data_1 = {'invoice_number': 100, 'event_type': "invalidate"} self.assert_request_status_code(404, url, method="POST", data=test_data_1) # Now invalidate the same invoice number and expect an Bad request response = self.assert_request_status_code(400, url, method="POST", data=data) self.assertContains( response, "The sale associated with this invoice has already been invalidated.", status_code=400, ) # now re_validate the invoice number data['event_type'] = "re_validate" self.assert_request_status_code(200, url, method="POST", data=data) # Now re_validate the same active invoice number and expect an Bad request response = self.assert_request_status_code(400, url, method="POST", data=data) self.assertContains(response, "This invoice is already active.", status_code=400) test_data_2 = {'invoice_number': self.sale_invoice_1.id} response = self.assert_request_status_code(400, url, method="POST", data=test_data_2) self.assertContains(response, "Missing required event_type parameter", status_code=400) test_data_3 = {'event_type': "re_validate"} response = self.assert_request_status_code(400, url, method="POST", data=test_data_3) self.assertContains(response, "Missing required invoice_number parameter", status_code=400) # submitting invalid invoice number data['invoice_number'] = 'testing' response = self.assert_request_status_code(400, url, method="POST", data=data) self.assertContains( response, u"invoice_number must be an integer, {value} provided".format(value=data['invoice_number']), status_code=400, ) def test_get_sale_order_records_features_csv(self): """ Test that the response from get_sale_order_records is in csv format. """ # add the coupon code for the course coupon = Coupon( code='test_code', description='test_description', course_id=self.course.id, percentage_discount='10', created_by=self.instructor, is_active=True ) coupon.save() self.cart.order_type = 'business' self.cart.save() self.cart.add_billing_details(company_name='Test Company', company_contact_name='Test', company_contact_email='test@123', recipient_name='R1', recipient_email='', customer_reference_number='PO#23') paid_course_reg_item = PaidCourseRegistration.add_to_order( self.cart, self.course.id, mode_slug=CourseMode.HONOR ) # update the quantity of the cart item paid_course_reg_item resp = self.client.post( reverse('shoppingcart.views.update_user_cart', is_dashboard_endpoint=False), {'ItemId': paid_course_reg_item.id, 'qty': '4'} ) self.assertEqual(resp.status_code, 200) # apply the coupon code to the item in the cart resp = self.client.post( reverse('shoppingcart.views.use_code', is_dashboard_endpoint=False), {'code': coupon.code} ) self.assertEqual(resp.status_code, 200) self.cart.purchase() # get the updated item item = self.cart.orderitem_set.all().select_subclasses()[0] # get the redeemed coupon information coupon_redemption = CouponRedemption.objects.select_related('coupon').filter(order=self.cart) sale_order_url = reverse('get_sale_order_records', kwargs={'course_id': text_type(self.course.id)}) response = self.client.post(sale_order_url) self.assertEqual(response['Content-Type'], 'text/csv') self.assertIn('36', response.content.decode('utf-8').split('\r\n')[1]) self.assertIn(str(item.unit_cost), response.content.decode('utf-8').split('\r\n')[1],) self.assertIn(str(item.list_price), response.content.decode('utf-8').split('\r\n')[1],) self.assertIn(item.status, response.content.decode('utf-8').split('\r\n')[1],) self.assertIn(coupon_redemption[0].coupon.code, response.content.decode('utf-8').split('\r\n')[1],) def test_coupon_redeem_count_in_ecommerce_section(self): """ Test that checks the redeem count in the instructor_dashboard coupon section """ # add the coupon code for the course coupon = Coupon( code='test_code', description='test_description', course_id=self.course.id, percentage_discount='10', created_by=self.instructor, is_active=True ) coupon.save() # Coupon Redeem Count only visible for Financial Admins. CourseFinanceAdminRole(self.course.id).add_users(self.instructor) PaidCourseRegistration.add_to_order(self.cart, self.course.id) # apply the coupon code to the item in the cart resp = self.client.post( reverse('shoppingcart.views.use_code', is_dashboard_endpoint=False), {'code': coupon.code} ) self.assertEqual(resp.status_code, 200) # URL for instructor dashboard instructor_dashboard = reverse( 'instructor_dashboard', kwargs={'course_id': text_type(self.course.id)}, is_dashboard_endpoint=False ) # visit the instructor dashboard page and # check that the coupon redeem count should be 0 resp = self.client.get(instructor_dashboard) self.assertContains(resp, 'Number Redeemed') self.assertContains(resp, '