Skip to content

Commit ec4b802

Browse files
committed
feat(timed_assessment): further improvement
- timer starts only after student click "Start" when start attempting the exam
1 parent 029a983 commit ec4b802

File tree

23 files changed

+252
-130
lines changed

23 files changed

+252
-130
lines changed

app/controllers/course/assessment/submission/submissions_controller.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class Course::Assessment::Submission::SubmissionsController < # rubocop:disable
3232
staff: 'staff',
3333
staff_w_phantom: 'staff_w_phantom' }.freeze
3434

35+
FORCE_SUBMIT_DELAY = 5.minutes
36+
3537
def index
3638
authorize!(:view_all_submissions, @assessment)
3739

@@ -129,6 +131,20 @@ def fetch_live_feedback_status
129131
render json: { threadStatus: response_body['data']['thread']['status'] }, status: response_status
130132
end
131133

134+
def set_timer_started_at
135+
timer_started_at = Time.zone.now
136+
137+
@submission.timer_started_at = timer_started_at
138+
139+
raise ActiveRecord::Rollback unless @submission.save
140+
141+
Course::Assessment::Submission::ForceSubmitTimedSubmissionJob.
142+
set(wait_until: timer_started_at + @assessment.time_limit.minutes + FORCE_SUBMIT_DELAY).
143+
perform_later(@assessment, @submission_id, @submission.creator)
144+
145+
render json: { timerStartedAt: timer_started_at }
146+
end
147+
132148
# Reload the current answer or reset it, depending on parameters.
133149
# current_answer has the most recent copy of the answer.
134150
def reload_answer

app/jobs/course/assessment/submission/force_submit_timed_submission_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def perform_tracked(assessment, submission_id, submitter)
1010
instance = Course.unscoped { assessment.course.instance }
1111

1212
ActsAsTenant.with_tenant(instance) do
13-
submission = Course::Assessment::Submission.find_by(id: submission_id)
13+
submission = Course::Assessment::Submission.find_by(id: submission_id, workflow_state: 'attempting')
1414
return unless submission
1515

1616
force_submit(submission, submitter)

app/models/course/assessment/assessment_ability.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ def allow_read_material
7575
def allow_create_assessment_submission
7676
can :create, Course::Assessment::Submission,
7777
experience_points_record: { course_user: { user_id: user.id } }
78-
can [:update, :generate_live_feedback, :save_live_feedback,
79-
:create_live_feedback_chat, :fetch_live_feedback_status],
78+
can [:update, :generate_live_feedback, :save_live_feedback, :set_timer_started_at, :create_live_feedback_chat,
79+
:fetch_live_feedback_status],
8080
Course::Assessment::Submission, assessment_submission_attempting_hash(user)
8181
end
8282

app/models/course/assessment/submission.rb

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@ class Course::Assessment::Submission < ApplicationRecord
1111

1212
acts_as_experience_points_record
1313

14-
FORCE_SUBMIT_DELAY = 5.minutes
15-
1614
after_save :auto_grade_submission, if: :submitted?
1715
after_save :retrieve_codaveri_feedback, if: :submitted?
18-
after_create :create_force_submission_job, if: :attempting?
1916

2017
workflow do
2118
state :attempting do
@@ -235,14 +232,6 @@ def assigned_questions
235232
extending(Course::Assessment::QuestionsConcern)
236233
end
237234

238-
def create_force_submission_job
239-
return unless assessment.time_limit
240-
241-
Course::Assessment::Submission::ForceSubmitTimedSubmissionJob.
242-
set(wait_until: created_at + assessment.time_limit.minutes + FORCE_SUBMIT_DELAY).
243-
perform_later(assessment, id, creator)
244-
end
245-
246235
# The answers with current_answer flag set to true, filtering out orphaned answers to questions which are no longer
247236
# assigned to the submission for randomized assessment.
248237
#

app/views/course/assessment/submission/submissions/_submission.json.jbuilder

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ json.submission do
2424
json.id submission.course_user.id
2525
end
2626

27+
json.timerStartedAt submission.timer_started_at if assessment.time_limit
28+
2729
submitter_course_user = submission.creator.course_users.find_by(course: submission.assessment.course)
2830
end_at = assessment.time_for(submitter_course_user).end_at
2931
bonus_end_at = assessment.time_for(submitter_course_user).bonus_end_at

app/views/course/assessment/submission/submissions/index.json.jbuilder

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ json.assessment do
1111
json.filesDownloadable @assessment.files_downloadable?
1212
json.csvDownloadable @assessment.csv_downloadable?
1313
json.passwordProtected @assessment.session_password_protected?
14+
json.hasTimeLimit @assessment.time_limit
1415
json.canViewLogs can? :manage, @assessment
1516
json.canPublishGrades can? :publish_grades, @assessment
1617
json.canForceSubmit can? :force_submit_assessment_submission, @assessment
@@ -38,6 +39,7 @@ json.submissions @course_users do |course_user|
3839
json.workflowState submission.workflow_state
3940
json.grade submission.grade.to_f
4041
json.pointsAwarded submission.current_points_awarded
42+
json.timerStartedAt submission.timer_started_at if @assessment.time_limit
4143
json.dateSubmitted submission.submitted_at&.iso8601
4244
json.dateGraded submission.graded_at&.iso8601
4345
json.logCount submission.log_count

client/app/api/course/Assessment/Submissions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ export default class SubmissionsAPI extends BaseAssessmentAPI {
9797
);
9898
}
9999

100+
setTimerStartAt(submissionId) {
101+
return this.client.patch(
102+
`${this.#urlPrefix}/${submissionId}/set_timer_started_at`,
103+
);
104+
}
105+
100106
reloadAnswer(submissionId, params) {
101107
return this.client.post(
102108
`${this.#urlPrefix}/${submissionId}/reload_answer`,

client/app/bundles/course/assessment/submission/actions/index.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,28 @@ export function unsubmit(submissionId) {
145145
};
146146
}
147147

148+
export function setTimerStartAt(submissionId, setExamNotice, setTimerNotice) {
149+
return (dispatch) => {
150+
dispatch({ type: actionTypes.SET_TIMER_STARTED_AT_REQUEST });
151+
152+
return CourseAPI.assessment.submissions
153+
.setTimerStartAt(submissionId)
154+
.then((response) => response.data)
155+
.then((data) => {
156+
dispatch({
157+
type: actionTypes.SET_TIMER_STARTED_AT_SUCCESS,
158+
payload: data,
159+
});
160+
setExamNotice(false);
161+
setTimerNotice(false);
162+
})
163+
.catch(() => {
164+
dispatch({ type: actionTypes.SET_TIMER_STARTED_AT_FAILURE });
165+
dispatch(setNotification(translations.startTimedExamAssessmentFailed));
166+
});
167+
};
168+
}
169+
148170
export function mark(submissionId, grades, exp) {
149171
const payload = {
150172
submission: {

client/app/bundles/course/assessment/submission/components/WarningDialog.tsx

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FC, useState } from 'react';
2+
import { useParams } from 'react-router-dom';
23
import {
34
Button,
45
Dialog,
@@ -8,11 +9,12 @@ import {
89
Typography,
910
} from '@mui/material';
1011

11-
import { useAppSelector } from 'lib/hooks/store';
12+
import { useAppDispatch, useAppSelector } from 'lib/hooks/store';
1213
import useTranslation from 'lib/hooks/useTranslation';
1314

14-
import { TIME_LAPSE_NEW_SUBMISSION_MS, workflowStates } from '../constants';
15-
import { remainingTimeDisplay } from '../pages/SubmissionEditIndex/TimeLimitBanner';
15+
import { setTimerStartAt } from '../actions';
16+
import { workflowStates } from '../constants';
17+
import RemainingTimeTranslations from '../pages/SubmissionEditIndex/components/RemainingTimeTranslations';
1618
import { getAssessment } from '../selectors/assessments';
1719
import { getSubmission } from '../selectors/submissions';
1820
import translations from '../translations';
@@ -23,27 +25,33 @@ const WarningDialog: FC = () => {
2325
const assessment = useAppSelector(getAssessment);
2426
const submission = useAppSelector(getSubmission);
2527

28+
const dispatch = useAppDispatch();
29+
2630
const { timeLimit, passwordProtected: isExamMode } = assessment;
27-
const { workflowState, attemptedAt } = submission;
31+
const { workflowState, timerStartedAt } = submission;
2832

2933
const isAttempting = workflowState === workflowStates.Attempting;
3034
const isTimedMode = isAttempting && !!timeLimit;
3135

32-
const startTime = new Date(attemptedAt).getTime();
33-
const currentTime = new Date().getTime();
36+
const isNewSubmission = isTimedMode && !timerStartedAt;
3437

35-
const submissionTimeLimitAt = isTimedMode
36-
? startTime + timeLimit * 60 * 1000
37-
: null;
38+
const currentTime = new Date().getTime();
3839

39-
const isNewSubmission =
40-
currentTime - startTime < TIME_LAPSE_NEW_SUBMISSION_MS;
40+
const submissionTimeLimitAt =
41+
isTimedMode && timerStartedAt
42+
? new Date(timerStartedAt).getTime() + timeLimit * 60 * 1000
43+
: null;
4144

4245
const [examNotice, setExamNotice] = useState(isExamMode);
4346
const [timedNotice, setTimedNotice] = useState(isTimedMode);
4447

48+
const { submissionId } = useParams();
49+
if (!submissionId) {
50+
return null;
51+
}
52+
4553
const remainingTime =
46-
isTimedMode && submissionTimeLimitAt! > currentTime
54+
isTimedMode && timerStartedAt && submissionTimeLimitAt! > currentTime
4755
? submissionTimeLimitAt! - currentTime
4856
: null;
4957

@@ -53,28 +61,41 @@ const WarningDialog: FC = () => {
5361
if (examNotice && timedNotice) {
5462
dialogTitle = t(translations.timedExamDialogTitle, {
5563
isNewSubmission,
56-
remainingTime: remainingTimeDisplay(
57-
isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0,
64+
remainingTime: (
65+
<RemainingTimeTranslations
66+
remainingTime={
67+
isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0
68+
}
69+
/>
5870
),
59-
stillSomeTimeRemaining: !!remainingTime,
60-
});
61-
dialogMessage = t(translations.timedExamDialogMessage, {
62-
stillSomeTimeRemaining: !!remainingTime,
71+
72+
stillSomeTimeRemaining: isNewSubmission || !!remainingTime,
6373
});
74+
dialogMessage = isNewSubmission
75+
? t(translations.timedExamStartDialogMessage)
76+
: t(translations.timedExamDialogMessage, {
77+
stillSomeTimeRemaining: !!remainingTime,
78+
});
6479
} else if (examNotice) {
6580
dialogTitle = t(translations.examDialogTitle);
6681
dialogMessage = t(translations.examDialogMessage);
6782
} else if (timedNotice) {
6883
dialogTitle = t(translations.timedAssessmentDialogTitle, {
6984
isNewSubmission,
70-
remainingTime: remainingTimeDisplay(
71-
isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0,
85+
remainingTime: (
86+
<RemainingTimeTranslations
87+
remainingTime={
88+
isNewSubmission ? timeLimit! * 60 * 1000 : remainingTime ?? 0
89+
}
90+
/>
7291
),
73-
stillSomeTimeRemaining: !!remainingTime,
74-
});
75-
dialogMessage = t(translations.timedAssessmentDialogMessage, {
76-
stillSomeTimeRemaining: !!remainingTime,
92+
stillSomeTimeRemaining: isNewSubmission || !!remainingTime,
7793
});
94+
dialogMessage = isNewSubmission
95+
? t(translations.timedAssessmentStartDialogMessage)
96+
: t(translations.timedAssessmentDialogMessage, {
97+
stillSomeTimeRemaining: !!remainingTime,
98+
});
7899
}
79100

80101
return (
@@ -89,11 +110,17 @@ const WarningDialog: FC = () => {
89110
<Button
90111
color="primary"
91112
onClick={() => {
92-
setExamNotice(false);
93-
setTimedNotice(false);
113+
if (isNewSubmission) {
114+
dispatch(
115+
setTimerStartAt(submissionId, setExamNotice, setTimedNotice),
116+
);
117+
} else {
118+
setExamNotice(false);
119+
setTimedNotice(false);
120+
}
94121
}}
95122
>
96-
{t(translations.ok)}
123+
{t(translations.start, { isNewSubmission })}
97124
</Button>
98125
</DialogActions>
99126
</Dialog>

client/app/bundles/course/assessment/submission/constants.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ export const MEGABYTES_TO_BYTES = 1024 * 1024;
1818

1919
export const BUFFER_TIME_TO_FORCE_SUBMIT_MS = 5 * 1000;
2020

21-
// calculate how long has it passed since the student starts the submission
22-
// to still be considered a "newly created" submission
23-
export const TIME_LAPSE_NEW_SUBMISSION_MS = 10 * 1000;
24-
2521
export const EVALUATE_POLL_INTERVAL_MILLISECONDS = 500;
2622
export const FEEDBACK_POLL_INTERVAL_MILLISECONDS = 2000;
2723

@@ -301,6 +297,11 @@ const actionTypes = mirrorCreator([
301297

302298
// Fetch annotations for single answer
303299
'FETCH_ANNOTATION_SUCCESS',
300+
301+
// Set timer upon starting timed assessment
302+
'SET_TIMER_STARTED_AT_REQUEST',
303+
'SET_TIMER_STARTED_AT_SUCCESS',
304+
'SET_TIMER_STARTED_AT_FAILURE',
304305
]);
305306

306307
export default actionTypes;

client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/SubmissionForm.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const SubmissionForm: FC<Props> = (props) => {
6060
const initialValues = useAppSelector(getInitialAnswer);
6161

6262
const { autograded, timeLimit, tabbedView, questionIds } = assessment;
63-
const { workflowState, attemptedAt } = submission;
63+
const { workflowState, timerStartedAt } = submission;
6464

6565
const answerIds = Object.values(questions).map(
6666
(question) => question.answerId,
@@ -71,11 +71,14 @@ const SubmissionForm: FC<Props> = (props) => {
7171
const submissionId = getSubmissionId();
7272

7373
const hasSubmissionTimeLimit =
74-
workflowState === workflowStates.Attempting && timeLimit;
74+
workflowState === workflowStates.Attempting && timeLimit && timerStartedAt;
7575
const submissionTimeLimitAt = hasSubmissionTimeLimit
76-
? new Date(attemptedAt).getTime() + timeLimit * 60 * 1000
76+
? new Date(timerStartedAt).getTime() + timeLimit * 60 * 1000
7777
: null;
7878

79+
const isNewSubmission =
80+
workflowState === workflowStates.Attempting && timeLimit && !timerStartedAt;
81+
7982
const initialStep = Math.min(maxInitialStep, Math.max(0, step || 0));
8083

8184
const [maxStep, setMaxStep] = useState(maxInitialStep);
@@ -223,7 +226,7 @@ const SubmissionForm: FC<Props> = (props) => {
223226
});
224227

225228
return (
226-
<div className="mt-4">
229+
<div className={`mt-4 ${isNewSubmission && 'blur-xl'}`}>
227230
<FormProvider {...methods}>
228231
<form
229232
encType="multipart/form-data"

0 commit comments

Comments
 (0)