diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index b72113b94788..ec790a4315c1 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -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="") diff --git a/lms/djangoapps/certificates/management/commands/purge_pii_from_generatedcertificates.py b/lms/djangoapps/certificates/management/commands/purge_pii_from_generatedcertificates.py new file mode 100644 index 000000000000..c478fd4fe0c5 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/purge_pii_from_generatedcertificates.py @@ -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}" + ) diff --git a/lms/djangoapps/certificates/management/commands/tests/test_purge_pii_from_generatedcertificates.py b/lms/djangoapps/certificates/management/commands/tests/test_purge_pii_from_generatedcertificates.py new file mode 100644 index 000000000000..50855ccaa804 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/tests/test_purge_pii_from_generatedcertificates.py @@ -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 diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index b718fcd2bac9..c766d4250bd9 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -40,6 +40,7 @@ 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.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 @@ def test_get_certificate_invalidation_entry_dne(self): 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 @@ def test_beta_tester(self): 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 == "" diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index 0cc2754e4760..9d4efb2fa77c 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -30,25 +30,6 @@ 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 @@ 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 @@ def setUp(self): 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 @@ def test_can_retire_users_datasharingconsent(self): 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 diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index cfe9872a95f8..720b3ba96af7 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -66,6 +66,7 @@ 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 @@ def post(self, request): } ``` - 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 @@ def post(self, request): 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 @@ def post(self, request): @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 @@ def retire_user_from_pending_enterprise_customer_user(user, retired_email): @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): """ diff --git a/openedx/core/djangoapps/user_api/api.py b/openedx/core/djangoapps/user_api/api.py new file mode 100644 index 000000000000..813845e52582 --- /dev/null +++ b/openedx/core/djangoapps/user_api/api.py @@ -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) diff --git a/openedx/core/djangoapps/user_api/tests/factories.py b/openedx/core/djangoapps/user_api/tests/factories.py index c3b11367bd2d..9b6302ae3a1e 100644 --- a/openedx/core/djangoapps/user_api/tests/factories.py +++ b/openedx/core/djangoapps/user_api/tests/factories.py @@ -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 Meta: 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) diff --git a/openedx/core/djangoapps/user_api/tests/test_api.py b/openedx/core/djangoapps/user_api/tests/test_api.py new file mode 100644 index 000000000000..a929d1b8c643 --- /dev/null +++ b/openedx/core/djangoapps/user_api/tests/test_api.py @@ -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