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)