diff --git a/lms/djangoapps/verify_student/config.py b/lms/djangoapps/verify_student/config.py new file mode 100644 index 000000000000..ac0e3b533524 --- /dev/null +++ b/lms/djangoapps/verify_student/config.py @@ -0,0 +1,16 @@ +""" +Configuration toggles for ManualVerification. +""" + +from edx_toggles.toggles import SettingToggle + +# .. toggle_name: REDACT_MANUAL_VERIFICATION_HISTORICAL_PII +# .. toggle_implementation: SettingToggle +# .. toggle_default: False +# .. toggle_description: Clears the `name` field for `ManualVerification` records +# before deleting those rows during user retirement. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2026-07-15 +REDACT_MANUAL_VERIFICATION_HISTORICAL_PII = SettingToggle( + 'REDACT_MANUAL_VERIFICATION_HISTORICAL_PII', default=False, module_name=__name__ +) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 64ea239215df..9f4ada118077 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -43,6 +43,7 @@ ) from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from openedx.core.djangoapps.signals.signals import LEARNER_SSO_VERIFIED, PHOTO_VERIFICATION_APPROVED +from openedx.core.djangolib.model_mixins import DeletableByUserValue from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -158,11 +159,14 @@ def active_at_datetime(self, deadline): ) -class ManualVerification(IDVerificationAttempt): +class ManualVerification(IDVerificationAttempt, DeletableByUserValue): """ Each ManualVerification represents a user's verification that bypasses the need for any other verification. + The PII is retained by default, but can be redacted during retirement + by enabling ``REDACT_MANUAL_VERIFICATION_HISTORICAL_PII``. + .. pii: The User's name is stored in the parent model .. pii_types: name .. pii_retirement: retained @@ -191,6 +195,13 @@ def should_display_status_to_user(self): """ return False + @classmethod + def redact_before_delete_fields(cls): + """ + Clear PII fields before delete in downstream soft-delete systems. + """ + return {'name': ''} + class SSOVerification(IDVerificationAttempt): """ diff --git a/lms/djangoapps/verify_student/signals/handlers.py b/lms/djangoapps/verify_student/signals/handlers.py index f5c6c8e588ff..b952d4786f48 100644 --- a/lms/djangoapps/verify_student/signals/handlers.py +++ b/lms/djangoapps/verify_student/signals/handlers.py @@ -9,7 +9,9 @@ from common.djangoapps.student.models_api import get_name, get_pending_name_change from lms.djangoapps.verify_student.apps import VerifyStudentConfig # pylint: disable=unused-import # noqa: F401 +from lms.djangoapps.verify_student.config import REDACT_MANUAL_VERIFICATION_HISTORICAL_PII from lms.djangoapps.verify_student.models import ( + ManualVerification, SoftwareSecurePhotoVerification, VerificationAttempt, VerificationDeadline, @@ -39,8 +41,13 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable @receiver(USER_RETIRE_LMS_CRITICAL) def _listen_for_lms_retire(sender, **kwargs): # pylint: disable=unused-argument + """ + Retire verify_student records handled in the LMS retirement. + """ user = kwargs.get('user') SoftwareSecurePhotoVerification.retire_user(user.id) + if REDACT_MANUAL_VERIFICATION_HISTORICAL_PII.is_enabled(): + ManualVerification.delete_by_user_value(value=user.id, field='user_id') @receiver(post_save, sender=SoftwareSecurePhotoVerification) diff --git a/lms/djangoapps/verify_student/tests/test_handlers.py b/lms/djangoapps/verify_student/tests/test_handlers.py index 31128c5cb2cd..336c2f622201 100644 --- a/lms/djangoapps/verify_student/tests/test_handlers.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -2,15 +2,18 @@ Unit tests for the VerificationDeadline signals """ - from datetime import timedelta from unittest.mock import patch # pylint: disable=wrong-import-order +from django.db import connection +from django.test import override_settings +from django.test.utils import CaptureQueriesContext from django.utils.timezone import now from common.djangoapps.student.models_api import do_name_change_request from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.verify_student.models import ( + ManualVerification, SoftwareSecurePhotoVerification, VerificationAttempt, VerificationDeadline, @@ -25,6 +28,7 @@ VerificationAttemptFactory, ) from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement +from openedx.core.djangolib.testing.utils import assert_redact_before_delete from xmodule.modulestore.tests.django_utils import ( ModuleStoreTestCase, # pylint: disable=wrong-import-order ) @@ -70,7 +74,7 @@ def test_deadline_explicit(self): class RetirementHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline handler + Tests for verify_student handlers in the LMS retirement flow. """ def _create_entry(self): @@ -118,6 +122,42 @@ def test_idempotent(self): for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'): assert '' == getattr(ver_obj, field) + def test_manual_verification_retirement_behavior(self): + user = UserFactory() + other_user = UserFactory() + user_name = 'Manual Verification Name' + ManualVerification.objects.create( + user=user, + name=user_name, + status='approved', + ) + ManualVerification.objects.create( + user=other_user, + name=user_name, + status='approved', + ) + + with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=False): + _listen_for_lms_retire(sender=self.__class__, user=user) + + manual_verification = ManualVerification.objects.get(user=user) + assert manual_verification.name == user_name + assert ManualVerification.objects.filter(user=user).exists() + assert ManualVerification.objects.filter(user=other_user, name=user_name).exists() + + with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=True): + with CaptureQueriesContext(connection) as context: + _listen_for_lms_retire(sender=self.__class__, user=user) + + assert_redact_before_delete( + [query['sql'] for query in context.captured_queries], + table=ManualVerification._meta.db_table, + expected_redacted_value_list=[''], + ) + assert not ManualVerification.objects.filter(user=user).exists() + + assert ManualVerification.objects.filter(user=other_user, name=user_name).exists() + class PostSavePhotoVerificationTest(ModuleStoreTestCase): """