diff --git a/cms/templates/emails/course_creator_admin_user_pending.txt b/cms/templates/emails/course_creator_admin_user_pending.txt index 7a7d41744d..b8eed75e20 100644 --- a/cms/templates/emails/course_creator_admin_user_pending.txt +++ b/cms/templates/emails/course_creator_admin_user_pending.txt @@ -1,5 +1,5 @@ <%! from django.utils.translation import gettext as _ %> -${_("User '{user}' with e-mail {email} has requested {studio_name} course creator privileges on edge.").format( +${_("User '{user}' with e-mail {email} has requested {studio_name} course creator privileges.").format( user=user_name, email=user_email, studio_name=settings.STUDIO_SHORT_NAME, )} ${_("To grant or deny this request, use the course creator admin table.")} diff --git a/common/djangoapps/third_party_auth/README.rst b/common/djangoapps/third_party_auth/README.rst new file mode 100644 index 0000000000..d2e1089eca --- /dev/null +++ b/common/djangoapps/third_party_auth/README.rst @@ -0,0 +1,11 @@ +Third Party Auth +---------------- + +This djangoapp provides the views and workflows for authenticating into edx-platform with third-party applications, including both OAuth and SAML workflows. + +We make use of the `social-auth-app-django`_ as our backend library for this djangoapp. + +To enable this feature, check out the `third party authentication documentation`. + +.. _social-auth-app-django: https://github.com/python-social-auth/social-app-django +.. _third party authentication documentation: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/index.html diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index 284c50fcf8..d44da6248e 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -53,13 +53,15 @@ class SAMLProviderConfigForm(forms.ModelForm): class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): """ Django Admin class for SAMLProviderConfig """ form = SAMLProviderConfigForm + search_fields = ['display_name'] def get_queryset(self, request): """ - Filter the queryset to exclude the archived records. + Filter the queryset to exclude the archived records unless it's the /change/ view. """ - queryset = super().get_queryset(request).exclude(archived=True) - return queryset + if request.path.endswith('/change/'): + return self.model.objects.all() + return super().get_queryset(request).exclude(archived=True) def archive_provider_configuration(self, request, queryset): """ @@ -99,7 +101,15 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): Record name with link for the change view. """ if not instance.is_active: - return instance.name + update_url = reverse( + f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_change', + args=[instance.pk] + ) + return format_html( + '{}', + update_url, + f'{instance.name}' + ) update_url = reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_add') update_url += f'?source={instance.pk}' @@ -167,11 +177,11 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): # Always redirect back to the SAMLProviderConfig listing page return HttpResponseRedirect(reverse('admin:third_party_auth_samlproviderconfig_changelist')) - def change_view(self, request, object_slug, form_url='', extra_context=None): + def change_view(self, request, object_id, form_url='', extra_context=None): """ Extend the change view to include CSV upload. """ extra_context = extra_context or {} extra_context['show_csv_upload'] = True - return super().change_view(request, object_slug, form_url, extra_context) + return super().change_view(request, object_id, form_url, extra_context) def csv_uuid_update_button(self, obj): """ Add CSV upload button to the form. """ diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 4bfc710fe9..c19b0b8d96 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -583,7 +583,7 @@ class SetIDVerificationStatusTestCase(TestCase): """ Verification signal is sent upon approval. """ - with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: + with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_SSO_VERIFIED.send_robust') as mock_signal: # Begin the pipeline. pipeline.set_id_verification_status( auth_entry=pipeline.AUTH_ENTRY_LOGIN, diff --git a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl index d7ca8fd9a4..e5b66bf3b2 100644 --- a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl +++ b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl @@ -32,6 +32,8 @@ workspace { grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal" verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal" + verify_student_app -> signal_handlers "Emits LEARNER_SSO_VERIFIED signal" + verify_student_app -> signal_handlers "Emits PHOTO_VERIFICATION_APPROVED signal" student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal" allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal" signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()" diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 53055bf9c8..39042ff341 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -32,6 +32,8 @@ from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACI from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, + LEARNER_SSO_VERIFIED, + PHOTO_VERIFICATION_APPROVED, ) from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED @@ -117,17 +119,13 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade') -@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed") -def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument +def _handle_id_verification_approved(user): """ - Listen for a signal indicating that the user's id verification status has changed. + Generate a certificate for the user if they are now verified """ if not auto_certificate_generation_enabled(): return - event_data = kwargs.get('idv_attempt') - user = User.objects.get(id=event_data.user.id) - user_enrollments = CourseEnrollment.enrollments_for_user(user=user) expected_verification_status = IDVerificationService.user_status(user) expected_verification_status = expected_verification_status['status'] @@ -145,6 +143,25 @@ def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pyl ) +@receiver(LEARNER_SSO_VERIFIED, dispatch_uid="sso_learner_verified") +@receiver(PHOTO_VERIFICATION_APPROVED, dispatch_uid="photo_verification_approved") +def _listen_for_sso_verification_approved(sender, user, **kwargs): # pylint: disable=unused-argument + """ + Listen for a signal on SSOVerification indicating that the user has been verified. + """ + _handle_id_verification_approved(user) + + +@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="openedx_idv_attempt_approved") +def _listen_for_id_verification_approved_event(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Listen for an openedx event indicating that the user's id verification status has changed. + """ + event_data = kwargs.get('idv_attempt') + user = User.objects.get(id=event_data.user.id) + _handle_id_verification_approved(user) + + @receiver(ENROLLMENT_TRACK_UPDATED) def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs): # pylint: disable=unused-argument """ diff --git a/lms/djangoapps/certificates/tests/test_filters.py b/lms/djangoapps/certificates/tests/test_filters.py index 781b77461f..a131a1d525 100644 --- a/lms/djangoapps/certificates/tests/test_filters.py +++ b/lms/djangoapps/certificates/tests/test_filters.py @@ -23,7 +23,7 @@ from lms.djangoapps.certificates.generation_handler import ( from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.certificates.signals import ( _listen_for_enrollment_mode_change, - _listen_for_id_verification_status_changed, + _handle_id_verification_approved, listen_for_passing_grade ) from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory @@ -272,7 +272,7 @@ class CertificateFiltersTest(SharedModuleStoreTestCase): mock.Mock(return_value={"status": "approved"}) ) @mock.patch("lms.djangoapps.certificates.api.auto_certificate_generation_enabled", mock.Mock(return_value=True)) - def test_listen_for_id_verification_status_changed(self): + def test_handle_id_verification_approved(self): """ Test stop certificate generation process after the verification status changed by raising a filters exception. @@ -280,7 +280,7 @@ class CertificateFiltersTest(SharedModuleStoreTestCase): - CertificateCreationRequested is triggered and executes TestStopCertificateGenerationStep. - The certificate is not generated. """ - _listen_for_id_verification_status_changed(None, self.user) + _handle_id_verification_approved(self.user) self.assertFalse( GeneratedCertificate.objects.filter( diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index 82ed8c4830..617852b4f6 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -238,6 +238,7 @@ def refund_entitlement(course_entitlement): return False +@pluggable_override('OVERRIDE_REFUND_SEAT') def refund_seat(course_enrollment, change_mode=False): """ Attempt to initiate a refund for any orders associated with the seat being unenrolled, @@ -287,7 +288,7 @@ def refund_seat(course_enrollment, change_mode=False): user=enrollee, ) if change_mode: - _auto_enroll(course_enrollment) + auto_enroll(course_enrollment) else: log.info('No refund opened for user [%s], course [%s]', enrollee.id, course_key_str) @@ -354,7 +355,7 @@ def _refund_in_commerce_coordinator(course_enrollment, change_mode): log.info('Refund successfully sent to Commerce Coordinator for user [%s], course [%s].', course_enrollment.user_id, course_key_str) if change_mode: - _auto_enroll(course_enrollment) + auto_enroll(course_enrollment) return True else: # Refund was not meant to be sent to Commerce Coordinator @@ -363,7 +364,7 @@ def _refund_in_commerce_coordinator(course_enrollment, change_mode): return False -def _auto_enroll(course_enrollment): +def auto_enroll(course_enrollment): """ Helper method to update an enrollment to a default course mode. diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 43fb40436a..e6070a2e3b 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -148,7 +148,6 @@ COURSEWARE_OPTIMIZED_RENDER_XBLOCK = CourseWaffleFlag( # .. toggle_creation_date: 2019-05-16 # .. toggle_expiration_date: None # .. toggle_tickets: https://github.com/mitodl/edx-platform/issues/123 -# .. toggle_status: unsupported COURSES_INVITE_ONLY = SettingToggle('COURSES_INVITE_ONLY', default=False) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 94184f6d93..2b1f28e4ce 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1416,13 +1416,9 @@ def get_issued_certificates(request, course_id): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def get_students_features(request, course_id, csv=False): # pylint: disable=redefined-outer-name +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): """ Respond with json which contains a summary of all enrolled students profile information. @@ -1431,86 +1427,108 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red TO DO accept requests for different attribute sets. """ - course_key = CourseKey.from_string(course_id) - course = get_course_by_id(course_key) - report_type = _('enrolled learner profile') - available_features = instructor_analytics_basic.AVAILABLE_FEATURES + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - # Allow for sites to be able to define additional columns. - # Note that adding additional columns has the potential to break - # the student profile report due to a character limit on the - # asynchronous job input which in this case is a JSON string - # containing the list of columns to include in the report. - # TODO: Refactor the student profile report code to remove the list of columns - # that should be included in the report from the asynchronous job input. - # We need to clone the list because we modify it below - query_features = list(configuration_helpers.get_value('student_profile_download_fields', [])) + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id, csv=False): # pylint: disable=redefined-outer-name + """ + Handle POST requests to retrieve student profile information for a specific course. - if not query_features: - query_features = [ - 'id', 'username', 'name', 'email', 'language', 'location', - 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', - 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' - ] - keep_field_private(query_features, 'year_of_birth') # protected information + Args: + request: The HTTP request object. + course_id: The ID of the course for which to retrieve student information. + csv: Optional; if 'csv' is present in the URL, it indicates that the response should be in CSV format. + Defaults to None. - # Provide human-friendly and translatable names for these features. These names - # will be displayed in the table generated in data_download.js. It is not (yet) - # used as the header row in the CSV, but could be in the future. - query_features_names = { - 'id': _('User ID'), - 'username': _('Username'), - 'name': _('Name'), - 'email': _('Email'), - 'language': _('Language'), - 'location': _('Location'), - # 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683, not to go in reports - 'gender': _('Gender'), - 'level_of_education': _('Level of Education'), - 'mailing_address': _('Mailing Address'), - 'goals': _('Goals'), - 'enrollment_mode': _('Enrollment Mode'), - 'last_login': _('Last Login'), - 'date_joined': _('Date Joined'), - 'external_user_key': _('External User Key'), - } + Returns: + Response: A JSON response containing student profile information, or CSV if the `csv` parameter is provided. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + report_type = _('enrolled learner profile') + available_features = instructor_analytics_basic.AVAILABLE_FEATURES - if is_course_cohorted(course.id): - # Translators: 'Cohort' refers to a group of students within a course. - query_features.append('cohort') - query_features_names['cohort'] = _('Cohort') + # Allow for sites to be able to define additional columns. + # Note that adding additional columns has the potential to break + # the student profile report due to a character limit on the + # asynchronous job input which in this case is a JSON string + # containing the list of columns to include in the report. + # TODO: Refactor the student profile report code to remove the list of columns + # that should be included in the report from the asynchronous job input. + # We need to clone the list because we modify it below + query_features = list(configuration_helpers.get_value('student_profile_download_fields', [])) - if course.teams_enabled: - query_features.append('team') - query_features_names['team'] = _('Team') + if not query_features: + query_features = [ + 'id', 'username', 'name', 'email', 'language', 'location', + 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', + 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' + ] + keep_field_private(query_features, 'year_of_birth') # protected information - # For compatibility reasons, city and country should always appear last. - query_features.append('city') - query_features_names['city'] = _('City') - query_features.append('country') - query_features_names['country'] = _('Country') - - if not csv: - student_data = instructor_analytics_basic.enrolled_students_features(course_key, query_features) - response_payload = { - 'course_id': str(course_key), - 'students': student_data, - 'students_count': len(student_data), - 'queried_features': query_features, - 'feature_names': query_features_names, - 'available_features': available_features, + # Provide human-friendly and translatable names for these features. These names + # will be displayed in the table generated in data_download.js. It is not (yet) + # used as the header row in the CSV, but could be in the future. + query_features_names = { + 'id': _('User ID'), + 'username': _('Username'), + 'name': _('Name'), + 'email': _('Email'), + 'language': _('Language'), + 'location': _('Location'), + # 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683, + # not to go in reports + 'gender': _('Gender'), + 'level_of_education': _('Level of Education'), + 'mailing_address': _('Mailing Address'), + 'goals': _('Goals'), + 'enrollment_mode': _('Enrollment Mode'), + 'last_login': _('Last Login'), + 'date_joined': _('Date Joined'), + 'external_user_key': _('External User Key'), } - return JsonResponse(response_payload) - else: - task_api.submit_calculate_students_features_csv( - request, - course_key, - query_features - ) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + if is_course_cohorted(course.id): + # Translators: 'Cohort' refers to a group of students within a course. + query_features.append('cohort') + query_features_names['cohort'] = _('Cohort') - return JsonResponse({"status": success_status}) + if course.teams_enabled: + query_features.append('team') + query_features_names['team'] = _('Team') + + # For compatibility reasons, city and country should always appear last. + query_features.append('city') + query_features_names['city'] = _('City') + query_features.append('country') + query_features_names['country'] = _('Country') + + if not csv: + student_data = instructor_analytics_basic.enrolled_students_features(course_key, query_features) + response_payload = { + 'course_id': str(course_key), + 'students': student_data, + 'students_count': len(student_data), + 'queried_features': query_features, + 'feature_names': query_features_names, + 'available_features': available_features, + } + return JsonResponse(response_payload) + + else: + try: + task_api.submit_calculate_students_features_csv( + request, + course_key, + query_features + ) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + except Exception as e: + raise self.api_error(status.HTTP_400_BAD_REQUEST, str(e), 'Requested task is already running') + + return JsonResponse({"status": success_status}) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -1637,6 +1655,7 @@ class CohortCSV(DeveloperErrorViewMixin, APIView): task_api.submit_cohort_students(request, course_key, file_name) except (FileValidationException, ValueError) as e: raise self.api_error(status.HTTP_400_BAD_REQUEST, str(e), 'failed-validation') + return Response(status=status.HTTP_204_NO_CONTENT) @@ -3239,26 +3258,31 @@ class MarkStudentCanSkipEntranceExam(APIView): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.START_CERTIFICATE_GENERATION) -@require_POST -@common_exceptions_400 -def start_certificate_generation(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class StartCertificateGeneration(DeveloperErrorViewMixin, APIView): """ Start generating certificates for all students enrolled in given course. """ - course_key = CourseKey.from_string(course_id) - task = task_api.generate_certificates_for_students(request, course_key) - message = _('Certificate generation task for all students of this course has been started. ' - 'You can view the status of the generation task in the "Pending Tasks" section.') - response_payload = { - 'message': message, - 'task_id': task.task_id - } + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.START_CERTIFICATE_GENERATION - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Generating certificates for all students enrolled in given course. + """ + course_key = CourseKey.from_string(course_id) + task = task_api.generate_certificates_for_students(request, course_key) + message = _('Certificate generation task for all students of this course has been started. ' + 'You can view the status of the generation task in the "Pending Tasks" section.') + response_payload = { + 'message': message, + 'task_id': task.task_id + } + + return JsonResponse(response_payload) @transaction.non_atomic_requests diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 5976411a97..eef299d1d7 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -27,8 +27,8 @@ urlpatterns = [ path('modify_access', api.ModifyAccess.as_view(), name='modify_access'), path('bulk_beta_modify_access', api.bulk_beta_modify_access, name='bulk_beta_modify_access'), path('get_problem_responses', api.get_problem_responses, name='get_problem_responses'), + re_path(r'^get_students_features(?P/csv)?$', api.GetStudentsFeatures.as_view(), name='get_students_features'), path('get_grading_config', api.GetGradingConfig.as_view(), name='get_grading_config'), - re_path(r'^get_students_features(?P/csv)?$', api.get_students_features, name='get_students_features'), path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), @@ -82,7 +82,7 @@ urlpatterns = [ # Certificates path('enable_certificate_generation', api.enable_certificate_generation, name='enable_certificate_generation'), - path('start_certificate_generation', api.start_certificate_generation, name='start_certificate_generation'), + path('start_certificate_generation', api.StartCertificateGeneration.as_view(), name='start_certificate_generation'), path('start_certificate_regeneration', api.start_certificate_regeneration, name='start_certificate_regeneration'), path('certificate_exception_view/', api.certificate_exception_view, name='certificate_exception_view'), re_path(r'^generate_certificate_exceptions/(?P[^/]*)', api.generate_certificate_exceptions, diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py index 891ff9fda5..d9f356758d 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py @@ -54,7 +54,7 @@ class TestBackfillSSOVerificationsCommand(TestCase): #self.assertNumQueries(100) def test_signal_called(self): - with patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: + with patch('openedx.core.djangoapps.signals.signals.LEARNER_SSO_VERIFIED.send_robust') as mock_signal: call_command('backfill_sso_verifications_for_old_account_links', '--provider-slug', self.provider.provider_id) # lint-amnesty, pylint: disable=line-too-long assert mock_signal.call_count == 1 diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 9a0ac36964..7d055ffc70 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -44,9 +44,8 @@ from lms.djangoapps.verify_student.ssencrypt import ( rsa_decrypt, rsa_encrypt ) +from openedx.core.djangoapps.signals.signals import LEARNER_SSO_VERIFIED, PHOTO_VERIFICATION_APPROVED from openedx.core.storage import get_storage -from openedx_events.learning.signals import IDV_ATTEMPT_APPROVED -from openedx_events.learning.data import UserData, VerificationAttemptData from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -251,23 +250,13 @@ class SSOVerification(IDVerificationAttempt): user_id=self.user, reviewer=approved_by )) - # Emit event to find and generate eligible certificates - verification_data = VerificationAttemptData( - attempt_id=self.id, - user=UserData( - pii=None, - id=self.user.id, - is_active=self.user.is_active, - ), - status=self.status, - name=self.name, - expiration_date=self.expiration_datetime, - ) - IDV_ATTEMPT_APPROVED.send_event( - idv_attempt=verification_data, + # Emit signal to find and generate eligible certificates + LEARNER_SSO_VERIFIED.send_robust( + sender=PhotoVerification, + user=self.user, ) - message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from SSOVerification' + message = 'LEARNER_SSO_VERIFIED signal fired for {user} from SSOVerification' log.info(message.format(user=self.user.username)) @@ -465,23 +454,13 @@ class PhotoVerification(IDVerificationAttempt): ) self.save() - # Emit event to find and generate eligible certificates - verification_data = VerificationAttemptData( - attempt_id=self.id, - user=UserData( - pii=None, - id=self.user.id, - is_active=self.user.is_active, - ), - status=self.status, - name=self.name, - expiration_date=self.expiration_datetime, - ) - IDV_ATTEMPT_APPROVED.send_event( - idv_attempt=verification_data, + # Emit signal to find and generate eligible certificates + PHOTO_VERIFICATION_APPROVED.send_robust( + sender=PhotoVerification, + user=self.user, ) - message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from PhotoVerification' + message = 'PHOTO_VERIFICATION_APPROVED signal fired for {user} from PhotoVerification' log.info(message.format(user=self.user.username)) @status_before_must_be("ready", "must_retry") @@ -1250,6 +1229,22 @@ class VerificationAttempt(StatusModel): """When called, returns true or false based on the type of VerificationAttempt""" return not self.hide_status_from_user + def active_at_datetime(self, deadline): + """Check whether the verification was active at a particular datetime. + + Arguments: + deadline (datetime): The date at which the verification was active + (created before and expiration datetime is after today). + + Returns: + bool + + """ + return ( + self.created_at <= deadline and + (self.expiration_datetime is None or self.expiration_datetime > now()) + ) + @classmethod def retire_user(cls, user_id): """ diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index bee016de77..95dbccf0d5 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -176,10 +176,20 @@ class IDVerificationService: if verifications: attempt = verifications[0] for verification in verifications: - if verification.expiration_datetime > now() and verification.status == 'approved': - # Always select the LATEST non-expired approved verification if there is such - if attempt.status != 'approved' or ( - attempt.expiration_datetime < verification.expiration_datetime + # If a verification has no expiration_datetime, it's implied that it never expires, so we should still + # consider verifications in the approved state that have no expiration date. + if ( + not verification.expiration_datetime or + verification.expiration_datetime > now() + ) and verification.status == 'approved': + # Always select the LATEST non-expired approved verification if there is such. + if ( + attempt.status != 'approved' or + ( + attempt.expiration_datetime and + verification.expiration_datetime and + attempt.expiration_datetime < verification.expiration_datetime + ) ): attempt = verification @@ -188,7 +198,7 @@ class IDVerificationService: user_status['should_display'] = attempt.should_display_status_to_user() - if attempt.expiration_datetime < now() and attempt.status == 'approved': + if attempt.expiration_datetime and attempt.expiration_datetime < now() and attempt.status == 'approved': if user_status['should_display']: user_status['status'] = 'expired' user_status['error'] = _("Your {platform_name} verification has expired.").format( diff --git a/lms/djangoapps/verify_student/tests/__init__.py b/lms/djangoapps/verify_student/tests/__init__.py index cea71e52c3..015cd354ab 100644 --- a/lms/djangoapps/verify_student/tests/__init__.py +++ b/lms/djangoapps/verify_student/tests/__init__.py @@ -9,7 +9,7 @@ from django.test import TestCase from django.utils.timezone import now from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationAttempt class TestVerificationBase(TestCase): @@ -52,11 +52,14 @@ class TestVerificationBase(TestCase): # Active immediately before expiration date expiration = attempt.expiration_datetime - before_expiration = expiration - timedelta(seconds=1) - assert attempt.active_at_datetime(before_expiration) + if expiration: + before_expiration = expiration - timedelta(seconds=1) + assert attempt.active_at_datetime(before_expiration) # Not active after the expiration date - attempt.expiration_date = now() - timedelta(days=1) + field = 'expiration_datetime' if isinstance(attempt, VerificationAttempt) else 'expiration_date' + setattr(attempt, field, now() - timedelta(days=1)) + # attempt.expiration_date = now() - timedelta(days=1) attempt.save() assert not attempt.active_at_datetime(now()) diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index 9ea4546301..dfea8081e4 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -20,6 +20,7 @@ from lms.djangoapps.verify_student.models import ( PhotoVerification, SoftwareSecurePhotoVerification, SSOVerification, + VerificationAttempt, VerificationException ) from lms.djangoapps.verify_student.tests import TestVerificationBase @@ -437,3 +438,14 @@ class ManualVerificationTest(TestVerificationBase): user = UserFactory.create() verification = ManualVerification.objects.create(user=user) self.verification_active_at_datetime(verification) + + +class VerificationAttemptTest(TestVerificationBase): + """ + Tests for the VerificationAttempt model + """ + + def test_active_at_datetime(self): + user = UserFactory.create() + attempt = VerificationAttempt.objects.create(user=user) + self.verification_active_at_datetime(attempt) diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 5b6d740a1b..4bfa8c27d5 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -2,6 +2,7 @@ Tests for the service classes in verify_student. """ +import itertools from datetime import datetime, timedelta, timezone from unittest.mock import patch @@ -273,6 +274,29 @@ class TestIDVerificationServiceUserStatus(TestCase): } self.assertDictEqual(status, expected_status) + def test_approved_verification_attempt_verification(self): + with freeze_time('2015-01-02'): + # test for when Verification Attempt verification has been created + VerificationAttempt.objects.create(user=self.user, status='approved') + status = IDVerificationService.user_status(self.user) + expected_status = {'status': 'approved', 'error': '', 'should_display': True, 'verification_expiry': '', + 'status_date': datetime.now(utc)} + self.assertDictEqual(status, expected_status) + + def test_denied_verification_attempt_verification(self): + with freeze_time('2015-2-02'): + # create denied photo verification for the user, make sure the denial + # is handled properly + VerificationAttempt.objects.create( + user=self.user, status='denied' + ) + status = IDVerificationService.user_status(self.user) + expected_status = { + 'status': 'must_reverify', 'error': '', + 'should_display': True, 'verification_expiry': '', 'status_date': '', + } + self.assertDictEqual(status, expected_status) + def test_approved_sso_verification(self): with freeze_time('2015-03-02'): # test for when sso verification has been created @@ -303,22 +327,19 @@ class TestIDVerificationServiceUserStatus(TestCase): 'status_date': datetime.now(utc)} self.assertDictEqual(status, expected_status) - @ddt.data( - 'submitted', - 'denied', - 'approved', - 'created', - 'ready', - 'must_retry' + @ddt.idata(itertools.product( + [SoftwareSecurePhotoVerification, VerificationAttempt], + ['submitted', 'denied', 'approved', 'created', 'ready', 'must_retry']) ) - def test_expiring_software_secure_verification(self, new_status): + def test_expiring_software_secure_verification(self, value): + verification_model, new_status = value with freeze_time('2015-07-11') as frozen_datetime: # create approved photo verification for the user - SoftwareSecurePhotoVerification.objects.create(user=self.user, status='approved') + verification_model.objects.create(user=self.user, status='approved') expiring_datetime = datetime.now(utc) frozen_datetime.move_to('2015-07-14') # create another according to status passed in. - SoftwareSecurePhotoVerification.objects.create(user=self.user, status=new_status) + verification_model.objects.create(user=self.user, status=new_status) status_date = expiring_datetime if new_status == 'approved': status_date = datetime.now(utc) @@ -327,13 +348,16 @@ class TestIDVerificationServiceUserStatus(TestCase): status = IDVerificationService.user_status(self.user) self.assertDictEqual(status, expected_status) - def test_expired_verification(self): + @ddt.data(SoftwareSecurePhotoVerification, VerificationAttempt) + def test_expired_verification(self, verification_model): with freeze_time('2015-07-11') as frozen_datetime: - # create approved photo verification for the user - SoftwareSecurePhotoVerification.objects.create( + key = 'expiration_datetime' if verification_model == VerificationAttempt else 'expiration_date' + + # create approved verification for the user + verification_model.objects.create( user=self.user, status='approved', - expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + **{key: now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])}, ) frozen_datetime.move_to('2016-07-11') expected_status = { @@ -348,28 +372,28 @@ class TestIDVerificationServiceUserStatus(TestCase): status = IDVerificationService.user_status(self.user) self.assertDictEqual(status, expected_status) - @ddt.data( - 'submitted', - 'denied', - 'approved', - 'created', - 'ready', - 'must_retry' + @ddt.idata(itertools.product( + [SoftwareSecurePhotoVerification, VerificationAttempt], + ['submitted', 'denied', 'approved', 'created', 'ready', 'must_retry']) ) - def test_reverify_after_expired(self, new_status): + def test_reverify_after_expired(self, value): + verification_model, new_status = value with freeze_time('2015-07-11') as frozen_datetime: - # create approved photo verification for the user - SoftwareSecurePhotoVerification.objects.create( + key = 'expiration_datetime' if verification_model == VerificationAttempt else 'expiration_date' + + # create approved verification for the user + verification_model.objects.create( user=self.user, status='approved', - expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + **{key: now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])}, ) + frozen_datetime.move_to('2016-07-12') # create another according to status passed in. - SoftwareSecurePhotoVerification.objects.create( + verification_model.objects.create( user=self.user, status=new_status, - expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + **{key: now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])}, ) check_status = new_status @@ -390,9 +414,10 @@ class TestIDVerificationServiceUserStatus(TestCase): @ddt.data( SSOVerification, - ManualVerification + ManualVerification, + VerificationAttempt ) - def test_override_verification(self, verification_type): + def test_override_verification(self, verification_model): with freeze_time('2015-07-11') as frozen_datetime: # create approved photo verification for the user SoftwareSecurePhotoVerification.objects.create( @@ -401,19 +426,27 @@ class TestIDVerificationServiceUserStatus(TestCase): expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) ) frozen_datetime.move_to('2015-07-14') - verification_type.objects.create( + + key = 'expiration_datetime' if verification_model == VerificationAttempt else 'expiration_date' + verification_model.objects.create( user=self.user, status='approved', - expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + **{key: now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])}, ) expected_status = { - 'status': 'approved', 'error': '', 'should_display': False, + 'status': 'approved', 'error': '', + 'should_display': verification_model == VerificationAttempt, 'verification_expiry': '', 'status_date': now() } status = IDVerificationService.user_status(self.user) self.assertDictEqual(status, expected_status) - def test_denied_after_approved_verification(self): + @ddt.data( + SSOVerification, + ManualVerification, + VerificationAttempt + ) + def test_denied_after_approved_verification(self, verification_model): with freeze_time('2015-07-11') as frozen_datetime: # create approved photo verification for the user SoftwareSecurePhotoVerification.objects.create( @@ -423,10 +456,12 @@ class TestIDVerificationServiceUserStatus(TestCase): ) expected_date = now() frozen_datetime.move_to('2015-07-14') - SoftwareSecurePhotoVerification.objects.create( + + key = 'expiration_datetime' if verification_model == VerificationAttempt else 'expiration_date' + verification_model.objects.create( user=self.user, status='denied', - expiration_date=now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + **{key: now() + timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])}, ) expected_status = { 'status': 'approved', 'error': '', 'should_display': True, diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index 495389152f..24624464ab 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -36,5 +36,16 @@ COURSE_GRADE_NOW_PASSED = Signal() # ] COURSE_GRADE_NOW_FAILED = Signal() +# Signal that indicates that a user has become verified via SSO for certificate purposes +# providing_args=['user'] +LEARNER_SSO_VERIFIED = Signal() + +# Signal that indicates a user has been verified via verify_studnet.PhotoVerification for certificate purposes +# Please note that this signal and the corresponding PhotoVerification model are planned for deprecation. +# Future implementations of IDV will use the verify_student.VerificationAttempt model and corresponding +# openedx events. +# DEPR: https://github.com/openedx/edx-platform/issues/35128 +PHOTO_VERIFICATION_APPROVED = Signal() + # providing_args=['user'] USER_ACCOUNT_ACTIVATED = Signal() # Signal indicating email verification diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 46d8fb4192..3809b753a5 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.26.1 +edx-enterprise==4.27.0 # Stay on LTS version, remove once this is added to common constraint Django<5.0 diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index 1d94a4649a..bf0a4376da 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -53,7 +53,7 @@ numpy==1.26.4 # matplotlib # openedx-calc # scipy -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx-sandbox/base.in packaging==24.1 # via matplotlib diff --git a/requirements/edx-sandbox/releases/quince.txt b/requirements/edx-sandbox/releases/quince.txt index c8b5cff881..270a6b4f0a 100644 --- a/requirements/edx-sandbox/releases/quince.txt +++ b/requirements/edx-sandbox/releases/quince.txt @@ -56,7 +56,7 @@ numpy==1.22.4 # matplotlib # openedx-calc # scipy -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx-sandbox/base.in packaging==23.2 # via matplotlib diff --git a/requirements/edx-sandbox/releases/redwood.txt b/requirements/edx-sandbox/releases/redwood.txt index d12c994fd2..432985e409 100644 --- a/requirements/edx-sandbox/releases/redwood.txt +++ b/requirements/edx-sandbox/releases/redwood.txt @@ -54,7 +54,7 @@ numpy==1.24.4 # matplotlib # openedx-calc # scipy -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx-sandbox/base.in packaging==24.0 # via matplotlib diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 1794e8ee58..0eca9a532a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -467,7 +467,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.26.1 +edx-enterprise==4.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -482,7 +482,7 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/kernel.in -edx-name-affirmation==2.4.2 +edx-name-affirmation==3.0.1 # via -r requirements/edx/kernel.in edx-opaque-keys[django]==2.11.0 # via @@ -502,7 +502,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/kernel.in -edx-proctoring==4.18.1 +edx-proctoring==4.18.2 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack @@ -801,7 +801,7 @@ openai==0.28.1 # edx-enterprise openedx-atlas==0.6.2 # via -r requirements/edx/kernel.in -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx/kernel.in openedx-django-pyfs==3.7.0 # via @@ -817,6 +817,7 @@ openedx-events==9.14.1 # edx-enterprise # edx-event-bus-kafka # edx-event-bus-redis + # edx-name-affirmation # event-tracking # ora2 openedx-filters==1.10.0 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index be79bdfe0c..abd9922253 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -741,7 +741,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.26.1 +edx-enterprise==4.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -766,7 +766,7 @@ edx-milestones==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-name-affirmation==2.4.2 +edx-name-affirmation==3.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -790,7 +790,7 @@ edx-organizations==6.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-proctoring==4.18.1 +edx-proctoring==4.18.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1340,7 +1340,7 @@ openedx-atlas==0.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1365,6 +1365,7 @@ openedx-events==9.14.1 # edx-enterprise # edx-event-bus-kafka # edx-event-bus-redis + # edx-name-affirmation # event-tracking # ora2 openedx-filters==1.10.0 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index db13c9290a..f8b45c5ddc 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -547,7 +547,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.26.1 +edx-enterprise==4.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -562,7 +562,7 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.2 +edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via @@ -581,7 +581,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==4.18.1 +edx-proctoring==4.18.2 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack @@ -959,7 +959,7 @@ openai==0.28.1 # edx-enterprise openedx-atlas==0.6.2 # via -r requirements/edx/base.txt -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 # via @@ -976,6 +976,7 @@ openedx-events==9.14.1 # edx-enterprise # edx-event-bus-kafka # edx-event-bus-redis + # edx-name-affirmation # event-tracking # ora2 openedx-filters==1.10.0 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index d2c18fb75b..7fa8962c4f 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -571,7 +571,7 @@ edx-drf-extensions==10.4.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.26.1 +edx-enterprise==4.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -588,7 +588,7 @@ edx-lint==5.4.0 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.2 +edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via @@ -607,7 +607,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==4.18.1 +edx-proctoring==4.18.2 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack @@ -1010,7 +1010,7 @@ openai==0.28.1 # edx-enterprise openedx-atlas==0.6.2 # via -r requirements/edx/base.txt -openedx-calc==3.1.0 +openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 # via @@ -1027,6 +1027,7 @@ openedx-events==9.14.1 # edx-enterprise # edx-event-bus-kafka # edx-event-bus-redis + # edx-name-affirmation # event-tracking # ora2 openedx-filters==1.10.0 diff --git a/xmodule/capa/tests/test_responsetypes.py b/xmodule/capa/tests/test_responsetypes.py index 37a6647ce7..e8df8894c7 100644 --- a/xmodule/capa/tests/test_responsetypes.py +++ b/xmodule/capa/tests/test_responsetypes.py @@ -1538,6 +1538,20 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs problem = self.build_problem(answer=given_answer) self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + def test_percentage(self): + """ + Test percentage + """ + problem_setup = [ + # [given_answer, [list of correct responses], [list of incorrect responses]] + ["1%", ["1%", "1.0%", "1.00%", "0.01"], [""]], + ["2.0%", ["2%", "2.0%", "2.00%", "0.02"], [""]], + ["4.00%", ["4%", "4.0%", "4.00%", "0.04"], [""]], + ] + for given_answer, correct_responses, incorrect_responses in problem_setup: + problem = self.build_problem(answer=given_answer) + self.assert_multiple_grade(problem, correct_responses, incorrect_responses) + def test_grade_with_script(self): script_text = "computed_response = math.sqrt(4)" problem = self.build_problem(answer="$computed_response", script=script_text)