MST-157 Part 3 Create the Program Enrollments Inspector tool UI and link to the Support tool index page

This commit is contained in:
Simon Chen
2020-03-15 22:08:05 -04:00
parent ce3f6e2b88
commit b1f61d929d
6 changed files with 302 additions and 115 deletions

View File

@@ -59,7 +59,7 @@ class ProgramEnrollmentSerializer(serializers.Serializer):
model = ProgramEnrollment
def serialize_user_info(user, user_social_auth=None):
def serialize_user_info(user, user_social_auths=None):
"""
Helper method to serialize resulting in user_info_object
based on passed in django models
@@ -68,11 +68,9 @@ def serialize_user_info(user, user_social_auth=None):
'username': user.username,
'email': user.email,
}
if user_social_auth:
_, external_key = user_social_auth.uid.split(':', 1)
user_info['external_user_key'] = external_key
user_info['sso'] = {
'uid': user_social_auth.uid,
'provider': user_social_auth.provider
}
if user_social_auths:
for user_social_auth in user_social_auths:
user_info.setdefault('sso_list', []).append({
'uid': user_social_auth.uid,
})
return user_info

View File

@@ -2,59 +2,227 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button, InputText, StatusAlert, InputSelect } from '@edx/paragon';
export const ProgramEnrollmentsInspectorPage = props => (
<form method="get">
{props.errors.map(errorItem => (
<StatusAlert
open
dismissible={false}
alertType="danger"
dialog={errorItem}
/>
))}
<div key="edX_accounts">
<InputText
name="edx_user"
label="edX account username or email"
value={(props.learnerInfo && props.learnerInfo.user && props.learnerInfo.user.username) || ''}
/>
/*
To improve the UI here, we should move this tool to the support Micro-Frontend.
This work will be groomed and covered by MST-180
*/
const renderUserSection = userObj => (
<div>
<h3>edX account Info</h3>
<div className="ml-5">
<div><span className="font-weight-bold">Username</span>: {userObj.username}</div>
<div><span className="font-weight-bold">Email</span>: {userObj.email}</div>
{userObj.external_user_key && (
<div>
<span className="font-weight-bold">External User Key</span>
: {userObj.external_user_key}
</div>
)}
{userObj.sso_list ? (
<div>
<h4>List of Single Sign On Records: </h4>
<ul>
{userObj.sso_list.map(sso => (
<li>{sso.uid}</li>
))}
</ul>
</div>
) : (
<div> There is no Single Sign On record associated with this user!</div>
)}
</div>
<div key="school_accounts">
<InputSelect
name="IdPSelect"
label="Learner Account Providers"
value="Select One"
options={
props.orgKeys
}
/>
<hr />
</div>
);
<InputText
name="external_user_key"
label="Institution user key from school. For example, GTPersonDirectoryId for GT students"
value={(props.learnerInfo && props.learnerInfo.user && props.learnerInfo.user.external_user_key) || ''}
/>
const renderVerificationSection = verificationStatus => (
<div>
<h3>ID Verification</h3>
<div className="ml-5">
<div><span className="font-weight-bold">Status</span>: {verificationStatus.status}</div>
{verificationStatus.error && (
<div>
<span className="font-weight-bold">Verification Error</span>: {verificationStatus.error}
</div>
)}
{verificationStatus.verification_expiry && (
<div>
<span className="font-weight-bold">Verification Expiration Date</span>
: {verificationStatus.verification_expiry}
</div>
)}
</div>
<Button label="Search" type="submit" className={['btn', 'btn-primary']} />
</form>
<hr />
</div>
);
const renderEnrollmentsSection = enrollments => (
<div>
<h3>Program Enrollments</h3>
{enrollments.map(enrollment => (
<div key={enrollment.program_uuid} className="ml-5">
<h4>Program {enrollment.program_uuid} Enrollment</h4>
<div> <span className="font-weight-bold">Status</span>: {enrollment.status} </div>
<div> <span className="font-weight-bold">Created</span>: {enrollment.created} </div>
<div> <span className="font-weight-bold">Last updated</span>: {enrollment.modified} </div>
<div>
<span className="font-weight-bold">External User Key</span>
: {enrollment.external_user_key}
</div>
{enrollment.program_course_enrollments && enrollment.program_course_enrollments.map(
programCourseEnrollment => (
<div key={programCourseEnrollment.course_key} className="ml-5">
<h4>Course {programCourseEnrollment.course_key}</h4>
<div>
<span className="font-weight-bold">Status</span>
: {programCourseEnrollment.status}
</div>
<div>
<span className="font-weight-bold">Created</span>
: {programCourseEnrollment.created}
</div>
<div>
<span className="font-weight-bold">Last updated</span>
: {programCourseEnrollment.modified}
</div>
{programCourseEnrollment.course_enrollment && (
<div className="ml-5">
<h4>Linked course enrollment</h4>
<div><span className="font-weight-bold">Course ID</span>
: {programCourseEnrollment.course_enrollment.course_id}
</div>
<div> <span className="font-weight-bold">Is Active</span>
: {String(programCourseEnrollment.course_enrollment.is_active)}
</div>
<div> <span className="font-weight-bold">Mode / Track</span>
: {programCourseEnrollment.course_enrollment.mode}
</div>
</div>
)}
</div>
))}
</div>
))}
<hr />
</div>
);
const validateInputs = () => {
const inputEdxUser = self.document.getElementById('edx_user');
const inputExternalKey = self.document.getElementById('external_key');
const inputAlert = self.document.getElementById('input_alert');
if (inputEdxUser.value && inputExternalKey.value) {
inputAlert.removeAttribute('hidden');
self.button.disabled = true;
} else {
inputAlert.setAttribute('hidden', '');
self.button.disabled = false;
}
};
export const ProgramEnrollmentsInspectorPage = props => (
<div>
{JSON.stringify(props.learnerInfo) !== '{}' && (<h2> Search Results </h2>)}
{props.learnerInfo.user &&
renderUserSection(props.learnerInfo.user)}
{props.learnerInfo.id_verification &&
renderVerificationSection(props.learnerInfo.id_verification)}
{props.learnerInfo.enrollments &&
renderEnrollmentsSection(props.learnerInfo.enrollments)}
<form method="get">
<h2>Search For A Masters Learner Below</h2>
{props.error && (
<StatusAlert
open
dismissible={false}
alertType="danger"
dialog={props.error}
/>
)}
<div id="input_alert" className={'alert alert-danger'} hidden>
Search either by edx username or email, or Institution user key, but not both
</div>
<div key="edX_accounts">
<InputText
id="edx_user"
name="edx_user"
label="edX account username or email"
onChange={validateInputs}
/>
</div>
<div key="school_accounts">
<InputSelect
name="org_key"
required
label="Identity-providing institution"
options={
props.orgKeys
}
/>
<InputText
id="external_key"
name="external_user_key"
label="Institution user key from school. For example, GTPersonDirectoryId for GT students"
onChange={validateInputs}
/>
</div>
<Button
id="search_button"
label="Search"
type="submit"
className={['btn', 'btn-primary']}
inputRef={(input) => { self.button = input; }}
/>
</form>
</div>
);
ProgramEnrollmentsInspectorPage.propTypes = {
errors: PropTypes.arrayOf(PropTypes.string),
error: PropTypes.string,
learnerInfo: PropTypes.shape({
user: PropTypes.shape({
external_user_key: PropTypes.string,
username: PropTypes.string,
email: PropTypes.email,
external_user_key: PropTypes.string,
sso_list: PropTypes.arrayOf(
PropTypes.shape({
uid: PropTypes.string,
}),
),
}),
id_verification: PropTypes.shape({
status: PropTypes.string,
error: PropTypes.string,
verification_expiry: PropTypes.string,
}),
enrollments: PropTypes.arrayOf(
PropTypes.string,
PropTypes.shape({
created: PropTypes.string,
modified: PropTypes.string,
program_uuid: PropTypes.string,
status: PropTypes.string,
external_user_key: PropTypes.string,
program_course_enrollments: PropTypes.arrayOf(
PropTypes.shape({
course_key: PropTypes.string,
created: PropTypes.string,
modified: PropTypes.string,
status: PropTypes.string,
course_enrollment: PropTypes.shape({
course_id: PropTypes.string,
is_active: PropTypes.bool,
mode: PropTypes.string,
}),
})),
}),
),
}),
orgKeys: PropTypes.arrayOf(PropTypes.object),
orgKeys: PropTypes.arrayOf(PropTypes.string),
};
ProgramEnrollmentsInspectorPage.defaultProps = {
errors: [],
error: '',
learnerInfo: {},
orgKeys: [],
};

View File

@@ -587,7 +587,9 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
def test_initial_rendering(self):
response = self.client.get(self.url)
content = six.text_type(response.content, encoding='utf-8')
expected_organization_serialized = '"orgKeys": {}'.format(json.dumps(self.org_key_list))
expected_organization_serialized = '"orgKeys": {}'.format(
json.dumps(sorted(self.org_key_list))
)
assert response.status_code == 200
assert expected_organization_serialized in content
assert '"learnerInfo": {}' in content
@@ -609,11 +611,9 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
uid='{0}:{1}'.format(org_key, external_user_key),
provider='tpa-saml'
)
user_info['external_user_key'] = external_user_key
user_info['sso'] = {
'uid': user_social_auth.uid,
'provider': user_social_auth.provider
}
user_info['sso_list'] = [{
'uid': user_social_auth.uid
}]
return user, user_info
def _construct_enrollments(self, program_uuids, course_ids, external_user_key, edx_user=None):
@@ -678,7 +678,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
created_user
)
self.client.get(self.url, data={
'edx_user': created_user.username
'edx_user': created_user.username,
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -693,7 +694,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
def test_search_username_user_not_connected(self, mocked_render):
created_user, expected_user_info = self._construct_user('user_not_connected')
self.client.get(self.url, data={
'edx_user': created_user.email
'edx_user': created_user.email,
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -711,7 +713,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
self.external_user_key
)
self.client.get(self.url, data={
'edx_user': created_user.email
'edx_user': created_user.email,
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -735,7 +738,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
created_user,
)
self.client.get(self.url, data={
'edx_user': created_user.email
'edx_user': created_user.email,
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -757,7 +761,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
self.external_user_key,
)
self.client.get(self.url, data={
'edx_user': created_user.email
'edx_user': created_user.email,
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -779,7 +784,8 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
}
self.client.get(self.url, data={
'edx_user': created_user.email
'edx_user': created_user.email,
'org_key': self.org_key_list[0]
})
render_call_dict = mocked_render.call_args[0][1]
@@ -801,7 +807,7 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
id_verified = self._construct_id_verification(created_user)
self.client.get(self.url, data={
'external_user_key': self.external_user_key,
'IdPSelect': self.org_key_list[0]
'org_key': self.org_key_list[0]
})
expected_info = {
'user': expected_user_info,
@@ -813,27 +819,27 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
assert expected_info == render_call_dict['learner_program_enrollments']
@ddt.data(
('aabbcc', ''),
('', 'test_org')
('', 'test_org'),
('bad_key', '')
)
@ddt.unpack
@patch_render
def test_search_external_key_no_idp(self, user_key_input, idp_input, mocked_render):
def test_search_no_external_user_key(self, user_key, org_key, mocked_render):
self.client.get(self.url, data={
'external_user_key': user_key_input,
'IdPSelect': idp_input,
'external_user_key': user_key,
'org_key': org_key,
})
expected_errors = [
expected_error = (
"To perform a search, you must provide either the student's "
"(a) edX username, "
"(b) email address associated with their edX account, or "
"(c) Identity-providing institution and external key!"
]
)
render_call_dict = mocked_render.call_args[0][1]
assert {} == render_call_dict['learner_program_enrollments']
assert expected_errors == render_call_dict['errors']
assert expected_error == render_call_dict['error']
@patch_render
def test_search_external_user_not_connected(self, mocked_render):
@@ -844,9 +850,12 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
)
self.client.get(self.url, data={
'external_user_key': self.external_user_key,
'IdPSelect': self.org_key_list[0]
'org_key': self.org_key_list[0]
})
expected_info = {
'user': {
'external_user_key': self.external_user_key,
},
'enrollments': expected_enrollments
}
@@ -858,13 +867,11 @@ class ProgramEnrollmentsInspectorViewTests(SupportViewTestCase):
external_user_key = 'not_in_system'
self.client.get(self.url, data={
'external_user_key': external_user_key,
'IdPSelect': self.org_key_list[0],
'org_key': self.org_key_list[0],
})
expected_errors = [
'No user found for external key {} for institution {}'.format(
external_user_key, self.org_key_list[0]
)
]
expected_error = 'No user found for external key {} for institution {}'.format(
external_user_key, self.org_key_list[0]
)
render_call_dict = mocked_render.call_args[0][1]
assert expected_errors == render_call_dict['errors']
assert expected_error == render_call_dict['error']

View File

@@ -48,6 +48,11 @@ SUPPORT_INDEX_URLS = [
"name": _("Link Program Enrollments"),
"description": _("Link LMS users to program enrollments"),
},
{
"url": reverse_lazy("support:program_enrollments_inspector"),
"name": _("Program Enrollments Inspector Tool"),
"description": _("Find information related to a learner's program enrollments"),
},
]

View File

@@ -134,28 +134,35 @@ class ProgramEnrollmentsInspectorView(View):
Search the data store for information about ProgramEnrollment and
SSO linkage with the user.
"""
errors = []
search_error = ''
edx_username_or_email = request.GET.get('edx_user', '').strip()
org_key = request.GET.get('IdPSelect', '').strip()
org_key = request.GET.get('org_key', '').strip()
external_user_key = request.GET.get('external_user_key', '').strip()
learner_program_enrollments = {}
saml_providers_with_org_key = self._get_org_keys_and_idps()
selected_provider = None
if org_key:
selected_provider = saml_providers_with_org_key.get(org_key)
if edx_username_or_email:
learner_program_enrollments, error = self._get_account_info(edx_username_or_email)
if error:
errors.append(error)
learner_program_enrollments, search_error = self._get_account_info(
edx_username_or_email,
selected_provider,
)
elif org_key and external_user_key:
learner_program_enrollments = self._get_external_user_info(
external_user_key,
org_key
org_key,
selected_provider,
)
if not learner_program_enrollments:
errors.append(
'No user found for external key {} for institution {}'.format(
external_user_key, org_key
)
search_error = 'No user found for external key {} for institution {}'.format(
external_user_key, org_key
)
elif not org_key and not external_user_key:
# This is initial rendering state.
pass
else:
errors.append(
search_error = (
"To perform a search, you must provide either the student's "
"(a) edX username, "
"(b) email address associated with their edX account, or "
@@ -165,38 +172,39 @@ class ProgramEnrollmentsInspectorView(View):
return render_to_response(
self.CONSOLE_TEMPLATE_PATH,
{
'errors': errors,
'error': search_error,
'learner_program_enrollments': learner_program_enrollments,
'org_keys': self._get_org_keys_with_idp(),
'org_keys': sorted(saml_providers_with_org_key.keys()),
}
)
def _get_org_keys_with_idp(self):
def _get_org_keys_and_idps(self):
"""
From our Third_party_auth models, return a list
of organizations whose SAMLProviders are active and configured
From our Third_party_auth models, return a dictionary of
of organizations keys and their correspondingactive and configured SAMLProviders
"""
saml_providers = SAMLProviderConfig.objects.current_set().filter(
enabled=True,
organization__isnull=False
).select_related('organization')
return [saml_provider.organization.short_name for saml_provider in saml_providers]
return {
saml_provider.organization.short_name: saml_provider for saml_provider in saml_providers
}
def _get_account_info(self, username_or_email):
def _get_account_info(self, username_or_email, idp_provider=None):
"""
Provided the edx account username or email, return edx account info
and program_enrollments_info. If we cannot identify the user, return
empty object and error.
Provided the edx account username or email, and the SAML provider selected,
return edx account info and program_enrollments_info.
If we cannot identify the user, return empty object and error.
"""
try:
user = User.objects.get(Q(username=username_or_email) | Q(email=username_or_email))
user_social_auth = None
try:
user_social_auth = UserSocialAuth.objects.get(user=user)
except UserSocialAuth.DoesNotExist:
pass
user_info = serialize_user_info(user, user_social_auth)
user_social_auths = None
user_social_auths = UserSocialAuth.objects.filter(user=user)
if idp_provider:
user_social_auths = user_social_auths.filter(provider=idp_provider.backend_name)
user_info = serialize_user_info(user, user_social_auths)
enrollments = self._get_enrollments(user=user)
result = {'user': user_info}
if enrollments:
@@ -207,7 +215,7 @@ class ProgramEnrollmentsInspectorView(View):
except User.DoesNotExist:
return {}, 'Could not find edx account with {}'.format(username_or_email)
def _get_external_user_info(self, external_user_key, org_key):
def _get_external_user_info(self, external_user_key, org_key, idp_provider=None):
"""
Provided the external_user_key and org_key, return edx account info
and program_enrollments_info if any. If we cannot identify the data,
@@ -229,17 +237,19 @@ class ProgramEnrollmentsInspectorView(View):
# We cannot identify edX user from external_user_key and org_key pair
pass
if found_user:
try:
user_social_auth = UserSocialAuth.objects.get(user=found_user)
except UserSocialAuth.DoesNotExist:
user_social_auth = None
user_info = serialize_user_info(found_user, user_social_auth)
result['user'] = user_info
result['id_verification'] = IDVerificationService.user_status(found_user)
enrollments = self._get_enrollments(external_user_key=external_user_key)
if enrollments:
result['enrollments'] = enrollments
if found_user:
user_social_auths = UserSocialAuth.objects.filter(user=found_user)
if idp_provider:
user_social_auths = user_social_auths.filter(provider=idp_provider.backend_name)
user_info = serialize_user_info(found_user, user_social_auths)
result['user'] = user_info
result['id_verification'] = IDVerificationService.user_status(found_user)
elif 'enrollments' in result:
result['user'] = {'external_user_key': external_user_key}
return result
def _get_enrollments(self, user=None, external_user_key=None):

View File

@@ -21,16 +21,15 @@ from openedx.core.djangolib.js_utils import js_escaped_string
<%block name="content">
<section class="container outside-app">
<h3> Program Enrollments Inspector </h3>
<h1> Program Enrollments Inspector </h1>
${static.renderReact(
component="ProgramEnrollmentsInspectorPage",
id="entitlement-support-page",
id="program-enrollments-inspector-page",
props={
'errors': errors,
'error': error,
'learnerInfo': learner_program_enrollments,
'orgKeys': org_keys
}
)
}
)}
</section>
</%block>