Files
edx-platform/scripts/user_retirement/retire_one_learner.py
Muhammad Farhan Khan 65ea55c8aa Move user retirement scripts code from the tubular repo (#34063)
* refactor: Migragte user retirement scripts code from the tubular repo
2024-02-22 11:09:00 -05:00

225 lines
8.6 KiB
Python
Executable File

#! /usr/bin/env python3
"""
Command-line script to drive the user retirement workflow for a single user
To run this script you will need a username to run against and a YAML config file in the format:
client_id: <client id from LMS DOT>
client_secret: <client secret from LMS DOT>
base_urls:
lms: http://localhost:18000/
ecommerce: http://localhost:18130/
credentials: http://localhost:18150/
demographics: http://localhost:18360/
retirement_pipeline:
- ['RETIRING_CREDENTIALS', 'CREDENTIALS_COMPLETE', 'CREDENTIALS', 'retire_learner']
- ['RETIRING_ECOM', 'ECOM_COMPLETE', 'ECOMMERCE', 'retire_learner']
- ['RETIRING_DEMOGRAPHICS', 'DEMOGRAPHICS_COMPLETE', 'DEMOGRAPHICS', 'retire_learner']
- ['RETIRING_LICENSE_MANAGER', 'LICENSE_MANAGER_COMPLETE', 'LICENSE_MANAGER', 'retire_learner']
- ['RETIRING_FORUMS', 'FORUMS_COMPLETE', 'LMS', 'retirement_retire_forum']
- ['RETIRING_EMAIL_LISTS', 'EMAIL_LISTS_COMPLETE', 'LMS', 'retirement_retire_mailings']
- ['RETIRING_ENROLLMENTS', 'ENROLLMENTS_COMPLETE', 'LMS', 'retirement_unenroll']
- ['RETIRING_LMS', 'LMS_COMPLETE', 'LMS', 'retirement_lms_retire']
"""
import logging
import sys
from functools import partial
from os import path
from time import time
import click
# Add top-level project path to sys.path before importing scripts code
sys.path.append(path.abspath(path.join(path.dirname(__file__), '../..')))
from scripts.user_retirement.utils.exception import HttpDoesNotExistException
# pylint: disable=wrong-import-position
from scripts.user_retirement.utils.helpers import (
_config_or_exit,
_fail,
_fail_exception,
_get_error_str_from_exception,
_log,
_setup_all_apis_or_exit
)
# Return codes for various fail cases
ERR_SETUP_FAILED = -1
ERR_USER_AT_END_STATE = -2
ERR_USER_IN_WORKING_STATE = -3
ERR_WHILE_RETIRING = -4
ERR_BAD_LEARNER = -5
ERR_UNKNOWN_STATE = -6
ERR_BAD_CONFIG = -7
SCRIPT_SHORTNAME = 'Learner Retirement'
LOG = partial(_log, SCRIPT_SHORTNAME)
FAIL = partial(_fail, SCRIPT_SHORTNAME)
FAIL_EXCEPTION = partial(_fail_exception, SCRIPT_SHORTNAME)
CONFIG_OR_EXIT = partial(_config_or_exit, FAIL_EXCEPTION, ERR_BAD_CONFIG)
SETUP_ALL_APIS_OR_EXIT = partial(_setup_all_apis_or_exit, FAIL_EXCEPTION, ERR_SETUP_FAILED)
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
# "Magic" states with special meaning, these are required to be in LMS
START_STATE = 'PENDING'
ERROR_STATE = 'ERRORED'
COMPLETE_STATE = 'COMPLETE'
ABORTED_STATE = 'ABORTED'
END_STATES = (ERROR_STATE, ABORTED_STATE, COMPLETE_STATE)
# We'll store the access token here once retrieved
AUTH_HEADER = {}
def _get_learner_state_index_or_exit(learner, config):
"""
Returns the index in the ALL_STATES retirement state list, validating that it is in
an appropriate state to work on.
"""
try:
learner_state = learner['current_state']['state_name']
learner_state_index = config['all_states'].index(learner_state)
if learner_state in END_STATES:
FAIL(ERR_USER_AT_END_STATE, 'User already in end state: {}'.format(learner_state))
if learner_state in config['working_states']:
FAIL(ERR_USER_IN_WORKING_STATE, 'User is already in a working state! {}'.format(learner_state))
return learner_state_index
except KeyError:
FAIL(ERR_BAD_LEARNER, 'Bad learner response missing current_state or state_name: {}'.format(learner))
except ValueError:
FAIL(ERR_UNKNOWN_STATE, 'Unknown learner retirement state for learner: {}'.format(learner))
def _config_retirement_pipeline(config):
"""
Organizes the pipeline and populate the various state types
"""
# List of states where an API call is currently in progress
retirement_pipeline = config['retirement_pipeline']
config['working_states'] = [state[0] for state in retirement_pipeline]
# Create the full list of all of our states
config['all_states'] = [START_STATE]
for working in config['retirement_pipeline']:
config['all_states'].append(working[0])
config['all_states'].append(working[1])
for end in END_STATES:
config['all_states'].append(end)
def _get_learner_and_state_index_or_exit(config, username):
"""
Double-checks the current learner state, contacting LMS, and maps that state to its
index in the pipeline. Exits out if the learner is in an invalid state or not found
in LMS.
"""
try:
learner = config['LMS'].get_learner_retirement_state(username)
learner_state_index = _get_learner_state_index_or_exit(learner, config)
return learner, learner_state_index
except HttpDoesNotExistException:
FAIL(ERR_BAD_LEARNER, 'Learner {} not found. Please check that the learner is present in '
'UserRetirementStatus, is not already retired, '
'and is in an appropriate state to be acted upon.'.format(username))
except Exception as exc: # pylint: disable=broad-except
FAIL_EXCEPTION(ERR_SETUP_FAILED, 'Unexpected error fetching user state!', str(exc))
def _get_ecom_segment_id(config, learner):
"""
Calls Ecommerce to get the ecom-specific Segment tracking id that we need to retire.
This is only available from Ecommerce, unfortunately, and makes more sense to handle
here than to pass all of the config down to SegmentApi.
"""
try:
return config['ECOMMERCE'].get_tracking_key(learner)
except HttpDoesNotExistException:
LOG('Learner {} not found in Ecommerce. Setting Ecommerce Segment ID to None'.format(learner))
return None
except Exception as exc: # pylint: disable=broad-except
FAIL_EXCEPTION(ERR_SETUP_FAILED, 'Unexpected error fetching Ecommerce tracking id!', str(exc))
@click.command("retire_learner")
@click.option(
'--username',
help='The original username of the user to retire'
)
@click.option(
'--config_file',
help='File in which YAML config exists that overrides all other params.'
)
def retire_learner(
username,
config_file
):
"""
Retrieves a JWT token as the retirement service learner, then performs the retirement process as
defined in WORKING_STATE_ORDER
"""
LOG('Starting learner retirement for {} using config file {}'.format(username, config_file))
if not config_file:
FAIL(ERR_BAD_CONFIG, 'No config file passed in.')
config = CONFIG_OR_EXIT(config_file)
_config_retirement_pipeline(config)
SETUP_ALL_APIS_OR_EXIT(config)
learner, learner_state_index = _get_learner_and_state_index_or_exit(config, username)
if config.get('fetch_ecommerce_segment_id', False):
learner['ecommerce_segment_id'] = _get_ecom_segment_id(config, learner)
start_state = None
try:
for start_state, end_state, service, method in config['retirement_pipeline']:
# Skip anything that has already been done
if config['all_states'].index(start_state) < learner_state_index:
LOG('State {} completed in previous run, skipping'.format(start_state))
continue
LOG('Starting state {}'.format(start_state))
config['LMS'].update_learner_retirement_state(username, start_state, 'Starting: {}'.format(start_state))
# This does the actual API call
start_time = time()
response = getattr(config[service], method)(learner)
end_time = time()
LOG('State {} completed in {} seconds'.format(start_state, end_time - start_time))
config['LMS'].update_learner_retirement_state(
username,
end_state,
'Ending: {} with response:\n{}'.format(end_state, response)
)
learner_state_index += 1
LOG('Progressing to state {}'.format(end_state))
config['LMS'].update_learner_retirement_state(username, COMPLETE_STATE, 'Learner retirement complete.')
LOG('Retirement complete for learner {}'.format(username))
except Exception as exc: # pylint: disable=broad-except
exc_msg = _get_error_str_from_exception(exc)
try:
LOG('Error in retirement state {}: {}'.format(start_state, exc_msg))
config['LMS'].update_learner_retirement_state(username, ERROR_STATE, exc_msg)
except Exception as update_exc: # pylint: disable=broad-except
LOG('Critical error attempting to change learner state to ERRORED: {}'.format(update_exc))
FAIL_EXCEPTION(ERR_WHILE_RETIRING, 'Error encountered in state "{}"'.format(start_state), exc)
if __name__ == '__main__':
# pylint: disable=unexpected-keyword-arg, no-value-for-parameter
retire_learner(auto_envvar_prefix='RETIREMENT')