diff --git a/src/components/Gradebook/index.jsx b/src/components/Gradebook/index.jsx index 60ef227..bb35189 100644 --- a/src/components/Gradebook/index.jsx +++ b/src/components/Gradebook/index.jsx @@ -28,14 +28,7 @@ export default class Gradebook extends React.Component { componentDidMount() { const urlQuery = queryString.parse(this.props.location.search); - this.props.getUserGrades( - this.props.match.params.courseId, - urlQuery.cohort, - urlQuery.track, - ); - this.props.getTracks(this.props.match.params.courseId); - this.props.getCohorts(this.props.match.params.courseId); - this.props.getAssignmentTypes(this.props.match.params.courseId); + this.props.getRoles(this.props.match.params.courseId, urlQuery); } setNewModalState = (userEntry, subsection) => { @@ -259,6 +252,11 @@ export default class Gradebook extends React.Component { The grades for this course are now frozen. Editing of grades is no longer allowed. } + { !this.props.canUserViewGradebook && +
+ You are not authorized to view the gradebook for this course. If you have a global role, please enroll in this course and try again. +
+ }
diff --git a/src/containers/GradebookPage/index.jsx b/src/containers/GradebookPage/index.jsx index 3e4dc4d..b0e437c 100644 --- a/src/containers/GradebookPage/index.jsx +++ b/src/containers/GradebookPage/index.jsx @@ -13,6 +13,7 @@ import { import { fetchCohorts } from '../../data/actions/cohorts'; import { fetchTracks } from '../../data/actions/tracks'; import { fetchAssignmentTypes } from '../../data/actions/assignmentTypes'; +import { getRoles } from '../../data/actions/roles'; const mapStateToProps = state => ( { @@ -28,10 +29,21 @@ const mapStateToProps = state => ( nextPage: state.grades.nextPage, assignmnetTypes: state.assignmentTypes.results, areGradesFrozen: state.assignmentTypes.areGradesFrozen, - showSpinner: state.grades.showSpinner, + showSpinner: shouldShowSpinner(state), + canUserViewGradebook: state.roles.canUserViewGradebook } ); +function shouldShowSpinner (state) { + if (state.roles.canUserViewGradebook === true){ + return state.grades.showSpinner; + } else if (state.roles.canUserViewGradebook === false){ + return false; + } else { // canUserViewGradebook === null + return true; + } +} + const mapDispatchToProps = dispatch => ( { getUserGrades: (courseId, cohort, track) => { @@ -64,6 +76,9 @@ const mapDispatchToProps = dispatch => ( updateBanner: (showSuccess) => { dispatch(updateBanner(showSuccess)); }, + getRoles: (matchParams, urlQuery) => { + dispatch(getRoles(matchParams, urlQuery)); + }, } ); diff --git a/src/data/actions/roles.js b/src/data/actions/roles.js new file mode 100644 index 0000000..af27ef5 --- /dev/null +++ b/src/data/actions/roles.js @@ -0,0 +1,38 @@ +import { + GOT_ROLES, + ERROR_FETCHING_ROLES + } from '../constants/actionTypes/roles'; +import { fetchGrades } from './grades'; +import { fetchTracks } from './tracks'; +import { fetchCohorts } from './cohorts'; +import { fetchAssignmentTypes } from './assignmentTypes'; +import LmsApiService from '../services/LmsApiService'; + +const allowed_roles = ['staff', 'instructor', 'support']; + +const gotRoles = canUserViewGradebook => ({ type: GOT_ROLES, canUserViewGradebook }); +const errorFetchingRoles = () => ({type: ERROR_FETCHING_ROLES }); + +const getRoles = (courseId, urlQuery) => ( + (dispatch) => { + return LmsApiService.fetchUserRoles(courseId) + .then(response => response.data) + .then(roles => { + var canUserViewGradebook = roles.some(role => (role.course_id === courseId) && allowed_roles.includes(role.role)); + dispatch(gotRoles(canUserViewGradebook)); + if(canUserViewGradebook){ + dispatch(fetchGrades(courseId, urlQuery.cohort, urlQuery.track)); + dispatch(fetchTracks(courseId)); + dispatch(fetchCohorts(courseId)); + dispatch(fetchAssignmentTypes(courseId)); + } + }) + .catch(() => { + dispatch(errorFetchingRoles()) + }); + }); + +export { + getRoles, + errorFetchingRoles, +}; \ No newline at end of file diff --git a/src/data/actions/roles.test.js b/src/data/actions/roles.test.js new file mode 100644 index 0000000..0e5bc5d --- /dev/null +++ b/src/data/actions/roles.test.js @@ -0,0 +1,103 @@ +import configureMockStore from 'redux-mock-store'; +import MockAdapter from 'axios-mock-adapter'; +import thunk from 'redux-thunk'; + +import apiClient from '../apiClient'; +import { configuration } from '../../config'; +import { getRoles } from './roles'; +import { + GOT_ROLES, + ERROR_FETCHING_ROLES, +} from '../constants/actionTypes/roles'; +import { STARTED_FETCHING_GRADES } from '../constants/actionTypes/grades'; +import { STARTED_FETCHING_TRACKS } from '../constants/actionTypes/tracks'; +import { STARTED_FETCHING_COHORTS } from '../constants/actionTypes/cohorts'; +import { STARTED_FETCHING_ASSIGNMENT_TYPES } from '../constants/actionTypes/assignmentTypes'; + + +const mockStore = configureMockStore([thunk]); +const axiosMock = new MockAdapter(apiClient); + +const rolesUrl = `${configuration.LMS_BASE_URL}/api/enrollment/v1/roles/`; + +const course1Id = 'course-v1:edX+DemoX+Demo_Course'; +const course2Id = 'course-v1:edX+DemoX+Demo_Course_2'; + +function makeRoleObj(courseId, role) { + return { + course_id: courseId, + role: role, + } +}; + +const course1StaffRole = makeRoleObj(course1Id, "staff"); +const course1DummyRole = makeRoleObj(course1Id, "dummy"); +const course2StaffRole = makeRoleObj(course2Id, "staff"); +const course2DummyRole = makeRoleObj(course2Id, "dummy"); +const urlParams = { cohort: null, track: null }; + +describe('actions', () => { + afterEach(() => { + axiosMock.reset(); + }); + + describe('getRoles', () => { + it('dispatches got_roles action and subsequent actions after fetching role that allows gradebook', () => { + const expectedActions = [ + { type: GOT_ROLES, canUserViewGradebook: true }, + { type: STARTED_FETCHING_GRADES }, + { type: STARTED_FETCHING_TRACKS }, + { type: STARTED_FETCHING_COHORTS }, + { type: STARTED_FETCHING_ASSIGNMENT_TYPES }, + ]; + const store = mockStore(); + axiosMock.onGet(rolesUrl) + .replyOnce(200, JSON.stringify([course1StaffRole, course2DummyRole])); + + return store.dispatch(getRoles(course1Id, urlParams)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('dispatches got_roles action and no other actions after fetching role that disallows gradebook', () => { + const expectedActions = [ + { type: GOT_ROLES, canUserViewGradebook: false }, + ]; + const store = mockStore(); + + axiosMock.onGet(rolesUrl) + .replyOnce(200, JSON.stringify([course1DummyRole, course2StaffRole])); + + return store.dispatch(getRoles(course1Id, urlParams)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('dispatches got_roles action and no other actions after fetching empty roles', () => { + const expectedActions = [ + { type: GOT_ROLES, canUserViewGradebook: false }, + ]; + const store = mockStore(); + + axiosMock.onGet(rolesUrl) + .replyOnce(200, JSON.stringify([])); + + return store.dispatch(getRoles(course1Id, urlParams)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it('dispatches error action after getting an error when trying to get roles', () => { + const expectedActions = [ + { type: ERROR_FETCHING_ROLES }, + ]; + const store = mockStore(); + + axiosMock.onGet(rolesUrl).replyOnce(400); + + return store.dispatch(getRoles(course1Id, urlParams)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + }); +}); diff --git a/src/data/constants/actionTypes/roles.js b/src/data/constants/actionTypes/roles.js new file mode 100644 index 0000000..2a91200 --- /dev/null +++ b/src/data/constants/actionTypes/roles.js @@ -0,0 +1,7 @@ +const GOT_ROLES = 'GOT_ROLES'; +const ERROR_FETCHING_ROLES = 'ERROR_FETCHING_ROLES' + +export { + GOT_ROLES, + ERROR_FETCHING_ROLES, +}; diff --git a/src/data/reducers/index.js b/src/data/reducers/index.js index 90ebb3e..392f013 100755 --- a/src/data/reducers/index.js +++ b/src/data/reducers/index.js @@ -4,12 +4,14 @@ import cohorts from './cohorts'; import grades from './grades'; import tracks from './tracks'; import assignmentTypes from './assignmentTypes'; +import roles from './roles'; const rootReducer = combineReducers({ grades, cohorts, tracks, assignmentTypes, + roles, }); export default rootReducer; diff --git a/src/data/reducers/roles.js b/src/data/reducers/roles.js new file mode 100644 index 0000000..2397dc4 --- /dev/null +++ b/src/data/reducers/roles.js @@ -0,0 +1,26 @@ +import { + GOT_ROLES, + ERROR_FETCHING_ROLES, + } from '../constants/actionTypes/roles'; + + const initialState = { + canUserViewGradebook: null, + }; + + const roles = (state = initialState, action) => { + switch (action.type) { + case GOT_ROLES: + return { + ...state, + canUserViewGradebook: action.canUserViewGradebook, + }; + case ERROR_FETCHING_ROLES: + return { + ...state, + canUserViewGradebook: false, + }; + default: + return state; + }}; + + export default roles; \ No newline at end of file diff --git a/src/data/reducers/roles.test.js b/src/data/reducers/roles.test.js new file mode 100644 index 0000000..cb29221 --- /dev/null +++ b/src/data/reducers/roles.test.js @@ -0,0 +1,47 @@ +import roles from './roles'; +import { + ERROR_FETCHING_ROLES, + GOT_ROLES, +} from '../constants/actionTypes/roles'; + +const initialState = { + canUserViewGradebook: null, +}; + +describe('tracks reducer', () => { + it('has initial state', () => { + expect(roles(undefined, {})).toEqual(initialState); + }); + + it('updates canUserViewGradebook to true', () => { + const expected = { + ...initialState, + canUserViewGradebook: true + }; + expect(roles(undefined, { + type: GOT_ROLES, + canUserViewGradebook: true, + })).toEqual(expected); + }); + + it('updates canUserViewGradebook to false', () => { + const expected = { + ...initialState, + canUserViewGradebook: false + }; + expect(roles(undefined, { + type: GOT_ROLES, + canUserViewGradebook: false, + })).toEqual(expected); + }); + + it('updates fetch roles failure state', () => { + const expected = { + ...initialState, + canUserViewGradebook: false, + }; + expect(roles(undefined, { + type: ERROR_FETCHING_ROLES, + })).toEqual(expected); + }); +}); diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 6124a6f..67cd082 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -59,6 +59,11 @@ class LmsApiService { const assignmentTypesUrl = `${LmsApiService.baseUrl}/api/grades/v1/gradebook/${courseId}/grading-info?graded_only=true`; return apiClient.get(assignmentTypesUrl); } + + static fetchUserRoles(){ + var rolesUrl = `${LmsApiService.baseUrl}/api/enrollment/v1/roles/`; + return apiClient.get(rolesUrl) + } } export default LmsApiService; diff --git a/src/index.jsx b/src/index.jsx index 4a27edb..7972f75 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -11,6 +11,8 @@ import Header from './components/Header'; import store from './data/store'; import './App.scss'; +var courseId = window.location.pathname.substring(1); + const App = () => (