Skip to content

Commit f8f8273

Browse files
committed
feat: management command to cleanup roles for all users
1 parent 6a74d43 commit f8f8273

5 files changed

Lines changed: 241 additions & 3 deletions

File tree

futurex_openedx_extensions/helpers/management/__init__.py

Whitespace-only changes.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
This command cleans up course access roles for all users, for tenants with the FX Dashboard enabled.
3+
"""
4+
from __future__ import annotations
5+
6+
import copy
7+
from typing import Dict
8+
9+
from common.djangoapps.student.models import CourseAccessRole
10+
from django.contrib.auth import get_user_model
11+
from django.core.management import BaseCommand, CommandParser
12+
13+
from futurex_openedx_extensions.dashboard.serializers import UserRolesSerializer
14+
from futurex_openedx_extensions.helpers import constants as cs
15+
from futurex_openedx_extensions.helpers.exceptions import FXExceptionCodes
16+
from futurex_openedx_extensions.helpers.roles import (
17+
cache_refresh_course_access_roles,
18+
delete_course_access_roles,
19+
get_user_course_access_roles,
20+
update_course_access_roles,
21+
)
22+
from futurex_openedx_extensions.helpers.tenants import get_all_tenant_ids, get_course_org_filter_list
23+
from futurex_openedx_extensions.helpers.users import is_system_staff_user
24+
25+
26+
class Command(BaseCommand):
27+
"""
28+
Creates enrollment codes for courses.
29+
"""
30+
31+
help = 'Cleans up course access roles for all users'
32+
33+
def add_arguments(self, parser: CommandParser) -> None:
34+
"""Add arguments to the command."""
35+
parser.add_argument(
36+
'--commit',
37+
action='store',
38+
dest='commit',
39+
default='no',
40+
help='Commit changes, default is no (just perform a dry-run).',
41+
type=str,
42+
)
43+
44+
def handle(self, *args: list, **options: Dict[str, str]) -> None:
45+
"""Handle the command."""
46+
commit = (str(options['commit']).lower() == 'yes')
47+
48+
tenant_ids = get_all_tenant_ids()
49+
user_ids = CourseAccessRole.objects.values_list('user_id', flat=True).distinct()
50+
user_ids_to_clean = []
51+
52+
print('-' * 80)
53+
print(f'{len(user_ids)} users to process..')
54+
for user_id in user_ids:
55+
cache_refresh_course_access_roles(user_id)
56+
roles = get_user_course_access_roles(user_id)
57+
if roles['useless_entries_exist']:
58+
user_ids_to_clean.append(user_id)
59+
if not user_ids_to_clean:
60+
print('No dirty entries found..')
61+
else:
62+
print(f'Found {len(user_ids_to_clean)} users with dirty entries..')
63+
64+
superuser = get_user_model().objects.filter(is_superuser=True, is_active=True).first()
65+
all_orgs = get_course_org_filter_list(tenant_ids)['course_org_filter_list']
66+
fx_permission_info = {
67+
'view_allowed_any_access_orgs': all_orgs,
68+
'view_allowed_tenant_ids_any_access': tenant_ids,
69+
}
70+
fake_request = type('Request', (object,), {
71+
'fx_permission_info': fx_permission_info,
72+
'query_params': {},
73+
})
74+
75+
for user_id in user_ids_to_clean:
76+
user = get_user_model().objects.get(id=user_id)
77+
print(f'\nCleaning up user {user_id}:{user.username}:{user.email}...')
78+
invalid_orgs = CourseAccessRole.objects.filter(
79+
user_id=user_id,
80+
).exclude(
81+
org__in=all_orgs,
82+
).values_list('org', flat=True).distinct()
83+
if invalid_orgs:
84+
print(f'**** User has invalid orgs in the roles: {list(invalid_orgs)}')
85+
print('**** this must be fixed manually..')
86+
if is_system_staff_user(user) or not user.is_active:
87+
user_desc = 'a system staff' if is_system_staff_user(user) else 'not active'
88+
print(f'**** User is {user_desc}, deleting all roles on all tenants..')
89+
try:
90+
delete_course_access_roles(
91+
caller=superuser,
92+
tenant_ids=tenant_ids,
93+
user=user,
94+
dry_run=not commit,
95+
)
96+
except Exception as exc:
97+
print(f'**** Failed to delete roles for user {user_id}:{user.username}:{user.email}..')
98+
print(f'**** {exc}')
99+
100+
print('**** Done.')
101+
continue
102+
103+
invalid_orgs = CourseAccessRole.objects.filter(
104+
user_id=user_id,
105+
).exclude(
106+
org__in=all_orgs,
107+
).exclude(
108+
org='',
109+
).values_list('org', flat=True).distinct()
110+
if invalid_orgs:
111+
print(f'**** User has invalid orgs in the roles: {list(invalid_orgs)}')
112+
print('**** this must be fixed manually..')
113+
114+
empty_orgs = CourseAccessRole.objects.filter(
115+
user_id=user_id,
116+
org='',
117+
).exclude(
118+
role__in=cs.COURSE_ACCESS_ROLES_GLOBAL,
119+
).values_list('org', flat=True).distinct()
120+
if empty_orgs:
121+
print('**** User has roles with no organization!')
122+
print('**** this must be fixed manually..')
123+
124+
unsupported_roles = CourseAccessRole.objects.filter(
125+
user_id=user_id,
126+
).exclude(
127+
role__in=cs.COURSE_ACCESS_ROLES_SUPPORTED_READ,
128+
).values_list('role', flat=True).distinct()
129+
if unsupported_roles:
130+
print(f'**** User has unsupported roles: {list(unsupported_roles)}')
131+
print('**** this must be fixed manually..')
132+
133+
roles = UserRolesSerializer(user, context={'request': fake_request}).data
134+
for tenant_id in roles['tenants']:
135+
tenant_roles = copy.deepcopy(roles['tenants'][tenant_id])
136+
tenant_roles['tenant_id'] = tenant_id
137+
result = update_course_access_roles(
138+
caller=superuser,
139+
user=user,
140+
new_roles_details=tenant_roles,
141+
dry_run=not commit,
142+
)
143+
if result['error_code']:
144+
print(f'**** Failed for user {user_id}:{user.username}:{user.email} for tenant {tenant_id}..')
145+
print(f'**** {result["error_code"]}:{result["error_message"]}')
146+
self.print_helper_action(int(result['error_code']))
147+
148+
if commit:
149+
print('Operation completed..')
150+
else:
151+
print('Dry-run completed..')
152+
153+
print('-' * 80)
154+
155+
@staticmethod
156+
def print_helper_action(code: int) -> None:
157+
"""Print helper action for the given error code."""
158+
message = None
159+
if code == FXExceptionCodes.INVALID_INPUT.value:
160+
message = (
161+
'Please check the input data and try again. Some roles are not supported in the update '
162+
'process and need to be removed manually.'
163+
)
164+
if message:
165+
print(f'**** {message}')

futurex_openedx_extensions/helpers/roles.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,9 @@ def _delete_course_access_roles(tenant_ids: list[int], user: get_user_model) ->
777777
cache_refresh_course_access_roles(user.id)
778778

779779

780-
def delete_course_access_roles(caller: get_user_model, tenant_ids: list[int], user: get_user_model) -> None:
780+
def delete_course_access_roles(
781+
caller: get_user_model, tenant_ids: list[int], user: get_user_model, dry_run: bool = False,
782+
) -> None:
781783
"""
782784
Delete the course access roles for the given tenant IDs and user
783785
@@ -787,12 +789,15 @@ def delete_course_access_roles(caller: get_user_model, tenant_ids: list[int], us
787789
:type tenant_ids: list
788790
:param user: The user to filter on
789791
:type user: get_user_model
792+
:param dry_run: True for dry-run, False otherwise
793+
:type dry_run: bool
790794
"""
791795
_verify_can_delete_course_access_roles(caller, tenant_ids, user)
792796

793-
_delete_course_access_roles(tenant_ids, user)
797+
if not dry_run:
798+
_delete_course_access_roles(tenant_ids, user)
794799

795-
cache_refresh_course_access_roles(user.id)
800+
cache_refresh_course_access_roles(user.id)
796801

797802

798803
def _clean_course_access_roles(redundant_hashes: set[DictHashcode], user: get_user_model) -> None:
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for management commands"""
2+
from unittest.mock import patch
3+
4+
import pytest
5+
from common.djangoapps.student.models import CourseAccessRole
6+
from django.contrib.auth import get_user_model
7+
from django.core.management import call_command
8+
9+
from futurex_openedx_extensions.helpers import constants as cs
10+
11+
COMMAND_PATH = 'futurex_openedx_extensions.helpers.management.commands.course_access_roles_clean_up'
12+
13+
14+
@pytest.mark.django_db
15+
@pytest.mark.parametrize('options', [
16+
['--commit=yes'], ['--commit=no'], [],
17+
])
18+
def test_course_access_roles_clean_up_sanity_check_handler(base_data, options): # pylint: disable=unused-argument
19+
"""Sanity check for course_access_roles_clean_up command"""
20+
with patch(f'{COMMAND_PATH}.update_course_access_roles', return_value={'error_code': None}):
21+
call_command('course_access_roles_clean_up', *options)
22+
23+
24+
@pytest.mark.django_db
25+
@pytest.mark.parametrize('update_result', [
26+
{'error_code': 4001, 'error_message': 'Some error message'},
27+
{'error_code': 99999, 'error_message': 'Some error message'},
28+
])
29+
def test_course_access_roles_clean_up_sanity_check_errors(base_data, update_result): # pylint: disable=unused-argument
30+
"""Sanity check for course_access_roles_clean_up command"""
31+
CourseAccessRole.objects.create(user_id=55, org='invalid_org')
32+
get_user_model().objects.filter(id=1).update(is_active=False)
33+
34+
with patch(f'{COMMAND_PATH}.update_course_access_roles', return_value=update_result):
35+
call_command('course_access_roles_clean_up', '--commit=yes')
36+
37+
38+
@pytest.mark.django_db
39+
def test_course_access_roles_clean_up_delete_error(base_data, capfd): # pylint: disable=unused-argument
40+
"""Sanity check for course_access_roles_clean_up command"""
41+
get_user_model().objects.filter(id=1).update(is_active=False)
42+
with patch(f'{COMMAND_PATH}.delete_course_access_roles', side_effect=Exception('Some error for testing')):
43+
call_command('course_access_roles_clean_up', '--commit=yes')
44+
out, _ = capfd.readouterr()
45+
assert 'Failed to delete roles for user' in out
46+
assert 'Some error for testing' in out
47+
48+
49+
@pytest.mark.django_db
50+
def test_course_access_roles_clean_up_sanity_check_cleaning(base_data, capfd): # pylint: disable=unused-argument
51+
"""Sanity check for course_access_roles_clean_up command"""
52+
CourseAccessRole.objects.filter(org='').delete()
53+
CourseAccessRole.objects.filter(user_id__in=[1, 2]).delete()
54+
CourseAccessRole.objects.exclude(role__in=cs.COURSE_ACCESS_ROLES_SUPPORTED_READ).delete()
55+
56+
call_command('course_access_roles_clean_up', '--commit=yes')
57+
out, _ = capfd.readouterr()
58+
assert 'users with dirty entries' in out
59+
assert 'No dirty entries found..' not in out
60+
61+
call_command('course_access_roles_clean_up', '--commit=yes')
62+
out, _ = capfd.readouterr()
63+
assert 'users with dirty entries' not in out
64+
assert 'No dirty entries found..' in out

tests/test_helpers/test_roles.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,10 @@ def test_delete_course_access_roles(roles_authorize_caller, base_data): # pylin
11651165
user=user70, org='', role=cs.COURSE_ACCESS_ROLES_UNSUPPORTED[0],
11661166
)
11671167

1168+
assert q_user70.count() == 6
1169+
delete_course_access_roles(None, get_all_tenant_ids(), user70, dry_run=True)
1170+
assert q_user70.count() == 6
1171+
11681172
delete_course_access_roles(None, get_all_tenant_ids(), user70)
11691173
assert q_user70.count() == 4
11701174
for record in q_user70:

0 commit comments

Comments
 (0)