diff --git a/app/controllers/course/assessment/submission/submissions_controller.rb b/app/controllers/course/assessment/submission/submissions_controller.rb index 0628bf3ad30..7a03e92183a 100644 --- a/app/controllers/course/assessment/submission/submissions_controller.rb +++ b/app/controllers/course/assessment/submission/submissions_controller.rb @@ -31,6 +31,8 @@ class Course::Assessment::Submission::SubmissionsController < \ staff: 'staff', staff_w_phantom: 'staff_w_phantom' }.freeze + FORCE_SUBMIT_DELAY = 5.minutes + def index authorize!(:view_all_submissions, @assessment) @@ -105,6 +107,20 @@ def generate_live_feedback render json: response_body, status: response_status end + def set_timer_started_at + unless @submission.timer_started_at + @submission.timer_started_at = Time.zone.now + + raise ActiveRecord::Rollback unless @submission.save + + Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. + set(wait_until: @submission.timer_started_at + @assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). + perform_later(@assessment, @submission_id, @submission.creator) + end + + render json: { timerStartedAt: @submission.timer_started_at } + end + # Reload the current answer or reset it, depending on parameters. # current_answer has the most recent copy of the answer. def reload_answer diff --git a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb index e3ea3bc9426..764e4e27906 100644 --- a/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb +++ b/app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb @@ -22,7 +22,7 @@ def perform_tracked(assessment, submission_id, submitter) def force_submit(submission, submitter) User.with_stamper(submitter) do ActiveRecord::Base.transaction do - submission.update!('finalise' => 'true') + submission.update!('finalise' => 'true') if submission.attempting? end end end diff --git a/app/models/course/assessment/assessment_ability.rb b/app/models/course/assessment/assessment_ability.rb index e1263ff9f48..92c97a0576c 100644 --- a/app/models/course/assessment/assessment_ability.rb +++ b/app/models/course/assessment/assessment_ability.rb @@ -73,7 +73,8 @@ def allow_read_material def allow_create_assessment_submission can :create, Course::Assessment::Submission, experience_points_record: { course_user: { user_id: user.id } } - can [:update, :generate_live_feedback], Course::Assessment::Submission, assessment_submission_attempting_hash(user) + can [:update, :generate_live_feedback, :set_timer_started_at], + Course::Assessment::Submission, assessment_submission_attempting_hash(user) end def allow_update_own_assessment_answer diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index 6c96cd4f19f..3e78e33bb62 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -11,11 +11,8 @@ class Course::Assessment::Submission < ApplicationRecord acts_as_experience_points_record - FORCE_SUBMIT_DELAY = 5.minutes - after_save :auto_grade_submission, if: :submitted? after_save :retrieve_codaveri_feedback, if: :submitted? - after_create :create_force_submission_job, if: :attempting? workflow do state :attempting do @@ -235,14 +232,6 @@ def assigned_questions extending(Course::Assessment::QuestionsConcern) end - def create_force_submission_job - return unless assessment.time_limit - - Course::Assessment::Submission::ForceSubmitTimedSubmissionJob. - set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY). - perform_later(assessment, id, creator) - end - # The answers with current_answer flag set to true, filtering out orphaned answers to questions which are no longer # assigned to the submission for randomized assessment. # diff --git a/app/views/course/assessment/submission/submissions/_submission.json.jbuilder b/app/views/course/assessment/submission/submissions/_submission.json.jbuilder index 4c95dc1ce1d..c14e844bc15 100644 --- a/app/views/course/assessment/submission/submissions/_submission.json.jbuilder +++ b/app/views/course/assessment/submission/submissions/_submission.json.jbuilder @@ -24,6 +24,8 @@ json.submission do json.id submission.course_user.id end + json.timerStartedAt submission.timer_started_at if assessment.time_limit + submitter_course_user = submission.creator.course_users.find_by(course: submission.assessment.course) end_at = assessment.time_for(submitter_course_user).end_at bonus_end_at = assessment.time_for(submitter_course_user).bonus_end_at diff --git a/app/views/course/assessment/submission/submissions/index.json.jbuilder b/app/views/course/assessment/submission/submissions/index.json.jbuilder index 8276468ccf1..1a0574b0f29 100644 --- a/app/views/course/assessment/submission/submissions/index.json.jbuilder +++ b/app/views/course/assessment/submission/submissions/index.json.jbuilder @@ -11,6 +11,7 @@ json.assessment do json.filesDownloadable @assessment.files_downloadable? json.csvDownloadable @assessment.csv_downloadable? json.passwordProtected @assessment.session_password_protected? + json.hasTimeLimit @assessment.time_limit json.canViewLogs can? :manage, @assessment json.canPublishGrades can? :publish_grades, @assessment json.canForceSubmit can? :force_submit_assessment_submission, @assessment @@ -36,6 +37,7 @@ json.submissions @course_users do |course_user| json.workflowState submission.workflow_state json.grade submission.grade.to_f json.pointsAwarded submission.current_points_awarded + json.timerStartedAt submission.timer_started_at if @assessment.time_limit json.dateSubmitted submission.submitted_at&.iso8601 json.dateGraded submission.graded_at&.iso8601 json.logCount submission.log_count diff --git a/client/app/api/course/Assessment/Submissions.js b/client/app/api/course/Assessment/Submissions.js index 61c755ec52f..343129a5a46 100644 --- a/client/app/api/course/Assessment/Submissions.js +++ b/client/app/api/course/Assessment/Submissions.js @@ -91,6 +91,12 @@ export default class SubmissionsAPI extends BaseAssessmentAPI { ); } + setTimerStartAt(submissionId) { + return this.client.patch( + `${this.#urlPrefix}/${submissionId}/set_timer_started_at`, + ); + } + reloadAnswer(submissionId, params) { return this.client.post( `${this.#urlPrefix}/${submissionId}/reload_answer`, diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx index 58436fb712b..e02e6f0dc72 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx +++ b/client/app/bundles/course/assessment/components/AssessmentForm/index.tsx @@ -65,7 +65,7 @@ const AssessmentForm = (props: AssessmentFormProps): JSX.Element => { handleSubmit, setError, watch, - formState: { errors, isDirty }, + formState: { errors, isDirty, dirtyFields }, } = useFormValidation(initialValues); const { t } = useTranslation(); diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts b/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts index 284e8bd2e21..a4b9dfac0e5 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts +++ b/client/app/bundles/course/assessment/components/AssessmentForm/translations.ts @@ -150,6 +150,12 @@ const translations = defineMessages({ defaultMessage: 'When enabled, each submission will have its own timer and will automatically be finalised when its timer ends.', }, + changingTimeLimitWarning: { + id: 'course.assessment.AssessmentForm.changingTimeLimitWarning', + defaultMessage: + 'Changing the Time Limit will create inconsistencies for the submissions \ + in progress', + }, gradingMode: { id: 'course.assessment.AssessmentForm.gradingMode', defaultMessage: 'Grading mode', diff --git a/client/app/bundles/course/assessment/submission/actions/index.js b/client/app/bundles/course/assessment/submission/actions/index.js index 24f5db20428..27eca96ed85 100644 --- a/client/app/bundles/course/assessment/submission/actions/index.js +++ b/client/app/bundles/course/assessment/submission/actions/index.js @@ -158,6 +158,28 @@ export function unsubmit(submissionId) { }; } +export function setTimerStartAt(submissionId, setExamNotice, setTimerNotice) { + return (dispatch) => { + dispatch({ type: actionTypes.SET_TIMER_STARTED_AT_REQUEST }); + + return CourseAPI.assessment.submissions + .setTimerStartAt(submissionId) + .then((response) => response.data) + .then((data) => { + dispatch({ + type: actionTypes.SET_TIMER_STARTED_AT_SUCCESS, + payload: data, + }); + setExamNotice(false); + setTimerNotice(false); + }) + .catch(() => { + dispatch({ type: actionTypes.SET_TIMER_STARTED_AT_FAILURE }); + dispatch(setNotification(translations.startTimedExamAssessmentFailed)); + }); + }; +} + export function mark(submissionId, grades, exp) { const payload = { submission: { diff --git a/client/app/bundles/course/assessment/submission/components/WarningDialog.tsx b/client/app/bundles/course/assessment/submission/components/WarningDialog.tsx index 90343b56cff..0e6b97781b8 100644 --- a/client/app/bundles/course/assessment/submission/components/WarningDialog.tsx +++ b/client/app/bundles/course/assessment/submission/components/WarningDialog.tsx @@ -1,4 +1,5 @@ import { FC, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { Button, Dialog, @@ -8,11 +9,12 @@ import { Typography, } from '@mui/material'; -import { useAppSelector } from 'lib/hooks/store'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -import { TIME_LAPSE_NEW_SUBMISSION_MS, workflowStates } from '../constants'; -import { remainingTimeDisplay } from '../pages/SubmissionEditIndex/TimeLimitBanner'; +import { setTimerStartAt } from '../actions'; +import { workflowStates } from '../constants'; +import RemainingTimeTranslations from '../pages/SubmissionEditIndex/components/RemainingTimeTranslation'; import { getAssessment } from '../selectors/assessments'; import { getSubmission } from '../selectors/submissions'; import translations from '../translations'; @@ -23,27 +25,33 @@ const WarningDialog: FC = () => { const assessment = useAppSelector(getAssessment); const submission = useAppSelector(getSubmission); + const dispatch = useAppDispatch(); + const { timeLimit, passwordProtected: isExamMode } = assessment; - const { workflowState, attemptedAt } = submission; + const { workflowState, timerStartedAt } = submission; const isAttempting = workflowState === workflowStates.Attempting; const isTimedMode = isAttempting && !!timeLimit; - const startTime = new Date(attemptedAt).getTime(); - const currentTime = new Date().getTime(); + const isNewSubmission = isTimedMode && !timerStartedAt; - const submissionTimeLimitAt = isTimedMode - ? startTime + timeLimit * 60 * 1000 - : null; + const currentTime = new Date().getTime(); - const isNewSubmission = - currentTime - startTime < TIME_LAPSE_NEW_SUBMISSION_MS; + const submissionTimeLimitAt = + isTimedMode && timerStartedAt + ? new Date(timerStartedAt).getTime() + timeLimit * 60 * 1000 + : null; const [examNotice, setExamNotice] = useState(isExamMode); const [timedNotice, setTimedNotice] = useState(isTimedMode); + const { submissionId } = useParams(); + if (!submissionId) { + return null; + } + const remainingTime = - isTimedMode && submissionTimeLimitAt! > currentTime + isTimedMode && timerStartedAt && submissionTimeLimitAt! > currentTime ? submissionTimeLimitAt! - currentTime : null; @@ -53,28 +61,41 @@ const WarningDialog: FC = () => { if (examNotice && timedNotice) { dialogTitle = t(translations.timedExamDialogTitle, { isNewSubmission, - remainingTime: remainingTimeDisplay( - isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0, + remainingTime: ( + ), - stillSomeTimeRemaining: !!remainingTime, - }); - dialogMessage = t(translations.timedExamDialogMessage, { - stillSomeTimeRemaining: !!remainingTime, + + stillSomeTimeRemaining: isNewSubmission || !!remainingTime, }); + dialogMessage = isNewSubmission + ? t(translations.timedExamStartDialogMessage) + : t(translations.timedExamDialogMessage, { + stillSomeTimeRemaining: !!remainingTime, + }); } else if (examNotice) { dialogTitle = t(translations.examDialogTitle); dialogMessage = t(translations.examDialogMessage); } else if (timedNotice) { dialogTitle = t(translations.timedAssessmentDialogTitle, { isNewSubmission, - remainingTime: remainingTimeDisplay( - isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0, + remainingTime: ( + ), - stillSomeTimeRemaining: !!remainingTime, - }); - dialogMessage = t(translations.timedAssessmentDialogMessage, { - stillSomeTimeRemaining: !!remainingTime, + stillSomeTimeRemaining: isNewSubmission || !!remainingTime, }); + dialogMessage = isNewSubmission + ? t(translations.timedAssessmentStartDialogMessage) + : t(translations.timedAssessmentDialogMessage, { + stillSomeTimeRemaining: !!remainingTime, + }); } return ( @@ -89,11 +110,17 @@ const WarningDialog: FC = () => { diff --git a/client/app/bundles/course/assessment/submission/constants.ts b/client/app/bundles/course/assessment/submission/constants.ts index 9c4c2f44e40..b5f509869a1 100644 --- a/client/app/bundles/course/assessment/submission/constants.ts +++ b/client/app/bundles/course/assessment/submission/constants.ts @@ -18,10 +18,6 @@ export const MEGABYTES_TO_BYTES = 1024 * 1024; export const BUFFER_TIME_TO_FORCE_SUBMIT_MS = 5 * 1000; -// calculate how long has it passed since the student starts the submission -// to still be considered a "newly created" submission -export const TIME_LAPSE_NEW_SUBMISSION_MS = 10 * 1000; - export const POLL_INTERVAL_MILLISECONDS = 2000; export const workflowStates = { @@ -303,6 +299,11 @@ const actionTypes = mirrorCreator([ // Fetch annotations for single answer 'FETCH_ANNOTATION_SUCCESS', + + // Set timer upon starting timed assessment + 'SET_TIMER_STARTED_AT_REQUEST', + 'SET_TIMER_STARTED_AT_SUCCESS', + 'SET_TIMER_STARTED_AT_FAILURE', ]); export default actionTypes; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx index 87524c1d65e..8be7acab4f0 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx @@ -56,18 +56,21 @@ const SubmissionForm: FC = (props) => { const initialValues = useAppSelector(getInitialAnswer); const { autograded, timeLimit, tabbedView, questionIds } = assessment; - const { workflowState, attemptedAt } = submission; + const { workflowState, timerStartedAt } = submission; const maxInitialStep = submission.maxStep ?? questionIds.length - 1; const submissionId = getSubmissionId(); const hasSubmissionTimeLimit = - workflowState === workflowStates.Attempting && timeLimit; + workflowState === workflowStates.Attempting && timeLimit && timerStartedAt; const submissionTimeLimitAt = hasSubmissionTimeLimit - ? new Date(attemptedAt).getTime() + timeLimit * 60 * 1000 + ? new Date(timerStartedAt).getTime() + timeLimit * 60 * 1000 : null; + const isNewSubmission = + workflowState === workflowStates.Attempting && timeLimit && !timerStartedAt; + const initialStep = Math.min(maxInitialStep, Math.max(0, step || 0)); const [maxStep, setMaxStep] = useState(maxInitialStep); @@ -172,7 +175,7 @@ const SubmissionForm: FC = (props) => { }); return ( -
+
{ - const hours = Math.floor(remainingTime / 1000 / 60 / 60) % 24; - const minutes = Math.floor(remainingTime / 1000 / 60) % 60; - const seconds = Math.floor(remainingTime / 1000) % 60; +const TimeLimitBanner: FC = () => { + const { t } = useTranslation(); - if (hours > 0) { - return ( - - ); - } + const assessment = useAppSelector(getAssessment); + const submission = useAppSelector(getSubmission); - if (minutes > 0) { - return ( - - ); - } - - if (seconds >= 0) { - return ( - - ); - } + const initialCurrentTime = new Date().getTime(); + const hasSubmissionTimeLimit = + assessment.timeLimit && + submission.workflowState === 'attempting' && + submission.timerStartedAt; - return
; -}; + const submissionTimeLimitAt = hasSubmissionTimeLimit + ? new Date(submission.timerStartedAt).getTime() + + assessment.timeLimit! * 60 * 1000 + : null; -const TimeLimitBanner: FC = (props) => { - const { submissionTimeLimitAt } = props; - const initialCurrentTime = new Date().getTime(); - const initialRemainingTime = submissionTimeLimitAt - initialCurrentTime; + const initialRemainingTime = submissionTimeLimitAt + ? submissionTimeLimitAt - initialCurrentTime + : assessment.timeLimit! * 60 * 1000; const [currentRemainingTime, setCurrentRemainingTime] = useState(initialRemainingTime); @@ -67,55 +41,61 @@ const TimeLimitBanner: FC = (props) => { ); useEffect(() => { - const interval = setInterval(() => { - const currentTime = new Date().getTime(); - const remainingSeconds = submissionTimeLimitAt - currentTime; - const remainingBufferSeconds = - submissionTimeLimitAt + BUFFER_TIME_TO_FORCE_SUBMIT_MS - currentTime; + if (submissionTimeLimitAt) { + const interval = setInterval(() => { + const currentTime = new Date().getTime(); + const remainingSeconds = submissionTimeLimitAt - currentTime; + const remainingBufferSeconds = + submissionTimeLimitAt + BUFFER_TIME_TO_FORCE_SUBMIT_MS - currentTime; - setCurrentRemainingTime(remainingSeconds); + setCurrentRemainingTime(remainingSeconds); - if (remainingSeconds < 0) { - setCurrentBufferTime(remainingBufferSeconds); - } - }, 1000); + if (remainingSeconds < 0) { + setCurrentBufferTime(remainingBufferSeconds); + } + }, 1000); - return () => clearInterval(interval); - }, [submissionTimeLimitAt]); + return () => clearInterval(interval); + } - let TimeBanner: JSX.Element; + return () => {}; + }, [submissionTimeLimitAt]); - if (currentRemainingTime > 0) { - TimeBanner = ( - } - > - - - ); - } else { - TimeBanner = ( + if (currentRemainingTime <= 0) { + return ( } > {currentBufferTime > 0 ? ( - + + {t(translations.remainingBufferTime, { + timeLimit: ( + + ), + })} + ) : ( - + {t(translations.timeIsUp)} )} ); } - return TimeBanner; + return ( + } + > + + {t(translations.remainingTime, { + timeLimit: ( + + ), + })} + + + ); }; export default TimeLimitBanner; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/RemainingTimeTranslation.tsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/RemainingTimeTranslation.tsx new file mode 100644 index 00000000000..5ca2d5b9bb6 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/components/RemainingTimeTranslation.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import translations from '../../../translations'; + +interface Props { + remainingTime: number; +} + +const RemainingTimeTranslations: FC = (props) => { + const { remainingTime } = props; + + const hours = Math.floor(remainingTime / 1000 / 60 / 60) % 24; + const minutes = Math.floor(remainingTime / 1000 / 60) % 60; + const seconds = Math.floor(remainingTime / 1000) % 60; + + if (hours > 0) { + return ( + + ); + } + + if (minutes > 0) { + return ( + + ); + } + + if (seconds >= 0) { + return ( + + ); + } + + return
; +}; + +export default RemainingTimeTranslations; diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index 5956789043b..3d5f036ac66 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -28,7 +28,6 @@ import { purgeSubmissionStore, } from '../../actions'; import ProgressPanel from '../../components/ProgressPanel'; -import { workflowStates } from '../../constants'; import { assessmentShape, gradingShape, @@ -66,13 +65,11 @@ class VisibleSubmissionEditIndex extends Component { } renderTimeLimitBanner() { - const { assessment, submission, submissionTimeLimitAt } = this.props; + const { assessment, submission } = this.props; return ( assessment.timeLimit && - submission.workflowState === 'attempting' && ( - - ) + submission.workflowState === 'attempting' && ); } @@ -184,7 +181,6 @@ VisibleSubmissionEditIndex.propTypes = { }), }), assessment: assessmentShape, - submissionTimeLimitAt: PropTypes.number, intl: PropTypes.object.isRequired, submission: submissionShape, isLoading: PropTypes.bool.isRequired, @@ -197,17 +193,8 @@ VisibleSubmissionEditIndex.propTypes = { }; function mapStateToProps({ assessments: { submission } }) { - const hasSubmissionTimeLimit = - submission.submission.workflowState === workflowStates.Attempting && - submission.assessment.timeLimit; - const submissionTimeLimitAt = hasSubmissionTimeLimit - ? new Date(submission.submission.attemptedAt).getTime() + - submission.assessment.timeLimit * 60 * 1000 - : null; - return { assessment: submission.assessment, - submissionTimeLimitAt, submission: submission.submission, isLoading: submission.submissionFlags.isLoading, isSaving: submission.submissionFlags.isSaving, diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTable.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTable.jsx index ed61f916e92..fc2a97d2709 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTable.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTable.jsx @@ -369,6 +369,9 @@ export default class SubmissionsTable extends Component { {assessment.gamified ? tableHeaderCenterColumnFor('experiencePoints') : null} + {assessment.hasTimeLimit + ? tableHeaderCenterColumnFor('timerStartedAt') + : null} {tableHeaderCenterColumnFor('dateSubmitted')} {tableHeaderCenterColumnFor('dateGraded')} {tableHeaderCenterColumnFor('grader')} diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx index 8e2cfe2b4ff..65862ee9526 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/SubmissionsTableRow.jsx @@ -306,6 +306,13 @@ const SubmissionsTableRow = (props) => { : null} ) : null} + {assessment.hasTimeLimit ? ( + + {submission.timerStartedAt + ? formatDate(submission.timerStartedAt) + : null} + + ) : null} {formatDate(submission.dateSubmitted)} diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/translations.js b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/translations.js index 1d3791d44d1..d82349a1c0b 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/translations.js +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/translations.js @@ -23,6 +23,10 @@ const translations = defineMessages({ id: 'course.assessment.submission.SubmissionsIndex.experiencePoints', defaultMessage: 'EXP Awarded', }, + timerStartedAt: { + id: 'course.assessment.submission.SubmissionsIndex.timerStartedAt', + defaultMessage: 'Timer Started At', + }, dateSubmitted: { id: 'course.assessment.submission.SubmissionsIndex.dateSubmitted', defaultMessage: 'Submitted At', diff --git a/client/app/bundles/course/assessment/submission/reducers/submission.js b/client/app/bundles/course/assessment/submission/reducers/submission.js index 0e7471ac8ab..cb64a6c1201 100644 --- a/client/app/bundles/course/assessment/submission/reducers/submission.js +++ b/client/app/bundles/course/assessment/submission/reducers/submission.js @@ -1,4 +1,7 @@ +/* eslint-disable no-param-reassign */ /* eslint-disable no-case-declarations */ +import { produce } from 'immer'; + import actions from '../constants'; /** @@ -29,6 +32,12 @@ export default function (state = {}, action) { isGrader: canGrade, graderView: calculateGraderView(state, canGrade), }; + case actions.SET_TIMER_STARTED_AT_SUCCESS: { + const timerStartedAt = action.payload.timerStartedAt; + return produce(state, (draftState) => { + draftState.timerStartedAt = timerStartedAt; + }); + } case actions.ENTER_STUDENT_VIEW: return { ...state, graderView: false }; case actions.EXIT_STUDENT_VIEW: diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index c2674f46b64..655b89b7596 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -316,6 +316,12 @@ const translations = defineMessages({ id: 'course.assessment.submission.updateFailure', defaultMessage: 'Submission update failed: {errors}', }, + startTimedExamAssessmentFailed: { + id: 'course.assessment.submission.startTimedExamAssessmentFailed', + defaultMessage: + 'There is an error in starting the exam / assessment. Please check your \ + internet connection and try again', + }, downloadRequestSuccess: { id: 'course.assessment.submission.downloadRequestSuccess', defaultMessage: 'Your download request is successful.', @@ -454,12 +460,20 @@ const translations = defineMessages({ '{stillSomeTimeRemaining, select, true {Once the time is up, \ the assessment will be automatically finalised.} other {Finalising the submission now!}}', }, + timedAssessmentStartDialogMessage: { + id: 'course.assessment.submission.timedExamStartDialogMessage', + defaultMessage: 'Click to start the assessment and the timer', + }, timedExamDialogTitle: { id: 'course.assessment.submission.timedExamDialogTitle', defaultMessage: '{stillSomeTimeRemaining, select, true {{remainingTime} {isNewSubmission, select, true {} other {remaining}} to \ complete this exam.} other {The exam has ended!}}', }, + timedExamStartDialogMessage: { + id: 'course.assessment.submission.timedExamStartDialogMessage', + defaultMessage: 'Click to start the exam and the timer', + }, timedExamDialogMessage: { id: 'course.assessment.submission.timedExamDialogMessage', defaultMessage: @@ -479,6 +493,10 @@ const translations = defineMessages({ id: 'course.assessment.submission.ok', defaultMessage: 'OK', }, + start: { + id: 'course.assessment.submission.start', + defaultMessage: '{isNewSubmission, select, true {Start} other {OK}}', + }, answerSubmitted: { id: 'course.assessment.submission.answerSubmitted', defaultMessage: 'Answer Submitted', diff --git a/client/app/bundles/course/assessment/submission/types.ts b/client/app/bundles/course/assessment/submission/types.ts index 6b3f2a34670..31398205392 100644 --- a/client/app/bundles/course/assessment/submission/types.ts +++ b/client/app/bundles/course/assessment/submission/types.ts @@ -99,6 +99,7 @@ export interface SubmissionState { name: string; }; submittedAt: Date; + timerStartedAt: Date; workflowState: WorkflowState; } diff --git a/config/routes.rb b/config/routes.rb index 80ec033656a..9b24e61b0d6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -251,6 +251,7 @@ post :generate_live_feedback, on: :member get :download_all, on: :collection get :download_statistics, on: :collection + patch :set_timer_started_at, on: :member patch :publish_all, on: :collection patch :force_submit_all, on: :collection patch :unsubmit, on: :collection diff --git a/db/migrate/20240911133816_add_timer_start_at_for_submission.rb b/db/migrate/20240911133816_add_timer_start_at_for_submission.rb new file mode 100644 index 00000000000..682a96b2391 --- /dev/null +++ b/db/migrate/20240911133816_add_timer_start_at_for_submission.rb @@ -0,0 +1,5 @@ +class AddTimerStartAtForSubmission < ActiveRecord::Migration[7.0] + def change + add_column :course_assessment_submissions, :timer_started_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c3320189bf..c25dca1eac5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_08_30_090759) do +ActiveRecord::Schema[7.0].define(version: 2024_09_11_133816) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -438,6 +438,7 @@ t.string "session_id", limit: 255 t.datetime "submitted_at", precision: nil t.datetime "last_graded_time", precision: nil, default: "2021-10-24 14:11:56" + t.datetime "timer_started_at" t.index ["assessment_id", "creator_id"], name: "unique_assessment_id_and_creator_id", unique: true t.index ["assessment_id"], name: "fk__course_assessment_submissions_assessment_id" t.index ["creator_id"], name: "fk__course_assessment_submissions_creator_id" diff --git a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb index c89b7957b82..d279f77a5b2 100644 --- a/spec/controllers/course/assessment/submission/submissions_controller_spec.rb +++ b/spec/controllers/course/assessment/submission/submissions_controller_spec.rb @@ -240,6 +240,55 @@ end end + describe '#set_timer_started_at' do + let!(:assessment) { create(:assessment, :published, *assessment_traits, course: course, time_limit: 120) } + let!(:assessment2) { create(:assessment, :published, *assessment_traits, course: course, time_limit: 120) } + let!(:submission) { create(:submission, :attempting, assessment: assessment, creator: user) } + let!(:submission2) do + create(:submission, :attempting, assessment: assessment2, creator: user, + timer_started_at: Time.zone.now - 5.seconds) + end + + context 'when user first-time attempt the timed assessment' do + subject do + patch :set_timer_started_at, params: { + course_id: course, assessment_id: assessment.id, id: submission.id + } + end + + it 'assigns the timer_started_at to current time' do + subject + json_result = JSON.parse(response.body) + expect(json_result['timerStartedAt'].to_datetime.utc).to \ + be_within(1.second).of Time.zone.now.utc + expect(submission.reload.timer_started_at.utc).to \ + be_within(1.second).of Time.zone.now.utc + end + end + + context 'when user has already attempted the timed assessment before' do + subject do + patch :set_timer_started_at, params: { + course_id: course, assessment_id: assessment2.id, id: submission2.id + } + end + + it 'assigns the timer_started_at to current time' do + subject + json_result = JSON.parse(response.body) + expect(json_result['timerStartedAt'].to_datetime.utc).not_to \ + be_within(1.second).of Time.zone.now.utc + expect(submission2.reload.timer_started_at.utc).not_to \ + be_within(1.second).of Time.zone.now.utc + + expect(json_result['timerStartedAt'].to_datetime.utc).to \ + be_within(1.second).of (Time.zone.now - 5.seconds).utc + expect(submission2.reload.timer_started_at.utc).to \ + be_within(1.second).of (Time.zone.now - 5.seconds).utc + end + end + end + describe 'submission_actions' do let!(:students) { create_list(:course_student, 5, course: course) } let!(:phantom_student) { create(:course_student, :phantom, course: course) }