Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API
This commit is contained in:
@@ -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.")}
|
||||
|
||||
11
common/djangoapps/third_party_auth/README.rst
Normal file
11
common/djangoapps/third_party_auth/README.rst
Normal file
@@ -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
|
||||
@@ -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(
|
||||
'<a href="{}" style="color: #999999;">{}</a>',
|
||||
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. """
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>/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>/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<generate_for>[^/]*)', api.generate_certificate_exceptions,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user