MICROBA-393 Add customized partner report headings (#24437)

This commit is contained in:
Christie Rice
2020-07-14 10:37:36 -04:00
committed by GitHub
parent 4bdcdf76d2
commit ef536e49de
3 changed files with 167 additions and 27 deletions

View File

@@ -475,10 +475,12 @@ class UserRetirementPartnerReportSerializer(serializers.Serializer):
Perform serialization for the UserRetirementPartnerReportingStatus model
"""
user_id = serializers.IntegerField()
student_id = serializers.CharField(required=False)
original_username = serializers.CharField()
original_email = serializers.EmailField()
original_name = serializers.CharField()
orgs = serializers.ListField(child=serializers.CharField())
orgs_config = serializers.ListField(required=False)
created = serializers.DateTimeField()
# Required overrides of abstract base class methods, but we don't use them

View File

@@ -46,6 +46,7 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirement,
CreditRequirementStatus
)
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
@@ -287,7 +288,7 @@ class TestPartnerReportingCleanup(ModuleStoreTestCase):
def create_partner_reporting_statuses(self, is_being_processed=True, num=2):
"""
Creates and returnes the given number of test users and UserRetirementPartnerReportingStatuses
Creates and returns the given number of test users and UserRetirementPartnerReportingStatuses
with the given is_being_processed value.
"""
statuses = []
@@ -482,6 +483,17 @@ class TestPartnerReportingList(ModuleStoreTestCase):
"""
Tests the partner reporting list endpoint
"""
EXPECTED_MB_ORGS_CONFIG = [
{
AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching',
AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [
AccountRetirementPartnerReportView.STUDENT_ID_KEY,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY,
AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY,
AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY
]
}
]
def setUp(self):
super(TestPartnerReportingList, self).setUp()
@@ -493,6 +505,7 @@ class TestPartnerReportingList(ModuleStoreTestCase):
self.url = reverse('accounts_retirement_partner_report')
self.maxDiff = None
self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC)
ExternalIdType.objects.get_or_create(name=ExternalIdType.MICROBACHELORS_COACHING)
def get_user_dict(self, user, enrollments):
"""
@@ -517,9 +530,10 @@ class TestPartnerReportingList(ModuleStoreTestCase):
their processing state to "is_being_processed".
Returns a list of user dicts representing what we would expect back from the
endpoint for the given user / enrollment.
endpoint for the given user / enrollment, and a list of the users themselves.
"""
user_dicts = []
users = []
courses = self.courses if courses is None else courses
for _ in range(num):
@@ -540,8 +554,9 @@ class TestPartnerReportingList(ModuleStoreTestCase):
user_dicts.append(
self.get_user_dict(user, enrollments)
)
users.append(user)
return user_dicts
return user_dicts, users
def assert_status_and_user_list(self, expected_users, expected_status=status.HTTP_200_OK):
"""
@@ -561,9 +576,19 @@ class TestPartnerReportingList(ModuleStoreTestCase):
# These sub-lists will fail assertCountEqual if they're out of order
for expected_user in expected_users:
expected_user['orgs'].sort()
if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in expected_user:
orgs_config = expected_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY]
orgs_config.sort()
for config in orgs_config:
config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort()
for returned_user in returned_users:
returned_user['orgs'].sort()
if AccountRetirementPartnerReportView.ORGS_CONFIG_KEY in returned_user:
orgs_config = returned_user[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY]
orgs_config.sort()
for config in orgs_config:
config[AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY].sort()
self.assertCountEqual(returned_users, expected_users)
@@ -571,21 +596,72 @@ class TestPartnerReportingList(ModuleStoreTestCase):
"""
Basic test to make sure that users in two different orgs are returned.
"""
users = self.create_partner_reporting_statuses()
users += self.create_partner_reporting_statuses(courses=(self.course_awesome_org,))
user_dicts, users = self.create_partner_reporting_statuses()
additional_dicts, additional_users = self.create_partner_reporting_statuses(courses=(self.course_awesome_org,))
user_dicts += additional_dicts
self.assert_status_and_user_list(users)
self.assert_status_and_user_list(user_dicts)
def test_success_multiple_statuses(self):
"""
Checks that only users in the correct is_being_processed state (False) are returned.
"""
users = self.create_partner_reporting_statuses()
user_dicts, users = self.create_partner_reporting_statuses()
# These should not come back
self.create_partner_reporting_statuses(courses=(self.course_awesome_org,), is_being_processed=True)
self.assert_status_and_user_list(users)
self.assert_status_and_user_list(user_dicts)
def test_success_mb_coaching(self):
"""
Check that MicroBachelors users who have consented to coaching have the proper info
included for the partner report.
"""
path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
with mock.patch(path, return_value=True) as mock_has_ever_consented:
user_dicts, users = self.create_partner_reporting_statuses(num=1)
external_id, created = ExternalId.add_new_user_id(
user=users[0],
type_name=ExternalIdType.MICROBACHELORS_COACHING
)
expected_user = user_dicts[0]
expected_users = [expected_user]
expected_user[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = str(external_id.external_user_id)
expected_user[
AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = TestPartnerReportingList.EXPECTED_MB_ORGS_CONFIG
self.assert_status_and_user_list(expected_users)
mock_has_ever_consented.assert_called_once()
def test_success_mb_coaching_no_external_id(self):
"""
Check that MicroBachelors users who have consented to coaching, but who do not have an external id, have the
proper info included for the partner report.
"""
path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
with mock.patch(path, return_value=True) as mock_has_ever_consented:
user_dicts, users = self.create_partner_reporting_statuses(num=1)
self.assert_status_and_user_list(user_dicts)
mock_has_ever_consented.assert_called_once()
def test_success_mb_coaching_no_consent(self):
"""
Check that MicroBachelors users who have not consented to coaching have the proper info
included for the partner report.
"""
path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
with mock.patch(path, return_value=False) as mock_has_ever_consented:
user_dicts, users = self.create_partner_reporting_statuses(num=1)
ExternalId.add_new_user_id(
user=users[0],
type_name=ExternalIdType.MICROBACHELORS_COACHING
)
self.assert_status_and_user_list(user_dicts)
mock_has_ever_consented.assert_called_once()
def test_no_users(self):
"""
@@ -606,10 +682,10 @@ class TestPartnerReportingList(ModuleStoreTestCase):
Checks that users are progressed to "is_being_processed" True upon being returned
from this call.
"""
users = self.create_partner_reporting_statuses()
user_dicts, users = self.create_partner_reporting_statuses()
# First time through we should get the users
self.assert_status_and_user_list(users)
self.assert_status_and_user_list(user_dicts)
# Second time they should be updated to is_being_processed=True
self.assert_status_and_user_list([])
@@ -1105,7 +1181,7 @@ class TestAccountRetirementUpdate(RetirementTestCase):
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should succeed'}
self.update_and_assert_status(data)
# Refresh the retirment object and confirm the messages and state are correct
# Refresh the retirement object and confirm the messages and state are correct
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='LOCKING_ACCOUNT'))
self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='PENDING'))
@@ -1127,7 +1203,7 @@ class TestAccountRetirementUpdate(RetirementTestCase):
for update_data in fake_retire_process:
self.update_and_assert_status(update_data)
# Refresh the retirment object and confirm the messages and state are correct
# Refresh the retirement object and confirm the messages and state are correct
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='COMPLETE'))
self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='CREDENTIALS_COMPLETE'))

View File

@@ -44,6 +44,7 @@ from openedx.core.djangoapps.ace_common.template_context import get_base_templat
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments
from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.profile_images.images import remove_profile_images
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
@@ -82,6 +83,11 @@ from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser
from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
try:
from coaching.api import has_ever_consented_to_coaching
except ImportError:
has_ever_consented_to_coaching = None
log = logging.getLogger(__name__)
USER_PROFILE_PII = {
@@ -530,6 +536,14 @@ class AccountRetirementPartnerReportView(ViewSet):
Provides API endpoints for managing partner reporting of retired
users.
"""
DELETION_COMPLETED_KEY = 'deletion_completed'
ORGS_CONFIG_KEY = 'orgs_config'
ORGS_CONFIG_ORG_KEY = 'org'
ORGS_CONFIG_FIELD_HEADINGS_KEY = 'field_headings'
ORIGINAL_EMAIL_KEY = 'original_email'
ORIGINAL_NAME_KEY = 'original_name'
STUDENT_ID_KEY = 'student_id'
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
parser_classes = (JSONParser,)
@@ -544,7 +558,7 @@ class AccountRetirementPartnerReportView(ViewSet):
for enrollment in user.courseenrollment_set.all():
org = enrollment.course_id.org
# Org can concievably be blank or this bogus default value
# Org can conceivably be blank or this bogus default value
if org and org != 'outdated_entry':
orgs.add(org)
try:
@@ -569,17 +583,9 @@ class AccountRetirementPartnerReportView(ViewSet):
is_being_processed=False
).order_by('id')
retirements = [
{
'user_id': retirement.user.pk,
'original_username': retirement.original_username,
'original_email': retirement.original_email,
'original_name': retirement.original_name,
'orgs': self._get_orgs_for_user(retirement.user),
'created': retirement.created,
}
for retirement in retirement_statuses
]
retirements = []
for retirement_status in retirement_statuses:
retirements.append(self._get_retirement_for_partner_report(retirement_status))
serializer = UserRetirementPartnerReportSerializer(retirements, many=True)
@@ -587,6 +593,62 @@ class AccountRetirementPartnerReportView(ViewSet):
return Response(serializer.data)
def _get_retirement_for_partner_report(self, retirement_status):
"""
Get the retirement for this retirement_status. The retirement info will be included in the partner report.
"""
retirement = {
'user_id': retirement_status.user.pk,
'original_username': retirement_status.original_username,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: retirement_status.original_email,
AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: retirement_status.original_name,
'orgs': self._get_orgs_for_user(retirement_status.user),
'created': retirement_status.created,
}
# Some orgs have a custom list of headings and content for the partner report. Add this, if applicable.
self._add_orgs_config_for_user(retirement, retirement_status.user)
return retirement
def _add_orgs_config_for_user(self, retirement, user):
"""
Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and
content for the partner report. If so, add this.
"""
# See if the MicroBachelors coaching provider needs to be notified of this user's retirement
if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user):
# See if the user has a MicroBachelors external id. If not, they were never sent to the
# coaching provider.
external_ids = ExternalId.objects.filter(
user=user,
external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING
)
if external_ids.exists():
# User has an external id. Add the additional info.
external_id = str(external_ids[0].external_user_id)
self._add_coaching_orgs_config(retirement, external_id)
def _add_coaching_orgs_config(self, retirement, external_id):
"""
Add the orgs configuration for MicroBachelors coaching
"""
# Add the custom field headings
retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [
{
AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching',
AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [
AccountRetirementPartnerReportView.STUDENT_ID_KEY,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY,
AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY,
AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY
]
}
]
# Add the custom field value
retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id
@request_requires_username
def retirement_partner_status_create(self, request):
"""
@@ -707,7 +769,7 @@ class AccountRetirementStatusView(ViewSet):
)
serializer = UserRetirementStatusSerializer(retirements, many=True)
return Response(serializer.data)
# This should only occur on the int() converstion of cool_off_days at this point
# This should only occur on the int() conversion of cool_off_days at this point
except ValueError:
return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST)
except KeyError as exc:
@@ -1056,7 +1118,7 @@ class UsernameReplacementView(APIView):
updates usernames across all services. DO NOT run this alone or users will
not match across the system and things will be broken.
API will recieve a list of current usernames and their requested new
API will receive a list of current usernames and their requested new
username. If their new username is taken, it will randomly assign a new username.
This API will be called first, before calling the APIs in other services as this
@@ -1166,7 +1228,7 @@ class UsernameReplacementView(APIView):
"""
Generates a unique username.
If the desired username is available, that will be returned.
Otherwise it will generate unique suffixs to the desired username until it is an available username.
Otherwise it will generate unique suffixes to the desired username until it is an available username.
"""
new_username = desired_username
# Keep checking usernames in case desired_username + random suffix is already taken