Skip to content
Open
16 changes: 16 additions & 0 deletions lms/djangoapps/verify_student/config.py
Original file line number Diff line number Diff line change
@@ -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__
)
13 changes: 12 additions & 1 deletion lms/djangoapps/verify_student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
7 changes: 7 additions & 0 deletions lms/djangoapps/verify_student/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 42 additions & 2 deletions lms/djangoapps/verify_student/tests/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down
Loading