Skip to content

Commit

Permalink
chore(api): gatekeep timer start at and set test
Browse files Browse the repository at this point in the history
- if submission already has timer started at, we won't allow to re-configure it
- add test cases regarding this API
- refactor the TimeLimitBanner to accommodate when timer not started yet
  • Loading branch information
bivanalhar committed Sep 13, 2024
1 parent c095ff8 commit 0e7b329
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,17 @@ def generate_live_feedback
end

def set_timer_started_at
timer_started_at = Time.zone.now
unless @submission.timer_started_at
@submission.timer_started_at = Time.zone.now

@submission.timer_started_at = timer_started_at
raise ActiveRecord::Rollback unless @submission.save

raise ActiveRecord::Rollback unless @submission.save

Course::Assessment::Submission::ForceSubmitTimedSubmissionJob.
set(wait_until: timer_started_at + @assessment.time_limit.minutes + FORCE_SUBMIT_DELAY).
perform_later(@assessment, @submission_id, @submission.creator)
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: timer_started_at }
render json: { timerStartedAt: @submission.timer_started_at }
end

# Reload the current answer or reset it, depending on parameters.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
import { FC, useEffect, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { HourglassTop } from '@mui/icons-material';
import { Typography } from '@mui/material';

import Banner from 'lib/components/core/layouts/Banner';
import { useAppSelector } from 'lib/hooks/store';
import useTranslation from 'lib/hooks/useTranslation';

import { BUFFER_TIME_TO_FORCE_SUBMIT_MS } from '../../constants';
import { getAssessment } from '../../selectors/assessments';
import { getSubmission } from '../../selectors/submissions';
import translations from '../../translations';

import RemainingTimeTranslations from './components/RemainingTimeTranslation';

interface Props {
submissionTimeLimitAt: number;
}
const TimeLimitBanner: FC = () => {
const { t } = useTranslation();

const assessment = useAppSelector(getAssessment);
const submission = useAppSelector(getSubmission);

const TimeLimitBanner: FC<Props> = (props) => {
const { submissionTimeLimitAt } = props;
const initialCurrentTime = new Date().getTime();
const initialRemainingTime = submissionTimeLimitAt - initialCurrentTime;
const hasSubmissionTimeLimit =
assessment.timeLimit &&
submission.workflowState === 'attempting' &&
submission.timerStartedAt;

const submissionTimeLimitAt = hasSubmissionTimeLimit
? new Date(submission.timerStartedAt).getTime() +
assessment.timeLimit! * 60 * 1000
: null;

const initialRemainingTime = submissionTimeLimitAt
? submissionTimeLimitAt - initialCurrentTime
: assessment.timeLimit! * 60 * 1000;

const [currentRemainingTime, setCurrentRemainingTime] =
useState(initialRemainingTime);
Expand All @@ -25,63 +41,61 @@ const TimeLimitBanner: FC<Props> = (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 = (
<Banner
className="bg-red-700 text-white border-only-b-fuchsia-200 fixed top-0 right-0"
icon={<HourglassTop />}
>
<FormattedMessage
{...translations.remainingTime}
values={{
timeLimit: (
<RemainingTimeTranslations remainingTime={currentRemainingTime} />
),
}}
/>
</Banner>
);
} else {
TimeBanner = (
if (currentRemainingTime <= 0) {
return (
<Banner
className="bg-yellow-700 text-white border-only-b-fuchsia-200 fixed top-0 right-0"
icon={<HourglassTop />}
>
{currentBufferTime > 0 ? (
<FormattedMessage
{...translations.remainingBufferTime}
values={{
<Typography variant="body2">
{t(translations.remainingBufferTime, {
timeLimit: (
<RemainingTimeTranslations remainingTime={currentBufferTime} />
),
}}
/>
})}
</Typography>
) : (
<FormattedMessage {...translations.timeIsUp} />
<Typography variant="body2">{t(translations.timeIsUp)}</Typography>
)}
</Banner>
);
}

return TimeBanner;
return (
<Banner
className="bg-red-700 text-white border-only-b-fuchsia-200 fixed top-0 right-0"
icon={<HourglassTop />}
>
<Typography variant="body2">
{t(translations.remainingTime, {
timeLimit: (
<RemainingTimeTranslations remainingTime={currentRemainingTime} />
),
})}
</Typography>
</Banner>
);
};

export default TimeLimitBanner;
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Component } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { HourglassTop } from '@mui/icons-material';
import InsertDriveFile from '@mui/icons-material/InsertDriveFile';
import {
Card,
Expand All @@ -15,7 +14,6 @@ import {
import PropTypes from 'prop-types';
import withHeartbeatWorker from 'workers/withHeartbeatWorker';

import Banner from 'lib/components/core/layouts/Banner';
import Page from 'lib/components/core/layouts/Page';
import Link from 'lib/components/core/Link';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
Expand All @@ -30,7 +28,6 @@ import {
purgeSubmissionStore,
} from '../../actions';
import ProgressPanel from '../../components/ProgressPanel';
import { workflowStates } from '../../constants';
import {
assessmentShape,
gradingShape,
Expand All @@ -39,7 +36,6 @@ import {
} from '../../propTypes';
import translations from '../../translations';

import RemainingTimeTranslations from './components/RemainingTimeTranslation';
import BlockedSubmission from './BlockedSubmission';
import SubmissionEmptyForm from './SubmissionEmptyForm';
import SubmissionForm from './SubmissionForm';
Expand Down Expand Up @@ -69,30 +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.timerStartedAt ? (
<TimeLimitBanner submissionTimeLimitAt={submissionTimeLimitAt} />
) : (
<Banner
className="bg-red-700 text-white border-only-b-fuchsia-200 fixed top-0 right-0"
icon={<HourglassTop />}
>
<FormattedMessage
{...translations.remainingTime}
values={{
timeLimit: (
<RemainingTimeTranslations
remainingTime={assessment.timeLimit * 60 * 1000}
/>
),
}}
/>
</Banner>
))
submission.workflowState === 'attempting' && <TimeLimitBanner />
);
}

Expand Down Expand Up @@ -204,7 +181,6 @@ VisibleSubmissionEditIndex.propTypes = {
}),
}),
assessment: assessmentShape,
submissionTimeLimitAt: PropTypes.number,
intl: PropTypes.object.isRequired,
submission: submissionShape,
isLoading: PropTypes.bool.isRequired,
Expand All @@ -217,18 +193,8 @@ VisibleSubmissionEditIndex.propTypes = {
};

function mapStateToProps({ assessments: { submission } }) {
const hasSubmissionTimeLimit =
submission.submission.workflowState === workflowStates.Attempting &&
submission.assessment.timeLimit &&
submission.submission.timerStartedAt;
const submissionTimeLimitAt = hasSubmissionTimeLimit
? new Date(submission.submission.timerStartedAt).getTime() +
submission.assessment.timeLimit * 60 * 1000
: null;

return {
assessment: submission.assessment,
submissionTimeLimitAt,
submission: submission.submission,
isLoading: submission.submissionFlags.isLoading,
isSaving: submission.submissionFlags.isSaving,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down

0 comments on commit 0e7b329

Please sign in to comment.