def test_caching(self): deadlines = { CourseKey.from_string("edX/DemoX/Fall"): datetime.now(pytz.UTC), CourseKey.from_string("edX/DemoX/Spring"): datetime.now(pytz.UTC) + timedelta(days=1) } course_keys = deadlines.keys() # Initially, no deadlines are set with self.assertNumQueries(1): all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys) self.assertEqual(all_deadlines, {}) # Create the deadlines for course_key, deadline in deadlines.iteritems(): VerificationDeadline.objects.create( course_key=course_key, deadline=deadline, ) # Warm the cache with self.assertNumQueries(1): VerificationDeadline.deadlines_for_courses(course_keys) # Load the deadlines from the cache with self.assertNumQueries(0): all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys) self.assertEqual(all_deadlines, deadlines) # Delete the deadlines VerificationDeadline.objects.all().delete() # Verify that the deadlines are updated correctly with self.assertNumQueries(1): all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys) self.assertEqual(all_deadlines, {})
def test_update_verification_deadline_left_alone(self): """ When the course's verification deadline is set and an update request doesn't include it, we should take no action on it. """ verification_deadline = datetime(year=1915, month=5, day=7, tzinfo=pytz.utc) response, __ = self._get_update_response_and_expected_data(None, verification_deadline) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline) verified_mode = CourseMode( mode_slug=u'verified', min_price=200, currency=u'USD', sku=u'ABC123', bulk_sku=u'BULK-ABC123', expiration_datetime=None ) updated_data = self._serialize_course(self.course, [verified_mode], None) # don't include the verification_deadline key in the PUT request updated_data.pop('verification_deadline', None) response = self.client.put(self.path, json.dumps(updated_data), content_type=JSON_CONTENT_TYPE) self.assertEqual(response.status_code, 200) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline)
def test_deadline(self): """ Verify deadline is set to course end date by signal when changed. """ deadline = datetime.now(tz=UTC) - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline) _listen_for_course_publish('store', self.course.id) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), self.course.end)
def test_deadline_explicit(self): """ Verify deadline is unchanged by signal when explicitly set. """ deadline = datetime.now(tz=UTC) - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline, is_explicit=True) _listen_for_course_publish('store', self.course.id) actual_deadline = VerificationDeadline.deadline_for_course(self.course.id) self.assertNotEqual(actual_deadline, self.course.end) self.assertEqual(actual_deadline, deadline)
def _setup_mode_and_enrollment(self, deadline, enrollment_mode): """Create a course mode and enrollment. Arguments: deadline (datetime): The deadline for submitting your verification. enrollment_mode (str): The mode of the enrollment. """ CourseModeFactory.create(course_id=self.course.id, mode_slug="verified", expiration_datetime=deadline) CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=enrollment_mode) VerificationDeadline.set_deadline(self.course.id, deadline)
def test_disable_verification_deadline(self): # Configure a verification deadline for the course VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE) # Create the course mode Django admin form form = self._admin_form("verified", upgrade_deadline=self.UPGRADE_DEADLINE) # Use the form to disable the verification deadline self._set_form_verification_deadline(form, None) form.save() # Check that the deadline was disabled self.assertIs(VerificationDeadline.deadline_for_course(self.course.id), None)
def save(self, *args, **kwargs): # pylint: disable=unused-argument """ Save the CourseMode objects to the database. """ # Update the verification deadline for the course (not the individual modes) VerificationDeadline.set_deadline(self.id, self.verification_deadline) for mode in self.modes: mode.course_id = self.id mode.mode_display_name = self.get_mode_display_name(mode) mode.save() deleted_mode_ids = [mode.id for mode in self._deleted_modes] CourseMode.objects.filter(id__in=deleted_mode_ids).delete() self._deleted_modes = []
def _configure(self, mode, upgrade_deadline=None, verification_deadline=None): """Configure course modes and deadlines. """ course_mode = CourseMode.objects.create( mode_slug=mode, mode_display_name=mode, ) if upgrade_deadline is not None: course_mode.upgrade_deadline = upgrade_deadline course_mode.save() VerificationDeadline.set_deadline(self.course.id, verification_deadline) return CourseModeForm(instance=course_mode)
def save(self, *args, **kwargs): # pylint: disable=unused-argument """ Save the CourseMode objects to the database. """ if self.verification_deadline is not UNDEFINED: # Override the verification deadline for the course (not the individual modes) # This will delete verification deadlines for the course if self.verification_deadline is null VerificationDeadline.set_deadline(self.id, self.verification_deadline, is_explicit=True) for mode in self.modes: mode.course_id = self.id mode.mode_display_name = self.get_mode_display_name(mode) mode.save() deleted_mode_ids = [mode.id for mode in self._deleted_modes] CourseMode.objects.filter(id__in=deleted_mode_ids).delete() self._deleted_modes = []
def test_set_verification_deadline(self, course_mode): # Configure a verification deadline for the course VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE) # Create the course mode Django admin form form = self._admin_form(course_mode) # Update the verification deadline form data # We need to set the date and time fields separately, since they're # displayed as separate widgets in the form. new_deadline = (self.VERIFICATION_DEADLINE + timedelta(days=1)).replace(microsecond=0) self._set_form_verification_deadline(form, new_deadline) form.save() # Check that the deadline was updated updated_deadline = VerificationDeadline.deadline_for_course(self.course.id) self.assertEqual(updated_deadline, new_deadline)
def test_update_remove_verification_deadline(self): """ Verify that verification deadlines can be removed through the API. """ verification_deadline = datetime(year=1915, month=5, day=7, tzinfo=pytz.utc) response, __ = self._get_update_response_and_expected_data(None, verification_deadline) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline) verified_mode = CourseMode( mode_slug=u"verified", min_price=200, currency=u"USD", sku=u"ABC123", expiration_datetime=None ) updated_data = self._serialize_course(self.course, [verified_mode], None) updated_data["verification_deadline"] = None response = self.client.put(self.path, json.dumps(updated_data), content_type=JSON_CONTENT_TYPE) self.assertEqual(response.status_code, 200) self.assertIsNone(VerificationDeadline.deadline_for_course(self.course.id))
def test_update(self): """ Verify the view supports updating a course. """ # Sanity check: Ensure no verification deadline is set self.assertIsNone(VerificationDeadline.deadline_for_course(self.course.id)) # Generate the expected data verification_deadline = datetime(year=2020, month=12, day=31, tzinfo=pytz.utc) expiration_datetime = datetime.now(pytz.utc) response, expected = self._get_update_response_and_expected_data(expiration_datetime, verification_deadline) # Sanity check: The API should return HTTP status 200 for updates self.assertEqual(response.status_code, 200) # Verify the course and modes are returned as JSON actual = json.loads(response.content) self.assertEqual(actual, expected) # Verify the verification deadline is updated self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline)
def test_load_verification_deadline(self, mode, expect_deadline): # Configure a verification deadline for the course VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE) # Configure a course mode with both an upgrade and verification deadline # and load the form to edit it. deadline = self.UPGRADE_DEADLINE if mode == "verified" else None form = self._admin_form(mode, upgrade_deadline=deadline) # Check that the verification deadline is loaded, # but ONLY for verified modes. loaded_deadline = form.initial.get("verification_deadline") if expect_deadline: self.assertEqual( loaded_deadline.replace(tzinfo=None), self.VERIFICATION_DEADLINE.replace(tzinfo=None) ) else: self.assertIs(loaded_deadline, None)
def test_update_verification_deadline_without_expiring_modes(self): """ Verify verification deadline can be set if no course modes expire. This accounts for the verified professional mode, which requires verification but should never expire. """ verification_deadline = datetime(year=1915, month=5, day=7, tzinfo=pytz.utc) response, __ = self._get_update_response_and_expected_data(None, verification_deadline) self.assertEqual(response.status_code, 200) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), verification_deadline)
def _serialize_course(cls, course, modes=None, verification_deadline=None): """ Serializes a course to a Python dict. """ modes = modes or [] verification_deadline = verification_deadline or VerificationDeadline.deadline_for_course(course.id) return { u'id': unicode(course.id), u'name': unicode(course.display_name), u'verification_deadline': cls._serialize_datetime(verification_deadline), u'modes': [cls._serialize_course_mode(mode) for mode in modes] }
def get(cls, course_id): """ Retrieve a single course. """ try: course_id = CourseKey.from_string(unicode(course_id)) except InvalidKeyError: log.debug('[%s] is not a valid course key.', course_id) raise ValueError course_modes = CourseMode.objects.filter(course_id=course_id) if course_modes: verification_deadline = VerificationDeadline.deadline_for_course(course_id) return cls(course_id, list(course_modes), verification_deadline=verification_deadline) return None
def include_verified_mode_info(enrollment_data, course_key): """ Add information about the verified mode for the given `course_key`, if that course has a verified mode. Args: enrollment_data (dict): Dictionary representing a single enrollment. course_key (CourseKey): The course which this enrollment belongs to. Returns: None """ course_modes = enrollment_data['course_modes'] for mode in course_modes: if mode['slug'] == CourseMode.VERIFIED: enrollment_data['verified_price'] = mode['min_price'] enrollment_data['verified_upgrade_deadline'] = mode['expiration_datetime'] enrollment_data['verification_deadline'] = VerificationDeadline.deadline_for_course(course_key)
def setUp(self): super(SupportViewEnrollmentsTests, self).setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in (CourseMode.AUDIT, CourseMode.VERIFIED): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( course_key=self.course.id, # pylint: disable=no-member deadline=datetime.now(UTC) + timedelta(days=365) ) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username})
def get( self, request, course_id, always_show_payment=False, current_step=None, message=FIRST_TIME_VERIFY_MSG ): """ Render the payment and verification flow. Arguments: request (HttpRequest): The request object. course_id (unicode): The ID of the course the user is trying to enroll in. Keyword Arguments: always_show_payment (bool): If True, show the payment steps even if the user has already paid. This is useful for users returning to the flow after paying. current_step (string): The current step in the flow. message (string): The messaging to display. Returns: HttpResponse Raises: Http404: The course does not exist or does not have a verified mode. """ # Parse the course key # The URL regex should guarantee that the key format is valid. course_key = CourseKey.from_string(course_id) course = modulestore().get_course(course_key) # Verify that the course exists if course is None: log.warn(u"Could not find course with ID %s.", course_id) raise Http404 # Check whether the user has access to this course # based on country access rules. redirect_url = embargo_api.redirect_if_blocked( course_key, user=request.user, ip_address=get_ip(request), url=request.path ) if redirect_url: return redirect(redirect_url) # If the verification deadline has passed # then show the user a message that he/she can't verify. # # We're making the assumptions (enforced in Django admin) that: # # 1) Only verified modes have verification deadlines. # # 2) If set, verification deadlines are always AFTER upgrade deadlines, because why would you # let someone upgrade into a verified track if they can't complete verification? # verification_deadline = VerificationDeadline.deadline_for_course(course.id) response = self._response_if_deadline_passed(course, self.VERIFICATION_DEADLINE, verification_deadline) if response is not None: log.info(u"Verification deadline for '%s' has passed.", course.id) return response # Retrieve the relevant course mode for the payment/verification flow. # # WARNING: this is technical debt! A much better way to do this would be to # separate out the payment flow and use the product SKU to figure out what # the user is trying to purchase. # # Nonetheless, for the time being we continue to make the really ugly assumption # that at some point there was a paid course mode we can query for the price. relevant_course_mode = self._get_paid_mode(course_key) # If we can find a relevant course mode, then log that we're entering the flow # Otherwise, this course does not support payment/verification, so respond with a 404. if relevant_course_mode is not None: if CourseMode.is_verified_mode(relevant_course_mode): log.info( u"Entering payment and verification flow for user '%s', course '%s', with current step '%s'.", request.user.id, course_id, current_step ) else: log.info( u"Entering payment flow for user '%s', course '%s', with current step '%s'", request.user.id, course_id, current_step ) else: # Otherwise, there has never been a verified/paid mode, # so return a page not found response. log.warn( u"No paid/verified course mode found for course '%s' for verification/payment flow request", course_id ) raise Http404 # If the user is trying to *pay* and the upgrade deadline has passed, # then they shouldn't be able to enter the flow. # # NOTE: This should match the availability dates used by the E-Commerce service # to determine whether a user can purchase a product. The idea is that if the service # won't fulfill the order, we shouldn't even let the user get into the payment flow. # user_is_trying_to_pay = message in [self.FIRST_TIME_VERIFY_MSG, self.UPGRADE_MSG] if user_is_trying_to_pay: upgrade_deadline = relevant_course_mode.expiration_datetime response = self._response_if_deadline_passed(course, self.UPGRADE_DEADLINE, upgrade_deadline) if response is not None: log.info(u"Upgrade deadline for '%s' has passed.", course.id) return response # Check whether the user has verified, paid, and enrolled. # A user is considered "paid" if he or she has an enrollment # with a paid course mode (such as "verified"). # For this reason, every paid user is enrolled, but not # every enrolled user is paid. # If the course mode is not verified(i.e only paid) then already_verified is always True already_verified = ( self._check_already_verified(request.user) if CourseMode.is_verified_mode(relevant_course_mode) else True ) already_paid, is_enrolled = self._check_enrollment(request.user, course_key) # Redirect the user to a more appropriate page if the # messaging won't make sense based on the user's # enrollment / payment / verification status. sku_to_use = relevant_course_mode.sku purchase_workflow = request.GET.get('purchase_workflow', 'single') if purchase_workflow == 'bulk' and relevant_course_mode.bulk_sku: sku_to_use = relevant_course_mode.bulk_sku redirect_response = self._redirect_if_necessary( message, already_verified, already_paid, is_enrolled, course_key, user_is_trying_to_pay, request.user, sku_to_use ) if redirect_response is not None: return redirect_response display_steps = self._display_steps( always_show_payment, already_verified, already_paid, relevant_course_mode ) # Override the actual value if account activation has been disabled # Also see the reference to this parameter in context dictionary further down user_is_active = self._get_user_active_status(request.user) requirements = self._requirements(display_steps, user_is_active) if current_step is None: current_step = display_steps[0]['name'] # Allow the caller to skip the first page # This is useful if we want the user to be able to # use the "back" button to return to the previous step. # This parameter should only work for known skip-able steps if request.GET.get('skip-first-step') and current_step in self.SKIP_STEPS: display_step_names = [step['name'] for step in display_steps] current_step_idx = display_step_names.index(current_step) if (current_step_idx + 1) < len(display_steps): current_step = display_steps[current_step_idx + 1]['name'] courseware_url = "" if not course.start or course.start < datetime.datetime.today().replace(tzinfo=UTC): courseware_url = reverse( 'course_root', kwargs={'course_id': unicode(course_key)} ) full_name = ( request.user.profile.name if request.user.profile.name else "" ) # If the user set a contribution amount on another page, # use that amount to pre-fill the price selection form. contribution_amount = request.session.get( 'donation_for_course', {} ).get(unicode(course_key), '') # Remember whether the user is upgrading # so we can fire an analytics event upon payment. request.session['attempting_upgrade'] = (message == self.UPGRADE_MSG) # Determine the photo verification status verification_good_until = self._verification_valid_until(request.user) # get available payment processors if relevant_course_mode.sku: # transaction will be conducted via ecommerce service processors = ecommerce_api_client(request.user).payment.processors.get() else: # transaction will be conducted using legacy shopping cart processors = [settings.CC_PROCESSOR_NAME] # Render the top-level page context = { 'contribution_amount': contribution_amount, 'course': course, 'course_key': unicode(course_key), 'checkpoint_location': request.GET.get('checkpoint'), 'course_mode': relevant_course_mode, 'courseware_url': courseware_url, 'current_step': current_step, 'disable_courseware_js': True, 'display_steps': display_steps, 'is_active': json.dumps(user_is_active), 'user_email': request.user.email, 'message_key': message, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'processors': processors, 'requirements': requirements, 'user_full_name': full_name, 'verification_deadline': verification_deadline or "", 'already_verified': already_verified, 'verification_good_until': verification_good_until, 'capture_sound': staticfiles_storage.url("audio/camera_capture.wav"), 'nav_hidden': True, 'is_ab_testing': 'begin-flow' in request.path, } return render_to_response("verify_student/pay_and_verify.html", context)
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase): """Tests for the enrollment support view.""" def setUp(self): super(SupportViewEnrollmentsTests, self).setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in (CourseMode.AUDIT, CourseMode.VERIFIED): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( course_key=self.course.id, # pylint: disable=no-member deadline=datetime.now(UTC) + timedelta(days=365)) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username}) def assert_enrollment(self, mode): """ Assert that the student's enrollment has the correct mode. """ enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) # pylint: disable=no-member self.assertEqual(enrollment.mode, mode) @ddt.data('username', 'email') def test_get_enrollments(self, search_string_type): url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertDictContainsSubset( { 'mode': CourseMode.AUDIT, 'manual_enrollment': {}, 'user': self.student.username, 'course_id': unicode(self.course.id), # pylint: disable=no-member 'is_active': True, 'verified_upgrade_deadline': None, }, data[0]) self.assertEqual({CourseMode.VERIFIED, CourseMode.AUDIT}, {mode['slug'] for mode in data[0]['course_modes']}) def test_get_manual_enrollment_history(self): ManualEnrollmentAudit.create_manual_enrollment_audit( self.user, self.student.email, ENROLLED_TO_ENROLLED, 'Financial Assistance', CourseEnrollment.objects.get(course_id=self.course.id, user=self.student) # pylint: disable=no-member ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertDictContainsSubset( { 'enrolled_by': self.user.email, 'reason': 'Financial Assistance', }, json.loads(response.content)[0]['manual_enrollment']) @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.post( url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) self.assertEqual(response.status_code, 200) self.assertIsNotNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) self.assert_enrollment(CourseMode.VERIFIED) @ddt.data( ({}, r"The field '\w+' is required."), ({ 'course_id': 'bad course key' }, 'Could not parse course key.'), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, 'Could not find enrollment for user'), ({ 'course_id': None, 'old_mode': CourseMode.HONOR, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ 'course_id': None, 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, 'reason': '' }, "Specified course mode '{}' unavailable".format( CourseMode.CREDIT_MODE))) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): # `self` isn't available from within the DDT declaration, so # assign the course ID here if 'course_id' in data and data['course_id'] is None: data['course_id'] = unicode(self.course.id) # pylint: disable=no-member response = self.client.post(self.url, data) self.assertEqual(response.status_code, 400) self.assertIsNotNone(re.match(error_message, response.content)) self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email))
def date(self): # lint-amnesty, pylint: disable=invalid-overridden-method return VerificationDeadline.deadline_for_course(self.course_id)
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase): """Tests for the enrollment support view.""" def setUp(self): super(SupportViewEnrollmentsTests, self).setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in (CourseMode.AUDIT, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.VERIFIED, CourseMode.HONOR): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( course_key=self.course.id, # pylint: disable=no-member deadline=datetime.now(UTC) + timedelta(days=365)) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username}) def assert_enrollment(self, mode): """ Assert that the student's enrollment has the correct mode. """ enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) # pylint: disable=no-member self.assertEqual(enrollment.mode, mode) @ddt.data('username', 'email') def test_get_enrollments(self, search_string_type): url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertDictContainsSubset( { 'mode': CourseMode.AUDIT, 'manual_enrollment': {}, 'user': self.student.username, 'course_id': unicode(self.course.id), # pylint: disable=no-member 'is_active': True, 'verified_upgrade_deadline': None, }, data[0]) self.assertEqual( { CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL }, {mode['slug'] for mode in data[0]['course_modes']}) def test_get_manual_enrollment_history(self): ManualEnrollmentAudit.create_manual_enrollment_audit( self.user, self.student.email, ENROLLED_TO_ENROLLED, 'Financial Assistance', CourseEnrollment.objects.get(course_id=self.course.id, user=self.student) # pylint: disable=no-member ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertDictContainsSubset( { 'enrolled_by': self.user.email, 'reason': 'Financial Assistance', }, json.loads(response.content)[0]['manual_enrollment']) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.post( url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) self.assertEqual(response.status_code, 200) self.assertIsNotNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) self.assert_enrollment(CourseMode.VERIFIED) @ddt.data( ({}, r"The field \"'\w+'\" is required." ), # The double quoting goes away in Django 2.0.1 ({ 'course_id': 'bad course key' }, 'Could not parse course key.'), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, 'Could not find enrollment for user'), ({ 'course_id': None, 'old_mode': CourseMode.HONOR, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, 'reason': 'Enrollment cannot be changed to credit mode' }, '')) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): # `self` isn't available from within the DDT declaration, so # assign the course ID here if 'course_id' in data and data['course_id'] is None: data['course_id'] = unicode(self.course.id) # pylint: disable=no-member response = self.client.post(self.url, data) self.assertEqual(response.status_code, 400) self.assertIsNotNone(re.match(error_message, response.content)) self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_all_modes(self, new_mode): """ Verify support can changed the enrollment to all available modes except credit. """ self.assert_update_enrollment('username', new_mode) @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_ended_course(self, new_mode): """ Verify support can changed the enrollment of archived course. """ self.set_course_end_date_and_expiry() self.assert_update_enrollment('username', new_mode) def test_update_enrollment_with_credit_mode_throws_error(self): """ Verify that enrollment cannot be changed to credit mode. """ self.assert_update_enrollment('username', CourseMode.CREDIT_MODE) @ddt.data('username', 'email') def test_get_enrollments_with_expired_mode(self, search_string_type): """ Verify that page can get the all modes with archived course. """ self.set_course_end_date_and_expiry() url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.get(url) self._assert_generated_modes(response) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_update_enrollments_with_expired_mode(self, search_string_type): """ Verify that enrollment can be updated to verified mode. """ self.set_course_end_date_and_expiry() self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) self.assert_update_enrollment(search_string_type, CourseMode.VERIFIED) def _assert_generated_modes(self, response): """Dry method to generate course modes dict and test with response data.""" modes = CourseMode.modes_for_course(self.course.id, include_expired=True) # pylint: disable=no-member modes_data = [] for mode in modes: expiry = mode.expiration_datetime.strftime( '%Y-%m-%dT%H:%M:%SZ') if mode.expiration_datetime else None modes_data.append({ 'sku': mode.sku, 'expiration_datetime': expiry, 'name': mode.name, 'currency': mode.currency, 'bulk_sku': mode.bulk_sku, 'min_price': mode.min_price, 'suggested_prices': mode.suggested_prices, 'slug': mode.slug, 'description': mode.description }) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertEqual(modes_data, data[0]['course_modes']) self.assertEqual( { CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.HONOR }, {mode['slug'] for mode in data[0]['course_modes']}) def assert_update_enrollment(self, search_string_type, new_mode): """ Dry method to update the enrollment and assert response.""" self.assertIsNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.post( url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': new_mode, 'reason': 'Financial Assistance' }) # Enrollment cannot be changed to credit mode. if new_mode == CourseMode.CREDIT_MODE: self.assertEqual(response.status_code, 400) else: self.assertEqual(response.status_code, 200) self.assertIsNotNone( ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email)) self.assert_enrollment(new_mode) def set_course_end_date_and_expiry(self): """ Set the course-end date and expire its verified mode.""" self.course.start = datetime(year=1970, month=1, day=1, tzinfo=UTC) self.course.end = datetime(year=1970, month=1, day=10, tzinfo=UTC) # change verified mode expiry. verified_mode = CourseMode.objects.get( course_id=self.course.id, # pylint: disable=no-member mode_slug=CourseMode.VERIFIED) verified_mode.expiration_datetime = datetime(year=1970, month=1, day=9, tzinfo=UTC) verified_mode.save()
def date(self): return VerificationDeadline.deadline_for_course(self.course_id)
def check_verify_status_by_course(user, course_enrollments): """ Determine the per-course verification statuses for a given user. The possible statuses are: * VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification. * VERIFY_STATUS_SUBMITTED: The student has submitted photos for verification, but has have not yet been approved. * VERIFY_STATUS_RESUBMITTED: The student has re-submitted photos for re-verification while they still have an active but expiring ID verification * VERIFY_STATUS_APPROVED: The student has been successfully verified. * VERIFY_STATUS_MISSED_DEADLINE: The student did not submit photos within the course's deadline. * VERIFY_STATUS_NEED_TO_REVERIFY: The student has an active verification, but it is set to expire before the verification deadline for the course. It is is also possible that a course does NOT have a verification status if: * The user is not enrolled in a verified mode, meaning that the user didn't pay. * The course does not offer a verified mode. * The user submitted photos but an error occurred while verifying them. * The user submitted photos but the verification was denied. In the last two cases, we rely on messages in the sidebar rather than displaying messages for each course. Arguments: user (User): The currently logged-in user. course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in. Returns: dict: Mapping of course keys verification status dictionaries. If no verification status is applicable to a course, it will not be included in the dictionary. The dictionaries have these keys: * status (str): One of the enumerated status codes. * days_until_deadline (int): Number of days until the verification deadline. * verification_good_until (str): Date string for the verification expiration date. """ status_by_course = {} # Retrieve all verifications for the user, sorted in descending # order by submission datetime verifications = SoftwareSecurePhotoVerification.objects.filter(user=user) # Check whether the user has an active or pending verification attempt # To avoid another database hit, we re-use the queryset we have already retrieved. has_active_or_pending = SoftwareSecurePhotoVerification.user_has_valid_or_pending( user, queryset=verifications ) # Retrieve expiration_datetime of most recent approved verification # To avoid another database hit, we re-use the queryset we have already retrieved. expiration_datetime = SoftwareSecurePhotoVerification.get_expiration_datetime(user, verifications) verification_expiring_soon = SoftwareSecurePhotoVerification.is_verification_expiring_soon(expiration_datetime) # Retrieve verification deadlines for the enrolled courses enrolled_course_keys = [enrollment.course_id for enrollment in course_enrollments] course_deadlines = VerificationDeadline.deadlines_for_courses(enrolled_course_keys) recent_verification_datetime = None for enrollment in course_enrollments: # If the user hasn't enrolled as verified, then the course # won't display state related to its verification status. if enrollment.mode in CourseMode.VERIFIED_MODES: # Retrieve the verification deadline associated with the course. # This could be None if the course doesn't have a deadline. deadline = course_deadlines.get(enrollment.course_id) relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications) # Picking the max verification datetime on each iteration only with approved status if relevant_verification is not None and relevant_verification.status == "approved": recent_verification_datetime = max( recent_verification_datetime if recent_verification_datetime is not None else relevant_verification.expiration_datetime, relevant_verification.expiration_datetime ) # By default, don't show any status related to verification status = None # Check whether the user was approved or is awaiting approval if relevant_verification is not None: if relevant_verification.status == "approved": if verification_expiring_soon: status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_APPROVED elif relevant_verification.status == "submitted": if verification_expiring_soon: status = VERIFY_STATUS_RESUBMITTED else: status = VERIFY_STATUS_SUBMITTED # If the user didn't submit at all, then tell them they need to verify # If the deadline has already passed, then tell them they missed it. # If they submitted but something went wrong (error or denied), # then don't show any messaging next to the course, since we already # show messages related to this on the left sidebar. submitted = ( relevant_verification is not None and relevant_verification.status not in ["created", "ready"] ) if status is None and not submitted: if deadline is None or deadline > datetime.now(UTC): if SoftwareSecurePhotoVerification.user_is_verified(user): if verification_expiring_soon: # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # Tell the student to reverify. status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_NEED_TO_VERIFY else: # If a user currently has an active or pending verification, # then they may have submitted an additional attempt after # the verification deadline passed. This can occur, # for example, when the support team asks a student # to reverify after the deadline so they can receive # a verified certificate. # In this case, we still want to show them as "verified" # on the dashboard. if has_active_or_pending: status = VERIFY_STATUS_APPROVED # Otherwise, the student missed the deadline, so show # them as "honor" (the kind of certificate they will receive). else: status = VERIFY_STATUS_MISSED_DEADLINE # Set the status for the course only if we're displaying some kind of message # Otherwise, leave the course out of the dictionary. if status is not None: days_until_deadline = None now = datetime.now(UTC) if deadline is not None and deadline > now: days_until_deadline = (deadline - now).days status_by_course[enrollment.course_id] = { 'status': status, 'days_until_deadline': days_until_deadline } if recent_verification_datetime: for key, value in status_by_course.iteritems(): # pylint: disable=unused-variable status_by_course[key]['verification_good_until'] = recent_verification_datetime.strftime("%m/%d/%Y") return status_by_course
def get( self, request, course_id, always_show_payment=False, current_step=None, message=FIRST_TIME_VERIFY_MSG ): """ Render the payment and verification flow. Arguments: request (HttpRequest): The request object. course_id (unicode): The ID of the course the user is trying to enroll in. Keyword Arguments: always_show_payment (bool): If True, show the payment steps even if the user has already paid. This is useful for users returning to the flow after paying. current_step (string): The current step in the flow. message (string): The messaging to display. Returns: HttpResponse Raises: Http404: The course does not exist or does not have a verified mode. """ # Parse the course key # The URL regex should guarantee that the key format is valid. course_key = CourseKey.from_string(course_id) course = modulestore().get_course(course_key) # Verify that the course exists if course is None: log.warn(u"Could not find course with ID %s.", course_id) raise Http404 # Check whether the user has access to this course # based on country access rules. redirect_url = embargo_api.redirect_if_blocked( course_key, user=request.user, ip_address=get_ip(request), url=request.path ) if redirect_url: return redirect(redirect_url) # If the verification deadline has passed # then show the user a message that he/she can't verify. # # We're making the assumptions (enforced in Django admin) that: # # 1) Only verified modes have verification deadlines. # # 2) If set, verification deadlines are always AFTER upgrade deadlines, because why would you # let someone upgrade into a verified track if they can't complete verification? # verification_deadline = VerificationDeadline.deadline_for_course(course.id) response = self._response_if_deadline_passed(course, self.VERIFICATION_DEADLINE, verification_deadline) if response is not None: log.info(u"Verification deadline for '%s' has passed.", course.id) return response # Retrieve the relevant course mode for the payment/verification flow. # # WARNING: this is technical debt! A much better way to do this would be to # separate out the payment flow and use the product SKU to figure out what # the user is trying to purchase. # # Nonetheless, for the time being we continue to make the really ugly assumption # that at some point there was a paid course mode we can query for the price. relevant_course_mode = self._get_paid_mode(course_key) # If we can find a relevant course mode, then log that we're entering the flow # Otherwise, this course does not support payment/verification, so respond with a 404. if relevant_course_mode is not None: if CourseMode.is_verified_mode(relevant_course_mode): log.info( u"Entering payment and verification flow for user '%s', course '%s', with current step '%s'.", request.user.id, course_id, current_step ) else: log.info( u"Entering payment flow for user '%s', course '%s', with current step '%s'", request.user.id, course_id, current_step ) else: # Otherwise, there has never been a verified/paid mode, # so return a page not found response. log.warn( u"No paid/verified course mode found for course '%s' for verification/payment flow request", course_id ) raise Http404 # If the user is trying to *pay* and the upgrade deadline has passed, # then they shouldn't be able to enter the flow. # # NOTE: This should match the availability dates used by the E-Commerce service # to determine whether a user can purchase a product. The idea is that if the service # won't fulfill the order, we shouldn't even let the user get into the payment flow. # user_is_trying_to_pay = message in [self.FIRST_TIME_VERIFY_MSG, self.UPGRADE_MSG] if user_is_trying_to_pay: upgrade_deadline = relevant_course_mode.expiration_datetime response = self._response_if_deadline_passed(course, self.UPGRADE_DEADLINE, upgrade_deadline) if response is not None: log.info(u"Upgrade deadline for '%s' has passed.", course.id) return response # Check whether the user has verified, paid, and enrolled. # A user is considered "paid" if he or she has an enrollment # with a paid course mode (such as "verified"). # For this reason, every paid user is enrolled, but not # every enrolled user is paid. # If the course mode is not verified(i.e only paid) then already_verified is always True already_verified = ( self._check_already_verified(request.user) if CourseMode.is_verified_mode(relevant_course_mode) else True ) already_paid, is_enrolled = self._check_enrollment(request.user, course_key) # Redirect the user to a more appropriate page if the # messaging won't make sense based on the user's # enrollment / payment / verification status. sku_to_use = relevant_course_mode.sku purchase_workflow = request.GET.get('purchase_workflow', 'single') if purchase_workflow == 'bulk' and relevant_course_mode.bulk_sku: sku_to_use = relevant_course_mode.bulk_sku redirect_response = self._redirect_if_necessary( message, already_verified, already_paid, is_enrolled, course_key, user_is_trying_to_pay, request.user, sku_to_use ) if redirect_response is not None: return redirect_response display_steps = self._display_steps( always_show_payment, already_verified, already_paid, relevant_course_mode ) # Override the actual value if account activation has been disabled # Also see the reference to this parameter in context dictionary further down user_is_active = self._get_user_active_status(request.user) requirements = self._requirements(display_steps, user_is_active) if current_step is None: current_step = display_steps[0]['name'] # Allow the caller to skip the first page # This is useful if we want the user to be able to # use the "back" button to return to the previous step. # This parameter should only work for known skip-able steps if request.GET.get('skip-first-step') and current_step in self.SKIP_STEPS: display_step_names = [step['name'] for step in display_steps] current_step_idx = display_step_names.index(current_step) if (current_step_idx + 1) < len(display_steps): current_step = display_steps[current_step_idx + 1]['name'] courseware_url = "" if not course.start or course.start < datetime.datetime.today().replace(tzinfo=UTC): courseware_url = reverse( 'course_root', kwargs={'course_id': unicode(course_key)} ) full_name = ( request.user.profile.name if request.user.profile.name else "" ) # If the user set a contribution amount on another page, # use that amount to pre-fill the price selection form. contribution_amount = request.session.get( 'donation_for_course', {} ).get(unicode(course_key), '') # Remember whether the user is upgrading # so we can fire an analytics event upon payment. request.session['attempting_upgrade'] = (message == self.UPGRADE_MSG) # Determine the photo verification status verification_good_until = self._verification_valid_until(request.user) # get available payment processors if relevant_course_mode.sku: # transaction will be conducted via ecommerce service processors = ecommerce_api_client(request.user).payment.processors.get() else: # transaction will be conducted using legacy shopping cart processors = [settings.CC_PROCESSOR_NAME] # Render the top-level page context = { 'contribution_amount': contribution_amount, 'course': course, 'course_key': unicode(course_key), 'checkpoint_location': request.GET.get('checkpoint'), 'course_mode': relevant_course_mode, 'courseware_url': courseware_url, 'current_step': current_step, 'disable_courseware_js': True, 'display_steps': display_steps, 'is_active': json.dumps(user_is_active), 'user_email': request.user.email, 'message_key': message, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'processors': processors, 'requirements': requirements, 'user_full_name': full_name, 'verification_deadline': verification_deadline or "", 'already_verified': already_verified, 'verification_good_until': verification_good_until, 'capture_sound': staticfiles_storage.url("audio/camera_capture.wav"), 'nav_hidden': True, 'is_ab_testing': 'begin-flow' in request.path, } return render_to_response("verify_student/pay_and_verify.html", context)
def test_no_deadline(self): """ Verify the signal sets deadline to course end when no deadline exists.""" _listen_for_course_publish('store', self.course.id) assert VerificationDeadline.deadline_for_course( self.course.id) == self.course.end
def test_no_deadline(self): """ Verify the signal sets deadline to course end when no deadline exists.""" _listen_for_course_publish('store', self.course.id) self.assertEqual(VerificationDeadline.deadline_for_course(self.course.id), self.course.end)
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase): """Tests for the enrollment support view.""" def setUp(self): super(SupportViewEnrollmentsTests, self).setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in (CourseMode.AUDIT, CourseMode.VERIFIED): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( course_key=self.course.id, # pylint: disable=no-member deadline=datetime.now(UTC) + timedelta(days=365) ) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username}) def assert_enrollment(self, mode): """ Assert that the student's enrollment has the correct mode. """ enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) # pylint: disable=no-member self.assertEqual(enrollment.mode, mode) @ddt.data('username', 'email') def test_get_enrollments(self, search_string_type): url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertDictContainsSubset({ 'mode': CourseMode.AUDIT, 'manual_enrollment': {}, 'user': self.student.username, 'course_id': unicode(self.course.id), # pylint: disable=no-member 'is_active': True, 'verified_upgrade_deadline': None, }, data[0]) self.assertEqual( {CourseMode.VERIFIED, CourseMode.AUDIT}, {mode['slug'] for mode in data[0]['course_modes']} ) def test_get_manual_enrollment_history(self): ManualEnrollmentAudit.create_manual_enrollment_audit( self.user, self.student.email, ENROLLED_TO_ENROLLED, 'Financial Assistance', CourseEnrollment.objects.get(course_id=self.course.id, user=self.student) # pylint: disable=no-member ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertDictContainsSubset({ 'enrolled_by': self.user.email, 'reason': 'Financial Assistance', }, json.loads(response.content)[0]['manual_enrollment']) @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.post(url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) self.assertEqual(response.status_code, 200) self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) self.assert_enrollment(CourseMode.VERIFIED) @ddt.data( ({}, r"The field '\w+' is required."), ({'course_id': 'bad course key'}, 'Could not parse course key.'), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, 'Could not find enrollment for user'), ({ 'course_id': None, 'old_mode': CourseMode.HONOR, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ 'course_id': None, 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, 'reason': '' }, "Specified course mode '{}' unavailable".format(CourseMode.CREDIT_MODE)) ) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): # `self` isn't available from within the DDT declaration, so # assign the course ID here if 'course_id' in data and data['course_id'] is None: data['course_id'] = unicode(self.course.id) # pylint: disable=no-member response = self.client.post(self.url, data) self.assertEqual(response.status_code, 400) self.assertIsNotNone(re.match(error_message, response.content)) self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase): """Tests for the enrollment support view.""" def setUp(self): super().setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name='teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in (CourseMode.AUDIT, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.VERIFIED, CourseMode.HONOR): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) self.verification_deadline = VerificationDeadline( course_key=self.course.id, deadline=datetime.now(UTC) + timedelta(days=365)) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username}) def assert_enrollment(self, mode): """ Assert that the student's enrollment has the correct mode. """ enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) assert enrollment.mode == mode @ddt.data('username', 'email') def test_get_enrollments(self, search_string_type): url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.get(url) assert response.status_code == 200 data = json.loads(response.content.decode('utf-8')) assert len(data) == 1 self.assertDictContainsSubset( { 'mode': CourseMode.AUDIT, 'manual_enrollment': {}, 'user': self.student.username, 'course_id': str(self.course.id), 'is_active': True, 'verified_upgrade_deadline': None, }, data[0]) assert { CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE } == {mode['slug'] for mode in data[0]['course_modes']} def test_get_manual_enrollment_history(self): ManualEnrollmentAudit.create_manual_enrollment_audit( self.user, self.student.email, ENROLLED_TO_ENROLLED, 'Financial Assistance', CourseEnrollment.objects.get(course_id=self.course.id, user=self.student)) response = self.client.get(self.url) assert response.status_code == 200 self.assertDictContainsSubset( { 'enrolled_by': self.user.email, 'reason': 'Financial Assistance', }, json.loads( response.content.decode('utf-8'))[0]['manual_enrollment']) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is None url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.post(url, data={ 'course_id': str(self.course.id), 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) assert response.status_code == 200 assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is not None self.assert_enrollment(CourseMode.VERIFIED) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') @patch("common.djangoapps.entitlements.models.get_course_uuid_for_course") def test_change_enrollment_mode_fullfills_entitlement( self, search_string_type, mock_get_course_uuid): """ Assert that changing student's enrollment fulfills it's respective entitlement if it exists. """ assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is None enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) entitlement = CourseEntitlementFactory.create( user=self.user, mode=CourseMode.VERIFIED, enrollment_course_run=enrollment) mock_get_course_uuid.return_value = entitlement.course_uuid url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.post(url, data={ 'course_id': str(self.course.id), 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) entitlement.refresh_from_db() assert response.status_code == 200 assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is not None assert entitlement.enrollment_course_run is not None assert entitlement.is_entitlement_redeemable() is False self.assert_enrollment(CourseMode.VERIFIED) @ddt.data( ({}, r"The field \w+ is required."), ({ 'course_id': 'bad course key' }, 'Could not parse course key.'), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, 'Could not find enrollment for user'), ({ 'course_id': None, 'old_mode': CourseMode.HONOR, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, 'reason': 'Enrollment cannot be changed to credit mode' }, '')) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): # `self` isn't available from within the DDT declaration, so # assign the course ID here if 'course_id' in data and data['course_id'] is None: data['course_id'] = str(self.course.id) response = self.client.post(self.url, data) assert response.status_code == 400 assert re.match( error_message, response.content.decode('utf-8').replace("'", '').replace( '"', '')) is not None self.assert_enrollment(CourseMode.AUDIT) assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is None @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional', 'credit') def test_update_enrollment_for_all_modes(self, new_mode): """ Verify support can changed the enrollment to all available modes""" self.assert_update_enrollment('username', new_mode) @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_ended_course(self, new_mode): """ Verify support can changed the enrollment of archived course. """ self.set_course_end_date_and_expiry() self.assert_update_enrollment('username', new_mode) @ddt.data('username', 'email') def test_get_enrollments_with_expired_mode(self, search_string_type): """ Verify that page can get the all modes with archived course. """ self.set_course_end_date_and_expiry() url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) response = self.client.get(url) self._assert_generated_modes(response) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_update_enrollments_with_expired_mode(self, search_string_type): """ Verify that enrollment can be updated to verified mode. """ self.set_course_end_date_and_expiry() assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is None self.assert_update_enrollment(search_string_type, CourseMode.VERIFIED) def _assert_generated_modes(self, response): """Dry method to generate course modes dict and test with response data.""" modes = CourseMode.modes_for_course(self.course.id, include_expired=True, only_selectable=False) modes_data = [] for mode in modes: expiry = mode.expiration_datetime.strftime( '%Y-%m-%dT%H:%M:%SZ') if mode.expiration_datetime else None modes_data.append({ 'sku': mode.sku, 'expiration_datetime': expiry, 'name': mode.name, 'currency': mode.currency, 'bulk_sku': mode.bulk_sku, 'min_price': mode.min_price, 'suggested_prices': mode.suggested_prices, 'slug': mode.slug, 'description': mode.description }) assert response.status_code == 200 data = json.loads(response.content.decode('utf-8')) assert len(data) == 1 assert modes_data == data[0]['course_modes'] assert { CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.HONOR, CourseMode.CREDIT_MODE } == {mode['slug'] for mode in data[0]['course_modes']} def assert_update_enrollment(self, search_string_type, new_mode): """ Dry method to update the enrollment and assert response.""" assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is None url = reverse('support:enrollment_list', kwargs={ 'username_or_email': getattr(self.student, search_string_type) }) with patch( 'lms.djangoapps.support.views.enrollments.get_credit_provider_attribute_values' ) as mock_method: credit_provider = ([ 'Arizona State University' ], 'You are now eligible for credit from Arizona State University') mock_method.return_value = credit_provider response = self.client.post(url, data={ 'course_id': str(self.course.id), 'old_mode': CourseMode.AUDIT, 'new_mode': new_mode, 'reason': 'Financial Assistance' }) assert response.status_code == 200 assert ManualEnrollmentAudit.get_manual_enrollment_by_email( self.student.email) is not None self.assert_enrollment(new_mode) if new_mode == 'credit': enrollment_attr = CourseEnrollmentAttribute.objects.first() assert enrollment_attr.value == str(credit_provider[0]) def set_course_end_date_and_expiry(self): """ Set the course-end date and expire its verified mode.""" self.course.start = datetime(year=1970, month=1, day=1, tzinfo=UTC) self.course.end = datetime(year=1970, month=1, day=10, tzinfo=UTC) # change verified mode expiry. verified_mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) verified_mode.expiration_datetime = datetime(year=1970, month=1, day=9, tzinfo=UTC) verified_mode.save()
class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase): """Tests for the enrollment support view.""" def setUp(self): super(SupportViewEnrollmentsTests, self).setUp() SupportStaffRole().add_users(self.user) self.course = CourseFactory(display_name=u'teꜱᴛ') self.student = UserFactory.create(username='******', email='*****@*****.**', password='******') for mode in ( CourseMode.AUDIT, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.VERIFIED, CourseMode.HONOR ): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) # pylint: disable=no-member self.verification_deadline = VerificationDeadline( course_key=self.course.id, # pylint: disable=no-member deadline=datetime.now(UTC) + timedelta(days=365) ) self.verification_deadline.save() CourseEnrollmentFactory.create(mode=CourseMode.AUDIT, user=self.student, course_id=self.course.id) # pylint: disable=no-member self.url = reverse('support:enrollment_list', kwargs={'username_or_email': self.student.username}) def assert_enrollment(self, mode): """ Assert that the student's enrollment has the correct mode. """ enrollment = CourseEnrollment.get_enrollment(self.student, self.course.id) # pylint: disable=no-member self.assertEqual(enrollment.mode, mode) @ddt.data('username', 'email') def test_get_enrollments(self, search_string_type): url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.get(url) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertDictContainsSubset({ 'mode': CourseMode.AUDIT, 'manual_enrollment': {}, 'user': self.student.username, 'course_id': unicode(self.course.id), # pylint: disable=no-member 'is_active': True, 'verified_upgrade_deadline': None, }, data[0]) self.assertEqual( {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL}, {mode['slug'] for mode in data[0]['course_modes']} ) def test_get_manual_enrollment_history(self): ManualEnrollmentAudit.create_manual_enrollment_audit( self.user, self.student.email, ENROLLED_TO_ENROLLED, 'Financial Assistance', CourseEnrollment.objects.get(course_id=self.course.id, user=self.student) # pylint: disable=no-member ) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertDictContainsSubset({ 'enrolled_by': self.user.email, 'reason': 'Financial Assistance', }, json.loads(response.content)[0]['manual_enrollment']) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_change_enrollment(self, search_string_type): self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.post(url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': 'Financial Assistance' }) self.assertEqual(response.status_code, 200) self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) self.assert_enrollment(CourseMode.VERIFIED) @ddt.data( ({}, r"The field \"'\w+'\" is required."), # The double quoting goes away in Django 2.0.1 ({'course_id': 'bad course key'}, 'Could not parse course key.'), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, 'Could not find enrollment for user'), ({ 'course_id': None, 'old_mode': CourseMode.HONOR, 'new_mode': CourseMode.VERIFIED, 'reason': '' }, r'User \w+ is not enrolled with mode ' + CourseMode.HONOR), ({ 'course_id': 'course-v1:TestX+T101+2015', 'old_mode': CourseMode.AUDIT, 'new_mode': CourseMode.CREDIT_MODE, 'reason': 'Enrollment cannot be changed to credit mode' }, '') ) @ddt.unpack def test_change_enrollment_bad_data(self, data, error_message): # `self` isn't available from within the DDT declaration, so # assign the course ID here if 'course_id' in data and data['course_id'] is None: data['course_id'] = unicode(self.course.id) # pylint: disable=no-member response = self.client.post(self.url, data) self.assertEqual(response.status_code, 400) self.assertIsNotNone(re.match(error_message, response.content)) self.assert_enrollment(CourseMode.AUDIT) self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_all_modes(self, new_mode): """ Verify support can changed the enrollment to all available modes except credit. """ self.assert_update_enrollment('username', new_mode) @disable_signal(signals, 'post_save') @ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional') def test_update_enrollment_for_ended_course(self, new_mode): """ Verify support can changed the enrollment of archived course. """ self.set_course_end_date_and_expiry() self.assert_update_enrollment('username', new_mode) def test_update_enrollment_with_credit_mode_throws_error(self): """ Verify that enrollment cannot be changed to credit mode. """ self.assert_update_enrollment('username', CourseMode.CREDIT_MODE) @ddt.data('username', 'email') def test_get_enrollments_with_expired_mode(self, search_string_type): """ Verify that page can get the all modes with archived course. """ self.set_course_end_date_and_expiry() url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.get(url) self._assert_generated_modes(response) @disable_signal(signals, 'post_save') @ddt.data('username', 'email') def test_update_enrollments_with_expired_mode(self, search_string_type): """ Verify that enrollment can be updated to verified mode. """ self.set_course_end_date_and_expiry() self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) self.assert_update_enrollment(search_string_type, CourseMode.VERIFIED) def _assert_generated_modes(self, response): """Dry method to generate course modes dict and test with response data.""" modes = CourseMode.modes_for_course(self.course.id, include_expired=True) # pylint: disable=no-member modes_data = [] for mode in modes: expiry = mode.expiration_datetime.strftime('%Y-%m-%dT%H:%M:%SZ') if mode.expiration_datetime else None modes_data.append({ 'sku': mode.sku, 'expiration_datetime': expiry, 'name': mode.name, 'currency': mode.currency, 'bulk_sku': mode.bulk_sku, 'min_price': mode.min_price, 'suggested_prices': mode.suggested_prices, 'slug': mode.slug, 'description': mode.description }) self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(len(data), 1) self.assertEqual( modes_data, data[0]['course_modes'] ) self.assertEqual( {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.HONOR}, {mode['slug'] for mode in data[0]['course_modes']} ) def assert_update_enrollment(self, search_string_type, new_mode): """ Dry method to update the enrollment and assert response.""" self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) url = reverse( 'support:enrollment_list', kwargs={'username_or_email': getattr(self.student, search_string_type)} ) response = self.client.post(url, data={ 'course_id': unicode(self.course.id), # pylint: disable=no-member 'old_mode': CourseMode.AUDIT, 'new_mode': new_mode, 'reason': 'Financial Assistance' }) # Enrollment cannot be changed to credit mode. if new_mode == CourseMode.CREDIT_MODE: self.assertEqual(response.status_code, 400) else: self.assertEqual(response.status_code, 200) self.assertIsNotNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email)) self.assert_enrollment(new_mode) def set_course_end_date_and_expiry(self): """ Set the course-end date and expire its verified mode.""" self.course.start = datetime(year=1970, month=1, day=1, tzinfo=UTC) self.course.end = datetime(year=1970, month=1, day=10, tzinfo=UTC) # change verified mode expiry. verified_mode = CourseMode.objects.get( course_id=self.course.id, # pylint: disable=no-member mode_slug=CourseMode.VERIFIED ) verified_mode.expiration_datetime = datetime(year=1970, month=1, day=9, tzinfo=UTC) verified_mode.save()
def date(self): return VerificationDeadline.deadline_for_course(self.course.id)
def check_verify_status_by_course(user, course_enrollments): """ Determine the per-course verification statuses for a given user. The possible statuses are: * VERIFY_STATUS_NEED_TO_VERIFY: The student has not yet submitted photos for verification. * VERIFY_STATUS_SUBMITTED: The student has submitted photos for verification, but has have not yet been approved. * VERIFY_STATUS_RESUBMITTED: The student has re-submitted photos for re-verification while they still have an active but expiring ID verification * VERIFY_STATUS_APPROVED: The student has been successfully verified. * VERIFY_STATUS_MISSED_DEADLINE: The student did not submit photos within the course's deadline. * VERIFY_STATUS_NEED_TO_REVERIFY: The student has an active verification, but it is set to expire before the verification deadline for the course. It is is also possible that a course does NOT have a verification status if: * The user is not enrolled in a verified mode, meaning that the user didn't pay. * The course does not offer a verified mode. * The user submitted photos but an error occurred while verifying them. * The user submitted photos but the verification was denied. In the last two cases, we rely on messages in the sidebar rather than displaying messages for each course. Arguments: user (User): The currently logged-in user. course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in. Returns: dict: Mapping of course keys verification status dictionaries. If no verification status is applicable to a course, it will not be included in the dictionary. The dictionaries have these keys: * status (str): One of the enumerated status codes. * days_until_deadline (int): Number of days until the verification deadline. * verification_good_until (str): Date string for the verification expiration date. """ status_by_course = {} # Retrieve all verifications for the user, sorted in descending # order by submission datetime verifications = IDVerificationService.verifications_for_user(user) # Check whether the user has an active or pending verification attempt has_active_or_pending = IDVerificationService.user_has_valid_or_pending( user) # Retrieve expiration_datetime of most recent approved verification expiration_datetime = IDVerificationService.get_expiration_datetime( user, ['approved']) verification_expiring_soon = is_verification_expiring_soon( expiration_datetime) # Retrieve verification deadlines for the enrolled courses course_deadlines = VerificationDeadline.deadlines_for_enrollments( CourseEnrollment.enrollments_for_user(user)) recent_verification_datetime = None for enrollment in course_enrollments: # If the user hasn't enrolled as verified, then the course # won't display state related to its verification status. if enrollment.mode in CourseMode.VERIFIED_MODES: # Retrieve the verification deadline associated with the course. # This could be None if the course doesn't have a deadline. deadline = course_deadlines.get(enrollment.course_id) relevant_verification = verification_for_datetime( deadline, verifications) # Picking the max verification datetime on each iteration only with approved status if relevant_verification is not None and relevant_verification.status == "approved": recent_verification_datetime = max( recent_verification_datetime if recent_verification_datetime is not None else relevant_verification.expiration_datetime, relevant_verification.expiration_datetime) # By default, don't show any status related to verification status = None should_display = True # Check whether the user was approved or is awaiting approval if relevant_verification is not None: should_display = relevant_verification.should_display_status_to_user( ) if relevant_verification.status == "approved": if verification_expiring_soon: status = VERIFY_STATUS_NEED_TO_REVERIFY else: status = VERIFY_STATUS_APPROVED elif relevant_verification.status == "submitted": if verification_expiring_soon: status = VERIFY_STATUS_RESUBMITTED else: status = VERIFY_STATUS_SUBMITTED # If the user didn't submit at all, then tell them they need to verify # If the deadline has already passed, then tell them they missed it. # If they submitted but something went wrong (error or denied), # then don't show any messaging next to the course, since we already # show messages related to this on the left sidebar. submitted = (relevant_verification is not None and relevant_verification.status not in ["created", "ready"]) if status is None and not submitted: if deadline is None or deadline > datetime.now(UTC): if IDVerificationService.user_is_verified( user) and verification_expiring_soon: # The user has an active verification, but the verification # is set to expire within "EXPIRING_SOON_WINDOW" days (default is 4 weeks). # Tell the student to reverify. status = VERIFY_STATUS_NEED_TO_REVERIFY elif not IDVerificationService.user_is_verified(user): status = VERIFY_STATUS_NEED_TO_VERIFY else: # If a user currently has an active or pending verification, # then they may have submitted an additional attempt after # the verification deadline passed. This can occur, # for example, when the support team asks a student # to reverify after the deadline so they can receive # a verified certificate. # In this case, we still want to show them as "verified" # on the dashboard. if has_active_or_pending: status = VERIFY_STATUS_APPROVED # Otherwise, the student missed the deadline, so show # them as "honor" (the kind of certificate they will receive). else: status = VERIFY_STATUS_MISSED_DEADLINE # Set the status for the course only if we're displaying some kind of message # Otherwise, leave the course out of the dictionary. if status is not None: days_until_deadline = None now = datetime.now(UTC) if deadline is not None and deadline > now: days_until_deadline = (deadline - now).days status_by_course[enrollment.course_id] = { 'status': status, 'days_until_deadline': days_until_deadline, 'should_display': should_display, } if recent_verification_datetime: for key, value in iteritems(status_by_course): # pylint: disable=unused-variable status_by_course[key][ 'verification_good_until'] = recent_verification_datetime.strftime( "%m/%d/%Y") return status_by_course