diff --git a/src/commons/achievement/utils/InsertFakeAchievements.ts b/src/commons/achievement/utils/InsertFakeAchievements.ts index 81dbef4901..a570f650a2 100644 --- a/src/commons/achievement/utils/InsertFakeAchievements.ts +++ b/src/commons/achievement/utils/InsertFakeAchievements.ts @@ -69,7 +69,7 @@ function insertFakeAchievements( requiredCompletionFrac: 0 } }, - assessmentOverview.gradingStatus === 'graded' + assessmentOverview.gradingStatus === 'published' ); } diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 3f86c93269..a94681efa8 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -43,6 +43,7 @@ import { LOGOUT_GOOGLE, NotificationConfiguration, NotificationPreference, + PUBLISH_GRADES, REAUTOGRADE_ANSWER, REAUTOGRADE_SUBMISSION, REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN, @@ -64,6 +65,7 @@ import { SUBMIT_GRADING_AND_CONTINUE, TimeOption, Tokens, + UNPUBLISH_GRADES, UNSUBMIT_SUBMISSION, UPDATE_ALL_USER_XP, UPDATE_ASSESSMENT, @@ -230,6 +232,16 @@ export const unsubmitSubmission = (submissionId: number) => submissionId }); +export const unpublishGrades = (submissionId: number) => + action(UNPUBLISH_GRADES, { + submissionId + }); + +export const publishGrades = (submissionId: number) => + action(PUBLISH_GRADES, { + submissionId + }); + /** * Notification actions */ diff --git a/src/commons/application/actions/__tests__/SessionActions.ts b/src/commons/application/actions/__tests__/SessionActions.ts index b4fa8cba57..ce7a505e3e 100644 --- a/src/commons/application/actions/__tests__/SessionActions.ts +++ b/src/commons/application/actions/__tests__/SessionActions.ts @@ -523,7 +523,8 @@ test('updateGradingOverviews generates correct action object', () => { groupName: 'group', gradingStatus: 'excluded', questionCount: 6, - gradedCount: 0 + gradedCount: 0, + isGradingPublished: true } ]; diff --git a/src/commons/application/reducers/__tests__/SessionReducer.ts b/src/commons/application/reducers/__tests__/SessionReducer.ts index 3cafa4cb5f..5f4cb700fd 100644 --- a/src/commons/application/reducers/__tests__/SessionReducer.ts +++ b/src/commons/application/reducers/__tests__/SessionReducer.ts @@ -487,7 +487,8 @@ const gradingOverviewTest1: GradingOverview[] = [ groupName: 'group', gradingStatus: 'excluded', questionCount: 0, - gradedCount: 6 + gradedCount: 6, + isGradingPublished: true } ]; @@ -508,7 +509,8 @@ const gradingOverviewTest2: GradingOverview[] = [ groupName: 'another group', gradingStatus: 'excluded', questionCount: 6, - gradedCount: 0 + gradedCount: 0, + isGradingPublished: false } ]; diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index f1b5d12681..ea32b63a63 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -27,6 +27,7 @@ export const LOGIN = 'LOGIN'; export const LOGOUT_GOOGLE = 'LOGOUT_GOOGLE'; export const LOGIN_GITHUB = 'LOGIN_GITHUB'; export const LOGOUT_GITHUB = 'LOGOUT_GITHUB'; +export const PUBLISH_GRADES = 'PUBLISH_GRADES'; export const SET_TOKENS = 'SET_TOKENS'; export const SET_USER = 'SET_USER'; export const SET_COURSE_CONFIGURATION = 'SET_COURSE_CONFIGURATION'; @@ -45,6 +46,7 @@ export const REAUTOGRADE_SUBMISSION = 'REAUTOGRADE_SUBMISSION'; export const REAUTOGRADE_ANSWER = 'REAUTOGRADE_ANSWER'; export const REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN = 'REMOVE_GITHUB_OCTOKIT_OBJECT_AND_ACCESS_TOKEN'; +export const UNPUBLISH_GRADES = 'UNPUBLISH_GRADES'; export const UNSUBMIT_SUBMISSION = 'UNSUBMIT_SUBMISSION'; export const UPDATE_ASSESSMENT_OVERVIEWS = 'UPDATE_ASSESSMENT_OVERVIEWS'; export const UPDATE_TOTAL_XP = 'UPDATE_TOTAL_XP'; diff --git a/src/commons/assessment/Assessment.tsx b/src/commons/assessment/Assessment.tsx index 6b211a4d5c..06cf57da1d 100644 --- a/src/commons/assessment/Assessment.tsx +++ b/src/commons/assessment/Assessment.tsx @@ -174,7 +174,7 @@ const Assessment: React.FC = props => { renderGradingStatus: boolean ) => { const showGrade = - overview.gradingStatus === 'graded' || !props.assessmentConfiguration.isManuallyGraded; + overview.gradingStatus === 'published' || !props.assessmentConfiguration.isManuallyGraded; const ratio = isMobileBreakpoint ? 5 : 3; return (
@@ -414,10 +414,15 @@ const makeGradingStatus = (gradingStatus: string) => { let tooltip: string; switch (gradingStatus) { - case GradingStatuses.graded: + case GradingStatuses.published: iconName = IconNames.TICK; intent = Intent.SUCCESS; - tooltip = 'Fully graded'; + tooltip = 'Grade published'; + break; + case GradingStatuses.graded: + iconName = IconNames.TIME; + intent = Intent.WARNING; + tooltip = 'Grading in progress'; break; case GradingStatuses.grading: iconName = IconNames.TIME; diff --git a/src/commons/assessment/AssessmentTypes.ts b/src/commons/assessment/AssessmentTypes.ts index 0169f52ad0..3ed7fcc174 100644 --- a/src/commons/assessment/AssessmentTypes.ts +++ b/src/commons/assessment/AssessmentTypes.ts @@ -20,6 +20,7 @@ export type AssessmentWorkspaceParams = { export enum GradingStatuses { excluded = 'excluded', + published = 'published', graded = 'graded', grading = 'grading', none = 'none' diff --git a/src/commons/mocks/GradingMocks.ts b/src/commons/mocks/GradingMocks.ts index 9f1849b858..2fd0776683 100644 --- a/src/commons/mocks/GradingMocks.ts +++ b/src/commons/mocks/GradingMocks.ts @@ -22,7 +22,8 @@ export const mockGradingOverviews: GradingOverview[] = [ groupName: '1D', gradingStatus: 'graded', questionCount: 6, - gradedCount: 6 + gradedCount: 6, + isGradingPublished: false }, { xpAdjustment: -2, @@ -40,7 +41,8 @@ export const mockGradingOverviews: GradingOverview[] = [ groupName: '1F', gradingStatus: 'grading', questionCount: 6, - gradedCount: 2 + gradedCount: 2, + isGradingPublished: false }, { xpAdjustment: 4, @@ -58,7 +60,8 @@ export const mockGradingOverviews: GradingOverview[] = [ groupName: '1F', gradingStatus: 'none', questionCount: 6, - gradedCount: 0 + gradedCount: 0, + isGradingPublished: false } ]; diff --git a/src/commons/notificationBadge/NotificationBadge.tsx b/src/commons/notificationBadge/NotificationBadge.tsx index 764ec1c601..45372129a0 100644 --- a/src/commons/notificationBadge/NotificationBadge.tsx +++ b/src/commons/notificationBadge/NotificationBadge.tsx @@ -85,8 +85,10 @@ const makeNotificationMessage = (type: NotificationType) => { return 'This submission is new.'; case NotificationTypes.unsubmitted: return 'This assessment has been unsubmitted.'; + case NotificationTypes.unpublished_grading: + return 'Grades have been hidden for this assessment.'; case NotificationTypes.graded: - return 'This assessment has been manually graded.'; + return 'Grades have been published for this assessment.'; case NotificationTypes.new_message: return 'There are new messages.'; default: diff --git a/src/commons/notificationBadge/NotificationBadgeTypes.ts b/src/commons/notificationBadge/NotificationBadgeTypes.ts index e7ed326036..c24772b5e7 100644 --- a/src/commons/notificationBadge/NotificationBadgeTypes.ts +++ b/src/commons/notificationBadge/NotificationBadgeTypes.ts @@ -14,6 +14,7 @@ export enum NotificationTypes { graded = 'graded', submitted = 'submitted', unsubmitted = 'unsubmitted', + unpublished_grading = 'unpublished_grading', new_message = 'new_message' } diff --git a/src/commons/profile/Profile.tsx b/src/commons/profile/Profile.tsx index 6fd1dda073..83b298622c 100644 --- a/src/commons/profile/Profile.tsx +++ b/src/commons/profile/Profile.tsx @@ -129,7 +129,7 @@ const Profile: React.FC = props => { .filter( item => item.status === AssessmentStatuses.submitted && - (item.gradingStatus === GradingStatuses.graded || + (item.gradingStatus === GradingStatuses.published || item.gradingStatus === GradingStatuses.excluded) ) .map((assessment, index) => { diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index 461ee6837f..dcbe619465 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -48,6 +48,7 @@ import { FETCH_TOTAL_XP_ADMIN, FETCH_USER_AND_COURSE, NotificationConfiguration, + PUBLISH_GRADES, REAUTOGRADE_ANSWER, REAUTOGRADE_SUBMISSION, SUBMIT_ANSWER, @@ -55,6 +56,7 @@ import { SUBMIT_GRADING_AND_CONTINUE, TimeOption, Tokens, + UNPUBLISH_GRADES, UNSUBMIT_SUBMISSION, UPDATE_ASSESSMENT_CONFIGS, UPDATE_COURSE_CONFIG, @@ -110,9 +112,11 @@ import { postAuth, postCreateCourse, postGrading, + postPublishGrades, postReautogradeAnswer, postReautogradeSubmission, postSourcecast, + postUnpublishGrades, postUnsubmit, putAssessmentConfigs, putCourseConfig, @@ -455,6 +459,43 @@ function* BackendSaga(): SagaIterator { } ); + /** + * Publishes the grades for the submission and refreshes the grading overviews to reflect backend + * changes to the grading published status. + */ + yield takeEvery( + PUBLISH_GRADES, + function* (action: ReturnType): any { + const tokens: Tokens = yield selectTokens(); + const { submissionId } = action.payload; + + const resp: Response | null = yield postPublishGrades(submissionId, tokens); + if (!resp || !resp.ok) { + return yield handleResponseError(resp); + } + + yield call(showSuccessMessage, 'Publish successful', 1000); + } + ); + + /** + * Unpublished the grades for the submission and refreshes the whole page automatically. + */ + yield takeEvery( + UNPUBLISH_GRADES, + function* (action: ReturnType): any { + const tokens: Tokens = yield selectTokens(); + const { submissionId } = action.payload; + + const resp: Response | null = yield postUnpublishGrades(submissionId, tokens); + if (!resp || !resp.ok) { + return yield handleResponseError(resp); + } + + yield call(showSuccessMessage, 'Unpublish successful', 1000); + } + ); + const sendGrade = function* ( action: | ReturnType diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 630ace8aa9..167d7acf51 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -410,7 +410,8 @@ export const getAssessmentOverviews = async ( overview.isManuallyGraded, overview.status, overview.gradedCount, - overview.questionCount + overview.questionCount, + overview.isGradingPublished ); delete overview.gradedCount; delete overview.questionCount; @@ -476,7 +477,8 @@ export const getUserAssessmentOverviews = async ( overview.isManuallyGraded, overview.status, overview.gradedCount, - overview.questionCount + overview.questionCount, + overview.isGradingPublished ); delete overview.gradedCount; delete overview.questionCount; @@ -631,6 +633,7 @@ export const getGradingOverviews = async ( gradingStatus: 'none', questionCount: overview.assessment.questionCount, gradedCount: overview.gradedCount, + isGradingPublished: overview.isGradingPublished, // XP initialXp: overview.xp, xpAdjustment: overview.xpAdjustment, @@ -642,7 +645,8 @@ export const getGradingOverviews = async ( overview.assessment.isManuallyGraded, gradingOverview.submissionStatus, gradingOverview.gradedCount, - gradingOverview.questionCount + gradingOverview.questionCount, + gradingOverview.isGradingPublished ); return gradingOverview; }) @@ -777,6 +781,40 @@ export const postUnsubmit = async ( return resp; }; +/** + * POST /courses/{courseId}/admin/grading/{submissionId}/unpublish_grades + */ +export const postUnpublishGrades = async ( + submissionId: number, + tokens: Tokens +): Promise => { + const resp = await request( + `${courseId()}/admin/grading/${submissionId}/unpublish_grades`, + 'POST', + { + ...tokens, + noHeaderAccept: true + } + ); + + return resp; +}; + +/** + * POST /courses/{courseId}/admin/grading/{submissionId}/publish_grades + */ +export const postPublishGrades = async ( + submissionId: number, + tokens: Tokens +): Promise => { + const resp = await request(`${courseId()}/admin/grading/${submissionId}/publish_grades`, 'POST', { + ...tokens, + noHeaderAccept: true + }); + + return resp; +}; + /** * GET /courses/{courseId}/notifications */ @@ -1356,16 +1394,19 @@ const computeGradingStatus = ( isManuallyGraded: boolean, submissionStatus: any, numGraded: number, - numQuestions: number + numQuestions: number, + isGradingPublished: boolean ): GradingStatus => // isGraded refers to whether the assessment type is graded or not, as specified in // the respective assessment configuration isManuallyGraded && submissionStatus === 'submitted' ? numGraded === 0 ? 'none' - : numGraded === numQuestions - ? 'graded' - : 'grading' + : numGraded < numQuestions + ? 'grading' + : isGradingPublished + ? 'published' + : 'graded' : 'excluded'; const courseId: () => string = () => { diff --git a/src/features/grading/GradingTypes.ts b/src/features/grading/GradingTypes.ts index 7154e58b58..1a3cd04cb5 100644 --- a/src/features/grading/GradingTypes.ts +++ b/src/features/grading/GradingTypes.ts @@ -30,6 +30,7 @@ export type GradingOverview = { gradingStatus: GradingStatus; questionCount: number; gradedCount: number; + isGradingPublished: boolean; }; export type GradingOverviewWithNotifications = { diff --git a/src/pages/academy/grading/subcomponents/GradingActions.tsx b/src/pages/academy/grading/subcomponents/GradingActions.tsx index 93e2400494..1dcbc0d175 100644 --- a/src/pages/academy/grading/subcomponents/GradingActions.tsx +++ b/src/pages/academy/grading/subcomponents/GradingActions.tsx @@ -4,19 +4,33 @@ import { Flex, Icon } from '@tremor/react'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; import { + publishGrades, reautogradeSubmission, + unpublishGrades, unsubmitSubmission } from 'src/commons/application/actions/SessionActions'; +import { GradingStatus } from 'src/commons/assessment/AssessmentTypes'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { useTypedSelector } from 'src/commons/utils/Hooks'; type GradingActionsProps = { submissionId: number; + isGradingPublished: boolean; + gradingStatus: GradingStatus; + submissionStatus: string; }; -const GradingActions: React.FC = ({ submissionId }) => { +const GradingActions: React.FC = ({ + submissionId, + isGradingPublished, + gradingStatus, + submissionStatus +}) => { const dispatch = useDispatch(); const courseId = useTypedSelector(store => store.session.courseId); + const isFullyGraded = + gradingStatus === 'graded' || + (gradingStatus === 'excluded' && submissionStatus === 'submitted'); const handleReautogradeClick = async () => { const confirm = await showSimpleConfirmDialog({ @@ -42,9 +56,60 @@ const GradingActions: React.FC = ({ submissionId }) => { }); if (confirm) { dispatch(unsubmitSubmission(submissionId)); + dispatch(unpublishGrades(submissionId)); } }; + const handlePublishClick = async () => { + const confirm = await showSimpleConfirmDialog({ + contents: 'Are you sure you want to publish? Student will be able to see their grade.', + positiveIntent: 'danger', + positiveLabel: 'Publish' + }); + if (confirm) { + dispatch(publishGrades(submissionId)); + } + }; + + const handleUnpublishClick = async () => { + const confirm = await showSimpleConfirmDialog({ + contents: "Are you sure you want to unpublish? Student's grade will be hidden.", + positiveIntent: 'danger', + positiveLabel: 'Unpublish' + }); + if (confirm) { + dispatch(unpublishGrades(submissionId)); + } + }; + + let publishButton; + if (isGradingPublished) { + publishButton = ( + + ); + } else { + publishButton = ( + + ); + } + return ( @@ -62,6 +127,8 @@ const GradingActions: React.FC = ({ submissionId }) => { + + {publishButton} ); }; diff --git a/src/pages/academy/grading/subcomponents/GradingBadges.tsx b/src/pages/academy/grading/subcomponents/GradingBadges.tsx index 31cdb6a161..82cbe42207 100644 --- a/src/pages/academy/grading/subcomponents/GradingBadges.tsx +++ b/src/pages/academy/grading/subcomponents/GradingBadges.tsx @@ -16,7 +16,8 @@ const BADGE_COLORS = { attempted: 'red', // grading status - graded: 'green', + published: 'green', + graded: 'cyan', grading: 'yellow', none: 'red' }; @@ -58,8 +59,10 @@ const GradingStatusBadge: React.FC = ({ status }) => { const badgeIcon = () => ( = ({ submissions, handle submission => !( submission.submissionStatus === 'submitted' && - submission.gradingStatus === GradingStatuses.graded + submission.gradingStatus === GradingStatuses.published ) ); const submissionsData = showGraded ? submissions : ungraded; @@ -53,7 +53,7 @@ const GradingDashboard: React.FC = ({ submissions, handle - + diff --git a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx index 3434cc84aa..11c5e2b223 100644 --- a/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSubmissionsTable.tsx @@ -90,14 +90,30 @@ const columns = [ ); } }), - columnHelper.accessor(({ submissionId }) => ({ submissionId }), { - header: 'Actions', - enableColumnFilter: false, - cell: info => { - const { submissionId } = info.getValue(); - return ; + columnHelper.accessor( + ({ submissionId, isGradingPublished, gradingStatus, submissionStatus }) => ({ + submissionId, + isGradingPublished, + gradingStatus, + submissionStatus + }), + { + header: 'Actions', + enableColumnFilter: false, + cell: info => { + const { submissionId, isGradingPublished, gradingStatus, submissionStatus } = + info.getValue(); + return ( + + ); + } } - }) + ) ]; type GradingSubmissionTableProps = { diff --git a/src/pages/academy/grading/subcomponents/GradingSummary.tsx b/src/pages/academy/grading/subcomponents/GradingSummary.tsx index 66a14dbbc4..8bf916765d 100644 --- a/src/pages/academy/grading/subcomponents/GradingSummary.tsx +++ b/src/pages/academy/grading/subcomponents/GradingSummary.tsx @@ -32,8 +32,12 @@ const GradingSummary: React.FC = ({ group, submissions, ass const groupSubmissions = submissions.filter( ({ groupName }) => group === null || groupName === group ); + const unpublished = groupSubmissions.filter( + ({ gradingStatus }) => gradingStatus !== GradingStatuses.published + ); const ungraded = groupSubmissions.filter( - ({ gradingStatus }) => gradingStatus !== GradingStatuses.graded + ({ gradingStatus }) => + gradingStatus !== GradingStatuses.graded && gradingStatus !== GradingStatuses.published ); const ungradedAssessments = [...new Set(ungraded.map(({ assessmentId }) => assessmentId))].reduce( (acc: AssessmentSummary[], assessmentId) => { @@ -53,7 +57,9 @@ const GradingSummary: React.FC = ({ group, submissions, ass const numSubmissions = groupSubmissions.length; const numGraded = numSubmissions - ungraded.length; + const numPublished = numSubmissions - unpublished.length; const percentGraded = Math.round((numGraded / numSubmissions) * 100); + const percentPublished = Math.round((numPublished / numSubmissions) * 100); const numUngradedByAssessment = (assessmentId: number) => { return ungraded.filter(({ assessmentId: id }) => id === assessmentId).length; @@ -62,6 +68,36 @@ const GradingSummary: React.FC = ({ group, submissions, ass return ( <> My gradings + + {numPublished} + / {numSubmissions} published + + + + + + Published + + {numPublished} ({percentPublished}%) + + + + Unpublished + + {numSubmissions - numPublished} ({100 - percentPublished}%) + + + +