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

@@ -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="")

View File

@@ -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}"
)

View File

@@ -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

View File

@@ -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 == ""

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