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:
@@ -953,3 +953,21 @@ def invalidate_certificate(user_id, course_key_or_id, source):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def clear_pii_from_certificate_records_for_user(user):
|
||||
"""
|
||||
Utility function to remove PII from certificate records when a learner's account is being retired. Used by the
|
||||
`AccountRetirementView` in the `user_api` Django app (invoked by the /api/user/v1/accounts/retire endpoint).
|
||||
|
||||
The update is performed using a bulk SQL update via the Django ORM. This will not trigger the GeneratedCertificate
|
||||
model's custom `save()` function, nor fire any Django signals (which is desired at the time of writing). There is
|
||||
nothing to update in our external systems by this update.
|
||||
|
||||
Args:
|
||||
user (User): The User instance of the learner actively being retired.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
GeneratedCertificate.objects.filter(user=user).update(name="")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
A management command, designed to be run once by Open edX Operators, to obfuscate learner PII from the
|
||||
`Certificates_GeneratedCertificate` table that should have been purged during learner retirement.
|
||||
|
||||
A fix has been included in the retirement pipeline to properly purge this data during learner retirement. This can be
|
||||
used to purge PII from accounts that have already been retired.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.user_api.api import get_retired_user_ids
|
||||
|
||||
User = get_user_model()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
This management command performs a bulk update on `GeneratedCertificate` instances. This means that it will not
|
||||
invoke the custom save() function defined as part of the `GeneratedCertificate` model, and thus will not emit any
|
||||
Django signals throughout the system after the update occurs. This is desired behavior. We are using this
|
||||
management command to purge remnant PII, retired elsewhere in the system, that should have already been removed
|
||||
from the Certificates tables. We don't need updates to propogate to external systems (like the Credentials IDA).
|
||||
|
||||
This management command functions by requesting a list of learners' user_ids whom have completed their journey
|
||||
through the retirement pipeline. The `get_retired_user_ids` utility function is responsible for filtering out any
|
||||
learners in the PENDING state, as they could still submit a request to cancel their account deletion request (and
|
||||
we don't want to remove any data that may still be good).
|
||||
|
||||
Example usage:
|
||||
|
||||
# Dry Run (preview changes):
|
||||
$ ./manage.py lms purge_pii_from_generatedcertificates --dry-run
|
||||
|
||||
# Purge data:
|
||||
$ ./manage.py lms purge_pii_from_generatedcertificates
|
||||
"""
|
||||
|
||||
help = """
|
||||
Purges learners' full names from the `Certificates_GeneratedCertificate` table if their account has been
|
||||
successfully retired.
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Shows a preview of what users would be affected by running this management command.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
retired_user_ids = get_retired_user_ids()
|
||||
if not options["dry_run"]:
|
||||
log.warning(
|
||||
f"Purging `name` from the certificate records of the following users: {retired_user_ids}"
|
||||
)
|
||||
GeneratedCertificate.objects.filter(user_id__in=retired_user_ids).update(name="")
|
||||
else:
|
||||
log.info(
|
||||
"DRY RUN: running this management command would purge `name` data from the following users: "
|
||||
f"{retired_user_ids}"
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Tests for the `purge_pii_from_generatedcertificates` management command.
|
||||
"""
|
||||
|
||||
|
||||
from django.core.management import call_command
|
||||
from testfixtures import LogCapture
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from openedx.core.djangoapps.user_api.models import RetirementState
|
||||
from openedx.core.djangoapps.user_api.tests.factories import (
|
||||
RetirementStateFactory,
|
||||
UserRetirementRequestFactory,
|
||||
UserRetirementStatusFactory,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class PurgePiiFromCertificatesTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the `purge_pii_from_generatedcertificates` management command.
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
The retirement pipeline is not fully enabled by default. In order to properly test the management command, we
|
||||
must ensure that at least one of the required RetirementState states (`COMPLETE`) exists.
|
||||
"""
|
||||
super().setUpClass()
|
||||
cls.complete = RetirementStateFactory(state_name="COMPLETE")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
# Remove any retirement state objects that we created during this test suite run. We don't want to poison other
|
||||
# test suites.
|
||||
RetirementState.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course_run = CourseFactory()
|
||||
# create an "active" learner that is not associated with any retirement requests, used to verify that the
|
||||
# management command doesn't purge any info for active users.
|
||||
self.user_active = UserFactory()
|
||||
self.user_active_name = "Teysa Karlov"
|
||||
GeneratedCertificateFactory(
|
||||
status=CertificateStatuses.downloadable,
|
||||
course_id=self.course_run.id,
|
||||
user=self.user_active,
|
||||
name=self.user_active_name,
|
||||
grade=1.00,
|
||||
)
|
||||
# create a second learner that is associated with a retirement request, used to verify that the management
|
||||
# command purges info successfully from a GeneratedCertificate instance associated with a retired learner
|
||||
self.user_retired = UserFactory()
|
||||
self.user_retired_name = "Nicol Bolas"
|
||||
GeneratedCertificateFactory(
|
||||
status=CertificateStatuses.downloadable,
|
||||
course_id=self.course_run.id,
|
||||
user=self.user_retired,
|
||||
name=self.user_retired_name,
|
||||
grade=0.99,
|
||||
)
|
||||
UserRetirementStatusFactory(
|
||||
user=self.user_retired,
|
||||
current_state=self.complete,
|
||||
last_state=self.complete,
|
||||
)
|
||||
UserRetirementRequestFactory(user=self.user_retired)
|
||||
|
||||
def test_management_command(self):
|
||||
"""
|
||||
Verify the management command purges expected data from a GeneratedCertificate instance if a learner has
|
||||
successfully had their account retired.
|
||||
"""
|
||||
cert_for_active_user = GeneratedCertificate.objects.get(user_id=self.user_active)
|
||||
assert cert_for_active_user.name == self.user_active_name
|
||||
cert_for_retired_user = GeneratedCertificate.objects.get(user_id=self.user_retired)
|
||||
assert cert_for_retired_user.name == self.user_retired_name
|
||||
|
||||
call_command("purge_pii_from_generatedcertificates")
|
||||
|
||||
cert_for_active_user = GeneratedCertificate.objects.get(user_id=self.user_active)
|
||||
assert cert_for_active_user.name == self.user_active_name
|
||||
cert_for_retired_user = GeneratedCertificate.objects.get(user_id=self.user_retired)
|
||||
assert cert_for_retired_user.name == ""
|
||||
|
||||
def test_management_command_dry_run(self):
|
||||
"""
|
||||
Verify that the management command does not purge any data when invoked with the `--dry-run` flag
|
||||
"""
|
||||
expected_log_msg = (
|
||||
"DRY RUN: running this management command would purge `name` data from the following users: "
|
||||
f"[{self.user_retired.id}]"
|
||||
)
|
||||
|
||||
cert_for_active_user = GeneratedCertificate.objects.get(user_id=self.user_active)
|
||||
assert cert_for_active_user.name == self.user_active_name
|
||||
cert_for_retired_user = GeneratedCertificate.objects.get(user_id=self.user_retired)
|
||||
assert cert_for_retired_user.name == self.user_retired_name
|
||||
|
||||
with LogCapture() as logger:
|
||||
call_command("purge_pii_from_generatedcertificates", "--dry-run")
|
||||
|
||||
cert_for_active_user = GeneratedCertificate.objects.get(user_id=self.user_active)
|
||||
assert cert_for_active_user.name == self.user_active_name
|
||||
cert_for_retired_user = GeneratedCertificate.objects.get(user_id=self.user_retired)
|
||||
assert cert_for_retired_user.name == self.user_retired_name
|
||||
|
||||
assert logger.records[0].msg == expected_log_msg
|
||||
@@ -40,6 +40,7 @@ from lms.djangoapps.certificates.api import (
|
||||
can_show_certificate_message,
|
||||
certificate_status_for_student,
|
||||
certificate_downloadable_status,
|
||||
clear_pii_from_certificate_records_for_user,
|
||||
create_certificate_invalidation_entry,
|
||||
create_or_update_certificate_allowlist_entry,
|
||||
display_date_for_certificate,
|
||||
@@ -76,6 +77,9 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
|
||||
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
|
||||
|
||||
CAN_GENERATE_METHOD = 'lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate'
|
||||
BETA_TESTER_METHOD = 'lms.djangoapps.certificates.api.access.is_beta_tester'
|
||||
CERTS_VIEWABLE_METHOD = 'lms.djangoapps.certificates.api.certificates_viewable_for_course'
|
||||
PASSED_OR_ALLOWLISTED_METHOD = 'lms.djangoapps.certificates.api._has_passed_or_is_allowlisted'
|
||||
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
|
||||
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
|
||||
|
||||
@@ -1120,10 +1124,6 @@ class CertificateInvalidationTests(ModuleStoreTestCase):
|
||||
for index, message in enumerate(expected_messages):
|
||||
assert message in log.records[index].getMessage()
|
||||
|
||||
BETA_TESTER_METHOD = 'lms.djangoapps.certificates.api.access.is_beta_tester'
|
||||
CERTS_VIEWABLE_METHOD = 'lms.djangoapps.certificates.api.certificates_viewable_for_course'
|
||||
PASSED_OR_ALLOWLISTED_METHOD = 'lms.djangoapps.certificates.api._has_passed_or_is_allowlisted'
|
||||
|
||||
|
||||
class MockGeneratedCertificate:
|
||||
"""
|
||||
@@ -1268,3 +1268,42 @@ class CertificatesMessagingTestCase(ModuleStoreTestCase):
|
||||
|
||||
with patch(BETA_TESTER_METHOD, return_value=True):
|
||||
assert not can_show_certificate_message(self.course, self.user, grade, certs_enabled)
|
||||
|
||||
|
||||
class CertificatesLearnerRetirementFunctionality(ModuleStoreTestCase):
|
||||
"""
|
||||
API tests for utility functions used as part of the learner retirement pipeline to remove PII from certificate
|
||||
records.
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = UserFactory()
|
||||
self.user_full_name = "Maeby Funke"
|
||||
self.course1 = CourseOverviewFactory()
|
||||
self.course2 = CourseOverviewFactory()
|
||||
GeneratedCertificateFactory(
|
||||
course_id=self.course1.id,
|
||||
name=self.user_full_name,
|
||||
user=self.user,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
course_id=self.course2.id,
|
||||
name=self.user_full_name,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
def test_clear_pii_from_certificate_records(self):
|
||||
"""
|
||||
Unit test for the `clear_pii_from_certificate_records` utility function, used to wipe PII from certificate
|
||||
records when a learner's account is being retired.
|
||||
"""
|
||||
cert_course1 = GeneratedCertificate.objects.get(user=self.user, course_id=self.course1.id)
|
||||
cert_course2 = GeneratedCertificate.objects.get(user=self.user, course_id=self.course2.id)
|
||||
assert cert_course1.name == self.user_full_name
|
||||
assert cert_course2.name == self.user_full_name
|
||||
|
||||
clear_pii_from_certificate_records_for_user(self.user)
|
||||
cert_course1 = GeneratedCertificate.objects.get(user=self.user, course_id=self.course1.id)
|
||||
cert_course2 = GeneratedCertificate.objects.get(user=self.user, course_id=self.course2.id)
|
||||
assert cert_course1.name == ""
|
||||
assert cert_course2.name == ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
29
openedx/core/djangoapps/user_api/api.py
Normal file
29
openedx/core/djangoapps/user_api/api.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
91
openedx/core/djangoapps/user_api/tests/test_api.py
Normal file
91
openedx/core/djangoapps/user_api/tests/test_api.py
Normal 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
|
||||
Reference in New Issue
Block a user