Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add following proctoring exam pages: user verification, software download, ready to start instruction #17

Merged
merged 10 commits into from
May 27, 2021
10 changes: 10 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable import/prefer-default-export */
export const ExamStatus = Object.freeze({
CREATED: 'created',
DOWNLOAD_SOFTWARE_CLICKED: 'download_software_clicked',
READY_TO_START: 'ready_to_start',
STARTED: 'started',
READY_TO_SUBMIT: 'ready_to_submit',
SUBMITTED: 'submitted',
Expand All @@ -19,4 +21,12 @@ export const ExamAction = Object.freeze({
PING: 'ping',
SUBMIT: 'submit',
ERROR: 'error',
CLICK_DOWNLOAD_SOFTWARE: 'click_download_software',
});

export const VerificationStatus = Object.freeze({
PENDING: 'pending',
MUST_REVERIFY: 'must_reverify',
APPROVED: 'approved',
EXPIRED: 'expired',
});
20 changes: 20 additions & 0 deletions src/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,28 @@ export async function endExamWithFailure(attemptId, error) {
return updateAttemptStatus(attemptId, ExamAction.ERROR, error);
}

export async function softwareDownloadAttempt(attemptId) {
return updateAttemptStatus(attemptId, ExamAction.CLICK_DOWNLOAD_SOFTWARE);
}

export async function fetchExamReviewPolicy(examId) {
const url = new URL(
`${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/review_policy/exam_id/${examId}/`,
);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return data;
}

export async function fetchProctoringSettings(examId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/settings/exam_id/${examId}/`);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return data;
}

export async function fetchVerificationStatus() {
const url = new URL(
`${getConfig().LMS_BASE_URL}/verify_student/status/`,
);
const { data } = await getAuthenticatedHttpClient().get(url.href);
return data;
}
2 changes: 2 additions & 0 deletions src/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export {
submitExam,
expireExam,
pollAttempt,
getVerificationData,
getExamReviewPolicy,
pingAttempt,
} from './thunks';

Expand Down
10 changes: 9 additions & 1 deletion src/data/slice.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const examSlice = createSlice({
activeAttempt: null,
proctoringSettings: {},
exam: {},
verification: {},
apiErrorMsg: '',
},
reducers: {
Expand All @@ -30,6 +31,12 @@ export const examSlice = createSlice({
state.timeIsOver = true;
},
getExamId: (state) => state.examId,
setVerificationData: (state, { payload }) => {
state.verification = payload.verification;
},
setReviewPolicy: (state, { payload }) => {
state.exam.reviewPolicy = payload.policy;
},
setApiError: (state, { payload }) => {
state.apiErrorMsg = payload.errorMsg;
},
Expand All @@ -38,7 +45,8 @@ export const examSlice = createSlice({

export const {
setIsLoading, setExamState, getExamId, expireExamAttempt,
setActiveAttempt, setProctoringSettings, setApiError,
setActiveAttempt, setProctoringSettings, setVerificationData,
setReviewPolicy, setApiError,
} = examSlice.actions;

export default examSlice.reducer;
46 changes: 43 additions & 3 deletions src/data/thunks.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ import {
submitAttempt,
pollExamAttempt,
fetchProctoringSettings,
softwareDownloadAttempt,
fetchVerificationStatus,
fetchExamReviewPolicy,
} from './api';
import { isEmpty } from '../helpers';
import {
setIsLoading,
setExamState,
expireExamAttempt,
setActiveAttempt,
setApiError,
setProctoringSettings,
setVerificationData,
setReviewPolicy,
setApiError,
} from './slice';
import { ExamStatus } from '../constants';
import { workerPromiseForEventNames, pingApplication } from './messages/handlers';
Expand Down Expand Up @@ -113,6 +118,8 @@ export function startProctoringExam() {
await updateAttemptAfter(
exam.course_id, exam.content_id, createExamAttempt(exam.id, false, true),
)(dispatch);
const proctoringSettings = await fetchProctoringSettings(exam.id);
dispatch(setProctoringSettings({ proctoringSettings }));
};
}

Expand Down Expand Up @@ -168,7 +175,7 @@ export function stopExam() {
};
}

export function continueExam() {
export function continueExam(noLoading = true) {
return async (dispatch, getState) => {
const { exam } = getState().examState;
const attemptId = exam.attempt.attempt_id;
Expand All @@ -181,7 +188,7 @@ export function continueExam() {
return;
}
await updateAttemptAfter(
exam.course_id, exam.content_id, continueAttempt(attemptId), true,
exam.course_id, exam.content_id, continueAttempt(attemptId), noLoading,
)(dispatch);
};
}
Expand Down Expand Up @@ -258,3 +265,36 @@ export function pingAttempt(timeoutInSeconds, workerUrl) {
));
};
}

export function startProctoringSoftwareDownload() {
return async (dispatch, getState) => {
const { exam } = getState().examState;
const attemptId = exam.attempt.attempt_id;
if (!attemptId) {
logError('Failed to start downloading proctoring software. No attempt id.');
return;
}
await updateAttemptAfter(
exam.course_id, exam.content_id, softwareDownloadAttempt(attemptId),
)(dispatch);
};
}

export function getVerificationData() {
return async (dispatch) => {
const data = await fetchVerificationStatus();
dispatch(setVerificationData({ verification: data }));
};
}

export function getExamReviewPolicy() {
return async (dispatch, getState) => {
const { exam } = getState().examState;
if (!exam.id) {
logError('Failed to fetch exam review policy. No exam id.');
return;
}
const data = await fetchExamReviewPolicy(exam.id);
dispatch(setReviewPolicy({ policy: data.review_policy }));
};
}
1 change: 1 addition & 0 deletions src/exam/ExamWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ExamWrapper = ({ children, ...props }) => {
const loadInitialData = async () => {
await state.getExamAttemptsData(courseId, sequence.id);
state.getProctoringSettings();
state.getVerificationData();
};

useEffect(() => {
Expand Down
4 changes: 4 additions & 0 deletions src/exam/ExamWrapper.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ store.getState = () => ({
examState: {
isLoading: false,
activeAttempt: null,
verification: {
status: 'none',
can_verify: true,
},
exam: {
time_limit_mins: 30,
attempt: {},
Expand Down
8 changes: 8 additions & 0 deletions src/instructions/Instructions.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ describe('SequenceExamWrapper', () => {
examState: {
isLoading: false,
activeAttempt: null,
verification: {
status: 'none',
can_verify: true,
},
exam: {
time_limit_mins: 30,
attempt: {},
Expand All @@ -43,6 +47,10 @@ describe('SequenceExamWrapper', () => {
store.getState = () => ({
examState: {
isLoading: false,
verification: {
status: 'none',
can_verify: true,
},
activeAttempt: {
attempt_status: 'started',
},
Expand Down
24 changes: 21 additions & 3 deletions src/instructions/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,40 @@ import {
SubmittedProctoredExamInstructions,
VerifiedProctoredExamInstructions,
RejectedProctoredExamInstructions,
DownloadSoftwareProctoredExamInstructions,
ReadyToStartProctoredExamInstructions,
} from './proctored_exam';
import { isEmpty } from '../helpers';
import { ExamStatus } from '../constants';
import { ExamStatus, VerificationStatus } from '../constants';
import ExamStateContext from '../context';

const Instructions = ({ children }) => {
const state = useContext(ExamStateContext);
const { attempt, is_proctored: isProctored } = state.exam;
const { exam, verification } = state;
const { attempt, is_proctored: isProctored } = exam || {};
let verificationStatus = verification.status || '';
const { verification_url: verificationUrl } = attempt || {};

// The API does not explicitly return 'expired' status, so we have to check manually.
// expires attribute is returned only for approved status, so it is safe to do this
// (meaning we won't override 'must_reverify' status for example)
if (verification.expires && new Date() > new Date(verification.expires)) {
verificationStatus = VerificationStatus.EXPIRED;
}

switch (true) {
case isEmpty(attempt):
return isProctored
? <EntranceProctoredExamInstructions />
: <StartExamInstructions />;
case attempt.attempt_status === ExamStatus.CREATED:
return <VerificationProctoredExamInstructions />;
return verificationStatus === VerificationStatus.APPROVED
? <DownloadSoftwareProctoredExamInstructions />
: <VerificationProctoredExamInstructions status={verificationStatus} verificationUrl={verificationUrl} />;
case attempt.attempt_status === ExamStatus.DOWNLOAD_SOFTWARE_CLICKED:
return <DownloadSoftwareProctoredExamInstructions />;
case attempt.attempt_status === ExamStatus.READY_TO_START:
return <ReadyToStartProctoredExamInstructions />;
case attempt.attempt_status === ExamStatus.READY_TO_SUBMIT:
return isProctored
? <SubmitProctoredExamInstructions />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useContext } from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, Container } from '@edx/paragon';
import ExamStateContext from '../../context';
import Footer from './Footer';

const EntranceProctoredExamInstructions = () => {
const state = useContext(ExamStateContext);
Expand Down Expand Up @@ -54,19 +55,7 @@ const EntranceProctoredExamInstructions = () => {
</Button>
</p>
</Container>

<div className="footer-sequence">
<Button
data-testid="request-exam-time-button"
variant="link"
onClick={() => {}}
>
<FormattedMessage
id="exam.startExamInstructions.footerButton"
defaultMessage="About Proctored Exams"
/>
</Button>
</div>
<Footer />
</div>
);
};
Expand Down
31 changes: 31 additions & 0 deletions src/instructions/proctored_exam/Footer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useContext } from 'react';
import { Button } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import ExamStateContext from '../../context';

const Footer = () => {
const state = useContext(ExamStateContext);
const { proctoringSettings } = state;
const { link_urls: linkUrls } = proctoringSettings;
const faqUrl = linkUrls && linkUrls.faq;

return (
<div className="footer-sequence">
{faqUrl && (
<Button
data-testid="request-exam-time-button"
variant="link"
href={faqUrl}
target="_blank"
>
<FormattedMessage
id="exam.startExamInstructions.footerButton"
defaultMessage="About Proctored Exams"
/>
</Button>
)}
</div>
);
};

export default Footer;
Loading