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 (
+
+
+
+ How many attempts do learners have before they have to wait to retake
+ the assignment?
+
+
+
+
+
+ id="attempts-before-cooldown-period"
+ items={dropdownItems}
+ selectedItem={attemptsBeforeCoolDown}
+ setSelectedItem={setAttemptsBeforeCoolDown}
+ />
+ {errors.attemptsBeforeCoolDown && (
+
+ {errors.attemptsBeforeCoolDown}
+
+ )}
+
+
+
+ How long do learners have to wait before making another attempt?
+
+
+
+
+
+ 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 (
-
+
- {assignmentState === "in-progress" ? "Resume " : "Begin "}the
- Assignment
+ {label} the Assignment
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 = () => {
)}
-
-
- How long do learners have to wait before making another attempt?
-
-
-
-
-
- id="cooldown-period"
- items={defaultCoolDownTimes}
- selectedItem={retakeAttemptCoolDownMinutes}
- setSelectedItem={setRetakeAttemptCoolDownMinutes}
- />
- {errors.retakeAttemptCoolDownMinutes && (
- 0 && (
+
+
- {errors.retakeAttemptCoolDownMinutes}
-
- )}
-
+ 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) => {
{label} the Assignment
diff --git a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx
index c5c11670..5cd05b6a 100644
--- a/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx
+++ b/apps/web/app/learner/(components)/AboutTheAssignment/index.tsx
@@ -300,19 +300,19 @@ const AboutTheAssignment: FC = ({
@@ -409,10 +409,10 @@ const AboutTheAssignment: FC = ({
diff --git a/apps/web/components/version-control/VersionTreeView.tsx b/apps/web/components/version-control/VersionTreeView.tsx
index e7a09734..c9c33a7d 100644
--- a/apps/web/components/version-control/VersionTreeView.tsx
+++ b/apps/web/components/version-control/VersionTreeView.tsx
@@ -1427,6 +1427,36 @@ export function VersionTreeView({ assignmentId }: Props) {
).numAttempts ?? "Unlimited")}
+
+
+ Attempts Before Cooldown Period:
+
+
+ {(selectedVersionDetails || selectedVersion)
+ .attemptsBeforeCoolDown === 0
+ ? "Never wait"
+ : ((
+ selectedVersionDetails ||
+ selectedVersion
+ ).attemptsBeforeCoolDown ?? "")}
+
+
+
+
+ Time Learners Wait Between Attempts (Minutes):
+
+
+ {(selectedVersionDetails || selectedVersion)
+ .attemptsBeforeCoolDown === 0 ||
+ (selectedVersionDetails || selectedVersion)
+ .retakeAttemptCoolDownMinutes === 0
+ ? "Never wait"
+ : ((
+ selectedVersionDetails ||
+ selectedVersion
+ ).retakeAttemptCoolDownMinutes ?? "")}
+
+
Passing Grade:
diff --git a/apps/web/hooks/useVersionControl.ts b/apps/web/hooks/useVersionControl.ts
index 5180a6c5..020944c5 100644
--- a/apps/web/hooks/useVersionControl.ts
+++ b/apps/web/hooks/useVersionControl.ts
@@ -11,6 +11,8 @@ export interface DraftData {
questions?: any[];
graded?: boolean;
numAttempts?: number;
+ attemptsBeforeCoolDown?: number;
+ retakeAttemptCoolDownMinutes?: number;
passingGrade?: number;
timeEstimateMinutes?: number;
allotedTimeMinutes?: number;
@@ -318,6 +320,14 @@ export function useVersionControl() {
typedDraftData.numAttempts !== undefined
? typedDraftData.numAttempts
: assignmentConfigStore.numAttempts,
+ attemptsBeforeCoolDown:
+ typedDraftData.attemptsBeforeCoolDown !== undefined
+ ? typedDraftData.attemptsBeforeCoolDown
+ : assignmentConfigStore.attemptsBeforeCoolDown,
+ retakeAttemptCoolDownMinutes:
+ typedDraftData.retakeAttemptCoolDownMinutes !== undefined
+ ? typedDraftData.retakeAttemptCoolDownMinutes
+ : assignmentConfigStore.retakeAttemptCoolDownMinutes,
passingGrade:
typedDraftData.passingGrade !== undefined
? typedDraftData.passingGrade
diff --git a/apps/web/lib/semantic-versioning.ts b/apps/web/lib/semantic-versioning.ts
index d75c807a..21ed4f0d 100644
--- a/apps/web/lib/semantic-versioning.ts
+++ b/apps/web/lib/semantic-versioning.ts
@@ -78,6 +78,8 @@ export function analyzeChanges(
"numberOfQuestionsPerAttempt",
"timeLimit",
"title",
+ "attemptsBeforeCoolDown",
+ "retakeAttemptCoolDownMinutes",
];
comparison.assignmentChanges.forEach((change) => {
diff --git a/apps/web/stores/assignmentConfig.ts b/apps/web/stores/assignmentConfig.ts
index bbcc13be..9a976aac 100644
--- a/apps/web/stores/assignmentConfig.ts
+++ b/apps/web/stores/assignmentConfig.ts
@@ -121,17 +121,11 @@ 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
- ) {
+ if (!(state.attemptsBeforeCoolDown >= 0)) {
errors.attemptsBeforeCoolDown =
"Please enter a valid number of attempts before a cool down period is required.";
}
- if (
- !state.retakeAttemptCoolDownMinutes ||
- state.retakeAttemptCoolDownMinutes < 0
- ) {
+ if (!(state.retakeAttemptCoolDownMinutes >= 0)) {
errors.retakeAttemptCoolDownMinutes =
"Please enter a valid number for the amount of time for a cooldown period in minutes";
}
diff --git a/apps/web/stores/author.ts b/apps/web/stores/author.ts
index 47f66ea3..5fb87d77 100644
--- a/apps/web/stores/author.ts
+++ b/apps/web/stores/author.ts
@@ -17,6 +17,7 @@ import { createWithEqualityFn } from "zustand/traditional";
import { withUpdatedAt } from "./middlewares";
import { DraftData } from "@/hooks/useVersionControl";
import { DraftSummary, VersionSummary } from "@/lib/author";
+import { config } from "process";
const NON_PERSIST_KEYS = new Set([
// version control state
"versions",
@@ -1938,6 +1939,15 @@ export const useAuthorStore = createWithEqualityFn<
versionData.numAttempts !== undefined
? versionData.numAttempts
: useAssignmentConfig.getState().numAttempts,
+ attemptsBeforeCoolDown:
+ versionData.attemptsBeforeCoolDown !== undefined
+ ? versionData.attemptsBeforeCoolDown
+ : useAssignmentConfig.getState().attemptsBeforeCoolDown,
+ retakeAttemptCoolDownMinutes:
+ versionData.retakeAttemptCoolDownMinutes !== undefined
+ ? versionData.retakeAttemptCoolDownMinutes
+ : useAssignmentConfig.getState()
+ .retakeAttemptCoolDownMinutes,
passingGrade:
versionData.passingGrade !== undefined
? versionData.passingGrade
@@ -2065,6 +2075,9 @@ export const useAuthorStore = createWithEqualityFn<
...encodedFields,
name: state.name,
numAttempts: configData.numAttempts,
+ attemptsBeforeCoolDown: configData.attemptsBeforeCoolDown,
+ retakeAttemptCoolDownMinutes:
+ configData.retakeAttemptCoolDownMinutes,
passingGrade: configData.passingGrade,
displayOrder: configData.displayOrder,
graded: configData.graded,
@@ -2195,6 +2208,9 @@ export const useAuthorStore = createWithEqualityFn<
// Assignment configuration
graded: assignmentConfig.graded,
numAttempts: assignmentConfig.numAttempts,
+ attemptsBeforeCoolDown: assignmentConfig.attemptsBeforeCoolDown,
+ retakeAttemptCoolDownMinutes:
+ assignmentConfig.retakeAttemptCoolDownMinutes,
passingGrade: assignmentConfig.passingGrade,
timeEstimateMinutes: assignmentConfig.timeEstimateMinutes,
allotedTimeMinutes: assignmentConfig.allotedTimeMinutes,
@@ -2373,6 +2389,12 @@ export const useAuthorStore = createWithEqualityFn<
numAttempts:
assignment.numAttempts ||
assignmentConfigStore.numAttempts,
+ attemptsBeforeCoolDown:
+ assignment.attemptsBeforeCoolDown ||
+ assignmentConfigStore.attemptsBeforeCoolDown,
+ retakeAttemptCoolDownMinutes:
+ assignment.retakeAttemptCoolDownMinutes ||
+ assignmentConfigStore.retakeAttemptCoolDownMinutes,
passingGrade:
assignment.passingGrade ||
assignmentConfigStore.passingGrade,