-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: management command to cleanup roles for all users
- Loading branch information
Showing
5 changed files
with
245 additions
and
3 deletions.
There are no files selected for viewing
Empty file.
169 changes: 169 additions & 0 deletions
169
futurex_openedx_extensions/helpers/management/commands/course_access_roles_clean_up.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
""" | ||
This command cleans up course access roles for all users, for tenants with the FX Dashboard enabled. | ||
""" | ||
from __future__ import annotations | ||
|
||
import copy | ||
from typing import Dict | ||
|
||
from common.djangoapps.student.models import CourseAccessRole | ||
from django.contrib.auth import get_user_model | ||
from django.core.management import BaseCommand, CommandParser | ||
|
||
from futurex_openedx_extensions.dashboard.serializers import UserRolesSerializer | ||
from futurex_openedx_extensions.helpers import constants as cs | ||
from futurex_openedx_extensions.helpers.exceptions import FXExceptionCodes | ||
from futurex_openedx_extensions.helpers.roles import ( | ||
cache_refresh_course_access_roles, | ||
delete_course_access_roles, | ||
get_user_course_access_roles, | ||
update_course_access_roles, | ||
) | ||
from futurex_openedx_extensions.helpers.tenants import get_all_tenant_ids, get_course_org_filter_list | ||
from futurex_openedx_extensions.helpers.users import is_system_staff_user | ||
|
||
|
||
class Command(BaseCommand): | ||
""" | ||
Creates enrollment codes for courses. | ||
""" | ||
|
||
help = 'Cleans up course access roles for all users' | ||
|
||
def add_arguments(self, parser: CommandParser) -> None: | ||
"""Add arguments to the command.""" | ||
parser.add_argument( | ||
'--commit', | ||
action='store', | ||
dest='commit', | ||
default='no', | ||
help='Commit changes, default is no (just perform a dry-run).', | ||
type=str, | ||
) | ||
|
||
def handle(self, *args: list, **options: Dict[str, str]) -> None: | ||
"""Handle the command.""" | ||
commit = (str(options['commit']).lower() == 'yes') | ||
|
||
tenant_ids = get_all_tenant_ids() | ||
user_ids = CourseAccessRole.objects.values_list( | ||
'user_id', flat=True, | ||
).exclude( | ||
course_id__startswith='library-v1:', # ignore library roles are not supported as they are not supported | ||
).distinct() | ||
user_ids_to_clean = [] | ||
|
||
print('-' * 80) | ||
print(f'{len(user_ids)} users to process..') | ||
for user_id in user_ids: | ||
cache_refresh_course_access_roles(user_id) | ||
roles = get_user_course_access_roles(user_id) | ||
if roles['useless_entries_exist']: | ||
user_ids_to_clean.append(user_id) | ||
if not user_ids_to_clean: | ||
print('No dirty entries found..') | ||
else: | ||
print(f'Found {len(user_ids_to_clean)} users with dirty entries..') | ||
|
||
superuser = get_user_model().objects.filter(is_superuser=True, is_active=True).first() | ||
all_orgs = get_course_org_filter_list(tenant_ids)['course_org_filter_list'] | ||
fx_permission_info = { | ||
'view_allowed_any_access_orgs': all_orgs, | ||
'view_allowed_tenant_ids_any_access': tenant_ids, | ||
} | ||
fake_request = type('Request', (object,), { | ||
'fx_permission_info': fx_permission_info, | ||
'query_params': {}, | ||
}) | ||
|
||
for user_id in user_ids_to_clean: | ||
user = get_user_model().objects.get(id=user_id) | ||
print(f'\nCleaning up user {user_id}:{user.username}:{user.email}...') | ||
invalid_orgs = CourseAccessRole.objects.filter( | ||
user_id=user_id, | ||
).exclude( | ||
org__in=all_orgs, | ||
).values_list('org', flat=True).distinct() | ||
if invalid_orgs: | ||
print(f'**** User has invalid orgs in the roles: {list(invalid_orgs)}') | ||
print('**** this must be fixed manually..') | ||
if is_system_staff_user(user) or not user.is_active: | ||
user_desc = 'a system staff' if is_system_staff_user(user) else 'not active' | ||
print(f'**** User is {user_desc}, deleting all roles on all tenants..') | ||
try: | ||
delete_course_access_roles( | ||
caller=superuser, | ||
tenant_ids=tenant_ids, | ||
user=user, | ||
dry_run=not commit, | ||
) | ||
except Exception as exc: | ||
print(f'**** Failed to delete roles for user {user_id}:{user.username}:{user.email}..') | ||
print(f'**** {exc}') | ||
|
||
print('**** Done.') | ||
continue | ||
|
||
invalid_orgs = CourseAccessRole.objects.filter( | ||
user_id=user_id, | ||
).exclude( | ||
org__in=all_orgs, | ||
).exclude( | ||
org='', | ||
).values_list('org', flat=True).distinct() | ||
if invalid_orgs: | ||
print(f'**** User has invalid orgs in the roles: {list(invalid_orgs)}') | ||
print('**** this must be fixed manually..') | ||
|
||
empty_orgs = CourseAccessRole.objects.filter( | ||
user_id=user_id, | ||
org='', | ||
).exclude( | ||
role__in=cs.COURSE_ACCESS_ROLES_GLOBAL, | ||
).values_list('org', flat=True).distinct() | ||
if empty_orgs: | ||
print('**** User has roles with no organization!') | ||
print('**** this must be fixed manually..') | ||
|
||
unsupported_roles = CourseAccessRole.objects.filter( | ||
user_id=user_id, | ||
).exclude( | ||
role__in=cs.COURSE_ACCESS_ROLES_SUPPORTED_READ, | ||
).values_list('role', flat=True).distinct() | ||
if unsupported_roles: | ||
print(f'**** User has unsupported roles: {list(unsupported_roles)}') | ||
print('**** this must be fixed manually..') | ||
|
||
roles = UserRolesSerializer(user, context={'request': fake_request}).data | ||
for tenant_id in roles['tenants']: | ||
tenant_roles = copy.deepcopy(roles['tenants'][tenant_id]) | ||
tenant_roles['tenant_id'] = tenant_id | ||
result = update_course_access_roles( | ||
caller=superuser, | ||
user=user, | ||
new_roles_details=tenant_roles, | ||
dry_run=not commit, | ||
) | ||
if result['error_code']: | ||
print(f'**** Failed for user {user_id}:{user.username}:{user.email} for tenant {tenant_id}..') | ||
print(f'**** {result["error_code"]}:{result["error_message"]}') | ||
self.print_helper_action(int(result['error_code'])) | ||
|
||
if commit: | ||
print('Operation completed..') | ||
else: | ||
print('Dry-run completed..') | ||
|
||
print('-' * 80) | ||
|
||
@staticmethod | ||
def print_helper_action(code: int) -> None: | ||
"""Print helper action for the given error code.""" | ||
message = None | ||
if code == FXExceptionCodes.INVALID_INPUT.value: | ||
message = ( | ||
'Please check the input data and try again. Some roles are not supported in the update ' | ||
'process and need to be removed manually.' | ||
) | ||
if message: | ||
print(f'**** {message}') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
"""Tests for management commands""" | ||
from unittest.mock import patch | ||
|
||
import pytest | ||
from common.djangoapps.student.models import CourseAccessRole | ||
from django.contrib.auth import get_user_model | ||
from django.core.management import call_command | ||
|
||
from futurex_openedx_extensions.helpers import constants as cs | ||
|
||
COMMAND_PATH = 'futurex_openedx_extensions.helpers.management.commands.course_access_roles_clean_up' | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize('options', [ | ||
['--commit=yes'], ['--commit=no'], [], | ||
]) | ||
def test_course_access_roles_clean_up_sanity_check_handler(base_data, options): # pylint: disable=unused-argument | ||
"""Sanity check for course_access_roles_clean_up command""" | ||
with patch(f'{COMMAND_PATH}.update_course_access_roles', return_value={'error_code': None}): | ||
call_command('course_access_roles_clean_up', *options) | ||
|
||
|
||
@pytest.mark.django_db | ||
@pytest.mark.parametrize('update_result', [ | ||
{'error_code': 4001, 'error_message': 'Some error message'}, | ||
{'error_code': 99999, 'error_message': 'Some error message'}, | ||
]) | ||
def test_course_access_roles_clean_up_sanity_check_errors(base_data, update_result): # pylint: disable=unused-argument | ||
"""Sanity check for course_access_roles_clean_up command""" | ||
CourseAccessRole.objects.create(user_id=55, org='invalid_org') | ||
get_user_model().objects.filter(id=1).update(is_active=False) | ||
|
||
with patch(f'{COMMAND_PATH}.update_course_access_roles', return_value=update_result): | ||
call_command('course_access_roles_clean_up', '--commit=yes') | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_course_access_roles_clean_up_delete_error(base_data, capfd): # pylint: disable=unused-argument | ||
"""Sanity check for course_access_roles_clean_up command""" | ||
get_user_model().objects.filter(id=1).update(is_active=False) | ||
with patch(f'{COMMAND_PATH}.delete_course_access_roles', side_effect=Exception('Some error for testing')): | ||
call_command('course_access_roles_clean_up', '--commit=yes') | ||
out, _ = capfd.readouterr() | ||
assert 'Failed to delete roles for user' in out | ||
assert 'Some error for testing' in out | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_course_access_roles_clean_up_sanity_check_cleaning(base_data, capfd): # pylint: disable=unused-argument | ||
"""Sanity check for course_access_roles_clean_up command""" | ||
CourseAccessRole.objects.filter(org='').delete() | ||
CourseAccessRole.objects.filter(user_id__in=[1, 2]).delete() | ||
CourseAccessRole.objects.exclude(role__in=cs.COURSE_ACCESS_ROLES_SUPPORTED_READ).delete() | ||
|
||
call_command('course_access_roles_clean_up', '--commit=yes') | ||
out, _ = capfd.readouterr() | ||
assert 'users with dirty entries' in out | ||
assert 'No dirty entries found..' not in out | ||
|
||
call_command('course_access_roles_clean_up', '--commit=yes') | ||
out, _ = capfd.readouterr() | ||
assert 'users with dirty entries' not in out | ||
assert 'No dirty entries found..' in out |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters