diff --git a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py index 92472b9288..cbfdd8e733 100644 --- a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py @@ -1,19 +1,14 @@ """ -A few places in the LMS want to notify the Credentials service when certain events -happen (like certificates being awarded or grades changing). To do this, they -listen for a signal. Sometimes we want to rebuild the data on these apps -regardless of an actual change in the database, either to recover from a bug or -to bootstrap a new feature we're rolling out for the first time. +A few places in the LMS want to notify the Credentials service when certain events happen (like certificates being +awarded or grades changing). To do this, they listen for a signal. Sometimes we want to rebuild the data on these apps +regardless of an actual change in the database, either to recover from a bug or to bootstrap a new feature we're +rolling out for the first time. -This management command will manually trigger the receivers we care about. -(We don't want to trigger all receivers for these signals, since these are busy -signals.) +This management command will manually trigger the receivers we care about. (We don't want to trigger all receivers +for these signals, since these are busy signals.) """ - - import logging import shlex -import sys # lint-amnesty, pylint: disable=unused-import from datetime import datetime, timedelta import dateutil.parser @@ -137,6 +132,11 @@ class Command(BaseCommand): nargs='+', help='Run the command for the given user or list of users', ) + parser.add_argument( + '--revoke_program_certs', + action='store_true', + help="If true, system will check if any program certificates need to be revoked from learners" + ) def get_args_from_database(self): """ Returns an options dictionary from the current NotifyCredentialsConfig model. """ @@ -159,17 +159,12 @@ class Command(BaseCommand): options['start_date'] = options['end_date'] - timedelta(hours=4) log.info( - "notify_credentials starting, dry-run=%s, site=%s, delay=%d seconds, page_size=%d, " - "from=%s, to=%s, notify_programs=%s, user_ids=%s, execution=%s", - options['dry_run'], - options['site'], - options['delay'], - options['page_size'], - options['start_date'] if options['start_date'] else 'NA', - options['end_date'] if options['end_date'] else 'NA', - options['notify_programs'], - options['user_ids'], - 'auto' if options['auto'] else 'manual', + f"notify_credentials starting, dry-run={options['dry_run']}, site={options['site']}, " + f"delay={options['delay']} seconds, page_size={options['page_size']}, " + f"from={options['start_date'] if options['start_date'] else 'NA'}, " + f"to={options['end_date'] if options['end_date'] else 'NA'}, notify_programs={options['notify_programs']}, " + f"user_ids={options['user_ids']}, execution={'auto' if options['auto'] else 'manual'}, " + f"revoke_program_certs={options['revoke_program_certs']}" ) program_course_run_keys = self._get_course_run_keys_for_programs(options["program_uuids"]) diff --git a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py index b409d21ded..b2f6916333 100644 --- a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py @@ -22,6 +22,7 @@ NOTIFY_CREDENTIALS_TASK = 'openedx.core.djangoapps.credentials.tasks.v1.tasks.ha @skip_unless_lms +@mock.patch(NOTIFY_CREDENTIALS_TASK) class TestNotifyCredentials(TestCase): """ Tests the ``notify_credentials`` management command. @@ -51,9 +52,9 @@ class TestNotifyCredentials(TestCase): 'verbose': False, 'verbosity': 1, 'skip_checks': True, + 'revoke_program_certs': False, } - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_course_args(self, mock_task): course_1_id = 'course-v1:edX+Test+1' course_2_id = 'course-v1:edX+Test+2' @@ -63,7 +64,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) @mock.patch( 'openedx.core.djangoapps.credentials.management.commands.notify_credentials.get_programs_from_cache_by_uuid' ) @@ -88,7 +88,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.call_args[0][0] == self.expected_options assert mock_task.call_args[0][1].sort() == [course_1_id, course_2_id].sort() - @mock.patch(NOTIFY_CREDENTIALS_TASK) @mock.patch( 'openedx.core.djangoapps.credentials.management.commands.notify_credentials.get_programs_from_cache_by_uuid' ) @@ -125,7 +124,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.call_args[0][1].sort() == [course_1_id, course_2_id].sort() @freeze_time(datetime(2017, 5, 1, 4)) - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_auto_execution(self, mock_task): self.expected_options['auto'] = True self.expected_options['start_date'] = '2017-05-01T00:00:00' @@ -135,7 +133,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_date_args(self, mock_task): self.expected_options['start_date'] = '2017-01-31T00:00:00Z' call_command(Command(), '--start-date', '2017-01-31') @@ -163,7 +160,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_username_arg(self, mock_task): self.expected_options['start_date'] = '2017-02-01T00:00:00Z' self.expected_options['end_date'] = '2017-02-02T00:00:00Z' @@ -215,13 +211,11 @@ class TestNotifyCredentials(TestCase): assert mock_task.call_args[0][0] == self.expected_options mock_task.reset_mock() - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_no_args(self, mock_task): with self.assertRaisesRegex(CommandError, 'You must specify a filter.*'): call_command(Command()) assert not mock_task.called - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_dry_run(self, mock_task): self.expected_options['start_date'] = '2017-02-01T00:00:00Z' self.expected_options['dry_run'] = True @@ -229,7 +223,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_hand_off(self, mock_task): self.expected_options['start_date'] = '2017-02-01T00:00:00Z' self.expected_options['notify_programs'] = True @@ -237,7 +230,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_delay(self, mock_task): self.expected_options['start_date'] = '2017-02-01T00:00:00Z' self.expected_options['delay'] = 0.2 @@ -245,7 +237,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_page_size(self, mock_task): self.expected_options['start_date'] = '2017-02-01T00:00:00Z' self.expected_options['page_size'] = 2 @@ -253,7 +244,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_site(self, mock_task): site_config = SiteConfigurationFactory.create( site_values={'course_org_filter': ['testX']} @@ -265,7 +255,6 @@ class TestNotifyCredentials(TestCase): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options - @mock.patch(NOTIFY_CREDENTIALS_TASK) def test_args_from_database(self, mock_task): # Nothing in the database, should default to disabled with self.assertRaisesRegex(CommandError, 'NotifyCredentialsConfig is disabled.*'): @@ -296,3 +285,10 @@ class TestNotifyCredentials(TestCase): # Explicitly disabled with self.assertRaisesRegex(CommandError, 'NotifyCredentialsConfig is disabled.*'): call_command(Command(), '--start-date', '2017-01-01', '--args-from-database') + + def test_args_revoke_program_cert(self, mock_task): + self.expected_options['user_ids'] = [str(self.user.id)] + self.expected_options['revoke_program_certs'] = True + call_command(Command(), '--user_ids', self.user.id, '--revoke_program_certs') + assert mock_task.called + assert mock_task.call_args[0][0] == self.expected_options diff --git a/openedx/core/djangoapps/credentials/tasks/v1/tasks.py b/openedx/core/djangoapps/credentials/tasks/v1/tasks.py index bc0c90c1bc..dad4d3618a 100644 --- a/openedx/core/djangoapps/credentials/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/credentials/tasks/v1/tasks.py @@ -151,7 +151,8 @@ def handle_notify_credentials(options, course_keys): delay=options['delay'], page_size=options['page_size'], verbose=options['verbose'], - notify_programs=options['notify_programs'] + notify_programs=options['notify_programs'], + revoke_program_certs=options['revoke_program_certs'] ) diff --git a/openedx/core/djangoapps/credentials/tests/test_tasks.py b/openedx/core/djangoapps/credentials/tests/test_tasks.py index 1afc6ef51f..be2a2ce1ad 100644 --- a/openedx/core/djangoapps/credentials/tests/test_tasks.py +++ b/openedx/core/djangoapps/credentials/tests/test_tasks.py @@ -92,6 +92,7 @@ class TestSendGradeToCredentialTask(TestCase): assert mock_get_api_client.call_count == (tasks.MAX_RETRIES + 1) +@ddt.ddt @skip_unless_lms class TestHandleNotifyCredentialsTask(TestCase): """ @@ -145,6 +146,7 @@ class TestHandleNotifyCredentialsTask(TestCase): 'verbose': False, 'verbosity': 1, 'skip_checks': True, + 'revoke_program_certs': False, } @mock.patch(TASKS_MODULE + '.send_notifications') @@ -427,6 +429,19 @@ class TestHandleNotifyCredentialsTask(TestCase): with pytest.raises(Exception): tasks.handle_notify_credentials(options=self.options, course_keys=[]) + @ddt.data([True], [False]) + @ddt.unpack + @mock.patch(TASKS_MODULE + '.send_notifications') + def test_revoke_program_certs(self, revoke_program_certs, mock_send_notifications): + """ + This test verifies that the `revoke_program_certs` option is forwarded as expected when included in the options. + """ + self.options['revoke_program_certs'] = revoke_program_certs + tasks.handle_notify_credentials(options=self.options, course_keys=[]) + assert mock_send_notifications.called + mock_call_args = mock_send_notifications.call_args_list[0] + assert mock_call_args.kwargs['revoke_program_certs'] == revoke_program_certs + @ddt.ddt @skip_unless_lms