From 2293f731b8cb068d7de5e8252cf0c8e29041d3d0 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 15 Jul 2026 08:43:24 +0000 Subject: [PATCH 1/9] feat: implement toggle for redacting PII in ManualVerification during user retirement --- lms/djangoapps/verify_student/config.py | 16 +++++++ lms/djangoapps/verify_student/models.py | 13 ++++- .../verify_student/signals/handlers.py | 7 +++ .../verify_student/tests/test_handlers.py | 48 ++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/verify_student/config.py diff --git a/lms/djangoapps/verify_student/config.py b/lms/djangoapps/verify_student/config.py new file mode 100644 index 000000000000..f48319368841 --- /dev/null +++ b/lms/djangoapps/verify_student/config.py @@ -0,0 +1,16 @@ +""" +Configuration toggles for the verify_student app. +""" + +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 learner 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..0b14c3790130 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): + """ + Redact 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..4ca934ce0409 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 critical retirement phase. + """ 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..0a48cc529105 100644 --- a/lms/djangoapps/verify_student/tests/test_handlers.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -2,15 +2,19 @@ Unit tests for the VerificationDeadline signals """ - +import ddt 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 +29,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 ) @@ -68,9 +73,10 @@ def test_deadline_explicit(self): assert actual_deadline == deadline +@ddt.ddt class RetirementHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline handler + Tests for verify_student handlers in the LMS retirement flow. """ def _create_entry(self): @@ -118,6 +124,44 @@ def test_idempotent(self): for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'): assert '' == getattr(ver_obj, field) + @ddt.data( + ddt.named_data('toggle_disabled_retains_record', False), + ddt.named_data('toggle_enabled_redacts_and_deletes', True), + ) + def test_manual_verification_retirement_behavior(self, toggle_enabled): + 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=toggle_enabled): + if toggle_enabled: + 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=[''], + ) + else: + _listen_for_lms_retire(sender=self.__class__, user=user) + + if toggle_enabled: + assert not ManualVerification.objects.filter(user=user).exists() + else: + manual_verification = ManualVerification.objects.get(user=user) + assert manual_verification.name == user_name + assert ManualVerification.objects.filter(user=other_user, name=user_name).exists() + class PostSavePhotoVerificationTest(ModuleStoreTestCase): """ From 6c1a5ed1b9c51a6da6dbfa0dd9c81f67123d64cd Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 15 Jul 2026 08:52:02 +0000 Subject: [PATCH 2/9] feat: implement toggle for redacting PII in ManualVerification during user retirement --- .../verify_student/tests/test_handlers.py | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/lms/djangoapps/verify_student/tests/test_handlers.py b/lms/djangoapps/verify_student/tests/test_handlers.py index 0a48cc529105..460ce81ef891 100644 --- a/lms/djangoapps/verify_student/tests/test_handlers.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -2,7 +2,6 @@ Unit tests for the VerificationDeadline signals """ -import ddt from datetime import timedelta from unittest.mock import patch # pylint: disable=wrong-import-order @@ -73,7 +72,6 @@ def test_deadline_explicit(self): assert actual_deadline == deadline -@ddt.ddt class RetirementHandlerTest(ModuleStoreTestCase): """ Tests for verify_student handlers in the LMS retirement flow. @@ -124,11 +122,7 @@ def test_idempotent(self): for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'): assert '' == getattr(ver_obj, field) - @ddt.data( - ddt.named_data('toggle_disabled_retains_record', False), - ddt.named_data('toggle_enabled_redacts_and_deletes', True), - ) - def test_manual_verification_retirement_behavior(self, toggle_enabled): + def test_manual_verification_retained_when_toggle_disabled(self): user = UserFactory() other_user = UserFactory() user_name = 'Manual Verification Name' @@ -143,23 +137,39 @@ def test_manual_verification_retirement_behavior(self, toggle_enabled): status='approved', ) - with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=toggle_enabled): - if toggle_enabled: - 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=[''], - ) - else: + 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() + + def test_manual_verification_redacted_and_deleted_when_toggle_enabled(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=True): + with CaptureQueriesContext(connection) as context: _listen_for_lms_retire(sender=self.__class__, user=user) - if toggle_enabled: - assert not ManualVerification.objects.filter(user=user).exists() - else: - manual_verification = ManualVerification.objects.get(user=user) - assert manual_verification.name == user_name + 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() From fafb45da2fbee1554faa3c8c2401b93fe9e0ac51 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 15 Jul 2026 08:54:13 +0000 Subject: [PATCH 3/9] feat: implement toggle for redacting PII in ManualVerification during user retirement --- .../verify_student/tests/test_handlers.py | 53 ++++++++----------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/lms/djangoapps/verify_student/tests/test_handlers.py b/lms/djangoapps/verify_student/tests/test_handlers.py index 460ce81ef891..234a9dab9c8b 100644 --- a/lms/djangoapps/verify_student/tests/test_handlers.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -5,6 +5,7 @@ from datetime import timedelta from unittest.mock import patch # pylint: disable=wrong-import-order +import ddt from django.db import connection from django.test import override_settings from django.test.utils import CaptureQueriesContext @@ -72,6 +73,7 @@ def test_deadline_explicit(self): assert actual_deadline == deadline +@ddt.ddt class RetirementHandlerTest(ModuleStoreTestCase): """ Tests for verify_student handlers in the LMS retirement flow. @@ -122,7 +124,8 @@ 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_retained_when_toggle_disabled(self): + @ddt.data(False, True) + def test_manual_verification_retirement_behavior(self, toggle_enabled): user = UserFactory() other_user = UserFactory() user_name = 'Manual Verification Name' @@ -137,39 +140,25 @@ def test_manual_verification_retained_when_toggle_disabled(self): 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() - - def test_manual_verification_redacted_and_deleted_when_toggle_enabled(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=True): - with CaptureQueriesContext(connection) as context: + with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=toggle_enabled): + if toggle_enabled: + 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=[''], + ) + else: _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() + if toggle_enabled: + assert not ManualVerification.objects.filter(user=user).exists() + else: + 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() From 5ee6688229156c70cb1a43294412f600060d8ffe Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 15 Jul 2026 09:26:35 +0000 Subject: [PATCH 4/9] feat: implement toggle for redacting PII in ManualVerification during user retirement --- .../verify_student/tests/test_handlers.py | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/verify_student/tests/test_handlers.py b/lms/djangoapps/verify_student/tests/test_handlers.py index 234a9dab9c8b..336c2f622201 100644 --- a/lms/djangoapps/verify_student/tests/test_handlers.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -5,7 +5,6 @@ from datetime import timedelta from unittest.mock import patch # pylint: disable=wrong-import-order -import ddt from django.db import connection from django.test import override_settings from django.test.utils import CaptureQueriesContext @@ -73,7 +72,6 @@ def test_deadline_explicit(self): assert actual_deadline == deadline -@ddt.ddt class RetirementHandlerTest(ModuleStoreTestCase): """ Tests for verify_student handlers in the LMS retirement flow. @@ -124,8 +122,7 @@ def test_idempotent(self): for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'): assert '' == getattr(ver_obj, field) - @ddt.data(False, True) - def test_manual_verification_retirement_behavior(self, toggle_enabled): + def test_manual_verification_retirement_behavior(self): user = UserFactory() other_user = UserFactory() user_name = 'Manual Verification Name' @@ -140,24 +137,24 @@ def test_manual_verification_retirement_behavior(self, toggle_enabled): status='approved', ) - with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=toggle_enabled): - if toggle_enabled: - 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=[''], - ) - else: + 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) - if toggle_enabled: - assert not ManualVerification.objects.filter(user=user).exists() - else: - manual_verification = ManualVerification.objects.get(user=user) - assert manual_verification.name == user_name - assert ManualVerification.objects.filter(user=user).exists() + 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() From b134f24e1031c31366c52496c58f80b16a9af5ab Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 15 Jul 2026 15:49:57 +0530 Subject: [PATCH 5/9] fix: Update config.py for ManualVerification retirement --- lms/djangoapps/verify_student/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/config.py b/lms/djangoapps/verify_student/config.py index f48319368841..e637af0f0bf1 100644 --- a/lms/djangoapps/verify_student/config.py +++ b/lms/djangoapps/verify_student/config.py @@ -1,5 +1,5 @@ """ -Configuration toggles for the verify_student app. +Configuration toggles for ManualVerification retirement. """ from edx_toggles.toggles import SettingToggle From 7a8ea60a72637a3c6997f08908383ac9956fc9ee Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 15 Jul 2026 16:04:24 +0530 Subject: [PATCH 6/9] fix: Update docstring for LMS retire handler --- lms/djangoapps/verify_student/signals/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/signals/handlers.py b/lms/djangoapps/verify_student/signals/handlers.py index 4ca934ce0409..b952d4786f48 100644 --- a/lms/djangoapps/verify_student/signals/handlers.py +++ b/lms/djangoapps/verify_student/signals/handlers.py @@ -42,7 +42,7 @@ 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 critical retirement phase. + Retire verify_student records handled in the LMS retirement. """ user = kwargs.get('user') SoftwareSecurePhotoVerification.retire_user(user.id) From 0171d0b808d98c2dad7809bb05fa2feb16afa6a2 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 15 Jul 2026 16:05:51 +0530 Subject: [PATCH 7/9] fix: Update configuration comment for ManualVerification --- lms/djangoapps/verify_student/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/config.py b/lms/djangoapps/verify_student/config.py index e637af0f0bf1..ae89f4fbfc77 100644 --- a/lms/djangoapps/verify_student/config.py +++ b/lms/djangoapps/verify_student/config.py @@ -1,5 +1,5 @@ """ -Configuration toggles for ManualVerification retirement. +Configuration toggles for ManualVerification. """ from edx_toggles.toggles import SettingToggle From 4b6ee1808b142f09a49c4fc9a1c886e5554892a8 Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 15 Jul 2026 16:07:23 +0530 Subject: [PATCH 8/9] fix: update comment --- lms/djangoapps/verify_student/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/config.py b/lms/djangoapps/verify_student/config.py index ae89f4fbfc77..ac0e3b533524 100644 --- a/lms/djangoapps/verify_student/config.py +++ b/lms/djangoapps/verify_student/config.py @@ -8,7 +8,7 @@ # .. toggle_implementation: SettingToggle # .. toggle_default: False # .. toggle_description: Clears the `name` field for `ManualVerification` records -# before deleting those rows during learner retirement. +# before deleting those rows during user retirement. # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2026-07-15 REDACT_MANUAL_VERIFICATION_HISTORICAL_PII = SettingToggle( From cc9f2321cc1180d4de863169aadfc84b775ca16d Mon Sep 17 00:00:00 2001 From: Krish Tyagi Date: Wed, 15 Jul 2026 16:19:13 +0530 Subject: [PATCH 9/9] fix: Update comment to clarify PII field handling --- lms/djangoapps/verify_student/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 0b14c3790130..9f4ada118077 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -198,7 +198,7 @@ def should_display_status_to_user(self): @classmethod def redact_before_delete_fields(cls): """ - Redact PII fields before delete in downstream soft-delete systems. + Clear PII fields before delete in downstream soft-delete systems. """ return {'name': ''}