diff --git a/.husky/pre-push b/.husky/pre-push
deleted file mode 100755
index 20d0d06e5..000000000
--- a/.husky/pre-push
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-. "$(dirname "$0")/_/husky.sh"
-
-npm run lint
diff --git a/src/containers/ReviewModal/ReviewContent.jsx b/src/containers/ReviewModal/ReviewContent.jsx
index 27e09717d..047b2dd05 100644
--- a/src/containers/ReviewModal/ReviewContent.jsx
+++ b/src/containers/ReviewModal/ReviewContent.jsx
@@ -7,6 +7,7 @@ import { Col, Row } from '@openedx/paragon';
import { selectors } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
+import TurnitinDisplay from 'containers/TurnitinDisplay';
import ResponseDisplay from 'containers/ResponseDisplay';
import Rubric from 'containers/Rubric';
import ReviewErrors from './ReviewErrors';
@@ -17,6 +18,11 @@ import ReviewErrors from './ReviewErrors';
export const ReviewContent = ({ isFailed, isLoaded, showRubric }) => (isLoaded || isFailed) && (
+
+
+
+
+
{isLoaded && (
diff --git a/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap
index 4a57d2bfd..f1008157e 100644
--- a/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap
+++ b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap
@@ -7,6 +7,13 @@ exports[`ReviewContent component component render tests snapshot: failed, showRu
+
+
+
+
+
@@ -19,6 +26,13 @@ exports[`ReviewContent component component render tests snapshot: hide rubric 1`
+
+
+
+
+
+
+
+
+
+
(
+ {value.split('.')?.shift()}
+);
+
+FileNameCell.propTypes = {
+ value: PropTypes.string.isRequired,
+};
+
+export default FileNameCell;
diff --git a/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx b/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx
new file mode 100644
index 000000000..c20af8b4c
--- /dev/null
+++ b/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Hyperlink } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+
+export const HyperlinkCell = ({ value }) => {
+ const intl = useIntl();
+ return (
+
+ {intl.formatMessage(messages.buttonViewerURLTitle)}
+
+ );
+};
+HyperlinkCell.propTypes = {
+ value: PropTypes.string.isRequired,
+};
+
+export default HyperlinkCell;
diff --git a/src/containers/TurnitinDisplay/components/messages.js b/src/containers/TurnitinDisplay/components/messages.js
new file mode 100644
index 000000000..3a6b1d698
--- /dev/null
+++ b/src/containers/TurnitinDisplay/components/messages.js
@@ -0,0 +1,41 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ fileNameTableHeader: {
+ id: 'ora-grading.TurnitinDisplay.FileNameCell.fileNameTitle',
+ defaultMessage: 'Responses',
+ description: 'Title for files attached to the submission',
+ },
+ URLTableHeader: {
+ id: 'ora-grading.TurnitinDisplay.FileNameCell.tableNameHeader',
+ defaultMessage: 'URL',
+ description: 'Title for the file name column in the table',
+ },
+ buttonViewerURLTitle: {
+ id: 'ora-grading.TurnitinDisplay.ButtonCell.URLButtonCellTitle',
+ defaultMessage: 'View in Turnitin',
+ description: 'Title for the button that opens the viewer in a new tab',
+ },
+ similarityReportsTitle: {
+ id: 'ora-grading.TurnitinDisplay.SimilarityReportsTitle',
+ defaultMessage: 'Turnitin Similarity Reports',
+ description: 'Title for the Turnitin Similarity Reports section',
+ },
+ noSimilarityReports: {
+ id: 'ora-grading.TurnitinDisplay.NoSimilarityReports',
+ defaultMessage: 'No Turnitin Similarity Reports to show',
+ description: 'Message to display when there are no Turnitin Similarity Reports to show',
+ },
+ viewerURLExpired: {
+ id: 'ora-grading.TurnitinDisplay.ViewerURLExpired',
+ defaultMessage: 'The Similarity Report URLs have a very short lifespan (less than 1 minute) after which it will no longer be valid. Once a user has been redirected to this URL, they will be given a session that will last for 1 hour. When expired, please refresh the page to get a new URL.',
+ description: 'Message to display when the viewer URL has expired',
+ },
+ viewerURLExpiredTitle: {
+ id: 'ora-grading.TurnitinDisplay.ViewerURLExpiredTitle',
+ defaultMessage: 'URLs expire quickly',
+ description: 'Title for the warning message when the viewer URL has expired',
+ },
+});
+
+export default messages;
diff --git a/src/containers/TurnitinDisplay/index.jsx b/src/containers/TurnitinDisplay/index.jsx
new file mode 100644
index 000000000..4d53e8300
--- /dev/null
+++ b/src/containers/TurnitinDisplay/index.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+
+import {
+ Card,
+ Collapsible,
+ Icon,
+ DataTable,
+ Alert,
+} from '@openedx/paragon';
+import {
+ ArrowDropDown,
+ ArrowDropUp,
+ WarningFilled,
+} from '@openedx/paragon/icons';
+import { selectors } from 'data/redux';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import messages from './components/messages';
+
+import FileNameCell from './components/FileNameCell';
+import HyperlinkCell from './components/HyperlinkCell';
+
+/**
+ *
+ */
+export const TurnitinDisplay = ({ viewers, intl }) => {
+ const [isWarningOpen, setIsWarningOpen] = useState(true);
+ return (
+
+ {viewers.length ? (
+ <>
+
+
+ {intl.formatMessage(messages.similarityReportsTitle)}
+
+
+
+
+
+
+
+
+ setIsWarningOpen(false)}
+ >
+
+ {intl.formatMessage(messages.viewerURLExpiredTitle)}
+
+
+ {intl.formatMessage(messages.viewerURLExpired)}
+
+
+
+
+
+
+
+
+
+ {}
+ >
+ ) : (
+
+
+
{intl.formatMessage(messages.noSimilarityReports)}
+
+ )}
+
+ );
+};
+
+TurnitinDisplay.defaultProps = {
+ viewers: [],
+};
+
+TurnitinDisplay.propTypes = {
+ viewers: PropTypes.arrayOf(
+ PropTypes.shape({
+ viewer_url: PropTypes.string.isRequired,
+ }),
+ ),
+ // injected
+ intl: intlShape.isRequired,
+};
+
+export const mapStateToProps = (state) => ({
+ viewers: selectors.grading.selected.turnitinViewers(state),
+});
+
+export const mapDispatchToProps = {};
+
+export default injectIntl(
+ connect(mapStateToProps, mapDispatchToProps)(TurnitinDisplay),
+);
diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js
index eaa4e90ac..470164cf2 100644
--- a/src/data/constants/requests.js
+++ b/src/data/constants/requests.js
@@ -17,6 +17,7 @@ export const RequestKeys = StrictDict({
prefetchPrev: 'prefetchPrev',
setLock: 'setLock',
submitGrade: 'submitGrade',
+ fetchTurnitinViewers: 'fetchTurnitinViewers',
});
export const ErrorCodes = StrictDict({
diff --git a/src/data/redux/grading/reducer.js b/src/data/redux/grading/reducer.js
index ee5ff0f5e..0efb9af22 100644
--- a/src/data/redux/grading/reducer.js
+++ b/src/data/redux/grading/reducer.js
@@ -199,6 +199,10 @@ const grading = createSlice({
},
};
},
+ loadTurnitinViewers: (state, { payload }) => ({
+ ...state,
+ current: { ...state.current, turnitinViewers: payload },
+ }),
},
});
diff --git a/src/data/redux/grading/selectors/selected.js b/src/data/redux/grading/selectors/selected.js
index 5e5113fde..ee4aeb79a 100644
--- a/src/data/redux/grading/selectors/selected.js
+++ b/src/data/redux/grading/selectors/selected.js
@@ -37,6 +37,11 @@ selected.submissionUUID = createSelector(
*/
selected.gradeStatus = createSelector([simpleSelectors.current], (current) => current.gradeStatus);
+selected.turnitinViewers = createSelector(
+ [simpleSelectors.current],
+ (current) => current.turnitinViewers,
+);
+
/**
* Returns the lock status for the selected submission
* @return {string} lock status
diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js
index 500ee4bb3..b586946fb 100644
--- a/src/data/redux/requests/reducer.js
+++ b/src/data/redux/requests/reducer.js
@@ -13,6 +13,7 @@ const initialState = {
[RequestKeys.prefetchNext]: { status: RequestStates.inactive },
[RequestKeys.prefetchPrev]: { status: RequestStates.inactive },
[RequestKeys.submitGrade]: { status: RequestStates.inactive },
+ [RequestKeys.fetchTurnitinViewers]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars
diff --git a/src/data/redux/thunkActions/grading.js b/src/data/redux/thunkActions/grading.js
index d26dae918..98c096a9a 100644
--- a/src/data/redux/thunkActions/grading.js
+++ b/src/data/redux/thunkActions/grading.js
@@ -39,6 +39,7 @@ export const loadSelectionForReview = (submissionUUIDs) => (dispatch) => {
dispatch(actions.grading.updateSelection(submissionUUIDs));
dispatch(actions.app.setShowReview(true));
dispatch(module.loadSubmission());
+ dispatch(module.loadTurnitinViewers());
}
};
@@ -60,6 +61,22 @@ export const loadSubmission = () => (dispatch, getState) => {
}));
};
+export const loadTurnitinViewers = () => (dispatch, getState) => {
+ const submissionUUID = selectors.grading.selected.submissionUUID(getState());
+ dispatch(requests.fetchTurnitinViewers({
+ submissionUUID,
+ courseId: selectors.app.courseId(getState()),
+ onSuccess: (response) => {
+ dispatch(actions.grading.loadTurnitinViewers(response));
+ },
+ onFailure: (error) => {
+ if (error.response.status === ErrorStatuses.notFound) {
+ dispatch(actions.grading.loadTurnitinViewers([]));
+ }
+ },
+ }));
+};
+
/**
* Start grading the current submission.
* Attempts to lock the submission, and on a success, sets the local grading state to
diff --git a/src/data/redux/thunkActions/requests.js b/src/data/redux/thunkActions/requests.js
index f1f9f68ca..5b6b23f4f 100644
--- a/src/data/redux/thunkActions/requests.js
+++ b/src/data/redux/thunkActions/requests.js
@@ -78,6 +78,14 @@ export const fetchSubmission = ({ submissionUUID, ...rest }) => (dispatch) => {
}));
};
+export const fetchTurnitinViewers = ({ submissionUUID, courseId, ...rest }) => (dispatch) => {
+ dispatch(module.networkRequest({
+ requestKey: RequestKeys.fetchTurnitinViewers,
+ promise: api.fetchTurnitinViewers(submissionUUID, courseId),
+ ...rest,
+ }));
+};
+
/**
* Tracked setLock api method. tracked to the `setLock` request key.
* @param {string} submissionUUID - target submission id
@@ -125,4 +133,5 @@ export default StrictDict({
fetchSubmissionStatus,
setLock,
submitGrade,
+ fetchTurnitinViewers,
});
diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js
index bc4c41ce3..39bae7c47 100644
--- a/src/data/services/lms/api.js
+++ b/src/data/services/lms/api.js
@@ -54,6 +54,17 @@ const fetchSubmission = (submissionUUID) => get(
}),
).then(response => response.data);
+/**
+ * get(':courseId/api/v1/viewer-url/:submissionUUID')
+ * @return {
+ * url:
+ * file_name:
+ * }
+ */
+const fetchTurnitinViewers = (submissionUUID, courseId) => get(
+ stringifyUrl(`${urls.fetchTurnitinViewersUrl()}/${courseId}/api/v1/viewer-url/${submissionUUID}/`),
+).then(response => response.data);
+
/**
* get('/api/submission/files', { oraLocation, submissionUUID })
* @return {
@@ -139,4 +150,5 @@ export default StrictDict({
updateGrade,
unlockSubmission,
batchUnlockSubmissions,
+ fetchTurnitinViewers,
});
diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js
index de95808bf..d4b510959 100644
--- a/src/data/services/lms/urls.js
+++ b/src/data/services/lms/urls.js
@@ -13,6 +13,7 @@ const fetchSubmissionStatusUrl = () => `${baseEsgUrl()}submission/status`;
const fetchSubmissionLockUrl = () => `${baseEsgUrl()}submission/lock`;
const batchUnlockSubmissionsUrl = () => `${baseEsgUrl()}submission/batch/unlock`;
const updateSubmissionGradeUrl = () => `${baseEsgUrl()}submission/grade`;
+const fetchTurnitinViewersUrl = () => `${baseUrl()}/platform-plugin-turnitin`;
const course = (courseId) => `${baseUrl()}/courses/${courseId}`;
@@ -30,6 +31,7 @@ export default StrictDict({
fetchSubmissionLockUrl,
batchUnlockSubmissionsUrl,
updateSubmissionGradeUrl,
+ fetchTurnitinViewersUrl,
baseUrl,
course,
openResponse,