From d7d851c074a3c69a89c77d5cbbccd5d31796733c Mon Sep 17 00:00:00 2001 From: arora-r Date: Tue, 2 Sep 2025 17:39:09 -0400 Subject: [PATCH 1/5] featue: authors can set cooldown periods for learner attempts --- .secrets.baseline | 288 ++++-------------- apps/api/prisma/schema.prisma | 4 +- .../attempt/api-exceptions/exceptions.ts | 2 + .../assignment/attempt/attempt.controller.ts | 11 + .../api/assignment/attempt/attempt.service.ts | 46 +-- .../dto/replace.assignment.request.dto.ts | 20 ++ .../dto/update.assignment.request.dto.ts | 20 ++ .../dto/update.questions.request.dto.ts | 20 ++ .../v2/repositories/assignment.repository.ts | 2 + .../v2/services/assignment.service.ts | 2 + .../v2/tests/unit/__mocks__/ common-mocks.ts | 10 + .../assignment.repository.spec.ts | 10 +- .../unit/services/assignment.service.spec.ts | 2 + .../api/src/api/attempt/attempt.controller.ts | 16 + .../services/attempt-validation.service.ts | 58 +++- .../src/assets/schema/assignment-example.json | 2 + .../src/assets/schema/assignment-schema.json | 8 + apps/web/app/Helpers/checkDiff.ts | 20 ++ .../AuthorQuestionsPage/Question.tsx | 4 +- .../Header/CheckLearnerSideButton.tsx | 3 + .../app/author/(components)/Header/index.tsx | 9 + .../StepTwo/AssignmentRetakeAttempts.tsx | 105 +++++++ .../app/author/[assignmentId]/config/page.tsx | 2 + .../app/author/[assignmentId]/review/page.tsx | 30 ++ .../BeginTheAssignmentButton.tsx | 48 +-- .../(components)/AboutTheAssignment/index.tsx | 149 +++++++-- .../questions/LearnerLayout.tsx | 12 + apps/web/config/constants.ts | 17 +- apps/web/config/types.ts | 8 + apps/web/lib/learner.ts | 7 +- apps/web/stores/assignmentConfig.ts | 36 +++ docs/CONTRIBUTING.md | 1 - 32 files changed, 645 insertions(+), 327 deletions(-) create mode 100644 apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx diff --git a/.secrets.baseline b/.secrets.baseline index fdb6063a..b9a8e796 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,42 +1,37 @@ { - "exclude": { - "files": "\\.secrets\\.baseline$", - "lines": null - }, - "generated_at": "2025-07-30T17:09:11Z", + "version": "1.5.0", "plugins_used": [ { - "name": "AWSKeyDetector" + "name": "ArtifactoryDetector" }, { - "name": "ArtifactoryDetector" + "name": "AWSKeyDetector" }, { "name": "AzureStorageKeyDetector" }, { - "base64_limit": 4.5, - "name": "Base64HighEntropyString" + "name": "Base64HighEntropyString", + "limit": 4.5 }, { "name": "BasicAuthDetector" }, - { - "name": "BoxDetector" - }, { "name": "CloudantDetector" }, { - "ghe_instance": "github.ibm.com", - "name": "GheDetector" + "name": "DiscordBotTokenDetector" }, { "name": "GitHubTokenDetector" }, { - "hex_limit": 3, - "name": "HexHighEntropyString" + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 }, { "name": "IbmCloudIamDetector" @@ -44,12 +39,15 @@ { "name": "IbmCosHmacDetector" }, + { + "name": "IPPublicDetector" + }, { "name": "JwtTokenDetector" }, { - "keyword_exclude": null, - "name": "KeywordDetector" + "name": "KeywordDetector", + "keyword_exclude": "" }, { "name": "MailchimpDetector" @@ -57,9 +55,18 @@ { "name": "NpmDetector" }, + { + "name": "OpenAIDetector" + }, { "name": "PrivateKeyDetector" }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, { "name": "SlackDetector" }, @@ -72,212 +79,49 @@ { "name": "StripeDetector" }, + { + "name": "TelegramBotTokenDetector" + }, { "name": "TwilioKeyDetector" } ], - "results": { - ".env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 21, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api-gateway/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 29, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api-gateway/src/api/api.service.ts": [ - { - "hashed_secret": "bb27330ad8e4418fd1545f76c797f9e495ced3f1", - "is_verified": false, - "line_number": 62, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "5df614d5ca1ea5b297766ca8fc7825aae5b71cb2", - "is_verified": false, - "line_number": 63, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 30, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/attempt/services/file-content-extraction.ts": [ - { - "hashed_secret": "2c0580ffd7d80319531cf629f5e90f747b1386f1", - "is_verified": false, - "line_number": 2468, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/github/github.service.ts": [ - { - "hashed_secret": "7ea6be9eecb6605329a1b1870c2fd2af9b896991", - "is_verified": false, - "line_number": 55, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/report/services/flo.service.ts": [ - { - "hashed_secret": "035576b41cbaa44c5a58b0e7e3e8df19c5226a3f", - "is_verified": false, - "line_number": 39, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 4, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/markChat/route.ts": [ - { - "hashed_secret": "3f70a0183ead614880b70bff64fd39c0d9155d62", - "is_verified": false, - "line_number": 8, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/markChat/services/reportingService.ts": [ - { - "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", - "is_verified": false, - "line_number": 62, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/transcribe/route.ts": [ - { - "hashed_secret": "60634a9040f4a588cd5ec80391507a208c79cf00", - "is_verified": false, - "line_number": 6, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/FileCodeUploadSection.tsx": [ - { - "hashed_secret": "cfb93ca9f329289d5f23de2b0e065103b81a374c", - "is_verified": false, - "line_number": 116, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/FileUploadSection.tsx": [ - { - "hashed_secret": "cfb93ca9f329289d5f23de2b0e065103b81a374c", - "is_verified": false, - "line_number": 158, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/GithubUploadModal.tsx": [ - { - "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", - "is_verified": false, - "line_number": 162, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc", - "is_verified": false, - "line_number": 177, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/[assignmentId]/successPage/Question.tsx": [ - { - "hashed_secret": "e1166e6dd837019ab04f130ab34c425e04161645", - "is_verified": false, - "line_number": 380, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", - "is_verified": false, - "line_number": 427, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "29080f1c58f9859ddaa6aeda7d2c410c12e222dc", - "is_verified": false, - "line_number": 459, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/lib/shared.ts": [ - { - "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", - "is_verified": false, - "line_number": 996, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/public/ffmpeg-core/ffmpeg-core.js": [ - { - "hashed_secret": "b4e44716dbbf57be3dae2f819d96795a85d06652", - "is_verified": false, - "line_number": 410, - "type": "Secret Keyword", - "verified_result": null - } - ], - "docs/CONTRIBUTING.md": [ - { - "hashed_secret": "1c2b0d17c738509518ecc6efa233ee6c10e724f2", - "is_verified": false, - "line_number": 225, - "type": "Basic Auth Credentials", - "verified_result": null - }, - { - "hashed_secret": "f6b2b77f83ae6122acb0954c6502cf87b2c097c3", - "is_verified": false, - "line_number": 265, - "type": "Secret Keyword", - "verified_result": null - } - ] - }, - "version": "0.13.1+ibm.62.dss", - "word_list": { - "file": null, - "hash": null - } + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": {}, + "generated_at": "2025-09-02T21:36:24Z" } diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 84f4f41f..1cc09eb8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -210,6 +210,8 @@ model Assignment { instructions String? /// Instructions for the assignment gradingCriteriaOverview String? /// Grading Criteria for the assignment timeEstimateMinutes Int? /// Estimated time it will take to complete the assignment in minutes + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded or not numAttempts Int? @default(-1) /// Max number of times a learner can attempt the assignment @@ -227,7 +229,7 @@ model Assignment { showAssignmentScore Boolean @default(true) /// Should the assignment score be shown to the learner after its submission showQuestionScore Boolean @default(true) /// Should the question score be shown to the learner after its submission showSubmissionFeedback Boolean @default(true) /// Should the AI provide feedback when the learner submits a question - showQuestions Boolean @default(true) /// Should the questions be shown to the learner + showQuestions Boolean @default(true) /// Should the questions be shown to the learner updatedAt DateTime @default(now()) @updatedAt /// The DateTime at which the assignment was last updated languageCode String? /// The language code for the assignment AIUsage AIUsage[] diff --git a/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts b/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts index 81ae5085..867058ff 100644 --- a/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts +++ b/apps/api/src/api/assignment/attempt/api-exceptions/exceptions.ts @@ -10,3 +10,5 @@ export const IN_PROGRESS_SUBMISSION_EXCEPTION = "A attempt is already in progress and has not expired."; export const TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE = "You have exceeded the allowed number of attempts within the specified time range."; +export const IN_COOLDOWN_PERIOD = + "You must wait some time before creating a new attempt for this assignment"; diff --git a/apps/api/src/api/assignment/attempt/attempt.controller.ts b/apps/api/src/api/assignment/attempt/attempt.controller.ts index 0434ad1a..58eb44a6 100644 --- a/apps/api/src/api/assignment/attempt/attempt.controller.ts +++ b/apps/api/src/api/assignment/attempt/attempt.controller.ts @@ -29,6 +29,7 @@ import { import { Roles } from "../../../auth/role/roles.global.guard"; import { GRADE_SUBMISSION_EXCEPTION, + IN_COOLDOWN_PERIOD, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, } from "./api-exceptions/exceptions"; @@ -77,6 +78,11 @@ export class AttemptControllerV1 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 403 }) createAssignmentAttempt( @Param("assignmentId") assignmentId: number, @@ -150,6 +156,11 @@ export class AttemptControllerV1 { type: String, description: SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index e485e73d..285b2b55 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -47,6 +47,7 @@ import { MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, + IN_COOLDOWN_PERIOD, } from "./api-exceptions/exceptions"; import { BaseAssignmentAttemptResponseDto } from "./dto/assignment-attempt/base.assignment.attempt.response.dto"; import { @@ -1422,6 +1423,7 @@ export class AttemptServiceV1 { assignment: LearnerGetAssignmentResponseDto, userSession: UserSession, ): Promise { + const now = new Date(); const timeRangeStartDate = this.calculateTimeRangeStartDate(assignment); const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -1431,36 +1433,28 @@ export class AttemptServiceV1 { OR: [ { submitted: false, - expiresAt: { - gte: new Date(), - }, + expiresAt: { gte: now }, }, { submitted: false, expiresAt: undefined, }, { - createdAt: { - gte: timeRangeStartDate, - lte: new Date(), - }, + createdAt: { gte: timeRangeStartDate, lte: now }, }, ], }, + orderBy: { createdAt: "desc" }, }); const ongoingAttempts = attempts.filter( - (sub) => - !sub.submitted && - (sub.expiresAt >= new Date() || sub.expiresAt === null), - ); - - const attemptsInTimeRange = attempts.filter( - (sub) => - sub.createdAt >= timeRangeStartDate && sub.createdAt <= new Date(), + (sub) => !sub.submitted && (!sub.expiresAt || sub.expiresAt >= now), ); if (ongoingAttempts.length > 0) { throw new UnprocessableEntityException(IN_PROGRESS_SUBMISSION_EXCEPTION); } + const attemptsInTimeRange = attempts.filter( + (sub) => sub.createdAt >= timeRangeStartDate && sub.createdAt <= now, + ); if ( assignment.attemptsPerTimeRange && @@ -1471,16 +1465,34 @@ export class AttemptServiceV1 { ); } if (assignment.numAttempts !== null && assignment.numAttempts !== -1) { - const attemptCount = await this.countUserAttempts( + const totalAttempts = await this.countUserAttempts( userSession.userId, assignment.id, ); - if (attemptCount >= assignment.numAttempts) { + if (totalAttempts >= assignment.numAttempts) { throw new UnprocessableEntityException( MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, ); } + + const attemptsBeforeCooldown = assignment.attemptsBeforeCoolDown ?? 1; + const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; + + if ( + attemptsBeforeCooldown > 0 && + totalAttempts >= attemptsBeforeCooldown + ) { + const latestAttempt = attemptsInTimeRange[0]; + if (latestAttempt?.expiresAt) { + const nextEligibleTime = + new Date(latestAttempt.expiresAt).getTime() + + cooldownMinutes * 60_000; + if (now.getTime() < nextEligibleTime) { + throw new UnprocessableEntityException(IN_COOLDOWN_PERIOD); + } + } + } } } diff --git a/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts b/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts index c48cd84e..8e9eb0bb 100644 --- a/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts +++ b/apps/api/src/api/assignment/dto/replace.assignment.request.dto.ts @@ -67,6 +67,26 @@ export class ReplaceAssignmentRequestDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", diff --git a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts index a588dc69..9363c724 100644 --- a/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts +++ b/apps/api/src/api/assignment/dto/update.assignment.request.dto.ts @@ -79,6 +79,26 @@ export class UpdateAssignmentRequestDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", diff --git a/apps/api/src/api/assignment/dto/update.questions.request.dto.ts b/apps/api/src/api/assignment/dto/update.questions.request.dto.ts index fe314b14..8dfad16d 100644 --- a/apps/api/src/api/assignment/dto/update.questions.request.dto.ts +++ b/apps/api/src/api/assignment/dto/update.questions.request.dto.ts @@ -416,6 +416,26 @@ export class UpdateAssignmentQuestionsDto { @IsInt() numAttempts: number | null; + @ApiProperty({ + description: + "The number of attempts before learners must wait for some period of time to retry (null means never waiting to retry)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + attemptsBeforeCoolDown: number | null; + + @ApiProperty({ + description: + "The amount of time learners must wait to retry in minutes (null means right away)", + type: Number, + required: false, + }) + @IsOptional() + @IsInt() + retakeAttemptCoolDownMinutes: number | null; + @ApiProperty({ description: "The allotted time for the assignment. (null means unlimited time)", diff --git a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts index 73b6c3cb..84ce0ea3 100644 --- a/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts +++ b/apps/api/src/api/assignment/v2/repositories/assignment.repository.ts @@ -266,6 +266,8 @@ export class AssignmentRepository { allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, displayOrder: undefined as unknown as AssignmentQuestionDisplayOrder, }; } diff --git a/apps/api/src/api/assignment/v2/services/assignment.service.ts b/apps/api/src/api/assignment/v2/services/assignment.service.ts index c2a2b1d2..f2bfffb8 100644 --- a/apps/api/src/api/assignment/v2/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v2/services/assignment.service.ts @@ -212,6 +212,8 @@ export class AssignmentServiceV2 { instructions: updateDto.instructions, gradingCriteriaOverview: updateDto.gradingCriteriaOverview, numAttempts: updateDto.numAttempts, + attemptsBeforeCoolDown: updateDto.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: updateDto.retakeAttemptCoolDownMinutes, passingGrade: updateDto.passingGrade, displayOrder: updateDto.displayOrder, graded: updateDto.graded, diff --git a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts index 1dd9267b..5dea19bd 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts @@ -951,6 +951,8 @@ export const createMockAssignment = ( type: AssignmentType.AI_GRADED, graded: false, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: null, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1048,6 +1050,8 @@ export const createMockUpdateAssignmentDto = ( timeEstimateMinutes: 45, graded: true, numAttempts: 3, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 60, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1076,6 +1080,8 @@ export const createMockReplaceAssignmentDto = ( timeEstimateMinutes: 60, graded: true, numAttempts: 2, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 90, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1101,6 +1107,8 @@ export const createMockUpdateAssignmentQuestionsDto = ( timeEstimateMinutes: 45, graded: true, numAttempts: 3, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 5, allotedTimeMinutes: 60, attemptsPerTimeRange: null, attemptsTimeRangeHours: null, @@ -1624,6 +1632,8 @@ export const createMockAssignmentRepository = () => ({ createEmptyDto: jest.fn().mockReturnValue({ instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts index b9960b14..b1bb51ec 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts @@ -1,19 +1,19 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable unicorn/no-null */ /* eslint-disable @typescript-eslint/unbound-method */ -import { Test, TestingModule } from "@nestjs/testing"; import { NotFoundException } from "@nestjs/common"; -import { PrismaService } from "src/prisma.service"; +import { Test, TestingModule } from "@nestjs/testing"; import { Assignment, QuestionType } from "@prisma/client"; +import { BaseAssignmentResponseDto } from "src/api/admin/dto/assignment/base.assignment.response.dto"; import { GetAssignmentResponseDto } from "src/api/assignment/dto/get.assignment.response.dto"; import { ScoringDto } from "src/api/assignment/dto/update.questions.request.dto"; -import { AssignmentRepository } from "../../../repositories/assignment.repository"; +import { PrismaService } from "src/prisma.service"; import { createMockAssignment, sampleAuthorSession, sampleLearnerSession, } from "../__mocks__/ common-mocks"; -import { BaseAssignmentResponseDto } from "src/api/admin/dto/assignment/base.assignment.response.dto"; +import { AssignmentRepository } from "../../../repositories/assignment.repository"; describe("AssignmentRepository", () => { let repository: AssignmentRepository; @@ -439,6 +439,8 @@ describe("AssignmentRepository", () => { jest.spyOn(repository as any, "createEmptyDto").mockReturnValue({ instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, diff --git a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts index acc50028..cb072853 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts @@ -333,6 +333,8 @@ describe("AssignmentServiceV2 – full unit-suite", () => { timeEstimateMinutes: 0, attemptsPerTimeRange: 0, attemptsTimeRangeHours: 0, + retakeAttemptCoolDownMinutes: 5, + attemptsBeforeCoolDown: 1, displayOrder: "DEFINED", questionDisplay: "ONE_PER_PAGE", questionOrder: [], diff --git a/apps/api/src/api/attempt/attempt.controller.ts b/apps/api/src/api/attempt/attempt.controller.ts index 310d9dda..2af8809d 100644 --- a/apps/api/src/api/attempt/attempt.controller.ts +++ b/apps/api/src/api/attempt/attempt.controller.ts @@ -36,6 +36,7 @@ import { GRADE_SUBMISSION_EXCEPTION, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, + IN_COOLDOWN_PERIOD, } from "../assignment/attempt/api-exceptions/exceptions"; import { BaseAssignmentAttemptResponseDto } from "../assignment/attempt/dto/assignment-attempt/base.assignment.attempt.response.dto"; import { LearnerUpdateAssignmentAttemptRequestDto } from "../assignment/attempt/dto/assignment-attempt/create.update.assignment.attempt.request.dto"; @@ -81,6 +82,11 @@ export class AttemptControllerV2 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 403 }) createAssignmentAttempt( @Param("assignmentId") assignmentId: number, @@ -103,6 +109,11 @@ export class AttemptControllerV2 { type: String, description: MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, @@ -176,6 +187,11 @@ export class AttemptControllerV2 { type: String, description: SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, }) + @ApiResponse({ + status: 429, + type: String, + description: IN_COOLDOWN_PERIOD, + }) @ApiResponse({ status: 500, type: String, diff --git a/apps/api/src/api/attempt/services/attempt-validation.service.ts b/apps/api/src/api/attempt/services/attempt-validation.service.ts index fda249cf..d6b8946b 100644 --- a/apps/api/src/api/attempt/services/attempt-validation.service.ts +++ b/apps/api/src/api/attempt/services/attempt-validation.service.ts @@ -1,4 +1,9 @@ -import { Injectable, UnprocessableEntityException } from "@nestjs/common"; +import { + Injectable, + UnprocessableEntityException, + HttpException, + HttpStatus, +} from "@nestjs/common"; import { PrismaService } from "../../../prisma.service"; import { UserSession } from "../../../auth/interfaces/user.session.interface"; import { @@ -6,6 +11,7 @@ import { TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, + IN_COOLDOWN_PERIOD, } from "src/api/assignment/attempt/api-exceptions/exceptions"; import { GetAssignmentResponseDto, @@ -25,6 +31,7 @@ export class AttemptValidationService { assignment: GetAssignmentResponseDto | LearnerGetAssignmentResponseDto, userSession: UserSession, ): Promise { + const now = new Date(); const timeRangeStartDate = this.calculateTimeRangeStartDate(assignment); const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -35,7 +42,7 @@ export class AttemptValidationService { { submitted: false, expiresAt: { - gte: new Date(), + gte: now, }, }, { @@ -45,17 +52,25 @@ export class AttemptValidationService { { createdAt: { gte: timeRangeStartDate, - lte: new Date(), + lte: now, }, }, ], }, + orderBy: { createdAt: "desc" }, + }); + + const lastSubmittedAttempt = await this.prisma.assignmentAttempt.findFirst({ + where: { + userId: userSession.userId, + assignmentId: assignment.id, + submitted: true, + }, + orderBy: { expiresAt: "desc" }, }); const ongoingAttempts = attempts.filter( - (sub) => - !sub.submitted && - (sub.expiresAt >= new Date() || sub.expiresAt === null), + (sub) => !sub.submitted && (!sub.expiresAt || sub.expiresAt >= now), ); if (ongoingAttempts.length > 0) { @@ -63,8 +78,7 @@ export class AttemptValidationService { } const attemptsInTimeRange = attempts.filter( - (sub) => - sub.createdAt >= timeRangeStartDate && sub.createdAt <= new Date(), + (sub) => sub.createdAt >= timeRangeStartDate && sub.createdAt <= now, ); if ( @@ -77,16 +91,40 @@ export class AttemptValidationService { } if (assignment.numAttempts !== null && assignment.numAttempts !== -1) { - const attemptCount = await this.countUserAttempts( + const totalAttempts = await this.countUserAttempts( userSession.userId, assignment.id, ); - if (attemptCount >= assignment.numAttempts) { + if (totalAttempts >= assignment.numAttempts) { throw new UnprocessableEntityException( MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, ); } + + const attemptsBeforeCooldown = assignment.attemptsBeforeCoolDown ?? 1; + const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; + + if ( + attemptsBeforeCooldown > 0 && + totalAttempts >= attemptsBeforeCooldown + ) { + const lastAttemptTime = new Date( + lastSubmittedAttempt.expiresAt, + ).getTime(); + const cooldownMs = cooldownMinutes * 60_000; + const nextEligibleTime = lastAttemptTime + cooldownMs; + + if (now.getTime() < nextEligibleTime) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: IN_COOLDOWN_PERIOD, + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } } } diff --git a/apps/api/src/assets/schema/assignment-example.json b/apps/api/src/assets/schema/assignment-example.json index 2f249150..40efe17d 100644 --- a/apps/api/src/assets/schema/assignment-example.json +++ b/apps/api/src/assets/schema/assignment-example.json @@ -5,6 +5,8 @@ "type": "AI_GRADED", "graded": true, "numAttempts": 5, + "attemptsBeforeCoolDown": 1, + "retakeAttemptCoolDownMinutes": 5, "allotedTimeMinutes": 60, "attemptsPerTimeRange": 5, "attemptsTimeRangeHours": 2, diff --git a/apps/api/src/assets/schema/assignment-schema.json b/apps/api/src/assets/schema/assignment-schema.json index c8e6e5ac..4e6c72db 100644 --- a/apps/api/src/assets/schema/assignment-schema.json +++ b/apps/api/src/assets/schema/assignment-schema.json @@ -28,6 +28,12 @@ "type": "string", "enum": ["DEFINED", "RANDOM"] }, + "attemptsBeforeCoolDown": { + "type": "integer" + }, + "retakeAttemptCoolDownMinutes": { + "type": "integer" + }, "questions": { "type": "array", "items": { @@ -79,6 +85,8 @@ "name", "type", "numAttempts", + "attemptsBeforeCoolDown", + "retakeAttemptCoolDownMinutes", "allotedTimeMinutes", "passingGrade", "displayOrder", diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index d5fe0674..ccd34cf9 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -58,6 +58,8 @@ export function useChangesSummary(): string { questionDisplay, questionVariationNumber, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, timeEstimateMinutes, allotedTimeMinutes, @@ -374,6 +376,24 @@ export function useChangesSummary(): string { diffs.push("Updated number of attempts."); } + if ( + !safeCompare( + attemptsBeforeCoolDown, + originalAssignment.attemptsBeforeCoolDown, + ) + ) { + diffs.push("Updated number of attempts before cooldown period."); + } + + if ( + !safeCompare( + retakeAttemptCoolDownMinutes, + originalAssignment.retakeAttemptCoolDownMinutes, + ) + ) { + diffs.push("Updated the cooldown time before retries allowed."); + } + if (!safeCompare(passingGrade, originalAssignment.passingGrade)) { diffs.push("Modified passing grade."); } diff --git a/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx index 87af5959..91e30f80 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/Question.tsx @@ -919,8 +919,8 @@ const Question: FC = ({ diff --git a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx index e81e0035..8d14be94 100644 --- a/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx +++ b/apps/web/app/author/(components)/Header/CheckLearnerSideButton.tsx @@ -34,6 +34,9 @@ const CheckLearnerSideButton: FC = (props) => { questionDisplay: assignmentConfigstate.questionDisplay, graded: assignmentConfigstate.graded, numAttempts: assignmentConfigstate.numAttempts, + retakeAttemptCoolDownMinutes: + assignmentConfigstate.retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown: assignmentConfigstate.attemptsBeforeCoolDown, passingGrade: assignmentConfigstate.passingGrade, allotedTimeMinutes: assignmentConfigstate.allotedTimeMinutes, timeEstimateMinutes: assignmentConfigstate.timeEstimateMinutes, diff --git a/apps/web/app/author/(components)/Header/index.tsx b/apps/web/app/author/(components)/Header/index.tsx index 91fc6fb2..50e3f33a 100644 --- a/apps/web/app/author/(components)/Header/index.tsx +++ b/apps/web/app/author/(components)/Header/index.tsx @@ -133,6 +133,8 @@ function AuthorHeader() { ]); const [ numAttempts, + retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown, passingGrade, displayOrder, graded, @@ -143,6 +145,8 @@ function AuthorHeader() { numberOfQuestionsPerAttempt, ] = useAssignmentConfig((state) => [ state.numAttempts, + state.retakeAttemptCoolDownMinutes, + state.attemptsBeforeCoolDown, state.passingGrade, state.displayOrder, state.graded, @@ -252,6 +256,9 @@ function AuthorHeader() { useAssignmentConfig.getState().setAssignmentConfigStore({ numAttempts: newAssignment.numAttempts, + retakeAttemptCoolDownMinutes: + newAssignment.retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown: newAssignment.attemptsBeforeCoolDown, passingGrade: newAssignment.passingGrade, displayOrder: newAssignment.displayOrder, graded: newAssignment.graded, @@ -446,6 +453,8 @@ function AuthorHeader() { const assignmentData: ReplaceAssignmentRequest = { ...encodedFields, numAttempts, + retakeAttemptCoolDownMinutes, + attemptsBeforeCoolDown, passingGrade, displayOrder, graded, diff --git a/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx new file mode 100644 index 00000000..f645881d --- /dev/null +++ b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Dropdown from "@/components/Dropdown"; +import Tooltip from "@/components/Tooltip"; +import { stepTwoSections } from "@/config/constants"; +import { useAssignmentConfig } from "@/stores/assignmentConfig"; +import { InformationCircleIcon } from "@heroicons/react/24/solid"; +import type { ComponentPropsWithoutRef, FC } from "react"; +import SectionWithTitle from "../ReusableSections/SectionWithTitle"; + +type Props = ComponentPropsWithoutRef<"div">; + +const Component: FC = () => { + const [ + attemptsBeforeCoolDown, + setAttemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, + setRetakeAttemptCoolDownMinutes, + errors, + ] = useAssignmentConfig((state) => [ + state.attemptsBeforeCoolDown, + state.setAttemptsBeforeCoolDown, + state.retakeAttemptCoolDownMinutes, + state.setRetakeAttemptCoolDownMinutes, + state.errors, + ]); + + const dropdownItems = [ + { value: 1, label: "Wait after every attempt" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, + { value: 4, label: "4" }, + { value: 5, label: "5" }, + { value: 10, label: "10" }, + { value: 0, label: "Never wait to retry" }, + ]; + const defaultCoolDownTimes = [ + { value: 5, label: "5 minutes" }, + { value: 10, label: "10 minutes" }, + { value: 60, label: "1 hour" }, + { value: 300, label: "5 hours" }, + { value: 1440, label: "1 day" }, + { value: 10080, label: "7 days" }, + { value: 0, label: "Never wait to retry" }, + ]; + + return ( + +
+ + + id="attempts-before-cooldown-period" + items={dropdownItems} + selectedItem={attemptsBeforeCoolDown} + setSelectedItem={setAttemptsBeforeCoolDown} + /> + {errors.attemptsBeforeCoolDown && ( +

+ {errors.attemptsBeforeCoolDown} +

+ )} +
+
+ + + id="cooldown-period" + items={defaultCoolDownTimes} + selectedItem={retakeAttemptCoolDownMinutes} + setSelectedItem={setRetakeAttemptCoolDownMinutes} + /> + {errors.retakeAttemptCoolDownMinutes && ( +

+ {errors.retakeAttemptCoolDownMinutes} +

+ )} +
+
+ ); +}; + +export default Component; diff --git a/apps/web/app/author/[assignmentId]/config/page.tsx b/apps/web/app/author/[assignmentId]/config/page.tsx index 77a3ac1f..3aeeae47 100644 --- a/apps/web/app/author/[assignmentId]/config/page.tsx +++ b/apps/web/app/author/[assignmentId]/config/page.tsx @@ -2,6 +2,7 @@ import AssignmentQuestionOrder from "@/app/author/(components)/StepTwo/Assignmen import AssignmentType from "@/app/author/(components)/StepTwo/AssignmentType"; import PageTitle from "@authorComponents/PageTitle"; import AssignmentCompletion from "@authorComponents/StepTwo/AssignmentCompletion"; +import AssignmentRetakeAttempts from "@authorComponents/StepTwo/AssignmentRetakeAttempts"; import AssignmentFeedback from "@authorComponents/StepTwo/AssignmentFeedback"; import AssignmentTime from "@authorComponents/StepTwo/AssignmentTime"; import { FooterNavigation } from "@authorComponents/StepTwo/FooterNavigation"; @@ -23,6 +24,7 @@ function Component(props: Props) { + diff --git a/apps/web/app/author/[assignmentId]/review/page.tsx b/apps/web/app/author/[assignmentId]/review/page.tsx index c7cd9421..8a0a368a 100644 --- a/apps/web/app/author/[assignmentId]/review/page.tsx +++ b/apps/web/app/author/[assignmentId]/review/page.tsx @@ -534,6 +534,8 @@ function Component() { allotedTimeMinutes, timeEstimateMinutes, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, questionDisplay, @@ -544,6 +546,8 @@ function Component() { state.allotedTimeMinutes, state.timeEstimateMinutes, state.numAttempts, + state.attemptsBeforeCoolDown, + state.retakeAttemptCoolDownMinutes, state.passingGrade, state.displayOrder, state.questionDisplay, @@ -635,6 +639,12 @@ function Component() { numAttempts: filteredChanges.some((c) => c.includes("Updated number of attempts"), ), + attemptsBeforeCoolDown: filteredChanges.some((c) => + c.includes("Updated number of attempts before cooldown period"), + ), + retakeAttemptCoolDownMinutes: filteredChanges.some((c) => + c.includes("Updated the cooldown time before retries allowed"), + ), passingGrade: filteredChanges.some((c) => c.includes("Modified passing grade"), ), @@ -754,6 +764,8 @@ function Component() { allotedTimeMinutes, timeEstimateMinutes, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, questionDisplay, @@ -1258,6 +1270,8 @@ function Component() { changes.allotedTime || changes.timeEstimate || changes.numAttempts || + changes.attemptsBeforeCoolDown || + changes.retakeAttemptCoolDownMinutes || changes.passingGrade || changes.displayOrder || changes.questionDisplay || @@ -1345,6 +1359,22 @@ function Component() { /> )} + {changes.attemptsBeforeCoolDown && ( + + )} + + {changes.retakeAttemptCoolDownMinutes && ( + + )} + {changes.passingGrade && ( { - assignmentState: string; - assignmentId: number; - role?: string; - attemptsLeft: number; + disabled: boolean; + message: string; + label: string; + href: string; + className?: string; } const BeginTheAssignment: React.FC = (props) => { - const { assignmentState, assignmentId, className, role, attemptsLeft } = - props; + const { disabled, message, label, href, className } = props; const userPreferedLanguage = useLearnerStore( (state) => state.userPreferedLanguage, ); const setUserPreferedLanguage = useLearnerStore( (state) => state.setUserPreferedLanguage, ); - const MoveToQuestionPage = () => { + const handleClick = () => { setUserPreferedLanguage(userPreferedLanguage); - const url = - role === "learner" - ? `/learner/${assignmentId}/questions` - : `/learner/${assignmentId}/questions?authorMode=true`; - - window.location.href = url; + if (!disabled) { + window.location.href = href; + } }; return (
- + diff --git a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx index 16cfe488..8a98d9eb 100644 --- a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx +++ b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx @@ -69,6 +69,8 @@ const AboutTheAssignment: FC = ({ allotedTimeMinutes, timeEstimateMinutes, numAttempts = -1, + attemptsBeforeCoolDown = 1, + retakeAttemptCoolDownMinutes = 5, passingGrade, name = "Untitled", id, @@ -92,29 +94,14 @@ const AboutTheAssignment: FC = ({ const pathname = usePathname(); const [toggleLanguageSelectionModal, setToggleLanguageSelectionModal] = useState(false); - useEffect(() => { - if (!userPreferedLanguage || languageModalTriggered) { - setToggleLanguageSelectionModal(true); - } - }, [userPreferedLanguage, languageModalTriggered]); - useEffect(() => { - async function fetchLanguages() { - setIsLoading(true); - const supportedLanguages = await getSupportedLanguages(assignmentId); - setLanguages(supportedLanguages); - setIsLoading(false); - } - void fetchLanguages(); - }, [assignmentId]); + const assignmentState = !published && role === "learner" ? "not-published" : getAssignmentState(attempts, numAttempts); const attemptsLeft = - (numAttempts ?? -1) === -1 - ? Infinity - : Math.max(0, numAttempts - attempts.length); + numAttempts === -1 ? Infinity : Math.max(0, numAttempts - attempts.length); const latestAttempt = attempts?.reduce((latest, attempt) => { if (!latest) return attempt; @@ -126,9 +113,79 @@ const AboutTheAssignment: FC = ({ } return latest; }, null); - const latestAttemptDate = latestAttempt - ? new Date(latestAttempt.createdAt).toLocaleString() - : "No attempts yet"; + + const attemptsCount = attempts.length; + const [cooldownMessage, setCooldownMessage] = useState(null); + const [isCooldown, setIsCooldown] = useState(false); + + useEffect(() => { + if ( + !latestAttempt || + (attemptsLeft > 0 && attemptsCount < attemptsBeforeCoolDown) || + attemptsLeft === 0 + ) { + setCooldownMessage(null); + setIsCooldown(false); + return; + } + + const finishedAt = new Date(latestAttempt?.expiresAt).getTime(); + const cooldownMs = retakeAttemptCoolDownMinutes * 60_000; + const nextEligibleAt = finishedAt + cooldownMs; + + function updateCountdown() { + const remainingMs = nextEligibleAt - Date.now(); + + if (remainingMs <= 0) { + setCooldownMessage(null); + setIsCooldown(false); + return; + } + + setIsCooldown(true); + + const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000)); + let remainder = remainingMs % (24 * 60 * 60 * 1000); + const hours = Math.floor(remainder / (60 * 60 * 1000)); + remainder %= 60 * 60 * 1000; + const minutes = Math.floor(remainder / 60000); + const seconds = Math.floor((remainder % 60000) / 1000); + + const parts = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds) parts.push(`${seconds}s`); + + setCooldownMessage(`Please wait ${parts.join(" ")} before retrying`); + } + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + return () => clearInterval(interval); + }, [ + latestAttempt, + attemptsLeft, + attemptsCount, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, + ]); + + useEffect(() => { + if (!userPreferedLanguage || languageModalTriggered) { + setToggleLanguageSelectionModal(true); + } + }, [userPreferedLanguage, languageModalTriggered]); + useEffect(() => { + async function fetchLanguages() { + setIsLoading(true); + const supportedLanguages = await getSupportedLanguages(assignmentId); + setLanguages(supportedLanguages); + setIsLoading(false); + } + void fetchLanguages(); + }, [assignmentId]); + const handleConfirm = () => { if (selectedLanguage) { router.replace(`${pathname}?lang=${selectedLanguage}`, undefined); @@ -144,6 +201,37 @@ const AboutTheAssignment: FC = ({ setLanguageModalTriggered(false); setToggleLanguageSelectionModal(false); }; + + const url = + role === "learner" + ? `/learner/${assignmentId}/questions` + : `/learner/${assignmentId}/questions?authorMode=true`; + + const buttonLabel = assignmentState === "in-progress" ? "Resume" : "Begin"; + let buttonMessage = ""; + let buttonDisabled = false; + + if (!role) { + buttonDisabled = true; + buttonMessage = "You must be signed in with a role to begin."; + } else if (role === "learner" && assignmentState === "not-published") { + buttonDisabled = true; + buttonMessage = "The assignment is not published yet."; + } else if (attemptsLeft === 0) { + buttonDisabled = true; + buttonMessage = + "Maximum attempts reached, contact the author to request more."; + } else if (isCooldown && cooldownMessage) { + buttonDisabled = true; + buttonMessage = cooldownMessage; + } else { + buttonMessage = `Click to ${assignmentState === "in-progress" ? "Resume" : "Begin"}`; + } + + const latestAttemptDate = latestAttempt + ? new Date(latestAttempt.createdAt).toLocaleString() + : "No attempts yet"; + return ( <>
@@ -167,13 +255,18 @@ const AboutTheAssignment: FC = ({ )}
+ {isCooldown && cooldownMessage && ( + + ({cooldownMessage}) + + )} @@ -232,10 +325,10 @@ const AboutTheAssignment: FC = ({
diff --git a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx index fe595c53..1f9c1927 100644 --- a/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx +++ b/apps/web/app/learner/[assignmentId]/questions/LearnerLayout.tsx @@ -69,6 +69,18 @@ async function LearnerLayout(props: Props) { ); } + if (attemptId === "in cooldown period") { + return ( + + ); + } + return ( }> { +): Promise { const endpointURL = `${getApiRoutes().assignments}/${assignmentId}/attempts`; try { const res = await fetch(endpointURL, { @@ -38,11 +38,14 @@ export async function createAttempt( ...(cookies ? { Cookie: cookies } : {}), }, }); + console.log("result: ", res); if (!res.ok) { + console.log("result status: ", res.status); const errorBody = (await res.json()) as { message: string }; + console.log("error: ", errorBody); if (res.status === 422) { return "no more attempts"; - } + } else if (res.status === 429) return "in cooldown period"; throw new Error(errorBody.message || "Failed to create attempt"); } const { success, error, id } = (await res.json()) as BaseBackendResponse; diff --git a/apps/web/stores/assignmentConfig.ts b/apps/web/stores/assignmentConfig.ts index 0f3cea0b..1081f011 100644 --- a/apps/web/stores/assignmentConfig.ts +++ b/apps/web/stores/assignmentConfig.ts @@ -14,6 +14,10 @@ type GradingDataActions = { setQuestionVariationNumber: (questionVariationNumber: number) => void; setGraded: (graded: boolean) => void; setNumAttempts: (numAttempts: number) => void; + setAttemptsBeforeCoolDown: (attemptsBeforeCoolDown: number) => void; + setRetakeAttemptCoolDownMinutes: ( + retakeAttemptCoolDownMinutes: number, + ) => void; setPassingGrade: (passingGrade: number) => void; setTimeEstimateMinutes: (timeEstimateMinutes: number) => void; setAllotedTimeMinutes: (allotedTimeMinutes: number) => void; @@ -35,6 +39,8 @@ export const useAssignmentConfig = createWithEqualityFn< withUpdatedAt((set, get) => ({ errors: {}, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 1, passingGrade: 50, displayOrder: "DEFINED", strictTimeLimit: false, @@ -62,6 +68,20 @@ export const useAssignmentConfig = createWithEqualityFn< set({ numAttempts: numAttempts === undefined ? -1 : numAttempts, }), + setAttemptsBeforeCoolDown: (attemptsBeforeCoolDown) => + set({ + attemptsBeforeCoolDown: + attemptsBeforeCoolDown === undefined + ? -1 + : attemptsBeforeCoolDown, + }), + setRetakeAttemptCoolDownMinutes: (retakeAttemptCoolDownMinutes) => + set({ + retakeAttemptCoolDownMinutes: + retakeAttemptCoolDownMinutes === undefined + ? -1 + : retakeAttemptCoolDownMinutes, + }), setPassingGrade: (passingGrade) => set({ passingGrade }), timeEstimateMinutes: undefined, setTimeEstimateMinutes: (timeEstimateMinutes) => @@ -101,6 +121,20 @@ export const useAssignmentConfig = createWithEqualityFn< if (!state.numAttempts || state.numAttempts < -1) { errors.numAttempts = "Please enter a valid number of attempts."; } + if ( + !state.attemptsBeforeCoolDown || + state.attemptsBeforeCoolDown < -1 + ) { + errors.attemptsBeforeCoolDown = + "Please enter a valid number of attempts before a cool down period is required."; + } + if ( + !state.retakeAttemptCoolDownMinutes || + state.retakeAttemptCoolDownMinutes < 0 + ) { + errors.retakeAttemptCoolDownMinutes = + "Please enter a valid number for the amount of time for a cooldown period in minutes"; + } if ( state.passingGrade === undefined || state.passingGrade <= 0 || @@ -125,6 +159,8 @@ export const useAssignmentConfig = createWithEqualityFn< set(() => ({ errors: {}, numAttempts: -1, + attemptsBeforeCoolDown: 1, + retakeAttemptCoolDownMinutes: 0, passingGrade: 50, displayOrder: "DEFINED", strictTimeLimit: false, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e98d556b..43859093 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -77,7 +77,6 @@ We follow **semantic conventions** for branches, versions, and PRs: ## Prerequisites Before contributing to this project, ensure the following tools and dependencies are installed: -Thought for 4 seconds **IBM's detect-secrets fork**: From 49ddf1d49249e9ca0628f2c85d65ec25d42c9e8c Mon Sep 17 00:00:00 2001 From: MmagdyhafezZ Date: Tue, 30 Sep 2025 00:46:56 -0600 Subject: [PATCH 2/5] adding migration.sql, fixing imports, hiding retakeAttemptCoolDownMinutes if attemptsBeforeCoolDown is 0. --- .secrets.baseline | 46 +++++++----------- .../migration.sql | 3 ++ .../services/attempt-validation.service.ts | 9 +++- apps/web/app/Helpers/checkDiff.ts | 2 +- .../StepTwo/AssignmentRetakeAttempts.tsx | 47 ++++++++++--------- 5 files changed, 53 insertions(+), 54 deletions(-) create mode 100644 apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql diff --git a/.secrets.baseline b/.secrets.baseline index b9bada00..6ff249ab 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,39 +3,40 @@ "files": null, "lines": null }, - "generated_at": "2025-09-21T22:56:11Z", + "generated_at": "2025-09-30T06:26:27Z", "plugins_used": [ { - "name": "ArtifactoryDetector" + "name": "AWSKeyDetector" }, { - "name": "AWSKeyDetector" + "name": "ArtifactoryDetector" }, { "name": "AzureStorageKeyDetector" }, { - "name": "Base64HighEntropyString", - "limit": 4.5 + "base64_limit": 4.5, + "name": "Base64HighEntropyString" }, { "name": "BasicAuthDetector" }, { - "name": "CloudantDetector" + "name": "BoxDetector" }, { - "name": "DiscordBotTokenDetector" + "name": "CloudantDetector" }, { - "name": "GitHubTokenDetector" + "ghe_instance": "github.ibm.com", + "name": "GheDetector" }, { - "name": "GitLabTokenDetector" + "name": "GitHubTokenDetector" }, { - "name": "HexHighEntropyString", - "limit": 3.0 + "hex_limit": 3, + "name": "HexHighEntropyString" }, { "name": "IbmCloudIamDetector" @@ -43,15 +44,12 @@ { "name": "IbmCosHmacDetector" }, - { - "name": "IPPublicDetector" - }, { "name": "JwtTokenDetector" }, { - "name": "KeywordDetector", - "keyword_exclude": "" + "keyword_exclude": null, + "name": "KeywordDetector" }, { "name": "MailchimpDetector" @@ -59,18 +57,9 @@ { "name": "NpmDetector" }, - { - "name": "OpenAIDetector" - }, { "name": "PrivateKeyDetector" }, - { - "name": "PypiTokenDetector" - }, - { - "name": "SendGridDetector" - }, { "name": "SlackDetector" }, @@ -83,9 +72,6 @@ { "name": "StripeDetector" }, - { - "name": "TelegramBotTokenDetector" - }, { "name": "TwilioKeyDetector" } @@ -347,14 +333,14 @@ { "hashed_secret": "1c2b0d17c738509518ecc6efa233ee6c10e724f2", "is_verified": false, - "line_number": 225, + "line_number": 224, "type": "Basic Auth Credentials", "verified_result": null }, { "hashed_secret": "f6b2b77f83ae6122acb0954c6502cf87b2c097c3", "is_verified": false, - "line_number": 265, + "line_number": 264, "type": "Secret Keyword", "verified_result": null } diff --git a/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql b/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql new file mode 100644 index 00000000..d87a595e --- /dev/null +++ b/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Assignment" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1, +ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5; diff --git a/apps/api/src/api/attempt/services/attempt-validation.service.ts b/apps/api/src/api/attempt/services/attempt-validation.service.ts index b10533b5..0d73cc15 100644 --- a/apps/api/src/api/attempt/services/attempt-validation.service.ts +++ b/apps/api/src/api/attempt/services/attempt-validation.service.ts @@ -1,10 +1,15 @@ import { + HttpException, + HttpStatus, + Injectable, + UnprocessableEntityException, +} from "@nestjs/common"; +import { + IN_COOLDOWN_PERIOD, IN_PROGRESS_SUBMISSION_EXCEPTION, MAX_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, SUBMISSION_DEADLINE_EXCEPTION_MESSAGE, - IN_COOLDOWN_PERIOD, TIME_RANGE_ATTEMPTS_SUBMISSION_EXCEPTION_MESSAGE, - } from "src/api/assignment/attempt/api-exceptions/exceptions"; import { GetAssignmentResponseDto, diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index 60f871f6..5511a5b6 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -131,7 +131,7 @@ export function useChangesSummary(): string { const originalQuestions = originalAssignment.questions || []; const currentQuestions = questions || []; - const addedQuestions = currentQuestions.filter( + const addedQuestions = currentQuestions?.filter( (question) => !originalQuestions.some((origQ) => origQ.id === question?.id), ); diff --git a/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx index f645881d..41ea9692 100644 --- a/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx +++ b/apps/web/app/author/(components)/StepTwo/AssignmentRetakeAttempts.tsx @@ -76,28 +76,33 @@ const Component: FC = () => {

)} -
- - - id="cooldown-period" - items={defaultCoolDownTimes} - selectedItem={retakeAttemptCoolDownMinutes} - setSelectedItem={setRetakeAttemptCoolDownMinutes} - /> - {errors.retakeAttemptCoolDownMinutes && ( -

0 && ( +

+
+ How long do learners have to wait before making another attempt? + + + + + + id="cooldown-period" + items={defaultCoolDownTimes} + selectedItem={retakeAttemptCoolDownMinutes} + setSelectedItem={setRetakeAttemptCoolDownMinutes} + /> + {errors.retakeAttemptCoolDownMinutes && ( +

+ {errors.retakeAttemptCoolDownMinutes} +

+ )} +
+ )} ); }; From e1262da162e9e81096c6636bd4c7aaba92af4054 Mon Sep 17 00:00:00 2001 From: Arora <40769427+arora-r@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:17:09 -0400 Subject: [PATCH 3/5] Delete apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql --- .../20250925181313_assignment_cooldown/migration.sql | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql diff --git a/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql b/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql deleted file mode 100644 index d87a595e..00000000 --- a/apps/api/prisma/migrations/20250925181313_assignment_cooldown/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "Assignment" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1, -ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5; From fa2c5dfa96f1489636d69641c888207b150ab356 Mon Sep 17 00:00:00 2001 From: arora-r Date: Wed, 1 Oct 2025 12:59:10 -0400 Subject: [PATCH 4/5] chore: simplify assignment export logic --- .../app/author/[assignmentId]/review/page.tsx | 88 ++++++++----------- apps/web/lib/utils.ts | 7 ++ 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/apps/web/app/author/[assignmentId]/review/page.tsx b/apps/web/app/author/[assignmentId]/review/page.tsx index f34c9e2d..9d0d4e79 100644 --- a/apps/web/app/author/[assignmentId]/review/page.tsx +++ b/apps/web/app/author/[assignmentId]/review/page.tsx @@ -33,6 +33,23 @@ import { } from "@heroicons/react/24/solid"; import { QuestionAuthorStore } from "@/config/types"; import ExportModal, { ExportOptions } from "../../(components)/ExportModal"; +import { omit } from "../../../../lib/utils"; + +const CONFIG_KEYS_TO_OMIT = [ + "errors", + "updatedAt", + "id", + "name", + "introduction", + "instructions", + "gradingCriteriaOverview", + "type", + "questionOrder", + "published", + "languageCode", + "currentVersionId", + "questions", +]; // Helper function to determine if a validation error is question-related const isQuestionRelatedValidationError = (message: string): boolean => { @@ -744,31 +761,7 @@ function Component() { const [isExportModalOpen, setIsExportModalOpen] = useState(false); const [isIssuesModalOpen, setIsIssuesModalOpen] = useState(false); - const [ - graded, - allotedTimeMinutes, - timeEstimateMinutes, - numAttempts, - attemptsBeforeCoolDown, - retakeAttemptCoolDownMinutes, - passingGrade, - displayOrder, - questionDisplay, - numberOfQuestionsPerAttempt, - strictTimeLimit, - ] = useAssignmentConfig((state) => [ - state.graded, - state.allotedTimeMinutes, - state.timeEstimateMinutes, - state.numAttempts, - state.attemptsBeforeCoolDown, - state.retakeAttemptCoolDownMinutes, - state.passingGrade, - state.displayOrder, - state.questionDisplay, - state.numberOfQuestionsPerAttempt, - state.strictTimeLimit, - ]); + const assignmentConfig = useAssignmentConfig((state) => state); const [ introduction, @@ -1124,19 +1117,7 @@ function Component() { } if (exportOptions.includeConfig) { - exportData.config = { - graded, - allotedTimeMinutes, - timeEstimateMinutes, - numAttempts, - attemptsBeforeCoolDown, - retakeAttemptCoolDownMinutes, - passingGrade, - displayOrder, - questionDisplay, - numberOfQuestionsPerAttempt, - strictTimeLimit, - }; + exportData.config = omit(assignmentConfig, CONFIG_KEYS_TO_OMIT); } if (exportOptions.includeFeedbackConfig) { @@ -1698,7 +1679,7 @@ function Component() { router.push(`/author/${activeAssignmentId}/config`) } @@ -1714,8 +1695,8 @@ function Component() { : "No time limit" } after={ - allotedTimeMinutes - ? `${allotedTimeMinutes} minutes` + assignmentConfig.allotedTimeMinutes + ? `${assignmentConfig.allotedTimeMinutes} minutes` : "No time limit" } /> @@ -1730,8 +1711,8 @@ function Component() { : "Not set" } after={ - timeEstimateMinutes - ? `${timeEstimateMinutes} minutes` + assignmentConfig.timeEstimateMinutes + ? `${assignmentConfig.timeEstimateMinutes} minutes` : "Not set" } /> @@ -1745,7 +1726,11 @@ function Component() { ? "Unlimited" : originalAssignment.numAttempts } - after={numAttempts === -1 ? "Unlimited" : numAttempts} + after={ + assignmentConfig.numAttempts === -1 + ? "Unlimited" + : assignmentConfig.numAttempts + } /> )} @@ -1753,7 +1738,7 @@ function Component() { )} @@ -1761,7 +1746,7 @@ function Component() { )} @@ -1769,7 +1754,7 @@ function Component() { )} @@ -1777,7 +1762,7 @@ function Component() { )} @@ -1788,7 +1773,10 @@ function Component() { /_/g, " ", )} - after={questionDisplay?.replace(/_/g, " ")} + after={assignmentConfig.questionDisplay?.replace( + /_/g, + " ", + )} /> )} @@ -1796,7 +1784,7 @@ function Component() { )} diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 98c10d33..3d5bd5bd 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -187,6 +187,13 @@ export const getSubmitButtonStatus = ( return { disabled: false, reason: null }; }; + export const generateTempQuestionId = (): number => { return Math.floor(Math.random() * 2e9); }; + +export const omit = (obj: object, keys: string[]): object => { + return Object.fromEntries( + Object.entries(obj).filter(([k]) => !keys.includes(k)), + ); +}; From 841cdd8572761bcaf2826faac5440c36462b5352 Mon Sep 17 00:00:00 2001 From: arora-r Date: Thu, 2 Oct 2025 14:34:33 -0400 Subject: [PATCH 5/5] fix: add versioning change --- .secrets.baseline | 353 ++++-------------- apps/api/prisma/schema.prisma | 4 + .../api/assignment/attempt/attempt.service.ts | 6 +- .../v1/services/assignment.service.ts | 8 + .../v2/services/draft-management.service.ts | 50 ++- .../v2/services/version-management.service.ts | 21 ++ .../services/attempt-validation.service.ts | 6 +- .../src/scripts/create-initial-versions.ts | 2 + apps/web/app/Helpers/checkDiff.ts | 2 + apps/web/app/Helpers/checkQuestionsReady.ts | 18 + .../AuthorQuestionsPage/index.tsx | 8 + .../BeginTheAssignmentButton.tsx | 6 +- .../(components)/AboutTheAssignment/index.tsx | 24 +- .../version-control/VersionTreeView.tsx | 30 ++ apps/web/hooks/useVersionControl.ts | 10 + apps/web/lib/semantic-versioning.ts | 2 + apps/web/stores/assignmentConfig.ts | 10 +- apps/web/stores/author.ts | 22 ++ 18 files changed, 255 insertions(+), 327 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 6ff249ab..c49ff51c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,42 +1,37 @@ { - "exclude": { - "files": null, - "lines": null - }, - "generated_at": "2025-09-30T06:26:27Z", + "version": "1.5.0", "plugins_used": [ { - "name": "AWSKeyDetector" + "name": "ArtifactoryDetector" }, { - "name": "ArtifactoryDetector" + "name": "AWSKeyDetector" }, { "name": "AzureStorageKeyDetector" }, { - "base64_limit": 4.5, - "name": "Base64HighEntropyString" + "name": "Base64HighEntropyString", + "limit": 4.5 }, { "name": "BasicAuthDetector" }, - { - "name": "BoxDetector" - }, { "name": "CloudantDetector" }, { - "ghe_instance": "github.ibm.com", - "name": "GheDetector" + "name": "DiscordBotTokenDetector" }, { "name": "GitHubTokenDetector" }, { - "hex_limit": 3, - "name": "HexHighEntropyString" + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 }, { "name": "IbmCloudIamDetector" @@ -44,12 +39,15 @@ { "name": "IbmCosHmacDetector" }, + { + "name": "IPPublicDetector" + }, { "name": "JwtTokenDetector" }, { - "keyword_exclude": null, - "name": "KeywordDetector" + "name": "KeywordDetector", + "keyword_exclude": "" }, { "name": "MailchimpDetector" @@ -57,9 +55,18 @@ { "name": "NpmDetector" }, + { + "name": "OpenAIDetector" + }, { "name": "PrivateKeyDetector" }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, { "name": "SlackDetector" }, @@ -72,283 +79,59 @@ { "name": "StripeDetector" }, + { + "name": "TelegramBotTokenDetector" + }, { "name": "TwilioKeyDetector" } ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], "results": { - ".env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 21, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api-gateway/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 29, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api-gateway/src/api/api.service.ts": [ - { - "hashed_secret": "bb27330ad8e4418fd1545f76c797f9e495ced3f1", - "is_verified": false, - "line_number": 62, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "5df614d5ca1ea5b297766ca8fc7825aae5b71cb2", - "is_verified": false, - "line_number": 63, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 30, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/docs/LLM_ARCHITECTURE.md": [ - { - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 351, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/docs/LLM_DEVELOPER_GUIDE.md": [ - { - "hashed_secret": "ae73efcbd9318d1bb83ba4d33ed6ea49269065d7", - "is_verified": false, - "line_number": 68, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "5bfdb31e6eeb23b5f8dccada86645912b9b633e1", - "is_verified": false, - "line_number": 359, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/attempt/services/file-content-extraction.ts": [ - { - "hashed_secret": "2c0580ffd7d80319531cf629f5e90f747b1386f1", - "is_verified": false, - "line_number": 2500, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/github/github.service.ts": [ - { - "hashed_secret": "7ea6be9eecb6605329a1b1870c2fd2af9b896991", - "is_verified": false, - "line_number": 55, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/api/src/api/report/services/flo.service.ts": [ - { - "hashed_secret": "035576b41cbaa44c5a58b0e7e3e8df19c5226a3f", - "is_verified": false, - "line_number": 39, - "type": "Secret Keyword", - "verified_result": null - } - ], "apps/api/src/auth/services/admin-email.service.ts": [ { - "hashed_secret": "0745833f7b44bb251e684ed54daa7f30db4a61ff", - "is_verified": false, - "line_number": 17, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "71011165e6f4116d3943a7b5ef8446c02f10ea7f", - "is_verified": false, - "line_number": 23, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "da32b2dd835ec07a74fb098cb9e617bbfac01eff", - "is_verified": false, - "line_number": 48, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "de493e0c6963b221bcc6f77606d52602cc46f1f0", - "is_verified": false, - "line_number": 51, "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", + "filename": "apps/api/src/auth/services/admin-email.service.ts", + "hashed_secret": "c218e39efa2e1aae69f39d2054528369ce1e1f46", "is_verified": false, - "line_number": 96, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/.env.template": [ - { - "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", - "is_verified": false, - "line_number": 4, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/markChat/route.ts": [ - { - "hashed_secret": "3f70a0183ead614880b70bff64fd39c0d9155d62", - "is_verified": false, - "line_number": 9, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/markChat/services/reportingService.ts": [ - { - "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", - "is_verified": false, - "line_number": 61, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/api/transcribe/route.ts": [ - { - "hashed_secret": "60634a9040f4a588cd5ec80391507a208c79cf00", - "is_verified": false, - "line_number": 6, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/FileCodeUploadSection.tsx": [ - { - "hashed_secret": "cfb93ca9f329289d5f23de2b0e065103b81a374c", - "is_verified": false, - "line_number": 116, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/FileUploadSection.tsx": [ - { - "hashed_secret": "cfb93ca9f329289d5f23de2b0e065103b81a374c", - "is_verified": false, - "line_number": 160, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/(components)/Question/GithubUploadModal.tsx": [ - { - "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", - "is_verified": false, - "line_number": 162, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "ee977806d7286510da8b9a7492ba58e2484c0ecc", - "is_verified": false, - "line_number": 177, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/app/learner/[assignmentId]/successPage/Question.tsx": [ - { - "hashed_secret": "e1166e6dd837019ab04f130ab34c425e04161645", - "is_verified": false, - "line_number": 382, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "f0c5bc5473fd2f959bdac630e625aa33346fd12a", - "is_verified": false, - "line_number": 429, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "29080f1c58f9859ddaa6aeda7d2c410c12e222dc", - "is_verified": false, - "line_number": 461, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/lib/author.ts": [ - { - "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", - "is_verified": false, - "line_number": 1596, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/lib/shared.ts": [ - { - "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", - "is_verified": false, - "line_number": 1051, - "type": "Secret Keyword", - "verified_result": null - } - ], - "apps/web/public/ffmpeg-core/ffmpeg-core.js": [ - { - "hashed_secret": "b4e44716dbbf57be3dae2f819d96795a85d06652", - "is_verified": false, - "line_number": 410, - "type": "Secret Keyword", - "verified_result": null - } - ], - "docs/CONTRIBUTING.md": [ - { - "hashed_secret": "1c2b0d17c738509518ecc6efa233ee6c10e724f2", - "is_verified": false, - "line_number": 224, - "type": "Basic Auth Credentials", - "verified_result": null - }, - { - "hashed_secret": "f6b2b77f83ae6122acb0954c6502cf87b2c097c3", - "is_verified": false, - "line_number": 264, - "type": "Secret Keyword", - "verified_result": null + "line_number": 96 } ] }, - "version": "0.13.1+ibm.62.dss", - "word_list": { - "file": null, - "hash": null - } + "generated_at": "2025-10-02T18:30:28Z" } diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index da2b32de..cdc75774 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -265,6 +265,8 @@ model AssignmentVersion { instructions String? /// Instructions for this version gradingCriteriaOverview String? /// Grading criteria for this version timeEstimateMinutes Int? /// Time estimate for this version + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded numAttempts Int? @default(-1) /// Max attempts allowed @@ -598,6 +600,8 @@ model AssignmentDraft { instructions String? /// Instructions for this draft gradingCriteriaOverview String? /// Grading criteria for this draft timeEstimateMinutes Int? /// Time estimate for this draft + attemptsBeforeCoolDown Int? @default(1) /// How many attempts someone has before they have to go through a cooldown period, cooldown starts after first attempt + retakeAttemptCoolDownMinutes Int? @default(5) /// How long before someone can make another attempt, defaulted to 5 minutes type AssignmentType /// Type of assignment graded Boolean? @default(false) /// Is the assignment graded numAttempts Int? @default(-1) /// Max attempts allowed diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index 55d6d9a2..574946bf 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -1528,12 +1528,12 @@ export class AttemptServiceV1 { ); } - const attemptsBeforeCooldown = assignment.attemptsBeforeCoolDown ?? 1; + const attemptsBeforeCoolDown = assignment.attemptsBeforeCoolDown ?? 1; const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; if ( - attemptsBeforeCooldown > 0 && - totalAttempts >= attemptsBeforeCooldown + attemptsBeforeCoolDown > 0 && + totalAttempts >= attemptsBeforeCoolDown ) { const latestAttempt = attemptsInTimeRange[0]; if (latestAttempt?.expiresAt) { diff --git a/apps/api/src/api/assignment/v1/services/assignment.service.ts b/apps/api/src/api/assignment/v1/services/assignment.service.ts index 400c588e..78fe3b1c 100644 --- a/apps/api/src/api/assignment/v1/services/assignment.service.ts +++ b/apps/api/src/api/assignment/v1/services/assignment.service.ts @@ -187,6 +187,8 @@ export class AssignmentServiceV1 { type: version.type, graded: version.graded, numAttempts: version.numAttempts, + attemptsBeforeCoolDown: version.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: version.retakeAttemptCoolDownMinutes, allotedTimeMinutes: version.allotedTimeMinutes, attemptsPerTimeRange: version.attemptsPerTimeRange, attemptsTimeRangeHours: version.attemptsTimeRangeHours, @@ -599,6 +601,8 @@ export class AssignmentServiceV1 { instructions, gradingCriteriaOverview, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, graded, @@ -695,6 +699,8 @@ export class AssignmentServiceV1 { instructions, gradingCriteriaOverview, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, displayOrder, graded, @@ -1704,6 +1710,8 @@ export class AssignmentServiceV1 { return { instructions: undefined, numAttempts: undefined, + attemptsBeforeCoolDown: undefined, + retakeAttemptCoolDownMinutes: undefined, allotedTimeMinutes: undefined, attemptsPerTimeRange: undefined, attemptsTimeRangeHours: undefined, diff --git a/apps/api/src/api/assignment/v2/services/draft-management.service.ts b/apps/api/src/api/assignment/v2/services/draft-management.service.ts index a2d9f858..11e6c794 100644 --- a/apps/api/src/api/assignment/v2/services/draft-management.service.ts +++ b/apps/api/src/api/assignment/v2/services/draft-management.service.ts @@ -28,6 +28,8 @@ export interface SaveDraftDto { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -61,13 +63,13 @@ export class DraftManagementService { constructor( private readonly prisma: PrismaService, - @Inject(WINSTON_MODULE_PROVIDER) private parentLogger: Logger, + @Inject(WINSTON_MODULE_PROVIDER) private parentLogger: Logger ) { this.logger = parentLogger.child({ context: "DraftManagementService" }); } private parseDisplayOrder( - value: any, + value: any ): AssignmentQuestionDisplayOrder | undefined { if (value === "DEFINED" || value === "RANDOM") { return value as AssignmentQuestionDisplayOrder; @@ -85,7 +87,7 @@ export class DraftManagementService { async saveDraft( assignmentId: number, saveDraftDto: SaveDraftDto, - userSession: UserSession, + userSession: UserSession ): Promise { this.logger.info(`Saving draft for assignment ${assignmentId}`, { userId: userSession.userId, @@ -132,6 +134,12 @@ export class DraftManagementService { graded: saveDraftDto.assignmentData?.graded ?? assignment.graded, numAttempts: saveDraftDto.assignmentData?.numAttempts ?? assignment.numAttempts, + attemptsBeforeCoolDown: + saveDraftDto.assignmentData?.attemptsBeforeCoolDown ?? + assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + saveDraftDto.assignmentData?.retakeAttemptCoolDownMinutes ?? + assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: saveDraftDto.assignmentData?.allotedTimeMinutes ?? assignment.allotedTimeMinutes, @@ -149,7 +157,7 @@ export class DraftManagementService { assignment.displayOrder, questionDisplay: this.parseQuestionDisplay( - saveDraftDto.assignmentData?.questionDisplay, + saveDraftDto.assignmentData?.questionDisplay ) ?? assignment.questionDisplay, numberOfQuestionsPerAttempt: saveDraftDto.assignmentData?.numberOfQuestionsPerAttempt ?? @@ -184,7 +192,7 @@ export class DraftManagementService { { draftId: assignmentDraft.id, userId: userSession.userId, - }, + } ); return { @@ -202,7 +210,7 @@ export class DraftManagementService { async updateDraft( draftId: number, saveDraftDto: SaveDraftDto, - userSession: UserSession, + userSession: UserSession ): Promise { this.logger.info(`Updating draft ${draftId}`, { userId: userSession.userId, @@ -248,6 +256,16 @@ export class DraftManagementService { ...(saveDraftDto.assignmentData?.numAttempts !== undefined && { numAttempts: saveDraftDto.assignmentData.numAttempts, }), + ...(saveDraftDto.assignmentData?.attemptsBeforeCoolDown !== + undefined && { + attemptsBeforeCoolDown: + saveDraftDto.assignmentData.attemptsBeforeCoolDown, + }), + ...(saveDraftDto.assignmentData?.retakeAttemptCoolDownMinutes !== + undefined && { + retakeAttemptCoolDownMinutes: + saveDraftDto.assignmentData.retakeAttemptCoolDownMinutes, + }), ...(saveDraftDto.assignmentData?.allotedTimeMinutes !== undefined && { allotedTimeMinutes: saveDraftDto.assignmentData.allotedTimeMinutes, }), @@ -265,12 +283,12 @@ export class DraftManagementService { }), ...(saveDraftDto.assignmentData?.displayOrder !== undefined && { displayOrder: this.parseDisplayOrder( - saveDraftDto.assignmentData.displayOrder, + saveDraftDto.assignmentData.displayOrder ), }), ...(saveDraftDto.assignmentData?.questionDisplay !== undefined && { questionDisplay: this.parseQuestionDisplay( - saveDraftDto.assignmentData.questionDisplay, + saveDraftDto.assignmentData.questionDisplay ), }), ...(saveDraftDto.assignmentData?.numberOfQuestionsPerAttempt !== @@ -317,7 +335,7 @@ export class DraftManagementService { async listUserDrafts( assignmentId: number, - userSession: UserSession, + userSession: UserSession ): Promise { // Verify assignment access // await this.verifyAssignmentAccess(assignmentId, userSession); @@ -344,7 +362,7 @@ export class DraftManagementService { async getDraft( draftId: number, - userSession: UserSession, + userSession: UserSession ): Promise<{ id: number; name: string; @@ -355,6 +373,8 @@ export class DraftManagementService { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -397,6 +417,8 @@ export class DraftManagementService { type: draft.type, graded: draft.graded, numAttempts: draft.numAttempts, + attemptsBeforeCoolDown: draft.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: draft.retakeAttemptCoolDownMinutes, allotedTimeMinutes: draft.allotedTimeMinutes, attemptsPerTimeRange: draft.attemptsPerTimeRange, attemptsTimeRangeHours: draft.attemptsTimeRangeHours, @@ -445,7 +467,7 @@ export class DraftManagementService { async getLatestDraft( assignmentId: number, - userSession: UserSession, + userSession: UserSession ): Promise<{ id: number; name: string; @@ -456,6 +478,8 @@ export class DraftManagementService { type: string; graded: boolean; numAttempts: number; + attemptsBeforeCoolDown: number; + retakeAttemptCoolDownMinutes: number; allotedTimeMinutes: number; attemptsPerTimeRange: number; attemptsTimeRangeHours: number; @@ -496,7 +520,7 @@ export class DraftManagementService { private async verifyAssignmentAccess( assignmentId: number, - userSession: UserSession, + userSession: UserSession ) { const assignment = await this.prisma.assignment.findUnique({ where: { id: assignmentId }, @@ -509,7 +533,7 @@ export class DraftManagementService { if (userSession.role === UserRole.AUTHOR) { const hasAccess = assignment.AssignmentAuthor.some( - (author) => author.userId === userSession.userId, + (author) => author.userId === userSession.userId ); if (!hasAccess) { throw new NotFoundException("Assignment not found"); diff --git a/apps/api/src/api/assignment/v2/services/version-management.service.ts b/apps/api/src/api/assignment/v2/services/version-management.service.ts index 66c88ecc..fd94adf1 100644 --- a/apps/api/src/api/assignment/v2/services/version-management.service.ts +++ b/apps/api/src/api/assignment/v2/services/version-management.service.ts @@ -24,6 +24,7 @@ import { import { Logger } from "winston"; import { PrismaService } from "../../../../prisma.service"; import { QuestionDto } from "../../dto/update.questions.request.dto"; +import { assign } from "nodemailer/lib/shared"; export interface CreateVersionDto { versionNumber?: string; @@ -302,6 +303,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -506,6 +509,8 @@ export class VersionManagementService { type: version.type, graded: version.graded, numAttempts: version.numAttempts, + attemptsBeforeCoolDown: version.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: version.retakeAttemptCoolDownMinutes, allotedTimeMinutes: version.allotedTimeMinutes, attemptsPerTimeRange: version.attemptsPerTimeRange, attemptsTimeRangeHours: version.attemptsTimeRangeHours, @@ -607,6 +612,8 @@ export class VersionManagementService { type: versionToRestore.type, graded: versionToRestore.graded, numAttempts: versionToRestore.numAttempts, + attemptsBeforeCoolDown: versionToRestore.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: versionToRestore.retakeAttemptCoolDownMinutes, allotedTimeMinutes: versionToRestore.allotedTimeMinutes, attemptsPerTimeRange: versionToRestore.attemptsPerTimeRange, attemptsTimeRangeHours: versionToRestore.attemptsTimeRangeHours, @@ -1056,6 +1063,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -1363,6 +1372,8 @@ export class VersionManagementService { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, @@ -1457,6 +1468,8 @@ export class VersionManagementService { type: string; graded: boolean; numAttempts: number | null; + attemptsBeforeCoolDown: number | null; + retakeAttemptCoolDownMinutes: number | null; allotedTimeMinutes: number | null; passingGrade: number | null; displayOrder: string | null; @@ -1499,6 +1512,8 @@ export class VersionManagementService { type: latestDraft.type, graded: latestDraft.graded, numAttempts: latestDraft.numAttempts, + attemptsBeforeCoolDown: latestDraft.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: latestDraft.retakeAttemptCoolDownMinutes, allotedTimeMinutes: latestDraft.allotedTimeMinutes, passingGrade: latestDraft.passingGrade, displayOrder: latestDraft.displayOrder, @@ -1595,6 +1610,8 @@ export class VersionManagementService { type: sourceVersion.type, graded: sourceVersion.graded, numAttempts: sourceVersion.numAttempts, + attemptsBeforeCoolDown: sourceVersion.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: sourceVersion.retakeAttemptCoolDownMinutes, allotedTimeMinutes: sourceVersion.allotedTimeMinutes, attemptsPerTimeRange: sourceVersion.attemptsPerTimeRange, attemptsTimeRangeHours: sourceVersion.attemptsTimeRangeHours, @@ -2055,6 +2072,10 @@ export class VersionManagementService { graded: draftData.assignmentData.graded ?? assignment.graded, numAttempts: draftData.assignmentData.numAttempts ?? assignment.numAttempts, + attemptsBeforeCoolDown: + draftData.assignmentData.attemptsBeforeCoolDown ?? assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: + draftData.assignmentData.retakeAttemptCoolDownMinutes ?? assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: draftData.assignmentData.allotedTimeMinutes ?? assignment.allotedTimeMinutes, diff --git a/apps/api/src/api/attempt/services/attempt-validation.service.ts b/apps/api/src/api/attempt/services/attempt-validation.service.ts index 0d73cc15..db206e81 100644 --- a/apps/api/src/api/attempt/services/attempt-validation.service.ts +++ b/apps/api/src/api/attempt/services/attempt-validation.service.ts @@ -102,12 +102,12 @@ export class AttemptValidationService { ); } - const attemptsBeforeCooldown = assignment.attemptsBeforeCoolDown ?? 1; + const attemptsBeforeCoolDown = assignment.attemptsBeforeCoolDown ?? 1; const cooldownMinutes = assignment.retakeAttemptCoolDownMinutes ?? 0; if ( - attemptsBeforeCooldown > 0 && - totalAttempts >= attemptsBeforeCooldown + attemptsBeforeCoolDown > 0 && + totalAttempts >= attemptsBeforeCoolDown ) { const lastAttemptTime = new Date( lastSubmittedAttempt.expiresAt, diff --git a/apps/api/src/scripts/create-initial-versions.ts b/apps/api/src/scripts/create-initial-versions.ts index 833eaa5e..2cb9362e 100644 --- a/apps/api/src/scripts/create-initial-versions.ts +++ b/apps/api/src/scripts/create-initial-versions.ts @@ -61,6 +61,8 @@ async function createInitialVersions() { type: assignment.type, graded: assignment.graded, numAttempts: assignment.numAttempts, + attemptsBeforeCoolDown: assignment.attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes: assignment.retakeAttemptCoolDownMinutes, allotedTimeMinutes: assignment.allotedTimeMinutes, attemptsPerTimeRange: assignment.attemptsPerTimeRange, attemptsTimeRangeHours: assignment.attemptsTimeRangeHours, diff --git a/apps/web/app/Helpers/checkDiff.ts b/apps/web/app/Helpers/checkDiff.ts index 5511a5b6..9ab3b7ac 100644 --- a/apps/web/app/Helpers/checkDiff.ts +++ b/apps/web/app/Helpers/checkDiff.ts @@ -438,6 +438,8 @@ export function useChangesSummary(): string { questionDisplay, questionVariationNumber, numAttempts, + attemptsBeforeCoolDown, + retakeAttemptCoolDownMinutes, passingGrade, timeEstimateMinutes, allotedTimeMinutes, diff --git a/apps/web/app/Helpers/checkQuestionsReady.ts b/apps/web/app/Helpers/checkQuestionsReady.ts index 435e67f9..5b7c11fa 100644 --- a/apps/web/app/Helpers/checkQuestionsReady.ts +++ b/apps/web/app/Helpers/checkQuestionsReady.ts @@ -299,6 +299,24 @@ export const useQuestionsAreReadyToBePublished = ( step = 1; isValid = false; } + if ( + assignmentConfig.attemptsBeforeCoolDown == null || + assignmentConfig.attemptsBeforeCoolDown < -1 + ) { + message = `Please enter a valid number of attempts before cooldown period.`; + debugLog(message); + step = 3; + isValid = false; + } + if ( + assignmentConfig.retakeAttemptCoolDownMinutes == null || + assignmentConfig.retakeAttemptCoolDownMinutes < -1 + ) { + message = `Please enter a valid number of minutes for the cooldown period.`; + debugLog(message); + step = 3; + isValid = false; + } if ( assignmentConfig.passingGrade == null || assignmentConfig.passingGrade <= 0 || diff --git a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx index 292bd779..2b30f00e 100644 --- a/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx +++ b/apps/web/app/author/(components)/AuthorQuestionsPage/index.tsx @@ -1020,6 +1020,14 @@ const AuthorQuestionsPage: FC = ({ configStore.setNumAttempts(assignmentData.config.numAttempts); settingsUpdated = true; } + if (assignmentData.config.attemptsBeforeCoolDown !== undefined) { + configStore.setAttemptsBeforeCoolDown(assignmentData.config.attemptsBeforeCoolDown); + settingsUpdated = true; + } + if (assignmentData.config.retakeAttemptCoolDownMinutes !== undefined) { + configStore.setRetakeAttemptCoolDownMinutes(assignmentData.config.retakeAttemptCoolDownMinutes); + settingsUpdated = true; + } if (assignmentData.config.allotedTimeMinutes !== undefined) { configStore.setAllotedTimeMinutes( assignmentData.config.allotedTimeMinutes, diff --git a/apps/web/app/learner/(components)/AboutTheAssignment/BeginTheAssignmentButton.tsx b/apps/web/app/learner/(components)/AboutTheAssignment/BeginTheAssignmentButton.tsx index 6cfcf4f0..9a09c7ca 100644 --- a/apps/web/app/learner/(components)/AboutTheAssignment/BeginTheAssignmentButton.tsx +++ b/apps/web/app/learner/(components)/AboutTheAssignment/BeginTheAssignmentButton.tsx @@ -20,9 +20,9 @@ const BeginTheAssignment: React.FC = (props) => { const setUserPreferedLanguage = useLearnerStore( (state) => state.setUserPreferedLanguage, ); - const handleClick = () => { - setUserPreferedLanguage(userPreferedLanguage); + const MoveToQuestionPage = () => { if (!disabled) { + setUserPreferedLanguage(userPreferedLanguage); window.location.href = href; } }; @@ -33,7 +33,7 @@ const BeginTheAssignment: React.FC = (props) => {