Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API

This commit is contained in:
Kyrylo Kireiev
2024-10-08 17:03:51 +03:00
committed by GitHub
27 changed files with 362 additions and 214 deletions

View File

@@ -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.")}

View 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

View File

@@ -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. """

View File

@@ -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,

View File

@@ -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()"

View File

@@ -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
"""

View File

@@ -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(

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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(

View File

@@ -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())

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)