From d9dc0d4b8dcdb820ea317ec7ed121d0223542992 Mon Sep 17 00:00:00 2001 From: Zach Hancock Date: Wed, 15 May 2019 15:04:54 -0400 Subject: [PATCH] command to expire waiting program enrollments --- lms/djangoapps/program_enrollments/apps.py | 6 + .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/expire_waiting_enrollments.py | 33 +++++ .../management/commands/tests/__init__.py | 0 .../tests/test_expire_waiting_enrollments.py | 39 ++++++ lms/djangoapps/program_enrollments/tasks.py | 60 +++++++++ .../program_enrollments/tests/test_tasks.py | 126 ++++++++++++++++++ 8 files changed, 264 insertions(+) create mode 100644 lms/djangoapps/program_enrollments/management/__init__.py create mode 100644 lms/djangoapps/program_enrollments/management/commands/__init__.py create mode 100644 lms/djangoapps/program_enrollments/management/commands/expire_waiting_enrollments.py create mode 100644 lms/djangoapps/program_enrollments/management/commands/tests/__init__.py create mode 100644 lms/djangoapps/program_enrollments/management/commands/tests/test_expire_waiting_enrollments.py create mode 100644 lms/djangoapps/program_enrollments/tasks.py create mode 100644 lms/djangoapps/program_enrollments/tests/test_tasks.py diff --git a/lms/djangoapps/program_enrollments/apps.py b/lms/djangoapps/program_enrollments/apps.py index 7cdabf684a..dd48585385 100644 --- a/lms/djangoapps/program_enrollments/apps.py +++ b/lms/djangoapps/program_enrollments/apps.py @@ -24,3 +24,9 @@ class ProgramEnrollmentsConfig(AppConfig): } }, } + + def ready(self): + """ + Connect handlers to signals. + """ + from . import tasks # pylint: disable=unused-variable diff --git a/lms/djangoapps/program_enrollments/management/__init__.py b/lms/djangoapps/program_enrollments/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/management/commands/__init__.py b/lms/djangoapps/program_enrollments/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/management/commands/expire_waiting_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/expire_waiting_enrollments.py new file mode 100644 index 0000000000..28f123e20f --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/expire_waiting_enrollments.py @@ -0,0 +1,33 @@ +""" Management command to cleanup old waiting enrollments """ +import logging +from django.core.management.base import BaseCommand +from ... import tasks + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Deletes enrollments not tied to a user that have not been modified + for at least 60 days. + + Example usage: + $ ./manage.py lms expire_waiting_enrollments + """ + + help = 'Remove expired enrollments that have not been linked to a user.' + WAITING_ENROLLMENTS_EXPIRATION_DAYS = 60 + + def add_arguments(self, parser): + parser.add_argument( + '--expiration_days', + help='Number of days before a waiting enrollment is considered expired', + default=self.WAITING_ENROLLMENTS_EXPIRATION_DAYS, + type=int + ) + + def handle(self, *args, **options): + expiration_days = options.get('expiration_days') + logger.info(u'Deleting waiting enrollments unmodified for %s days', expiration_days) + task = tasks.expire_waiting_enrollments.apply_async(args=[expiration_days]) + task.get() # wait for task to complete before exiting diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/__init__.py b/lms/djangoapps/program_enrollments/management/commands/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_expire_waiting_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_expire_waiting_enrollments.py new file mode 100644 index 0000000000..ee8ba3e908 --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_expire_waiting_enrollments.py @@ -0,0 +1,39 @@ +""" +Tests for the expire_waiting_enrollments management command. +""" +import ddt +from django.core.management import call_command +from django.test import TestCase +from mock import patch + +from lms.djangoapps.program_enrollments.management.commands import expire_waiting_enrollments + + +@ddt.ddt +class TestExpireWaitingEnrollments(TestCase): + """ Test expire_waiting_enrollments command """ + + @classmethod + def setUpClass(cls): + super(TestExpireWaitingEnrollments, cls).setUpClass() + cls.command = expire_waiting_enrollments.Command() + + @ddt.data(90, None) + @patch('lms.djangoapps.program_enrollments.tasks.expire_waiting_enrollments') + def test_task_fired_with_args(self, expire_days_argument, mock_task): + mock_task.return_value = {} + expected_expiration = 60 + command = 'expire_waiting_enrollments' + if expire_days_argument: + expected_expiration = expire_days_argument + call_command(command, expiration_days=expire_days_argument) + else: + call_command(command) + + mock_task.apply_async.assert_called_with(args=[expected_expiration]) + + @patch('lms.djangoapps.program_enrollments.tasks.expire_waiting_enrollments') + def test_task_failure_fails_command(self, mock_task): + mock_task.apply_async.side_effect = Exception('BOOM!') + with self.assertRaises(Exception): + call_command('expire_waiting_enrollments') diff --git a/lms/djangoapps/program_enrollments/tasks.py b/lms/djangoapps/program_enrollments/tasks.py new file mode 100644 index 0000000000..7344bf4834 --- /dev/null +++ b/lms/djangoapps/program_enrollments/tasks.py @@ -0,0 +1,60 @@ +""" Tasks for program enrollments """ +from datetime import timedelta +import logging +from celery import task +from celery_utils.logged_task import LoggedTask +from django.utils import timezone + +from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment + +log = logging.getLogger(__name__) + + +@task(base=LoggedTask) +def expire_waiting_enrollments(expiration_days): + """ + Remove all ProgramEnrollments and related ProgramCourseEnrollments for + ProgramEnrollments not modified for + """ + expiry_date = timezone.now() - timedelta(days=expiration_days) + + program_enrollments = ProgramEnrollment.objects.filter( + user=None, + modified__lte=expiry_date + ).prefetch_related('program_course_enrollments') + + program_enrollment_ids = [] + program_course_enrollment_ids = [] + for program_enrollment in program_enrollments: + program_enrollment_ids.append(program_enrollment.id) + log.info( + u'Found expired program_enrollment (id=%s) for program_uuid=%s', + program_enrollment.id, + program_enrollment.program_uuid, + ) + for course_enrollment in program_enrollment.program_course_enrollments.all(): + program_course_enrollment_ids.append(course_enrollment.id) + log.info( + u'Found expired program_course_enrollment (id=%s) for program_uuid=%s, course_key=%s', + course_enrollment.id, + program_enrollment.program_uuid, + course_enrollment.course_key, + ) + + deleted_enrollments = program_enrollments.delete() + log.info(u'Removed %s expired records: %s', deleted_enrollments[0], deleted_enrollments[1]) + + deleted_hist_program_enroll = ProgramEnrollment.historical_records.filter( # pylint: disable=no-member + id__in=program_enrollment_ids + ).delete() + deleted_hist_course_enroll = ProgramCourseEnrollment.historical_records.filter( # pylint: disable=no-member + id__in=program_course_enrollment_ids + ).delete() + log.info( + u'Removed %s historical program_enrollment records with id in %s', + deleted_hist_program_enroll[0], program_enrollment_ids + ) + log.info( + u'Removed %s historical program_course_enrollment records with id in %s', + deleted_hist_course_enroll[0], program_course_enrollment_ids + ) diff --git a/lms/djangoapps/program_enrollments/tests/test_tasks.py b/lms/djangoapps/program_enrollments/tests/test_tasks.py new file mode 100644 index 0000000000..c0402c46e1 --- /dev/null +++ b/lms/djangoapps/program_enrollments/tests/test_tasks.py @@ -0,0 +1,126 @@ +""" +Unit tests for program_course_enrollments tasks +""" +from datetime import timedelta +from django.db.models.base import ObjectDoesNotExist +from django.test import TestCase +from django.utils import timezone +from freezegun import freeze_time +from testfixtures import LogCapture +from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment +from lms.djangoapps.program_enrollments.tasks import expire_waiting_enrollments, log +from lms.djangoapps.program_enrollments.api.v1.tests.factories import ( + ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory +) +from student.tests.factories import UserFactory + + +class ExpireWaitingEnrollmentsTest(TestCase): + """ Test expire_waiting_enrollments task """ + + def _setup_enrollments(self, external_user_key, user, created_date): + """ helper function to setup enrollments """ + with freeze_time(created_date): + program_enrollment = ProgramEnrollmentFactory( + user=user, + external_user_key=external_user_key, + ) + ProgramCourseEnrollmentFactory( + program_enrollment=program_enrollment + ) + # additional course enrollment that is always fresh + ProgramCourseEnrollmentFactory( + program_enrollment=program_enrollment + ) + + def test_expire(self): + self._setup_enrollments('student_expired_waiting', None, timezone.now() - timedelta(60)) + self._setup_enrollments('student_waiting', None, timezone.now() - timedelta(59)) + self._setup_enrollments('student_actualized', UserFactory(), timezone.now() - timedelta(90)) + + expired_program_enrollment = ProgramEnrollment.objects.get( + external_user_key='student_expired_waiting' + ) + expired_course_enrollments = list(ProgramCourseEnrollment.objects.filter( + program_enrollment=expired_program_enrollment + )) + + # assert deleted enrollments are logged (without pii) + with LogCapture(log.name) as log_capture: + expire_waiting_enrollments(60) + + program_enrollment_message_tmpl = u'Found expired program_enrollment (id={}) for program_uuid={}' + course_enrollment_message_tmpl = ( + u'Found expired program_course_enrollment (id={}) for program_uuid={}, course_key={}' + ) + + log_capture.check_present( + ( + log.name, + 'INFO', + program_enrollment_message_tmpl.format( + expired_program_enrollment.id, + expired_program_enrollment.program_uuid, + ) + ), + ( + log.name, + 'INFO', + course_enrollment_message_tmpl.format( + expired_course_enrollments[0].id, + expired_program_enrollment.program_uuid, + expired_course_enrollments[0].course_key, + ) + ), + ( + log.name, + 'INFO', + course_enrollment_message_tmpl.format( + expired_course_enrollments[1].id, + expired_program_enrollment.program_uuid, + expired_course_enrollments[1].course_key, + ) + ), + ( + log.name, + 'INFO', + u'Removed 3 expired records:' + u' {u\'program_enrollments.ProgramCourseEnrollment\': 2,' + u' u\'program_enrollments.ProgramEnrollment\': 1}' + ), + ) + + program_enrollments = ProgramEnrollment.objects.all() + program_course_enrollments = ProgramCourseEnrollment.objects.all() + historical_program_enrollments = ProgramEnrollment.historical_records.all() # pylint: disable=no-member + historical_program_course_enrollments = ProgramCourseEnrollment.historical_records.all() # pylint: disable=no-member + + # assert expired records no longer exist + with self.assertRaises(ProgramEnrollment.DoesNotExist): + program_enrollments.get(external_user_key='student_expired_waiting') + self.assertEqual(len(program_course_enrollments), 4) + + # assert fresh waiting records are not affected + waiting_enrollment = program_enrollments.get(external_user_key='student_waiting') + self.assertEqual(len(waiting_enrollment.program_course_enrollments.all()), 2) + + # assert actualized enrollments are not affected + actualized_enrollment = program_enrollments.get(external_user_key='student_actualized') + self.assertEqual(len(actualized_enrollment.program_course_enrollments.all()), 2) + + # assert expired historical records are also removed + with self.assertRaises(ObjectDoesNotExist): + historical_program_enrollments.get(external_user_key='student_expired_waiting') + self.assertEqual( + len(historical_program_course_enrollments.filter(program_enrollment_id=expired_program_enrollment.id)), + 0 + ) + + # assert other historical records are not affected + self.assertEqual(len(historical_program_enrollments), 2) + self.assertEqual(len(historical_program_course_enrollments), 4) + + def test_expire_none(self): + """ Asserts no exceptions are thrown if no enrollments are found """ + expire_waiting_enrollments(60) + self.assertEqual(len(ProgramEnrollment.objects.all()), 0)