feat: purge name from certificate records during user retirement (#34799)

[APER-3241]

This PR updates the retirement pipeline to purge learners' names from certificate records when their account is being retired.

It also introduces a new management command that can be used by Open edX operators to purge the leftover name data (PII data) from the `certificates_generatedcertificate` table. This is designed as a one-time use data fixup, as the retirement functionality should clean this moving forward.
This commit is contained in:
Justin Hynes
2024-05-16 09:17:40 -04:00
committed by GitHub
parent 7709f4b2c6
commit 9fbc6e3bf4
9 changed files with 501 additions and 71 deletions

View File

@@ -30,25 +30,6 @@ from wiki.models.pluginbase import RevisionPlugin, RevisionPluginRevision
from common.djangoapps.entitlements.models import CourseEntitlementSupportDetail
from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments
from openedx.core.djangoapps.credit.models import (
CreditCourse,
CreditProvider,
CreditRequest,
CreditRequirement,
CreditRequirementStatus
)
from openedx.core.djangoapps.external_user_ids.models import ExternalIdType
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
)
from common.djangoapps.student.models import (
AccountRecovery,
CourseEnrollment,
@@ -71,10 +52,31 @@ from common.djangoapps.student.tests.factories import (
SuperuserFactory,
UserFactory
)
from lms.djangoapps.certificates.api import get_certificate_for_user_id
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments
from openedx.core.djangoapps.credit.models import (
CreditCourse,
CreditProvider,
CreditRequest,
CreditRequirement,
CreditRequirementStatus
)
from openedx.core.djangoapps.external_user_ids.models import ExternalIdType
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory, AccessTokenFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ...tests.factories import UserOrgTagFactory
from ..views import USER_PROFILE_PII, AccountRetirementView
@@ -1346,6 +1348,46 @@ class TestAccountRetirementPost(RetirementTestCase):
self.headers['content_type'] = "application/json"
self.url = reverse('accounts_retire')
def _data_sharing_consent_assertions(self):
"""
Helper method for asserting that ``DataSharingConsent`` objects are retired.
"""
self.consent.refresh_from_db()
assert self.retired_username == self.consent.username
test_users_data_sharing_consent = DataSharingConsent.objects.filter(
username=self.original_username
)
assert not test_users_data_sharing_consent.exists()
def _entitlement_support_detail_assertions(self):
"""
Helper method for asserting that ``CourseEntitleSupportDetail`` objects are retired.
"""
self.entitlement_support_detail.refresh_from_db()
assert '' == self.entitlement_support_detail.comments
def _pending_enterprise_customer_user_assertions(self):
"""
Helper method for asserting that ``PendingEnterpriseCustomerUser`` objects are retired.
"""
self.pending_enterprise_user.refresh_from_db()
assert self.retired_email == self.pending_enterprise_user.user_email
pending_enterprise_users = PendingEnterpriseCustomerUser.objects.filter(
user_email=self.original_email
)
assert not pending_enterprise_users.exists()
def _sapsf_audit_assertions(self):
"""
Helper method for asserting that ``SapSuccessFactorsLearnerDataTransmissionAudit`` objects are retired.
"""
self.sapsf_audit.refresh_from_db()
assert '' == self.sapsf_audit.sapsf_user_id
audits_for_original_user_id = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
sapsf_user_id=self.test_user.id,
)
assert not audits_for_original_user_id.exists()
def post_and_assert_status(self, data, expected_status=status.HTTP_204_NO_CONTENT):
"""
Helper function for making a request to the retire subscriptions endpoint, and asserting the status.
@@ -1482,57 +1524,30 @@ class TestAccountRetirementPost(RetirementTestCase):
AccountRetirementView.retire_users_data_sharing_consent(self.test_user.username, self.retired_username)
self._data_sharing_consent_assertions()
def _data_sharing_consent_assertions(self):
"""
Helper method for asserting that ``DataSharingConsent`` objects are retired.
"""
self.consent.refresh_from_db()
assert self.retired_username == self.consent.username
test_users_data_sharing_consent = DataSharingConsent.objects.filter(
username=self.original_username
)
assert not test_users_data_sharing_consent.exists()
def test_can_retire_users_sap_success_factors_audits(self):
AccountRetirementView.retire_sapsf_data_transmission(self.test_user)
self._sapsf_audit_assertions()
def _sapsf_audit_assertions(self):
"""
Helper method for asserting that ``SapSuccessFactorsLearnerDataTransmissionAudit`` objects are retired.
"""
self.sapsf_audit.refresh_from_db()
assert '' == self.sapsf_audit.sapsf_user_id
audits_for_original_user_id = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
sapsf_user_id=self.test_user.id,
)
assert not audits_for_original_user_id.exists()
def test_can_retire_user_from_pendingenterprisecustomeruser(self):
AccountRetirementView.retire_user_from_pending_enterprise_customer_user(self.test_user, self.retired_email)
self._pending_enterprise_customer_user_assertions()
def _pending_enterprise_customer_user_assertions(self):
"""
Helper method for asserting that ``PendingEnterpriseCustomerUser`` objects are retired.
"""
self.pending_enterprise_user.refresh_from_db()
assert self.retired_email == self.pending_enterprise_user.user_email
pending_enterprise_users = PendingEnterpriseCustomerUser.objects.filter(
user_email=self.original_email
)
assert not pending_enterprise_users.exists()
def test_course_entitlement_support_detail_comments_are_retired(self):
AccountRetirementView.retire_entitlement_support_detail(self.test_user)
self._entitlement_support_detail_assertions()
def _entitlement_support_detail_assertions(self):
def test_clear_pii_from_certificate_records(self):
"""
Helper method for asserting that ``CourseEntitleSupportDetail`` objects are retired.
Test to verify a learner's name is scrubbed from associated certificate records when the AccountRetirementView's
`clear_pii_from_certificate_records` static function is called.
"""
self.entitlement_support_detail.refresh_from_db()
assert '' == self.entitlement_support_detail.comments
GeneratedCertificateFactory(course_id=self.course_key, name="Bob Loblaw", user=self.test_user)
cert = get_certificate_for_user_id(self.test_user.id, self.course_key)
assert cert.name == "Bob Loblaw"
AccountRetirementView.clear_pii_from_certificate_records(self.test_user)
cert = get_certificate_for_user_id(self.test_user.id, self.course_key)
assert cert.name == ""
@skip_unless_lms

View File

@@ -66,6 +66,7 @@ from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_ca
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
from lms.djangoapps.certificates.api import clear_pii_from_certificate_records_for_user
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
from ..message_types import DeletionNotificationMessage
@@ -1144,9 +1145,8 @@ class AccountRetirementView(ViewSet):
}
```
Retires the user with the given username. This includes
retiring this username, the associated email address, and
any other PII associated with this user.
Retires the user with the given username. This includes retiring this username, the associated email address,
and any other PII associated with this user.
"""
username = request.data['username']
@@ -1162,6 +1162,9 @@ class AccountRetirementView(ViewSet):
self.delete_users_profile_images(user)
self.delete_users_country_cache(user)
# Retire user information from any certificate records associated with the learner
self.clear_pii_from_certificate_records(user)
# Retire data from Enterprise models
self.retire_users_data_sharing_consent(username, retired_username)
self.retire_sapsf_data_transmission(user)
@@ -1197,8 +1200,8 @@ class AccountRetirementView(ViewSet):
@staticmethod
def clear_pii_from_userprofile(user):
"""
For the given user, sets all of the user's profile fields to some retired value.
This also deletes all ``SocialLink`` objects associated with this user's profile.
For the given user, sets all of the user's profile fields to some retired value. This also deletes all
``SocialLink`` objects associated with this user's profile.
"""
for model_field, value_to_assign in USER_PROFILE_PII.items():
setattr(user.profile, model_field, value_to_assign)
@@ -1250,12 +1253,19 @@ class AccountRetirementView(ViewSet):
@staticmethod
def retire_entitlement_support_detail(user):
"""
Updates all CourseEntitleSupportDetail records for the given
user to have an empty ``comments`` field.
Updates all CourseEntitleSupportDetail records for the given user to have an empty ``comments`` field.
"""
for entitlement in CourseEntitlement.objects.filter(user_id=user.id):
entitlement.courseentitlementsupportdetail_set.all().update(comments='')
@staticmethod
def clear_pii_from_certificate_records(user):
"""
Calls a utility function in the `certificates` Django app responsible for removing PII (name) from any
certificate records associated with the learner being retired.
"""
clear_pii_from_certificate_records_for_user(user)
class UsernameReplacementView(APIView):
"""

View File

@@ -0,0 +1,29 @@
"""
Python APIs exposed by the user_api app to other in-process apps.
"""
from openedx.core.djangoapps.user_api.models import UserRetirementRequest, UserRetirementStatus
def get_retired_user_ids():
"""
Returns a list of learners' user_ids who have retired their account. This utility method removes any learners who
are in the "PENDING" retirement state, they have _requested_ retirement but have yet to have all their data purged.
These learners are still within their cooloff period where they can submit a request to restore their account.
Args:
None
Returns:
list[int] - A list of user ids of learners who have retired their account, minus any accounts currently in the
"PENDING" state.
"""
retired_user_ids = set(UserRetirementRequest.objects.values_list("user_id", flat=True))
pending_retired_user_ids = set(
UserRetirementStatus.objects
.filter(current_state__state_name="PENDING")
.values_list("user_id", flat=True)
)
return list(retired_user_ids - pending_retired_user_ids)

View File

@@ -1,13 +1,20 @@
"""Provides factories for User API models."""
from factory import SubFactory
from factory import Sequence, SubFactory
from factory.django import DjangoModelFactory
from opaque_keys.edx.locator import CourseLocator
from common.djangoapps.student.tests.factories import UserFactory
from ..models import UserCourseTag, UserOrgTag, UserPreference
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserCourseTag,
UserOrgTag,
UserPreference,
UserRetirementRequest,
UserRetirementStatus,
)
# Factories are self documenting
@@ -40,3 +47,44 @@ class UserOrgTagFactory(DjangoModelFactory):
org = 'org'
key = None
value = None
class RetirementStateFactory(DjangoModelFactory):
"""
Factory class for generating RetirementState instances.
"""
class Meta:
model = RetirementState
state_name = Sequence("STEP_{}".format)
state_execution_order = Sequence(lambda n: n * 10)
is_dead_end_state = False
required = False
class UserRetirementStatusFactory(DjangoModelFactory):
"""
Factory class for generating UserRetirementStatus instances.
"""
class Meta:
model = UserRetirementStatus
user = SubFactory(UserFactory)
original_username = Sequence('learner_{}'.format)
original_email = Sequence("learner{}@email.org".format)
original_name = Sequence("Learner{} Shmearner".format)
retired_username = Sequence("retired__learner_{}".format)
retired_email = Sequence("returned__learner{}@retired.invalid".format)
current_state = None
last_state = None
responses = ""
class UserRetirementRequestFactory(DjangoModelFactory):
"""
Factory class for generating UserRetirementRequest instances.
"""
class Meta:
model = UserRetirementRequest
user = SubFactory(UserFactory)

View File

@@ -0,0 +1,91 @@
"""
Unit tests for the `user_api` app's public Python interface.
"""
from django.test import TestCase
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.user_api.api import get_retired_user_ids
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserRetirementRequest,
UserRetirementStatus,
)
from openedx.core.djangoapps.user_api.tests.factories import (
RetirementStateFactory,
UserRetirementRequestFactory,
UserRetirementStatusFactory,
)
class UserApiRetirementTests(TestCase):
"""
Tests for utility functions exposed by the `user_api` app's public Python interface that are related to the user
retirement pipeline.
"""
@classmethod
def setUpClass(cls):
"""
The retirement pipeline is not fully enabled by default. We must ensure that the required RetirementState's
exist before executing any of our unit tests.
"""
super().setUpClass()
cls.pending = RetirementStateFactory(state_name="PENDING")
cls.complete = RetirementStateFactory(state_name="COMPLETE")
@classmethod
def tearDownClass(cls):
# Remove any retirement state objects that we created during this test suite run.
RetirementState.objects.all().delete()
super().tearDownClass()
def tearDown(self):
# clear retirement requests and related data between each test
UserRetirementRequest.objects.all().delete()
UserRetirementStatus.objects.all().delete()
super().tearDown()
def test_get_retired_user_ids(self):
"""
A unit test to verify that the only user id's returned from the `get_retired_user_ids` function are learners who
aren't in the "PENDING" state.
"""
user_pending = UserFactory()
# create a retirement request and status entry for a learner in the PENDING state
UserRetirementRequestFactory(user=user_pending)
UserRetirementStatusFactory(user=user_pending, current_state=self.pending, last_state=self.pending)
user_complete = UserFactory()
# create a retirement request and status entry for a learner in the COMPLETE state
UserRetirementRequestFactory(user=user_complete)
UserRetirementStatusFactory(user=user_complete, current_state=self.complete, last_state=self.complete)
results = get_retired_user_ids()
assert len(results) == 1
assert results == [user_complete.id]
def test_get_retired_user_ids_no_results(self):
"""
A unit test to verify that if the only retirement requests pending are in the "PENDING" state, we don't return
any learners' user_ids when calling the `get_retired_user_ids` function.
"""
user_pending_1 = UserFactory()
# create a retirement request and status entry for a learner in the PENDING state
UserRetirementRequestFactory(user=user_pending_1)
UserRetirementStatusFactory(
user=user_pending_1,
current_state=self.pending,
last_state=self.pending,
)
user_pending_2 = UserFactory()
# create a retirement request and status entry for a learner in the PENDING state
UserRetirementRequestFactory(user=user_pending_2)
UserRetirementStatusFactory(
user=user_pending_2,
current_state=self.pending,
last_state=self.pending,
)
results = get_retired_user_ids()
assert len(results) == 0
assert not results