diff --git a/.env b/.env
index 05e2ed61b..5bafdc397 100644
--- a/.env
+++ b/.env
@@ -6,6 +6,7 @@ CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
+LICENSE_MANAGER_URL=null
DISCOVERY_API_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
diff --git a/.env.development b/.env.development
index a9e6c2680..a0a3972cb 100644
--- a/.env.development
+++ b/.env.development
@@ -7,6 +7,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
+LICENSE_MANAGER_URL='http://localhost:18170'
DISCOVERY_API_BASE_URL='http://localhost:18381'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
diff --git a/.env.test b/.env.test
index 972885570..2d5090b15 100644
--- a/.env.test
+++ b/.env.test
@@ -5,6 +5,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
+LICENSE_MANAGER_URL='http://localhost:18170'
DISCOVERY_API_BASE_URL='http://localhost:18381'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
diff --git a/src/index.jsx b/src/index.jsx
index 3f6d8ec49..792e1575b 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -1,10 +1,11 @@
import 'babel-polyfill';
import {
- APP_INIT_ERROR, APP_READY, subscribe, initialize,
+ APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig,
} from '@edx/frontend-platform';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+
import React from 'react';
import ReactDOM from 'react-dom';
import { Switch, Route } from 'react-router-dom';
@@ -17,6 +18,10 @@ import UserMessagesProvider from './user-messages/UserMessagesProvider';
import './index.scss';
+mergeConfig({
+ LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL,
+});
+
subscribe(APP_READY, () => {
const { administrator } = getAuthenticatedUser();
if (!administrator) {
diff --git a/src/setupTest.js b/src/setupTest.js
index 9acb48d06..d381014b4 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -8,6 +8,10 @@ import { MockAuthService } from '@edx/frontend-platform/auth';
Enzyme.configure({ adapter: new Adapter() });
+mergeConfig({
+ LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL,
+});
+
initialize({
handlers: {
config: () => {
diff --git a/src/users/Licenses.jsx b/src/users/Licenses.jsx
new file mode 100644
index 000000000..f6327f358
--- /dev/null
+++ b/src/users/Licenses.jsx
@@ -0,0 +1,145 @@
+import React, {
+ useMemo,
+ useState,
+ useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import { Collapsible, Badge } from '@edx/paragon';
+import sort from './sort';
+import Table from '../Table';
+import formatDate from '../dates/formatDate';
+
+export default function Licenses({
+ data, status, expanded,
+}) {
+ const [sortColumn, setSortColumn] = useState('status');
+ const [sortDirection, setSortDirection] = useState('desc');
+ const responseStatus = useMemo(() => status, [status]);
+ const tableData = useMemo(() => {
+ if (data === null || data.length === 0) {
+ return [];
+ }
+ return data.map(result => ({
+ status: {
+ value: result.status,
+ },
+ assignedDate: {
+ displayValue: formatDate(result.assignedDate),
+ value: result.assignedDate,
+ },
+ activationDate: {
+ displayValue: formatDate(result.activationDate),
+ value: result.activationDate,
+ },
+ revokedDate: {
+ displayValue: formatDate(result.revokedDate),
+ value: result.revokedDate,
+ },
+ lastRemindDate: {
+ displayValue: formatDate(result.lastRemindDate),
+ value: result.lastRemindDate,
+ },
+ subscriptionPlanTitle: {
+ value: result.subscriptionPlanTitle,
+ },
+ subscriptionPlanExpirationDate: {
+ displayValue: formatDate(result.subscriptionPlanExpirationDate),
+ value: result.subscriptionPlanExpirationDate,
+ },
+ activationLink: {
+ displayValue: {result.activationLink},
+ value: result.activationLink,
+ },
+ }));
+ }, [data]);
+
+ const setSort = useCallback((column) => {
+ if (sortColumn === column) {
+ setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc');
+ } else {
+ setSortDirection('desc');
+ }
+ setSortColumn(column);
+ });
+ const columns = [
+
+ {
+ label: 'Status', key: 'status', columnSortable: true, onSort: () => setSort('status'), width: 'col-3',
+ },
+ {
+ label: 'Assigned Date', key: 'assignedDate', columnSortable: true, onSort: () => setSort('assignedDate'), width: 'col-3',
+ },
+ {
+ label: 'Activation Date', key: 'activationDate', columnSortable: true, onSort: () => setSort('activationDate'), width: 'col-3',
+ },
+ {
+ label: 'Revoked Date', key: 'revokedDate', columnSortable: true, onSort: () => setSort('revokedDate'), width: 'col-3',
+ },
+ {
+ label: 'Last Remind Date', key: 'lastRemindDate', columnSortable: true, onSort: () => setSort('lastRemindDate'), width: 'col-3',
+ },
+ {
+ label: 'Subscription Plan Title', key: 'subscriptionPlanTitle', columnSortable: true, onSort: () => setSort('subscriptionPlanTitle'), width: 'col-3',
+ },
+ {
+ label: 'Subscription Plan Expiration Date', key: 'subscriptionPlanExpirationDate', columnSortable: true, onSort: () => setSort('subscriptionPlanExpirationDate'), width: 'col-3',
+ },
+ {
+ label: 'Activation Link', key: 'activationLink', columnSortable: true, onSort: () => setSort('activationLink'), width: 'col-3',
+ },
+ ];
+
+ const tableDataSortable = [...tableData];
+ let statusMsg;
+ if (responseStatus !== '') {
+ statusMsg = {`Fetch Status: ${responseStatus}`};
+ } else {
+ statusMsg = null;
+ }
+
+ return (
+
+
+ {`Licenses (${tableData.length})`}
+ {statusMsg}
+ >
+ )}
+ defaultOpen={expanded}
+ >
+ sort(firstElement, secondElement, sortColumn, sortDirection),
+ )}
+ columns={columns}
+ tableSortable
+ defaultSortedColumn="status"
+ defaultSortDirection="desc"
+ />
+
+
+ );
+}
+
+Licenses.propTypes = {
+ data: PropTypes.arrayOf(PropTypes.shape({
+ status: PropTypes.string,
+ assignedDate: PropTypes.string,
+ revokedDate: PropTypes.string,
+ activationDate: PropTypes.string,
+ subscriptionPlanTitle: PropTypes.string,
+ lastRemindDate: PropTypes.string,
+ activationLink: PropTypes.string,
+ subscriptionPlanExpirationDate: PropTypes.string,
+ })),
+ status: PropTypes.string,
+ expanded: PropTypes.bool,
+};
+
+Licenses.defaultProps = {
+ data: null,
+ status: '',
+ expanded: false,
+};
diff --git a/src/users/Licenses.test.jsx b/src/users/Licenses.test.jsx
new file mode 100644
index 000000000..762de77e1
--- /dev/null
+++ b/src/users/Licenses.test.jsx
@@ -0,0 +1,48 @@
+import { mount } from 'enzyme';
+import React from 'react';
+
+import Licenses from './Licenses';
+import licensesData from './data/test/licenses';
+import UserMessagesProvider from '../user-messages/UserMessagesProvider';
+
+const LicensesPageWrapper = (props) => (
+
+
+
+);
+
+describe('User Licenses Listing', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount();
+ });
+
+ it('default collapsible with enrollment data', () => {
+ const collapsible = wrapper.find('CollapsibleAdvanced').find('.collapsible-trigger').hostNodes();
+ expect(collapsible.text()).toEqual('Licenses (2)');
+ });
+
+ it('No License Data', () => {
+ const licenseData = { ...licensesData, data: [], status: 'No record found' };
+ wrapper = mount();
+ const collapsible = wrapper.find('CollapsibleAdvanced').find('.collapsible-trigger').hostNodes();
+ expect(collapsible.text()).toEqual('Licenses (0)Fetch Status: No record found');
+ });
+
+ it('Sorting Columns Button Enabled by default', () => {
+ const dataTable = wrapper.find('table.table');
+ const tableHeaders = dataTable.find('thead tr th');
+
+ tableHeaders.forEach(header => {
+ const sortButton = header.find('button.btn-header');
+ expect(sortButton.disabled).toBeFalsy();
+ });
+ });
+
+ it('Table Header Lenght', () => {
+ const dataTable = wrapper.find('table.table');
+ const tableHeaders = dataTable.find('thead tr th');
+ expect(tableHeaders).toHaveLength(Object.keys(licensesData.data[0]).length);
+ });
+});
diff --git a/src/users/UserPage.jsx b/src/users/UserPage.jsx
index 13f3beca9..f46366bbc 100644
--- a/src/users/UserPage.jsx
+++ b/src/users/UserPage.jsx
@@ -11,6 +11,7 @@ import UserMessagesContext from '../user-messages/UserMessagesContext';
import { isEmail, isValidUsername } from '../utils/index';
import { getAllUserData } from './data/api';
import Enrollments from './Enrollments';
+import Licenses from './Licenses';
import Entitlements from './entitlements/Entitlements';
import UserSearch from './UserSearch';
import UserSummary from './UserSummary';
@@ -27,6 +28,7 @@ export default function UserPage({ location }) {
const [loading, setLoading] = useState(false);
const [showEnrollments, setShowEnrollments] = useState(true);
const [showEntitlements, setShowEntitlements] = useState(false);
+ const [showLicenses, setShowLicenses] = useState(false);
const { add, clear } = useContext(UserMessagesContext);
function pushHistoryIfChanged(nextUrl) {
@@ -93,6 +95,7 @@ export default function UserPage({ location }) {
setSearching(true);
setShowEntitlements(false);
setShowEnrollments(true);
+ setShowLicenses(false);
handleFetchSearchResults(searchValue);
});
@@ -103,12 +106,14 @@ export default function UserPage({ location }) {
const handleEntitlementsChange = useCallback(() => {
setShowEntitlements(true);
+ setShowLicenses(true);
setShowEnrollments(false);
handleFetchSearchResults(userIdentifier);
});
const handleEnrollmentsChange = useCallback(() => {
setShowEntitlements(false);
+ setShowLicenses(false);
setShowEnrollments(true);
handleFetchSearchResults(userIdentifier);
});
@@ -149,6 +154,11 @@ export default function UserPage({ location }) {
ssoRecords={data.ssoRecords}
changeHandler={handleUserSummaryChange}
/>
+
+
>
)}
{!loading && !userIdentifier && (
diff --git a/src/users/data/api.js b/src/users/data/api.js
index 88ef3f889..f221577d2 100644
--- a/src/users/data/api.js
+++ b/src/users/data/api.js
@@ -1,4 +1,4 @@
-import { getConfig } from '@edx/frontend-platform';
+import { getConfig, ensureConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import * as messages from '../../user-messages/messages';
import { isEmail, isValidUsername } from '../../utils/index';
@@ -156,6 +156,44 @@ export async function getUserPasswordStatus(userIdentifier) {
return data;
}
+ensureConfig([
+ 'LICENSE_MANAGER_URL',
+], 'getLicense');
+
+export async function getLicense(userEmail) {
+ const defaultResponse = {
+ status: '',
+ results: [],
+ };
+ try {
+ const { data } = await getAuthenticatedHttpClient().post(
+ `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`,
+ { user_email: userEmail },
+ );
+ defaultResponse.results = data;
+ return defaultResponse;
+ } catch (error) {
+ let errorStatus = -1;
+
+ if ('customAttributes' in error) {
+ errorStatus = error.customAttributes.httpErrorStatus;
+ }
+
+ if (errorStatus === 404) {
+ defaultResponse.status = 'No record found';
+ } else if (errorStatus === 400) {
+ defaultResponse.status = 'User email is not provided';
+ } else if (errorStatus === 403) {
+ defaultResponse.status = 'Forbidden: User does not have permission to view this data';
+ } else if (errorStatus === 401) {
+ defaultResponse.status = 'Unauthenticated: Could not autheticate user to view this data';
+ } else {
+ defaultResponse.status = 'Unable to connect to the service';
+ }
+ return defaultResponse;
+ }
+}
+
export async function getAllUserData(userIdentifier) {
const errors = [];
let user = null;
@@ -163,6 +201,7 @@ export async function getAllUserData(userIdentifier) {
let enrollments = [];
let verificationStatus = null;
let ssoRecords = null;
+ let licenses = [];
try {
user = await getUser(userIdentifier);
} catch (error) {
@@ -178,6 +217,7 @@ export async function getAllUserData(userIdentifier) {
verificationStatus = await getUserVerificationStatus(user.username);
ssoRecords = await getSsoRecords(user.username);
user.passwordStatus = await getUserPasswordStatus(user.username);
+ licenses = await getLicense(user.email);
}
return {
@@ -187,6 +227,7 @@ export async function getAllUserData(userIdentifier) {
enrollments,
verificationStatus,
ssoRecords,
+ licenses,
};
}
diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js
index feb2c2ba4..cdfea5eb2 100644
--- a/src/users/data/api.test.js
+++ b/src/users/data/api.test.js
@@ -15,6 +15,7 @@ describe('API', () => {
const entitlementsApiBaseUrl = `${getConfig().LMS_BASE_URL}/api/entitlements/v1/entitlements/?user=${testUsername}`;
const verificationDetailsApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verifications/`;
const verificationStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verification_status/`;
+ const licensesApiUrl = `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`;
let mockAdapter;
@@ -278,6 +279,7 @@ describe('API', () => {
mockAdapter.onGet(verificationDetailsApiUrl).reply(200, {});
mockAdapter.onGet(verificationStatusApiUrl).reply(200, {});
mockAdapter.onGet(passwordStatusApiUrl).reply(200, {});
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(200, []);
const response = await api.getAllUserData(testUsername);
expect(response).toEqual({
@@ -287,6 +289,7 @@ describe('API', () => {
verificationStatus: { extraData: {} },
enrollments: {},
entitlements: { results: [], next: null },
+ licenses: { results: [], status: '' },
});
});
});
@@ -530,6 +533,54 @@ describe('API', () => {
const response = await api.postEnrollmentChange({ ...apiCallData });
expect(response).toEqual(expectedSuccessResponse);
});
+
+ it('Successful license fetch with data', async () => {
+ const expectedSuccessResponse = [
+ {
+ status: 'unassigned',
+ assigned_date: null,
+ activation_date: null,
+ revoked_date: null,
+ last_remind_date: null,
+ subscription_plan_title: 'test',
+ subscription_plan_expiration_date: '2021-04-01',
+ activation_link: 'http://localhost:8734/test/licenses/None/activate',
+ },
+ ];
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(200, expectedSuccessResponse);
+ const response = await api.getLicense(testEmail);
+ expect(response).toEqual({ results: expectedSuccessResponse, status: '' });
+ });
+
+ it('Unsuccessful license fetch when no email provided', async () => {
+ mockAdapter.onPost(licensesApiUrl, { user_email: null }).reply(() => throwError(400, ''));
+ const response = await api.getLicense(null);
+ expect(response).toEqual({ results: [], status: 'User email is not provided' });
+ });
+
+ it('Unsuccessful license fetch when no record found', async () => {
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(404, ''));
+ const response = await api.getLicense(testEmail);
+ expect(response).toEqual({ results: [], status: 'No record found' });
+ });
+
+ it('Unsuccessful license fetch when user does not have permission to license manager', async () => {
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(403, ''));
+ const response = await api.getLicense(testEmail);
+ expect(response).toEqual({ results: [], status: 'Forbidden: User does not have permission to view this data' });
+ });
+
+ it('Unsuccessful license fetch when user is not authenticated', async () => {
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(401, ''));
+ const response = await api.getLicense(testEmail);
+ expect(response).toEqual({ results: [], status: 'Unauthenticated: Could not autheticate user to view this data' });
+ });
+
+ it('Unsuccessful license fetch when unexpected error comes', async () => {
+ mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(500, ''));
+ const response = await api.getLicense(testEmail);
+ expect(response).toEqual({ results: [], status: 'Unable to connect to the service' });
+ });
});
});
});
diff --git a/src/users/data/test/licenses.js b/src/users/data/test/licenses.js
new file mode 100644
index 000000000..efc71ed94
--- /dev/null
+++ b/src/users/data/test/licenses.js
@@ -0,0 +1,28 @@
+const licensesData = {
+ data: [
+ {
+ status: 'unassigned',
+ assignedDate: null,
+ revokedDate: null,
+ activationDate: new Date().toISOString().replace(/T.*/, ''),
+ subscriptionPlanTitle: 'test',
+ lastRemindDate: new Date().toISOString().replace(/T.*/, ''),
+ activationLink: null,
+ subscriptionPlanExpirationDate: 'http://localhost:8734/test/licenses/None/activate',
+ },
+ {
+ status: 'unassigned',
+ assignedDate: new Date().toISOString().replace(/T.*/, ''),
+ revokedDate: null,
+ activationDate: null,
+ subscriptionPlanTitle: 'test2',
+ lastRemindDate: new Date().toISOString().replace(/T.*/, ''),
+ activationLink: null,
+ subscriptionPlanExpirationDate: null,
+ },
+ ],
+ status: '',
+ expanded: true,
+};
+
+export default licensesData;