Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": null,
"lines": null
},
"generated_at": "2025-10-03T10:22:15Z",
"generated_at": "2025-10-09T10:00:02Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -306,7 +306,7 @@
{
"hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe",
"is_verified": false,
"line_number": 1562,
"line_number": 1430,
"type": "Secret Keyword",
"verified_result": null
}
Expand All @@ -333,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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Assignment" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1,
ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5;

-- AlterTable
ALTER TABLE "AssignmentDraft" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1,
ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5;

-- AlterTable
ALTER TABLE "AssignmentVersion" ADD COLUMN "attemptsBeforeCoolDown" INTEGER DEFAULT 1,
ADD COLUMN "retakeAttemptCoolDownMinutes" INTEGER DEFAULT 5;
8 changes: 7 additions & 1 deletion apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,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
Expand All @@ -229,7 +231,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
showCorrectAnswer Boolean @default(true) /// Should the correct answer be shown to the learner after its submission
updatedAt DateTime @default(now()) @updatedAt /// The DateTime at which the assignment was last updated
languageCode String? /// The language code for the assignment
Expand Down Expand Up @@ -262,6 +264,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
Expand Down Expand Up @@ -595,6 +599,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
11 changes: 11 additions & 0 deletions apps/api/src/api/assignment/attempt/attempt.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 29 additions & 17 deletions apps/api/src/api/assignment/attempt/attempt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1474,6 +1475,7 @@ export class AttemptServiceV1 {
assignment: LearnerGetAssignmentResponseDto,
userSession: UserSession,
): Promise<void> {
const now = new Date();
const timeRangeStartDate = this.calculateTimeRangeStartDate(assignment);

const attempts = await this.prisma.assignmentAttempt.findMany({
Expand All @@ -1483,36 +1485,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 &&
Expand All @@ -1523,16 +1517,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);
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/api/assignment/dto/update.questions.request.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -599,6 +601,8 @@ export class AssignmentServiceV1 {
instructions,
gradingCriteriaOverview,
numAttempts,
attemptsBeforeCoolDown,
retakeAttemptCoolDownMinutes,
passingGrade,
displayOrder,
graded,
Expand Down Expand Up @@ -695,6 +699,8 @@ export class AssignmentServiceV1 {
instructions,
gradingCriteriaOverview,
numAttempts,
attemptsBeforeCoolDown,
retakeAttemptCoolDownMinutes,
passingGrade,
displayOrder,
graded,
Expand Down Expand Up @@ -1704,6 +1710,8 @@ export class AssignmentServiceV1 {
return {
instructions: undefined,
numAttempts: undefined,
attemptsBeforeCoolDown: undefined,
retakeAttemptCoolDownMinutes: undefined,
allotedTimeMinutes: undefined,
attemptsPerTimeRange: undefined,
attemptsTimeRangeHours: undefined,
Expand Down
Loading
Loading