diff --git a/api/__mocks__/env-exam.ts b/api/__mocks__/env-exam.ts index 2a863283b1f..e56d7b05122 100644 --- a/api/__mocks__/env-exam.ts +++ b/api/__mocks__/env-exam.ts @@ -45,7 +45,8 @@ export const config: EnvConfig = { numberOfCorrectAnswers: 1, numberOfIncorrectAnswers: 1 } - ] + ], + retakeTimeInMS: 24 * 60 * 60 * 1000 }; export const questionSets: EnvQuestionSet[] = [ @@ -292,8 +293,7 @@ export const examAttempt: EnvExamAttempt = { } ], startTimeInMS: Date.now(), - userId: defaultUserId, - submissionTimeInMS: null + userId: defaultUserId }; export const examAttemptSansSubmissionTimeInMS: Static< diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2191291d500..99064a35afd 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -223,15 +223,17 @@ type EnvAnswer { /// Configuration for an exam in the Exam Environment App type EnvConfig { /// Human-readable exam name - name String + name String /// Notes given about exam - note String + note String /// Category configuration for question selection - tags EnvTagConfig[] + tags EnvTagConfig[] /// Total time allocated for exam in milliseconds - totalTimeInMS Int + totalTimeInMS Int /// Configuration for sets of questions - questionSets EnvQuestionSetConfig[] + questionSets EnvQuestionSetConfig[] + /// Duration after exam completion before a retake is allowed in milliseconds + retakeTimeInMS Int } /// Configuration for a set of questions in the Exam Environment App @@ -267,14 +269,10 @@ model EnvExamAttempt { /// Foreign key to generated exam id generatedExamId String @db.ObjectId - questionSets EnvQuestionSetAttempt[] + questionSets EnvQuestionSetAttempt[] /// Time exam was started as milliseconds since epoch - startTimeInMS Int - /// Time exam was submitted as milliseconds since epoch - /// - /// As attempt might not be submitted (disconnection or quit), field is optional - submissionTimeInMS Int? - needsRetake Boolean + startTimeInMS Int + needsRetake Boolean // Relations user user @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -301,7 +299,6 @@ type EnvMultipleChoiceQuestionAttempt { /// A generated exam for the Exam Environment App /// /// This is the user-facing information for an exam. -/// TODO: Add userId? model EnvGeneratedExam { id String @id @default(auto()) @map("_id") @db.ObjectId /// Foreign key to exam diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index de4a35b1dd9..da4a1e02ec4 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -435,8 +435,6 @@ describe('/exam-environment/', () => { 24 * 60 * 60 * 1000 - mock.exam.config.totalTimeInMS - 1 * 60 * 60 * 1000; - submittedAttempt.submissionTimeInMS = - Date.now() - mock.exam.config.totalTimeInMS - 24 * 60 * 60 * 1000; await fastifyTestInstance.prisma.envExamAttempt.create({ data: submittedAttempt }); @@ -492,7 +490,6 @@ describe('/exam-environment/', () => { generatedExamId: generatedExam!.id, questionSets: [], needsRetake: false, - submissionTimeInMS: null, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment startTimeInMS: expect.any(Number) }); @@ -579,7 +576,8 @@ describe('/exam-environment/', () => { config: { name: mock.exam.config.name, note: mock.exam.config.note, - totalTimeInMS: mock.exam.config.totalTimeInMS + totalTimeInMS: mock.exam.config.totalTimeInMS, + retakeTimeInMS: mock.exam.config.retakeTimeInMS }, id: mock.examId } diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index 3e8d5f28857..ba310a9b749 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -8,7 +8,6 @@ import * as schemas from '../schemas'; import { mapErr, syncMapErr, UpdateReqType } from '../../utils'; import { JWT_SECRET } from '../../utils/env'; import { - checkAttemptAgainstGeneratedExam, checkPrerequisites, constructUserExam, userAttemptToDatabaseAttemptQuestionSets, @@ -209,16 +208,13 @@ async function postExamGeneratedExamHandler( : null; if (lastAttempt) { - const attemptIsExpired = - lastAttempt.startTimeInMS + exam.config.totalTimeInMS < Date.now(); - if (attemptIsExpired) { - // If exam is not submitted, use exam start time + time allocated for exam - const effectiveSubmissionTime = - lastAttempt.submissionTimeInMS ?? - lastAttempt.startTimeInMS + exam.config.totalTimeInMS; - const twentyFourHoursAgo = Date.now() - 24 * 60 * 60 * 1000; + const examExpirationTime = + lastAttempt.startTimeInMS + exam.config.totalTimeInMS; + if (examExpirationTime < Date.now()) { + const retakeAllowed = + examExpirationTime + exam.config.retakeTimeInMS < Date.now(); - if (effectiveSubmissionTime > twentyFourHoursAgo) { + if (!retakeAllowed) { void reply.code(429); // TODO: Consider sending last completed time return reply.send( @@ -429,20 +425,6 @@ async function postExamAttemptHandler( latest.startTimeInMS > current.startTimeInMS ? latest : current ); - // TODO: Currently, submission time is set when all questions have been answered. - // This might not necessarily be fully submitted. So, provided there is time - // left on the clock, the attempt should still be updated, even if the submission - // time is set. - // The submission time just needs to be updated. - // if (latestAttempt.submissionTimeInMS !== null) { - // void reply.code(403); - // return reply.send( - // ERRORS.FCC_EINVAL_EXAM_ENVIRONMENT_EXAM_ATTEMPT( - // 'Attempt has already been submitted.' - // ) - // ); - // } - const maybeExam = await mapErr( this.prisma.envExam.findUnique({ where: { @@ -515,12 +497,6 @@ async function postExamAttemptHandler( validateAttempt(generatedExam, databaseAttemptQuestionSets) ); - // If all questions have been answered, add submission time - const allQuestionsAnswered = checkAttemptAgainstGeneratedExam( - databaseAttemptQuestionSets, - generatedExam - ); - // Update attempt in database const maybeUpdatedAttempt = await mapErr( this.prisma.envExamAttempt.update({ @@ -528,8 +504,6 @@ async function postExamAttemptHandler( id: latestAttempt.id }, data: { - // NOTE: submission time is set to null, because it just depends on whether all questions have been answered. - submissionTimeInMS: allQuestionsAnswered ? Date.now() : null, questionSets: databaseAttemptQuestionSets, // If attempt is not valid, immediately flag attempt as needing retake // TODO: If `needsRetake`, prevent further submissions? @@ -592,7 +566,8 @@ async function getExams( config: { name: exam.config.name, note: exam.config.note, - totalTimeInMS: exam.config.totalTimeInMS + totalTimeInMS: exam.config.totalTimeInMS, + retakeTimeInMS: exam.config.retakeTimeInMS }, canTake: isExamPrerequisitesMet }; diff --git a/api/src/exam-environment/schemas/exams.ts b/api/src/exam-environment/schemas/exams.ts index a348e795ef0..748067b9e55 100644 --- a/api/src/exam-environment/schemas/exams.ts +++ b/api/src/exam-environment/schemas/exams.ts @@ -12,7 +12,8 @@ export const examEnvironmentExams = { config: Type.Object({ name: Type.String(), note: Type.String(), - totalTimeInMS: Type.Number() + totalTimeInMS: Type.Number(), + retakeTimeInMS: Type.Number() }), canTake: Type.Boolean() }) diff --git a/api/src/exam-environment/utils/exam.ts b/api/src/exam-environment/utils/exam.ts index 3d207cddb24..18e4188e5b9 100644 --- a/api/src/exam-environment/utils/exam.ts +++ b/api/src/exam-environment/utils/exam.ts @@ -101,7 +101,8 @@ export function constructUserExam( const config = { totalTimeInMS: exam.config.totalTimeInMS, name: exam.config.name, - note: exam.config.note + note: exam.config.note, + retakeTimeInMS: exam.config.retakeTimeInMS }; const userExam: UserExam = {