Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Unreleased
----------
* nothing unreleased

[6.6.8] - 2026-03-05
---------------------
* feat: moving retirement code to edx-enterprise

[6.6.7] - 2026-03-04
---------------------
* feat: expose admin list endpoint with search and pagination
Expand Down
6 changes: 6 additions & 0 deletions consent/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ class ConsentConfig(AppConfig):

name = 'consent'
verbose_name = "Enterprise Consent"

def ready(self):
"""
Perform one-time initialization: connect signal handlers.
"""
import consent.signals # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
36 changes: 36 additions & 0 deletions consent/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Django signal handlers for the consent module.
"""
from logging import getLogger

from consent.models import DataSharingConsent

logger = getLogger(__name__)

try:
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
except ImportError:
USER_RETIRE_LMS_CRITICAL = None


def retire_users_data_sharing_consent(sender, user, retired_username, **kwargs): # pylint: disable=unused-argument
"""
Handle USER_RETIRE_LMS_CRITICAL signal: retire DataSharingConsent username records.

Idempotent: only updates records where username hasn't already been changed to the retired value.
"""
if user.username != retired_username:
logger.info(
"Updating username %s to retired username %s on DataSharingConsent records",
user.username,
retired_username,
)
DataSharingConsent.objects.filter(
username=user.username,
).exclude(
username=retired_username,
).update(username=retired_username)


if USER_RETIRE_LMS_CRITICAL is not None:
USER_RETIRE_LMS_CRITICAL.connect(retire_users_data_sharing_consent)
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "6.6.7"
__version__ = "6.6.8"
25 changes: 25 additions & 0 deletions enterprise/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@

try:
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
from openedx_events.learning.signals import COURSE_ENROLLMENT_CHANGED, COURSE_UNENROLLMENT_COMPLETED

except ImportError:
CourseEnrollment = None
COURSE_ENROLLMENT_CHANGED = None
COURSE_UNENROLLMENT_COMPLETED = None
USER_RETIRE_LMS_CRITICAL = None

logger = getLogger(__name__)
_UNSAVED_FILEFIELD = 'unsaved_filefield'
Expand Down Expand Up @@ -451,3 +453,26 @@ def generate_default_orchestration_record_display_name(sender, instance, **kwarg

if COURSE_ENROLLMENT_CHANGED is not None:
COURSE_ENROLLMENT_CHANGED.connect(course_enrollment_changed_receiver)


def retire_user_from_pending_enterprise_customer_user(sender, user, retired_email, **kwargs): # pylint: disable=unused-argument
"""
Handle USER_RETIRE_LMS_CRITICAL signal: retire PendingEnterpriseCustomerUser email address.

Idempotent: only updates records where user_email hasn't already been changed to the retired value.
"""
if user.email != retired_email:
logger.info(
"Updating PendingEnterpriseCustomerUser email record from %s to retired email %s",
user.email,
retired_email,
)
models.PendingEnterpriseCustomerUser.objects.filter(
user_email=user.email,
).exclude(
user_email=retired_email,
).update(user_email=retired_email)


if USER_RETIRE_LMS_CRITICAL is not None:
USER_RETIRE_LMS_CRITICAL.connect(retire_user_from_pending_enterprise_customer_user)
8 changes: 8 additions & 0 deletions integrated_channels/integrated_channel/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ class IntegratedChannelConfig(AppConfig):
"""
name = 'integrated_channels.integrated_channel'
verbose_name = "Enterprise Integrated Channels"

# TODO: We should move these integrated-channel-specific retirement handlers to the edx-integrated-channels repo
# and use `channel_integrations.integrated_channel` instead of `integrated_channels.integrated_channel`
def ready(self):
Copy link
Contributor

@pwnage101 pwnage101 Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, integrated_channels is deprecated, we migrated to channel_integrations. Confusingly, BOTH are still installed and active:

Your hunch was correct to migrate these because they are indeed functions we currently call during retirement, but unfortunately I think they're no-ops since the old tables just don't get updated anymore.

Please just add a code comment that we should really move these integrated-channel-specific retirement handlers again to the edx-integrated-channels repository and update the handlers to import the new models of the same name.

"""
Perform one-time initialization: connect signal handlers.
"""
import integrated_channels.integrated_channel.signals # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
Copy link
Contributor

@pwnage101 pwnage101 Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Looks like claude version of this PR got this slightly wrong by doing the connect() call inside ready() which technically works but is less clean.

54 changes: 54 additions & 0 deletions integrated_channels/integrated_channel/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Django signal handlers for integrated channels user retirement.
"""
from logging import getLogger

from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit
from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit

logger = getLogger(__name__)

try:
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL
except ImportError:
USER_RETIRE_LMS_CRITICAL = None


def retire_sapsf_data_transmission(sender, user, **kwargs): # pylint: disable=unused-argument
"""
Handle USER_RETIRE_LMS_CRITICAL signal: clear sapsf_user_id on audit records.

Idempotent: only updates records where sapsf_user_id is not already empty.
"""
logger.info(f'Retiring sapsf_user_id {user.id} on audit records')
for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id):
for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user):
logger.info(f'Updating enrollment {enrollment.id} to clear sapsf_user_id {user.id}')
SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
enterprise_course_enrollment_id=enrollment.id,
).exclude(
sapsf_user_id="",
).update(sapsf_user_id="")


def retire_degreed_data_transmission(sender, user, **kwargs): # pylint: disable=unused-argument
"""
Handle USER_RETIRE_LMS_CRITICAL signal: clear degreed_user_email on audit records.

Idempotent: only updates records where degreed_user_email is not already empty.
"""
logger.info(f'Retiring degreed_user_email on audit records for user {user.id}')
for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id):
for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user):
logger.info(f'Updating enrollment {enrollment.id} to clear degreed_user_email for ECU {ent_user}')
DegreedLearnerDataTransmissionAudit.objects.filter(
enterprise_course_enrollment_id=enrollment.id,
).exclude(
degreed_user_email="",
).update(degreed_user_email="")


if USER_RETIRE_LMS_CRITICAL is not None:
USER_RETIRE_LMS_CRITICAL.connect(retire_sapsf_data_transmission)
USER_RETIRE_LMS_CRITICAL.connect(retire_degreed_data_transmission)
2 changes: 1 addition & 1 deletion requirements/edx-platform-constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ edx-drf-extensions==10.6.0
# enterprise-integrated-channels
# openedx-authz
# openedx-core
edx-enterprise==6.6.5
edx-enterprise==6.6.8
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
Expand Down
56 changes: 56 additions & 0 deletions tests/test_consent/test_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Tests for consent signal handlers.
"""
import unittest

from pytest import mark

from consent.models import DataSharingConsent
from consent.signals import retire_users_data_sharing_consent
from test_utils.factories import DataSharingConsentFactory, EnterpriseCustomerFactory, UserFactory


@mark.django_db
class TestRetireUsersDataSharingConsent(unittest.TestCase):
"""
Tests for the retire_users_data_sharing_consent signal handler.
"""

def setUp(self):
super().setUp()
self.user = UserFactory()
self.retired_username = f'retired__{self.user.username}'
self.enterprise_customer = EnterpriseCustomerFactory()
self.consent = DataSharingConsentFactory(
username=self.user.username,
enterprise_customer=self.enterprise_customer,
)

def _send_signal(self):
"""Helper to invoke the handler as the signal would."""
retire_users_data_sharing_consent(
sender=None,
user=self.user,
retired_username=self.retired_username,
retired_email=f'retired__{self.user.email}',
)

def test_retires_username_on_consent_records(self):
self._send_signal()

self.consent.refresh_from_db()
assert self.consent.username == self.retired_username

def test_no_consent_records_with_original_username_remain(self):
original_username = self.user.username
self._send_signal()

assert not DataSharingConsent.objects.filter(username=original_username).exists()

def test_idempotent_when_already_retired(self):
"""Calling the handler twice does not raise and leaves data in the correct state."""
self._send_signal()
self._send_signal()

self.consent.refresh_from_db()
assert self.consent.username == self.retired_username
44 changes: 44 additions & 0 deletions tests/test_enterprise/test_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
create_enterprise_enrollment_receiver,
enterprise_unenrollment_receiver,
handle_user_post_save,
retire_user_from_pending_enterprise_customer_user,
)
from integrated_channels.integrated_channel.models import OrphanedContentTransmissions
from test_utils import EmptyCacheMixin
Expand Down Expand Up @@ -1174,3 +1175,46 @@ def test_update_enterprise_catalog_query(self, api_client_mock):
include_exec_ed_2u_courses=test_query.include_exec_ed_2u_courses,
)
api_client_mock.return_value.refresh_catalogs.assert_called_with([enterprise_catalog_2])


@mark.django_db
class TestRetireUserFromPendingEnterpriseCustomerUser(unittest.TestCase):
"""
Tests for the retire_user_from_pending_enterprise_customer_user signal handler.
"""

def setUp(self):
super().setUp()
self.user = UserFactory()
self.retired_email = f'retired__{self.user.email}'
self.pending_enterprise_user = PendingEnterpriseCustomerUserFactory(
user_email=self.user.email,
)

def _send_signal(self):
retire_user_from_pending_enterprise_customer_user(
sender=None,
user=self.user,
retired_username=f'retired__{self.user.username}',
retired_email=self.retired_email,
)

def test_retires_user_email_on_pending_records(self):
self._send_signal()

self.pending_enterprise_user.refresh_from_db()
assert self.pending_enterprise_user.user_email == self.retired_email

def test_no_records_with_original_email_remain(self):
original_email = self.user.email
self._send_signal()

assert not PendingEnterpriseCustomerUser.objects.filter(user_email=original_email).exists()

def test_idempotent_when_already_retired(self):
"""Calling the handler twice does not raise and leaves data in the correct state."""
self._send_signal()
self._send_signal()

self.pending_enterprise_user.refresh_from_db()
assert self.pending_enterprise_user.user_email == self.retired_email
Loading