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 (
-