diff --git a/eox_core/apps.py b/eox_core/apps.py index 628eb692..565a4da8 100644 --- a/eox_core/apps.py +++ b/eox_core/apps.py @@ -10,6 +10,7 @@ class EoxCoreConfig(AppConfig): """App configuration""" name = 'eox_core' verbose_name = "eduNEXT Openedx Extensions" + plugin_app = { 'url_config': { @@ -28,6 +29,12 @@ class EoxCoreConfig(AppConfig): }, }, } + + def ready(self): + """ + Import handlers to register signal receivers via @receiver decorators. + """ + from eox_core import handlers # pylint: disable=import-outside-toplevel, unused-import # noqa: F401 def ready(self): """ diff --git a/eox_core/handlers.py b/eox_core/handlers.py index 02da8e72..0276d26e 100644 --- a/eox_core/handlers.py +++ b/eox_core/handlers.py @@ -1,255 +1,46 @@ """ -Signal handlers for platform_plugins_ca. +Signal handlers for eox_core. -This module handles: -1. Account deactivation logging (when user requests deletion via deactivate_logout) -2. User retirement signal handling (MetaRed policy: permanent deletion after pipeline) +This module handles user retirement signal handling (MetaRed policy: permanent deletion +after pipeline completion to allow email reuse). -The retirement handlers listen for USER_RETIRE_LMS_CRITICAL signal at the END -of the retirement pipeline to permanently delete users, allowing email reuse. +The deletion is executed via a background task with a short delay to avoid conflicts +with the sender still modifying and saving the user instance after the signal is emitted. """ import logging -import json -from django.contrib.auth.signals import user_logged_out -from django.db import transaction -from django.dispatch import Signal, receiver -from openedx.core.djangoapps.user_api.accounts.signals import ( # pylint: disable=import-error - USER_RETIRE_LMS_CRITICAL, - USER_RETIRE_LMS_MISC, -) -from openedx.core.djangoapps.user_api.models import UserRetirementStatus # pylint: disable=import-error -from openedx_events.analytics.signals import TRACKING_EVENT_EMITTED +from django.dispatch import receiver -try: - from common.djangoapps.student.models import UserProfile - from common.djangoapps.util.model_utils import USER_FIELDS_CHANGED - from lms.djangoapps.certificates.api import get_recently_modified_certificates -except ImportError: - UserProfile = None - USER_FIELDS_CHANGED = Signal() - get_recently_modified_certificates = None +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_MISC # pylint: disable=import-error -logger = logging.getLogger("platform_plugins_ca.deactivation") -retirement_logger = logging.getLogger("platform_plugins_ca.retirement") +from eox_core.tasks import delete_user_task +retirement_logger = logging.getLogger(__name__) -@receiver(user_logged_out) -def handle_account_deactivation(sender, request, user, **kwargs): - """ - Signal receiver for user logout events. - - Logs when a user requests account deletion via the deactivate_logout endpoint. - The actual deletion happens later via the retirement pipeline. - """ - if not user: - return - - if request and 'deactivate_logout' in request.path: - logger.info( - "ACCOUNT_DEACTIVATION_INITIATED: User requested account deletion. " - "user_id=%s, username=%s, email=%s. " - "User will be deleted after retirement pipeline completes.", - user.id, - user.username, - user.email, - ) +DEFAULT_LMS_QUEUE = "edx.lms.core.default" @receiver(USER_RETIRE_LMS_MISC) -def handle_retire_lms_misc(sender, user, **kwargs): +def handle_retire_user(sender, user, **kwargs): # pylint: disable=unused-argument """ Signal receiver for USER_RETIRE_LMS_MISC retirement step. - Logs when the LMS_MISC retirement step is reached for a user. - """ - retirement_logger.info( - "[METARED_RETIREMENT] USER_RETIRE_LMS_MISC signal received - " - "sender=%s, user=%s, kwargs=%s", - sender, - user, - kwargs, - ) - - if not user: - retirement_logger.warning( - "[METARED_RETIREMENT] LMS_MISC step received with None user - ignoring" - ) - return - - try: - user_id = getattr(user, 'id', None) - username = getattr(user, 'username', None) - email = getattr(user, 'email', None) - retirement_logger.info( - "[METARED_RETIREMENT] LMS_MISC step for user: %s (id=%s, email=%s)", - username, - user_id, - email, - ) - except Exception as e: # pylint: disable=broad-except - retirement_logger.error( - "[METARED_RETIREMENT] Error accessing user attributes in LMS_MISC: %s", - e, - exc_info=True, - ) - - -@receiver(USER_RETIRE_LMS_CRITICAL) -def handle_retire_lms_critical(sender, user, **kwargs): - """ - Signal receiver for USER_RETIRE_LMS_CRITICAL retirement step. - - Permanently deletes the user after the retirement pipeline completes. - This implements the MetaRed policy allowing email reuse after deletion. - """ - retirement_logger.info( - "[METARED_RETIREMENT] USER_RETIRE_LMS_CRITICAL signal received - " - "sender=%s, user=%s, kwargs=%s", - sender, - user, - kwargs, - ) - - if not user: - retirement_logger.warning( - "[METARED_RETIREMENT] LMS_CRITICAL step received with None user - ignoring" - ) - return - - try: - user_id = getattr(user, 'id', None) - username = getattr(user, 'username', None) - email = getattr(user, 'email', None) - retirement_logger.info( - "[METARED_RETIREMENT] LMS_CRITICAL step for user: %s (id=%s, email=%s) - deleting now", - username, - user_id, - email, - ) - except Exception as e: # pylint: disable=broad-except - retirement_logger.error( - "[METARED_RETIREMENT] Error accessing user attributes in LMS_CRITICAL: %s", - e, - exc_info=True, - ) - return - - delete_user_permanently(user) - - -def delete_user_permanently(user): - """ - Permanently delete a user from the database. - - This function deletes the UserRetirementStatus record first (since it - references the user), then deletes the user record itself. This allows - the user to re-register with the same email address (MetaRed policy). - - Parameters - ---------- - user : User - The Django user instance to delete. + Schedules the permanent deletion of the user via a background task with a short + delay. This implements the MetaRed policy allowing email reuse after deletion. """ if not user: - retirement_logger.warning( - "[METARED_RETIREMENT] delete_user_permanently called with None user - ignoring" - ) + retirement_logger.warning("Retirement signal received with None user - ignoring") return - try: - user_id, username, email = user.id, user.username, user.email - except AttributeError as e: - retirement_logger.error( - "[METARED_RETIREMENT] User object missing required attributes: %s", - e, - ) - return - - retirement_logger.info( - "[METARED_RETIREMENT] Deleting user: %s (id=%s, email=%s)", - username, - user_id, - email, - ) + username = user.username + user_id = user.id - try: - with transaction.atomic(): - UserRetirementStatus.objects.filter(user=user).delete() - user.delete() - retirement_logger.info( - "[METARED_RETIREMENT] User deleted: %s - can re-register with %s", - username, - email, - ) + retirement_logger.info("Scheduling deletion for user: %s (id=%s)", username, user_id) - except Exception as e: # pylint: disable=broad-except - retirement_logger.error( - "[METARED_RETIREMENT] Failed to delete user %s: %s", - username, - e, - exc_info=True, - ) - - -def connect_signals(): - """ - Connect all signal receivers for platform_plugins_ca. - - This function is called from the AppConfig.ready() method to ensure - signals are registered when Django starts. - - Note: The @receiver decorators handle signal connection automatically, - but this function provides a hook for logging and any future manual - signal connections. - """ - logger.info( - "SIGNALS: platform_plugins_ca signal receivers configured " - "(deactivation logging + retirement handlers)" + # Schedule deletion with a 10-second delay to let the pipeline finish. + delete_user_task.apply_async( + args=[user_id, username], + countdown=10, + queue=DEFAULT_LMS_QUEUE, + routing_key=DEFAULT_LMS_QUEUE, ) - retirement_logger.info( - "[METARED_RETIREMENT] Signal handlers registered: " - "USER_RETIRE_LMS_MISC -> handle_retire_lms_misc, " - "USER_RETIRE_LMS_CRITICAL -> handle_retire_lms_critical" - ) - - -# pylint: disable=unused-argument -@receiver(USER_FIELDS_CHANGED) -def update_certificates_for_user(sender, user, table, changed_fields, **kwargs): - """ - Update certificates when a user's name changes. - - This handler listens to the `USER_FIELDS_CHANGED` signal and updates the name of - the certificates of the user if the user's name has changed. - - Args: - sender: The model class that sent the signal. - user: The User instance whose fields have changed. - table: The database table name where the change occurred. - changed_fields: Dictionary mapping field names to (old_value, new_value) tuples. - **kwargs: Additional keyword arguments passed by the signal. - """ - # Only update certificates if the user's name has changed - if table == UserProfile._meta.db_table and "name" in changed_fields: - certificates = get_recently_modified_certificates(user_ids=[user.id]) - certificates.update(name=user.profile.name) - - -@receiver(TRACKING_EVENT_EMITTED) -def handle_tracking_event(sender, tracking_log, **kwargs): - """ - Handle tracking events emitted by the Open edX analytics system. - - Args: - sender: The sender of the signal. - tracking_log: TrackingLogData instance with event information. - **kwargs: Additional keyword arguments. - """ - print(f"Raw tracking_log: {tracking_log}") - print(f"Event Name: {tracking_log.name}") - print(f"Timestamp: {tracking_log.timestamp.isoformat()}") - print(f"Data (raw): {tracking_log.data}") - print(f"Data (parsed): {json.loads(tracking_log.data) if tracking_log.data else {}}") - print(f"Context (raw): {tracking_log.context}") - print(f"Context (parsed): {json.loads(tracking_log.context) if tracking_log.context else {}}") diff --git a/eox_core/tasks.py b/eox_core/tasks.py new file mode 100644 index 00000000..458b77cf --- /dev/null +++ b/eox_core/tasks.py @@ -0,0 +1,35 @@ +""" +Celery tasks for eox_core. +""" +import logging + +from celery import shared_task + +retirement_logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=5) +def delete_user_task(self, user_id, username): + """ + Celery task to delete a user after a short delay. + + This task is executed asynchronously to give the retirement pipeline sender + time to finish updating and saving the user before the actual deletion. + """ + from django.contrib.auth import get_user_model + + User = get_user_model() + + try: + user = User.objects.get(id=user_id) + user.delete() + retirement_logger.info("User deleted successfully: %s", username) + except User.DoesNotExist: + retirement_logger.warning( + "User %s (id=%s) already deleted or does not exist", + username, + user_id, + ) + except Exception as e: # pylint: disable=broad-except + retirement_logger.error("Failed to delete user %s: %s", username, e, exc_info=True) + raise self.retry(exc=e)