From 792d79a42017e00d6f4b7a815dec3936a64e6ef0 Mon Sep 17 00:00:00 2001 From: Jiyun Park <80032256+cho-to@users.noreply.github.com> Date: Sat, 11 Jan 2025 21:01:41 +0900 Subject: [PATCH] feat(be): migrate assignment module from contest module (#2289) --- apps/backend/apps/admin/src/admin.module.ts | 2 + .../admin/src/assignment/assignment.module.ts | 11 + .../src/assignment/assignment.resolver.ts | 247 +++++ .../src/assignment/assignment.service.spec.ts | 494 +++++++++ .../src/assignment/assignment.service.ts | 973 ++++++++++++++++++ ...nment-submission-summary-for-user.model.ts | 54 + .../assignment-with-participants.model.ts | 8 + .../model/assignment-with-scores.model.ts | 11 + .../src/assignment/model/assignment.input.ts | 71 ++ .../assignments-grouped-by-status.output.ts | 14 + .../duplicated-assignment-response.output.ts | 20 + .../assignment/model/problem-score.input.ts | 11 + .../model/publicizing-request.model.ts | 13 + .../model/publicizing-response.output.ts | 10 + .../src/assignment/model/score-summary.ts | 64 ++ .../announcement/announcement.controller.ts | 41 +- .../announcement/announcement.service.spec.ts | 33 +- .../src/announcement/announcement.service.ts | 62 +- apps/backend/apps/client/src/app.module.ts | 2 + .../assignment/assignment.controller.spec.ts | 25 + .../src/assignment/assignment.controller.ts | 207 ++++ .../src/assignment/assignment.module.ts | 16 + .../src/assignment/assignment.service.spec.ts | 473 +++++++++ .../src/assignment/assignment.service.ts | 516 ++++++++++ .../src/submission/mock/submission.mock.ts | 2 + .../src/submission/submission.service.ts | 1 + .../migration.sql | 77 ++ .../migration.sql | 88 ++ apps/backend/prisma/schema.prisma | 181 +++- apps/backend/prisma/seed.ts | 463 ++++++++- .../Assignment/Create Assignment/Succeed.bru | 48 + ...[ERR] End Time Earlier than Start Time.bru | 38 + .../[ERR] Nonexistent Group.bru | 38 + .../Create Publicizing Request/Succeed.bru | 35 + .../[ERR] Duplicated Publicizing Request.bru | 28 + ...[ERR] Request Publicizing to OpenSpace.bru | 24 + .../Assignment/Delete Assignment/Succeed.bru | 36 + .../[ERR] Nonexistent Assignment.bru | 30 + .../Duplicate Assignment/Succeed.bru | 53 + .../Succeed.bru | 52 + .../Succeed.bru | 74 ++ .../Assignment/Get Assignment/NOT_FOUND.bru | 32 + .../Assignment/Get Assignment/Succeed.bru | 40 + .../Assignment/Get Assignments/Succeed.bru | 39 + .../Succeed.bru | 55 + .../Get Publicizing Requests/Succeed.bru | 31 + .../Handle Publicizing Request/Succeed.bru | 35 + .../[ERR] Nonexistent Publicizing Request.bru | 27 + .../Import Problems to Assignment/Succeed.bru | 55 + .../[ERR] Nonexistent Assignment.bru | 32 + .../Succeed.bru | 39 + .../[ERR] Nonexistent Assignment.bru | 32 + .../Assignment/Update Assignment/Succeed.bru | 44 + .../[404] Nonexistent Assignment.bru | 38 + ...[422] End Time Earlier than Start Time.bru | 38 + .../Get assignment by ID/Succeed.bru | 58 ++ .../[404] Assignment does not exist.bru | 22 + .../Get finished assignment/Succeed.bru | 50 + .../Succeed.bru | 43 + .../Succeed.bru | 42 + .../Succeed.bru | 51 + .../Succeed.bru | 44 + .../Participate group assignment/Succeed.bru | 32 + .../[404] Nonexistent assignment.bru | 24 + .../[409] Already participated.bru | 40 + .../[409] Ended assignment.bru | 25 + .../[409] Invalid Invitation Code.bru | 16 + ...unregister ongoing or ended assignment.bru | 26 + .../404: No Assignment found.bru | 26 + .../404: No AssignmentRecord found.bru | 26 + .../Unregister Assignment/Succeed.bru | 60 ++ .../Contest/Get contest by ID/Succeed.bru | 5 +- 72 files changed, 5671 insertions(+), 102 deletions(-) create mode 100644 apps/backend/apps/admin/src/assignment/assignment.module.ts create mode 100644 apps/backend/apps/admin/src/assignment/assignment.resolver.ts create mode 100644 apps/backend/apps/admin/src/assignment/assignment.service.spec.ts create mode 100644 apps/backend/apps/admin/src/assignment/assignment.service.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/assignment.input.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/problem-score.input.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts create mode 100644 apps/backend/apps/admin/src/assignment/model/score-summary.ts create mode 100644 apps/backend/apps/client/src/assignment/assignment.controller.spec.ts create mode 100644 apps/backend/apps/client/src/assignment/assignment.controller.ts create mode 100644 apps/backend/apps/client/src/assignment/assignment.module.ts create mode 100644 apps/backend/apps/client/src/assignment/assignment.service.spec.ts create mode 100644 apps/backend/apps/client/src/assignment/assignment.service.ts create mode 100644 apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql create mode 100644 apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql create mode 100644 collection/admin/Assignment/Create Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Create Assignment/[ERR] End Time Earlier than Start Time.bru create mode 100644 collection/admin/Assignment/Create Assignment/[ERR] Nonexistent Group.bru create mode 100644 collection/admin/Assignment/Create Publicizing Request/Succeed.bru create mode 100644 collection/admin/Assignment/Create Publicizing Request/[ERR] Duplicated Publicizing Request.bru create mode 100644 collection/admin/Assignment/Create Publicizing Request/[ERR] Request Publicizing to OpenSpace.bru create mode 100644 collection/admin/Assignment/Delete Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Delete Assignment/[ERR] Nonexistent Assignment.bru create mode 100644 collection/admin/Assignment/Duplicate Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Get Assignment Score Summaries/Succeed.bru create mode 100644 collection/admin/Assignment/Get Assignment Score Summary of User/Succeed.bru create mode 100644 collection/admin/Assignment/Get Assignment/NOT_FOUND.bru create mode 100644 collection/admin/Assignment/Get Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Get Assignments/Succeed.bru create mode 100644 collection/admin/Assignment/Get Belonged Assignments by Problem ID/Succeed.bru create mode 100644 collection/admin/Assignment/Get Publicizing Requests/Succeed.bru create mode 100644 collection/admin/Assignment/Handle Publicizing Request/Succeed.bru create mode 100644 collection/admin/Assignment/Handle Publicizing Request/[ERR] Nonexistent Publicizing Request.bru create mode 100644 collection/admin/Assignment/Import Problems to Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Import Problems to Assignment/[ERR] Nonexistent Assignment.bru create mode 100644 collection/admin/Assignment/Remove Problems From Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Remove Problems From Assignment/[ERR] Nonexistent Assignment.bru create mode 100644 collection/admin/Assignment/Update Assignment/Succeed.bru create mode 100644 collection/admin/Assignment/Update Assignment/[404] Nonexistent Assignment.bru create mode 100644 collection/admin/Assignment/Update Assignment/[422] End Time Earlier than Start Time.bru create mode 100644 collection/client/Assignment/Get assignment by ID/Succeed.bru create mode 100644 collection/client/Assignment/Get assignment by ID/[404] Assignment does not exist.bru create mode 100644 collection/client/Assignment/Get finished assignment/Succeed.bru create mode 100644 collection/client/Assignment/Get ongoing & upcoming assignments (logged in)/Succeed.bru create mode 100644 collection/client/Assignment/Get ongoing & upcoming assignments/Succeed.bru create mode 100644 collection/client/Assignment/Get registered finished assignments/Succeed.bru create mode 100644 collection/client/Assignment/Get registered ongoing & upcoming assignments/Succeed.bru create mode 100644 collection/client/Assignment/Participate group assignment/Succeed.bru create mode 100644 collection/client/Assignment/Participate group assignment/[404] Nonexistent assignment.bru create mode 100644 collection/client/Assignment/Participate group assignment/[409] Already participated.bru create mode 100644 collection/client/Assignment/Participate group assignment/[409] Ended assignment.bru create mode 100644 collection/client/Assignment/Participate group assignment/[409] Invalid Invitation Code.bru create mode 100644 collection/client/Assignment/Unregister Assignment/403: Cannot unregister ongoing or ended assignment.bru create mode 100644 collection/client/Assignment/Unregister Assignment/404: No Assignment found.bru create mode 100644 collection/client/Assignment/Unregister Assignment/404: No AssignmentRecord found.bru create mode 100644 collection/client/Assignment/Unregister Assignment/Succeed.bru diff --git a/apps/backend/apps/admin/src/admin.module.ts b/apps/backend/apps/admin/src/admin.module.ts index 35b87db812..8b59be58d2 100644 --- a/apps/backend/apps/admin/src/admin.module.ts +++ b/apps/backend/apps/admin/src/admin.module.ts @@ -21,6 +21,7 @@ import { NoticeModule } from '@admin/notice/notice.module' import { AdminController } from './admin.controller' import { AdminService } from './admin.service' import { AnnouncementModule } from './announcement/announcement.module' +import { AssignmentModule } from './assignment/assignment.module' import { ContestModule } from './contest/contest.module' import { GroupModule } from './group/group.module' import { ProblemModule } from './problem/problem.module' @@ -50,6 +51,7 @@ import { UserModule } from './user/user.module' RolesModule, PrismaModule, ContestModule, + AssignmentModule, ProblemModule, StorageModule, GroupModule, diff --git a/apps/backend/apps/admin/src/assignment/assignment.module.ts b/apps/backend/apps/admin/src/assignment/assignment.module.ts new file mode 100644 index 0000000000..d07981049f --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { RolesModule } from '@libs/auth' +import { ProblemModule } from '@admin/problem/problem.module' +import { AssignmentResolver } from './assignment.resolver' +import { AssignmentService } from './assignment.service' + +@Module({ + imports: [RolesModule, ProblemModule], + providers: [AssignmentService, AssignmentResolver] +}) +export class AssignmentModule {} diff --git a/apps/backend/apps/admin/src/assignment/assignment.resolver.ts b/apps/backend/apps/admin/src/assignment/assignment.resolver.ts new file mode 100644 index 0000000000..6e83eb21ef --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.resolver.ts @@ -0,0 +1,247 @@ +import { ParseBoolPipe } from '@nestjs/common' +import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql' +import { Assignment, AssignmentProblem } from '@generated' +import { AuthenticatedRequest, UseRolesGuard } from '@libs/auth' +import { OPEN_SPACE_ID } from '@libs/constants' +import { + CursorValidationPipe, + GroupIDPipe, + IDValidationPipe, + RequiredIntPipe +} from '@libs/pipe' +import { AssignmentService } from './assignment.service' +import { AssignmentSubmissionSummaryForUser } from './model/assignment-submission-summary-for-user.model' +import { AssignmentWithParticipants } from './model/assignment-with-participants.model' +import { CreateAssignmentInput } from './model/assignment.input' +import { UpdateAssignmentInput } from './model/assignment.input' +import { AssignmentsGroupedByStatus } from './model/assignments-grouped-by-status.output' +import { DuplicatedAssignmentResponse } from './model/duplicated-assignment-response.output' +import { AssignmentProblemScoreInput } from './model/problem-score.input' +import { AssignmentPublicizingRequest } from './model/publicizing-request.model' +import { AssignmentPublicizingResponse } from './model/publicizing-response.output' +import { UserAssignmentScoreSummaryWithUserInfo } from './model/score-summary' + +@Resolver(() => Assignment) +export class AssignmentResolver { + constructor(private readonly assignmentService: AssignmentService) {} + + @Query(() => [AssignmentWithParticipants]) + async getAssignments( + @Args( + 'take', + { type: () => Int, defaultValue: 10 }, + new RequiredIntPipe('take') + ) + take: number, + @Args( + 'groupId', + { defaultValue: OPEN_SPACE_ID, type: () => Int }, + GroupIDPipe + ) + groupId: number, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null + ) { + return await this.assignmentService.getAssignments(take, groupId, cursor) + } + + @Query(() => AssignmentWithParticipants) + async getAssignment( + @Args( + 'assignmentId', + { type: () => Int }, + new RequiredIntPipe('assignmentId') + ) + assignmentId: number + ) { + return await this.assignmentService.getAssignment(assignmentId) + } + + @Mutation(() => Assignment) + async createAssignment( + @Args('input') input: CreateAssignmentInput, + @Args( + 'groupId', + { defaultValue: OPEN_SPACE_ID, type: () => Int }, + GroupIDPipe + ) + groupId: number, + @Context('req') req: AuthenticatedRequest + ) { + return await this.assignmentService.createAssignment( + groupId, + req.user.id, + input + ) + } + + @Mutation(() => Assignment) + async updateAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('input') input: UpdateAssignmentInput + ) { + return await this.assignmentService.updateAssignment(groupId, input) + } + + @Mutation(() => Assignment) + async deleteAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number + ) { + return await this.assignmentService.deleteAssignment(groupId, assignmentId) + } + + /** + * Assignment의 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Requests)들을 불러옵니다. + * @returns Publicizing Request 배열 + */ + @Query(() => [AssignmentPublicizingRequest]) + @UseRolesGuard() + async getPublicizingRequests() { + return await this.assignmentService.getPublicizingRequests() + } + + /** + * Assignment 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)를 생성합니다. + * @param groupId Assignment가 속한 Group의 ID. 이미 Open Space(groupId === 1)이 아니어야 합니다. + * @param assignemtnId Assignment ID + * @returns 생성된 Publicizing Request + */ + @Mutation(() => AssignmentPublicizingRequest) + async createPublicizingRequest( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number + ) { + return await this.assignmentService.createPublicizingRequest( + groupId, + assignmentId + ) + } + + /** + * Assignment 소속 Group을 Open Space(groupId === 1)로 이동시키기 위한 요청(Publicizing Request)을 처리합니다. + * @param assignmentId Publicizing Request를 생성한 assignment의 Id + * @param isAccepted 요청 수락 여부 + * @returns + */ + @Mutation(() => AssignmentPublicizingResponse) + @UseRolesGuard() + async handlePublicizingRequest( + @Args('assignmentId', { type: () => Int }) assignmentId: number, + @Args('isAccepted', ParseBoolPipe) isAccepted: boolean + ) { + return await this.assignmentService.handlePublicizingRequest( + assignmentId, + isAccepted + ) + } + + @Mutation(() => [AssignmentProblem]) + async importProblemsToAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) assignmentId: number, + @Args('problemIdsWithScore', { type: () => [AssignmentProblemScoreInput] }) + problemIdsWithScore: AssignmentProblemScoreInput[] + ) { + return await this.assignmentService.importProblemsToAssignment( + groupId, + assignmentId, + problemIdsWithScore + ) + } + + @Mutation(() => [AssignmentProblem]) + async removeProblemsFromAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) + assignmentId: number, + @Args('problemIds', { type: () => [Int] }) problemIds: number[] + ) { + return await this.assignmentService.removeProblemsFromAssignment( + groupId, + assignmentId, + problemIds + ) + } + + /** + * 특정 User의 Assignment 제출 내용 요약 정보를 가져옵니다. + * + * Assignment Overall 페이지에서 특정 유저를 선택했을 때 사용 + * @see https://github.com/skkuding/codedang/pull/1894 + */ + @Query(() => AssignmentSubmissionSummaryForUser) + async getAssignmentSubmissionSummaryByUserId( + @Args('assignmentId', { type: () => Int }, IDValidationPipe) + assignmentId: number, + @Args('userId', { type: () => Int }, IDValidationPipe) userId: number, + @Args('problemId', { nullable: true, type: () => Int }, IDValidationPipe) + problemId: number, + @Args( + 'take', + { type: () => Int, defaultValue: 10 }, + new RequiredIntPipe('take') + ) + take: number, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null + ) { + return await this.assignmentService.getAssignmentSubmissionSummaryByUserId( + take, + assignmentId, + userId, + problemId, + cursor + ) + } + + @Mutation(() => DuplicatedAssignmentResponse) + async duplicateAssignment( + @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, + @Args('assignmentId', { type: () => Int }) + assignmentId: number, + @Context('req') req: AuthenticatedRequest + ) { + return await this.assignmentService.duplicateAssignment( + groupId, + assignmentId, + req.user.id + ) + } + + /** + * Assignment의 참여한 User와, 점수 요약을 함께 불러옵니다. + * + * Assignment Overall 페이지의 Participants 탭의 정보 + * @see https://github.com/skkuding/codedang/pull/2029 + */ + @Query(() => [UserAssignmentScoreSummaryWithUserInfo]) + async getAssignmentScoreSummaries( + @Args( + 'assignmentId', + { type: () => Int, nullable: false }, + IDValidationPipe + ) + assignmentId: number, + @Args('take', { type: () => Int, defaultValue: 10 }) + take: number, + @Args('cursor', { type: () => Int, nullable: true }, CursorValidationPipe) + cursor: number | null, + @Args('searchingName', { type: () => String, nullable: true }) + searchingName?: string + ) { + return await this.assignmentService.getAssignmentScoreSummaries( + assignmentId, + take, + cursor, + searchingName + ) + } + + @Query(() => AssignmentsGroupedByStatus) + async getAssignmentsByProblemId( + @Args('problemId', { type: () => Int }) problemId: number + ) { + return await this.assignmentService.getAssignmentsByProblemId(problemId) + } +} diff --git a/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts new file mode 100644 index 0000000000..3ce1036514 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.service.spec.ts @@ -0,0 +1,494 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Test, type TestingModule } from '@nestjs/testing' +import { AssignmentProblem, Group, AssignmentRecord } from '@generated' +import { Problem } from '@generated' +import { Assignment } from '@generated' +import { faker } from '@faker-js/faker' +import { ResultStatus } from '@prisma/client' +import type { Cache } from 'cache-manager' +import { expect } from 'chai' +import { stub } from 'sinon' +import { EntityNotExistException } from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import { AssignmentService } from './assignment.service' +import type { AssignmentWithParticipants } from './model/assignment-with-participants.model' +import type { + CreateAssignmentInput, + UpdateAssignmentInput +} from './model/assignment.input' +import type { AssignmentPublicizingRequest } from './model/publicizing-request.model' + +const assignmentId = 1 +const userId = 1 +const groupId = 1 +const problemId = 2 +const startTime = faker.date.past() +const endTime = faker.date.future() +const createTime = faker.date.past() +const updateTime = faker.date.past() +const invitationCode = '123456' +const problemIdsWithScore = { + problemId, + score: 10 +} +// const duplicatedAssignmentId = 2 + +const assignment: Assignment = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + isJudgeResultVisible: true, + enableCopyPaste: true, + createTime, + updateTime, + invitationCode, + assignmentProblem: [] +} + +const assignmentWithCount = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + isJudgeResultVisible: true, + enableCopyPaste: true, + createTime, + updateTime, + invitationCode, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + assignmentRecord: 10 + } +} + +const assignmentWithParticipants: AssignmentWithParticipants = { + id: assignmentId, + createdById: userId, + groupId, + title: 'title', + description: 'description', + startTime, + endTime, + isVisible: true, + isRankVisible: true, + enableCopyPaste: true, + isJudgeResultVisible: true, + createTime, + updateTime, + participants: 10, + invitationCode +} + +const group: Group = { + id: groupId, + groupName: 'groupName', + description: 'description', + config: { + showOnList: true, + allowJoinFromSearch: true, + allowJoinWithURL: false, + requireApprovalBeforeJoin: true + }, + createTime: faker.date.past(), + updateTime: faker.date.past() +} + +const problem: Problem = { + id: problemId, + createdById: 2, + groupId: 2, + title: 'test problem', + description: 'thisistestproblem', + inputDescription: 'inputdescription', + outputDescription: 'outputdescription', + hint: 'hint', + template: [], + languages: ['C'], + timeLimit: 10000, + memoryLimit: 100000, + difficulty: 'Level1', + source: 'source', + visibleLockTime: faker.date.past(), + createTime: faker.date.past(), + updateTime: faker.date.past(), + submissionCount: 0, + acceptedCount: 0, + acceptedRate: 0, + engDescription: null, + engHint: null, + engInputDescription: null, + engOutputDescription: null, + engTitle: null +} + +const assignmentProblem: AssignmentProblem = { + order: 0, + assignmentId, + problemId, + score: 50, + createTime: faker.date.past(), + updateTime: faker.date.past() +} + +const submissionsWithProblemTitleAndUsername = { + id: 1, + userId: 1, + userIp: '127.0.0.1', + problemId: 1, + assignmentId: 1, + workbookId: 1, + code: [], + codeSize: 1, + language: 'C', + result: ResultStatus.Accepted, + createTime: '2000-01-01', + updateTime: '2000-01-02', + problem: { + title: 'submission' + }, + user: { + username: 'user01', + studentId: '1234567890' + } +} + +// const submissionResults = [ +// { +// id: 1, +// submissionId: 1, +// problemTestcaseId: 1, +// result: ResultStatus.Accepted, +// cpuTime: BigInt(1), +// memory: 1, +// createTime: '2000-01-01', +// updateTime: '2000-01-02' +// } +// ] + +const publicizingRequest: AssignmentPublicizingRequest = { + assignmentId, + userId, + expireTime: new Date('2050-08-19T07:32:07.533Z') +} + +const input = { + title: 'test title10', + description: 'test description', + startTime: faker.date.past(), + endTime: faker.date.future(), + isVisible: false, + isRankVisible: false, + enableCopyPaste: true, + isJudgeResultVisible: true +} satisfies CreateAssignmentInput + +const updateInput = { + id: 1, + title: 'test title10', + description: 'test description', + startTime: faker.date.past(), + endTime: faker.date.future(), + isVisible: false, + isRankVisible: false, + enableCopyPaste: false +} satisfies UpdateAssignmentInput + +const db = { + assignment: { + findFirst: stub().resolves(Assignment), + findUnique: stub().resolves(Assignment), + findMany: stub().resolves([Assignment]), + create: stub().resolves(Assignment), + update: stub().resolves(Assignment), + delete: stub().resolves(Assignment) + }, + assignmentProblem: { + create: stub().resolves(AssignmentProblem), + findMany: stub().resolves([AssignmentProblem]), + findFirstOrThrow: stub().resolves(AssignmentProblem), + findFirst: stub().resolves(AssignmentProblem) + }, + assignmentRecord: { + findMany: stub().resolves([AssignmentRecord]), + create: stub().resolves(AssignmentRecord) + }, + problem: { + update: stub().resolves(Problem), + updateMany: stub().resolves([Problem]), + findFirstOrThrow: stub().resolves(Problem) + }, + group: { + findUnique: stub().resolves(Group) + }, + submission: { + findMany: stub().resolves([submissionsWithProblemTitleAndUsername]) + }, + // submissionResult: { + // findMany: stub().resolves([submissionResults]) + // }, + $transaction: stub().callsFake(async () => { + const updatedProblem = await db.problem.update() + const newAssignmentProblem = await db.assignmentProblem.create() + return [newAssignmentProblem, updatedProblem] + }), + getPaginator: PrismaService.prototype.getPaginator +} + +describe('AssignmentService', () => { + let service: AssignmentService + let cache: Cache + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentService, + { provide: PrismaService, useValue: db }, + { + provide: CACHE_MANAGER, + useFactory: () => ({ + set: () => [], + get: () => [], + del: () => [], + store: { + keys: () => [] + } + }) + } + ] + }).compile() + + service = module.get(AssignmentService) + cache = module.get(CACHE_MANAGER) + stub(cache.store, 'keys').resolves(['assignment:1:publicize']) + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) + + describe('getAssignments', () => { + it('should return an array of assignments', async () => { + db.assignment.findMany.resolves([assignmentWithCount]) + + const res = await service.getAssignments(5, 2, 0) + expect(res).to.deep.equal([assignmentWithParticipants]) + }) + }) + + describe('getPublicizingRequests', () => { + it('should return an array of PublicizingRequest', async () => { + const cacheSpyGet = stub(cache, 'get').resolves([publicizingRequest]) + const res = await service.getPublicizingRequests() + + expect(cacheSpyGet.called).to.be.true + expect(res).to.deep.equal([publicizingRequest]) + }) + }) + + describe('createAssignment', () => { + it('should return created assignment', async () => { + db.assignment.create.resolves(assignment) + db.group.findUnique.resolves(group) + + const res = await service.createAssignment(groupId, userId, input) + expect(res).to.deep.equal(assignment) + }) + }) + + describe('updateAssignment', () => { + it('should return updated assignment', async () => { + db.assignment.findFirst.resolves(assignment) + db.assignment.update.resolves(assignment) + + const res = await service.updateAssignment(groupId, updateInput) + expect(res).to.deep.equal(assignment) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.updateAssignment(1000, updateInput)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('deleteAssignment', () => { + it('should return deleted assignment', async () => { + db.assignment.findFirst.resolves(assignment) + db.assignment.delete.resolves(assignment) + + const res = await service.deleteAssignment(groupId, assignmentId) + expect(res).to.deep.equal(assignment) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.deleteAssignment(1000, 1000)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('handlePublicizingRequest', () => { + it('should return accepted state', async () => { + db.assignment.update.resolves(assignment) + + const cacheSpyGet = stub(cache, 'get').resolves([publicizingRequest]) + const res = await service.handlePublicizingRequest(assignmentId, true) + + expect(cacheSpyGet.called).to.be.true + expect(res).to.deep.equal({ + assignmentId, + isAccepted: true + }) + }) + + it('should throw error when groupId or assignmentId not exist', async () => { + expect(service.handlePublicizingRequest(1000, true)).to.be.rejectedWith( + EntityNotExistException + ) + }) + + it('should throw error when the assignment is not requested to public', async () => { + expect(service.handlePublicizingRequest(3, true)).to.be.rejectedWith( + EntityNotExistException + ) + }) + }) + + describe('importProblemsToAssignment', () => { + const assignmentWithEmptySubmissions = { + ...assignment, + submission: [] + } + + it('should return created AssignmentProblems', async () => { + db.assignment.findUnique.resolves(assignmentWithEmptySubmissions) + db.problem.update.resolves(problem) + db.assignmentProblem.create.resolves(assignmentProblem) + db.assignmentProblem.findFirst.resolves(null) + + const res = await Promise.all( + await service.importProblemsToAssignment(groupId, assignmentId, [ + problemIdsWithScore + ]) + ) + + expect(res).to.deep.equal([assignmentProblem]) + }) + + it('should return an empty array when the problem already exists in assignment', async () => { + db.assignment.findUnique.resolves(assignmentWithEmptySubmissions) + db.problem.update.resolves(problem) + db.assignmentProblem.findFirst.resolves(AssignmentProblem) + + const res = await service.importProblemsToAssignment( + groupId, + assignmentId, + [problemIdsWithScore] + ) + + expect(res).to.deep.equal([]) + }) + + it('should throw error when the assignmentId not exist', async () => { + expect( + service.importProblemsToAssignment(groupId, 9999, [problemIdsWithScore]) + ).to.be.rejectedWith(EntityNotExistException) + }) + }) + + // describe('getAssignmentSubmissionSummaryByUserId', () => { + // it('should return assignment submission summaries', async () => { + // const res = await service.getAssignmentSubmissionSummaryByUserId(10, 1, 1, 1) + + // expect(res.submissions).to.deep.equal([ + // { + // assignmentId: 1, + // problemTitle: 'submission', + // username: 'user01', + // studentId: '1234567890', + // submissionResult: ResultStatus.Accepted, + // language: 'C', + // submissionTime: '2000-01-01', + // codeSize: 1, + // ip: '127.0.0.1' // TODO: submission.ip 사용 + // } + // ]) + // expect(res.scoreSummary).to.deep.equal({ + // totalProblemCount: 1, + // submittedProblemCount: 1, + // totalScore: 1, + // acceptedTestcaseCountPerProblem: [ + // { + // acceptedTestcaseCount: 0, + // problemId: 1, + // totalTestcaseCount: 1 + // } + // ] + // }) + // }) + // }) + + // describe('duplicateAssignment', () => { + // db['$transaction'] = stub().callsFake(async () => { + // const newAssignment = await db.assignment.create() + // const newAssignmentProblem = await db.assignmentProblem.create() + // const newAssignmentRecord = await db.assignmentRecord.create() + // return [newAssignment, newAssignmentProblem, newAssignmentRecord] + // }) + + // it('should return duplicated assignment', async () => { + // db.assignment.findFirst.resolves(assignment) + // db.assignmentProblem.create.resolves({ + // ...assignment, + // createdById: userId, + // groupId, + // isVisible: false + // }) + // db.assignmentProblem.findMany.resolves([assignmentProblem]) + // db.assignmentProblem.create.resolves({ + // ...assignmentProblem, + // assignmentId: duplicatedAssignmentId + // }) + // db.assignmentRecord.findMany.resolves([assignmentRecord]) + // db.assignmentRecord.create.resolves({ + // ...assignmentRecord, + // assignmentId: duplicatedAssignmentId + // }) + + // const res = await service.duplicateAssignment(groupId, assignmentId, userId) + // expect(res.assignment).to.deep.equal(assignment) + // expect(res.problems).to.deep.equal([ + // { + // ...assignmentProblem, + // assignmentId: duplicatedAssignmentId + // } + // ]) + // expect(res.records).to.deep.equal([ + // { ...assignmentRecord, assignmentId: duplicatedAssignmentId } + // ]) + // }) + + // it('should throw error when the assignmentId not exist', async () => { + // expect( + // service.duplicateAssignment(groupId, 9999, userId) + // ).to.be.rejectedWith(EntityNotExistException) + // }) + + // it('should throw error when the groupId not exist', async () => { + // expect( + // service.duplicateAssignment(9999, assignmentId, userId) + // ).to.be.rejectedWith(EntityNotExistException) + // }) + // }) +}) diff --git a/apps/backend/apps/admin/src/assignment/assignment.service.ts b/apps/backend/apps/admin/src/assignment/assignment.service.ts new file mode 100644 index 0000000000..da26e5152f --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/assignment.service.ts @@ -0,0 +1,973 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { + Inject, + Injectable, + UnprocessableEntityException +} from '@nestjs/common' +import { + Assignment, + ResultStatus, + Submission, + AssignmentProblem +} from '@generated' +import { Cache } from 'cache-manager' +import { + OPEN_SPACE_ID, + PUBLICIZING_REQUEST_EXPIRE_TIME, + PUBLICIZING_REQUEST_KEY, + MIN_DATE, + MAX_DATE +} from '@libs/constants' +import { + ConflictFoundException, + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' +import type { AssignmentWithScores } from './model/assignment-with-scores.model' +import type { CreateAssignmentInput } from './model/assignment.input' +import type { UpdateAssignmentInput } from './model/assignment.input' +import type { AssignmentProblemScoreInput } from './model/problem-score.input' +import type { AssignmentPublicizingRequest } from './model/publicizing-request.model' +import type { AssignmentPublicizingResponse } from './model/publicizing-response.output' + +@Injectable() +export class AssignmentService { + constructor( + private readonly prisma: PrismaService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache + ) {} + + async getAssignments(take: number, groupId: number, cursor: number | null) { + const paginator = this.prisma.getPaginator(cursor) + + const assignments = await this.prisma.assignment.findMany({ + ...paginator, + where: { groupId }, + take, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { assignmentRecord: true } + } + } + }) + + return assignments.map((assignment) => { + const { _count, ...data } = assignment + return { + ...data, + participants: _count.assignmentRecord + } + }) + } + + async getAssignment(assignmentId: number) { + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId + }, + include: { + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { assignmentRecord: true } + } + } + }) + + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const { _count, ...data } = assignment + + return { + ...data, + participants: _count.assignmentRecord + } + } + + async createAssignment( + groupId: number, + userId: number, + assignment: CreateAssignmentInput + ): Promise { + if (assignment.startTime >= assignment.endTime) { + throw new UnprocessableDataException( + 'The start time must be earlier than the end time' + ) + } + + const group = await this.prisma.group.findUnique({ + where: { + id: groupId + } + }) + if (!group) { + throw new EntityNotExistException('Group') + } + + try { + return await this.prisma.assignment.create({ + data: { + createdById: userId, + groupId, + ...assignment + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async updateAssignment( + groupId: number, + assignment: UpdateAssignmentInput + ): Promise { + const assignmentFound = await this.prisma.assignment.findFirst({ + where: { + id: assignment.id, + groupId + }, + select: { + startTime: true, + endTime: true, + assignmentProblem: { + select: { + problemId: true + } + } + } + }) + if (!assignmentFound) { + throw new EntityNotExistException('assignment') + } + const isEndTimeChanged = + assignment.endTime && assignment.endTime !== assignmentFound.endTime + assignment.startTime = assignment.startTime || assignmentFound.startTime + assignment.endTime = assignment.endTime || assignmentFound.endTime + if (assignment.startTime >= assignment.endTime) { + throw new UnprocessableDataException( + 'The start time must be earlier than the end time' + ) + } + + const problemIds = assignmentFound.assignmentProblem.map( + (problem) => problem.problemId + ) + if (problemIds.length && isEndTimeChanged) { + for (const problemId of problemIds) { + try { + // 문제가 포함된 대회 중 가장 늦게 끝나는 대회의 종료시각으로 visibleLockTime 설정 + let visibleLockTime = assignment.endTime + + const assignmentIds = ( + await this.prisma.assignmentProblem.findMany({ + where: { + problemId + } + }) + ) + .filter( + (assignmentProblem) => + assignmentProblem.assignmentId !== assignment.id + ) + .map((assignmentProblem) => assignmentProblem.assignmentId) + + if (assignmentIds.length) { + const latestAssignment = + await this.prisma.assignment.findFirstOrThrow({ + where: { + id: { + in: assignmentIds + } + }, + orderBy: { + endTime: 'desc' + }, + select: { + endTime: true + } + }) + if (assignment.endTime < latestAssignment.endTime) + visibleLockTime = latestAssignment.endTime + } + + await this.prisma.problem.update({ + where: { + id: problemId + }, + data: { + visibleLockTime + } + }) + } catch { + continue + } + } + } + + try { + return await this.prisma.assignment.update({ + where: { + id: assignment.id + }, + data: { + title: assignment.title, + ...assignment + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async deleteAssignment(groupId: number, assignmentId: number) { + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + }, + select: { + assignmentProblem: { + select: { + problemId: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const problemIds = assignment.assignmentProblem.map( + (problem) => problem.problemId + ) + if (problemIds.length) { + await this.removeProblemsFromAssignment(groupId, assignmentId, problemIds) + } + + try { + return await this.prisma.assignment.delete({ + where: { + id: assignmentId + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + async getPublicizingRequests() { + const requests = await this.cacheManager.get< + AssignmentPublicizingRequest[] + >(PUBLICIZING_REQUEST_KEY) + + if (!requests) { + return [] + } + + const filteredRequests = requests.filter( + (req) => new Date(req.expireTime) > new Date() + ) + + if (requests.length != filteredRequests.length) { + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + filteredRequests, + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + } + + return filteredRequests + } + + async handlePublicizingRequest(assignmentId: number, isAccepted: boolean) { + const requests = (await this.cacheManager.get( + PUBLICIZING_REQUEST_KEY + )) as Array + if (!requests) { + throw new EntityNotExistException('AssignmentPublicizingRequest') + } + + const request = requests.find((req) => req.assignmentId === assignmentId) + if (!request || new Date(request.expireTime) < new Date()) { + throw new EntityNotExistException('AssignmentPublicizingRequest') + } + + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + requests.filter((req) => req.assignmentId != assignmentId), + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + + if (isAccepted) { + try { + await this.prisma.assignment.update({ + where: { + id: assignmentId + }, + data: { + groupId: OPEN_SPACE_ID + } + }) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return { + assignmentId, + isAccepted + } as AssignmentPublicizingResponse + } + + async createPublicizingRequest(groupId: number, assignmentId: number) { + if (groupId == OPEN_SPACE_ID) { + throw new UnprocessableEntityException( + 'This assignment is already publicized' + ) + } + + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + let requests = (await this.cacheManager.get( + PUBLICIZING_REQUEST_KEY + )) as Array + if (!requests) { + requests = [] + } + + const duplicatedRequest = requests.find( + (req) => req.assignmentId == assignmentId + ) + if (duplicatedRequest) { + throw new ConflictFoundException('duplicated publicizing request') + } + + const newRequest: AssignmentPublicizingRequest = { + assignmentId, + userId: assignment.createdById!, // TODO: createdById가 null일 경우 예외처리 + expireTime: new Date(Date.now() + PUBLICIZING_REQUEST_EXPIRE_TIME) + } + requests.push(newRequest) + + await this.cacheManager.set( + PUBLICIZING_REQUEST_KEY, + requests, + PUBLICIZING_REQUEST_EXPIRE_TIME + ) + + return newRequest + } + + async importProblemsToAssignment( + groupId: number, + assignmentId: number, + problemIdsWithScore: AssignmentProblemScoreInput[] + ) { + const assignment = await this.prisma.assignment.findUnique({ + where: { + id: assignmentId, + groupId + }, + include: { + submission: { + select: { + id: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const assignmentProblems: AssignmentProblem[] = [] + + for (const { problemId, score } of problemIdsWithScore) { + const isProblemAlreadyImported = + await this.prisma.assignmentProblem.findFirst({ + where: { + assignmentId, + problemId + } + }) + if (isProblemAlreadyImported) { + continue + } + + try { + const [assignmentProblem] = await this.prisma.$transaction([ + this.prisma.assignmentProblem.create({ + data: { + // 원래 id: 'temp'이었는데, assignmentProblem db schema field가 바뀌어서 + // 임시 방편으로 order: 0으로 설정합니다. + order: 0, + assignmentId, + problemId, + score + } + }), + this.prisma.problem.updateMany({ + where: { + id: problemId, + OR: [ + { + visibleLockTime: { + equals: MIN_DATE + } + }, + { + visibleLockTime: { + equals: MAX_DATE + } + }, + { + visibleLockTime: { + lte: assignment.endTime + } + } + ] + }, + data: { + visibleLockTime: assignment.endTime + } + }) + ]) + assignmentProblems.push(assignmentProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return assignmentProblems + } + + async removeProblemsFromAssignment( + groupId: number, + assignmentId: number, + problemIds: number[] + ) { + const assignment = await this.prisma.assignment.findUnique({ + where: { + id: assignmentId, + groupId + }, + include: { + submission: { + select: { + id: true + } + } + } + }) + if (!assignment) { + throw new EntityNotExistException('assignment') + } + + const assignmentProblems: AssignmentProblem[] = [] + + for (const problemId of problemIds) { + // 문제가 포함된 대회 중 가장 늦게 끝나는 대회의 종료시각으로 visibleLockTime 설정 (없을시 비공개 전환) + let visibleLockTime = MAX_DATE + + const assignmentIds = ( + await this.prisma.assignmentProblem.findMany({ + where: { + problemId + } + }) + ) + .filter( + (assignmentProblem) => assignmentProblem.assignmentId !== assignmentId + ) + .map((assignmentProblem) => assignmentProblem.assignmentId) + + if (assignmentIds.length) { + const latestAssignment = await this.prisma.assignment.findFirst({ + where: { + id: { + in: assignmentIds + } + }, + orderBy: { + endTime: 'desc' + }, + select: { + endTime: true + } + }) + + if (!latestAssignment) { + throw new EntityNotExistException('assignment') + } + + visibleLockTime = latestAssignment.endTime + } + + try { + const [, assignmentProblem] = await this.prisma.$transaction([ + this.prisma.problem.updateMany({ + where: { + id: problemId, + visibleLockTime: { + lte: assignment.endTime + } + }, + data: { + visibleLockTime + } + }), + this.prisma.assignmentProblem.delete({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assignmentId_problemId: { + assignmentId, + problemId + } + } + }) + ]) + + assignmentProblems.push(assignmentProblem) + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + return assignmentProblems + } + + async getAssignmentSubmissionSummaryByUserId( + take: number, + assignmentId: number, + userId: number, + problemId: number | null, + cursor: number | null + ) { + const paginator = this.prisma.getPaginator(cursor) + const submissions = await this.prisma.submission.findMany({ + ...paginator, + take, + where: { + userId, + assignmentId, + problemId: problemId ?? undefined + }, + include: { + problem: { + select: { + title: true, + assignmentProblem: { + where: { + assignmentId, + problemId: problemId ?? undefined + } + } + } + }, + user: { + select: { + username: true, + studentId: true + } + } + } + }) + + const mappedSubmission = submissions.map((submission) => { + return { + assignmentId: submission.assignmentId, + problemTitle: submission.problem.title, + username: submission.user?.username, + studentId: submission.user?.studentId, + submissionResult: submission.result, + language: submission.language, + submissionTime: submission.createTime, + codeSize: submission.codeSize, + ip: submission.userIp, + id: submission.id, + problemId: submission.problemId, + order: submission.problem.assignmentProblem.length + ? submission.problem.assignmentProblem[0].order + : null + } + }) + + const scoreSummary = await this.getAssignmentScoreSummary( + userId, + assignmentId + ) + + return { + scoreSummary, + submissions: mappedSubmission + } + } + + /** + * Duplicate assignment with assignment problems and users who participated in the assignment + * Not copied: submission + * @param groupId group to duplicate assignment + * @param assignmentId assignment to duplicate + * @param userId user who tries to duplicates the assignment + * @returns + */ + async duplicateAssignment( + groupId: number, + assignmentId: number, + userId: number + ) { + const [assignmentFound, assignmentProblemsFound, userAssignmentRecords] = + await Promise.all([ + this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + groupId + } + }), + this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + } + }), + this.prisma.assignmentRecord.findMany({ + where: { + assignmentId + } + }) + ]) + + if (!assignmentFound) { + throw new EntityNotExistException('assignment') + } + + // if assignment status is ongoing, visible would be true. else, false + const now = new Date() + let newVisible = false + if (assignmentFound.startTime <= now && now <= assignmentFound.endTime) { + newVisible = true + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, createTime, updateTime, title, ...assignmentDataToCopy } = + assignmentFound + + try { + const [newAssignment, newAssignmentProblems, newAssignmentRecords] = + await this.prisma.$transaction(async (tx) => { + // 1. copy assignment + const newAssignment = await tx.assignment.create({ + data: { + ...assignmentDataToCopy, + title: 'Copy of ' + title, + createdById: userId, + groupId, + isVisible: newVisible + } + }) + + // 2. copy assignment problems + const newAssignmentProblems = await Promise.all( + assignmentProblemsFound.map((assignmentProblem) => + tx.assignmentProblem.create({ + data: { + order: assignmentProblem.order, + assignmentId: newAssignment.id, + problemId: assignmentProblem.problemId, + score: assignmentProblem.score + } + }) + ) + ) + + // 3. copy assignment records (users who participated in the assignment) + const newAssignmentRecords = await Promise.all( + userAssignmentRecords.map((userAssignmentRecord) => + tx.assignmentRecord.create({ + data: { + assignmentId: newAssignment.id, + userId: userAssignmentRecord.userId + } + }) + ) + ) + + return [newAssignment, newAssignmentProblems, newAssignmentRecords] + }) + + return { + assignment: newAssignment, + problems: newAssignmentProblems, + records: newAssignmentRecords + } + } catch (error) { + throw new UnprocessableDataException(error.message) + } + } + + /** + * 특정 user의 특정 Assignment에 대한 총점, 통과한 문제 개수와 각 문제별 테스트케이스 통과 개수를 불러옵니다. + */ + async getAssignmentScoreSummary(userId: number, assignmentId: number) { + const [assignmentProblems, rawSubmissions] = await Promise.all([ + this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + } + }), + this.prisma.submission.findMany({ + where: { + userId, + assignmentId + }, + orderBy: { + createTime: 'desc' + } + }) + ]) + + // 오직 현재 Assignment에 남아있는 문제들의 제출에 대해서만 ScoreSummary 계산 + const assignmentProblemIds = assignmentProblems.map( + (assignmentProblem) => assignmentProblem.problemId + ) + const submissions = rawSubmissions.filter((submission) => + assignmentProblemIds.includes(submission.problemId) + ) + + if (!submissions.length) { + return { + submittedProblemCount: 0, + totalProblemCount: assignmentProblems.length, + userAssignmentScore: 0, + assignmentPerfectScore: assignmentProblems.reduce( + (total, { score }) => total + score, + 0 + ), + problemScores: [] + } + } + + // 하나의 Problem에 대해 여러 개의 Submission이 존재한다면, 마지막에 제출된 Submission만을 점수 계산에 반영함 + const latestSubmissions: { + [problemId: string]: { + result: ResultStatus + score: number // Problem에서 획득한 점수 (maxScore 만점 기준) + maxScore: number // Assignment에서 Problem이 갖는 배점(만점) + } + } = await this.getlatestSubmissions(submissions) + + const problemScores: { + problemId: number + score: number + maxScore: number + }[] = [] + + for (const problemId in latestSubmissions) { + problemScores.push({ + problemId: parseInt(problemId), + score: latestSubmissions[problemId].score, + maxScore: latestSubmissions[problemId].maxScore + }) + } + + const scoreSummary = { + submittedProblemCount: Object.keys(latestSubmissions).length, // Assignment에 존재하는 문제 중 제출된 문제의 개수 + totalProblemCount: assignmentProblems.length, // Assignment에 존재하는 Problem의 총 개수 + userAssignmentScore: problemScores.reduce( + (total, { score }) => total + score, + 0 + ), // Assignment에서 유저가 받은 점수 + assignmentPerfectScore: assignmentProblems.reduce( + (total, { score }) => total + score, + 0 + ), // Assignment의 만점 + problemScores // 개별 Problem의 점수 리스트 (각 문제에서 몇 점을 획득했는지) + } + + return scoreSummary + } + + async getlatestSubmissions(submissions: Submission[]) { + const latestSubmissions: { + [problemId: string]: { + result: ResultStatus + score: number // Problem에서 획득한 점수 + maxScore: number // Assignment에서 Problem이 갖는 배점 + } + } = {} + + for (const submission of submissions) { + const problemId = submission.problemId + if (problemId in latestSubmissions) continue + + const assignmentProblem = await this.prisma.assignmentProblem.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assignmentId_problemId: { + assignmentId: submission.assignmentId!, + problemId: submission.problemId + } + }, + select: { + score: true + } + }) + + if (!assignmentProblem) { + throw new EntityNotExistException('assignmentProblem') + } + + const maxScore = assignmentProblem.score + + latestSubmissions[problemId] = { + result: submission.result as ResultStatus, + score: (submission.score / 100) * maxScore, // assignment에 할당된 Problem의 총점에 맞게 계산 + maxScore + } + } + + return latestSubmissions + } + + async getAssignmentScoreSummaries( + assignmentId: number, + take: number, + cursor: number | null, + searchingName?: string + ) { + const paginator = this.prisma.getPaginator(cursor) + + const assignmentRecords = await this.prisma.assignmentRecord.findMany({ + ...paginator, + where: { + assignmentId, + userId: { + not: null + }, + user: searchingName + ? { + userProfile: { + realName: { + contains: searchingName, + mode: 'insensitive' + } + } + } + : undefined + }, + take, + include: { + user: { + select: { + username: true, + studentId: true, + userProfile: { + select: { + realName: true + } + }, + major: true + } + } + }, + orderBy: { + user: { + studentId: 'asc' + } + } + }) + + const assignmentRecordsWithScoreSummary = await Promise.all( + assignmentRecords.map(async (record) => { + return { + userId: record.userId, + username: record.user?.username, + studentId: record.user?.studentId, + realName: record.user?.userProfile?.realName, + major: record.user?.major, + ...(await this.getAssignmentScoreSummary( + record.userId as number, + assignmentId + )) + } + }) + ) + + return assignmentRecordsWithScoreSummary + } + + async getAssignmentsByProblemId(problemId: number) { + const assignmentProblems = await this.prisma.assignmentProblem.findMany({ + where: { + problemId + }, + select: { + assignment: true, + score: true + } + }) + + const assignments = await Promise.all( + assignmentProblems.map(async (assignmentProblem) => { + return { + ...assignmentProblem.assignment, + problemScore: assignmentProblem.score, + totalScore: await this.getTotalScoreOfAssignment( + assignmentProblem.assignment.id + ) + } + }) + ) + + const now = new Date() + + const assignmentsGroupedByStatus = assignments.reduce( + (acc, assignment) => { + if (assignment.endTime > now) { + if (assignment.startTime <= now) { + acc.ongoing.push(assignment) + } else { + acc.upcoming.push(assignment) + } + } else { + acc.finished.push(assignment) + } + return acc + }, + { + upcoming: [] as AssignmentWithScores[], + ongoing: [] as AssignmentWithScores[], + finished: [] as AssignmentWithScores[] + } + ) + + return assignmentsGroupedByStatus + } + + async getTotalScoreOfAssignment(assignmentId: number) { + const assignmentProblemScores = + await this.prisma.assignmentProblem.findMany({ + where: { + assignmentId + }, + select: { + score: true + } + }) + + return assignmentProblemScores.reduce( + (total, problem) => total + problem.score, + 0 + ) + } +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts new file mode 100644 index 0000000000..4468609f5e --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-submission-summary-for-user.model.ts @@ -0,0 +1,54 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Language, ResultStatus } from '@admin/@generated' +import { UserAssignmentScoreSummary } from './score-summary' + +@ObjectType({ description: 'AssignmentSubmissionSummaryForUser' }) +export class AssignmentSubmissionSummaryForUser { + @Field(() => UserAssignmentScoreSummary, { nullable: false }) + scoreSummary: UserAssignmentScoreSummary + + @Field(() => [AssignmentSubmissionSummaryForOne], { nullable: false }) + submissions: AssignmentSubmissionSummaryForOne[] +} + +/** + * 특정 User의 특정 Assignment에 대한 Submission 정보 (!== model SubmissionResult) + */ +@ObjectType({ description: 'AssignmentSubmissionSummaryForOne' }) +export class AssignmentSubmissionSummaryForOne { + @Field(() => Int, { nullable: false }) + assignmentId: number + + @Field(() => String, { nullable: false }) + problemTitle: string + + @Field(() => String, { nullable: false }) + username: string + + @Field(() => String, { nullable: false }) + studentId: string + + @Field(() => ResultStatus, { nullable: false }) + submissionResult: ResultStatus // Accepted, RuntimeError, ... + + @Field(() => Language, { nullable: false }) + language: Language + + @Field(() => String, { nullable: false }) + submissionTime: Date + + @Field(() => Int, { nullable: true }) + codeSize?: number + + @Field(() => String, { nullable: true }) + ip?: string + + @Field(() => Int, { nullable: false }) + id: number + + @Field(() => Int, { nullable: false }) + problemId: number + + @Field(() => Int, { nullable: true }) + order: number | null +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts new file mode 100644 index 0000000000..a3e1a85933 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-with-participants.model.ts @@ -0,0 +1,8 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Assignment } from '@admin/@generated' + +@ObjectType({ description: 'assignmentWithParticipants' }) +export class AssignmentWithParticipants extends Assignment { + @Field(() => Int) + participants: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts b/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts new file mode 100644 index 0000000000..9baa2b1ee5 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment-with-scores.model.ts @@ -0,0 +1,11 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Assignment } from '@admin/@generated' + +@ObjectType() +export class AssignmentWithScores extends Assignment { + @Field(() => Int) + problemScore: number + + @Field(() => Int) + totalScore: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignment.input.ts b/apps/backend/apps/admin/src/assignment/model/assignment.input.ts new file mode 100644 index 0000000000..633e5a8a40 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignment.input.ts @@ -0,0 +1,71 @@ +import { Field, GraphQLISODateTime, InputType, Int } from '@nestjs/graphql' +import { IsNumberString, IsOptional, Length } from 'class-validator' + +@InputType() +export class CreateAssignmentInput { + @Field(() => String, { nullable: false }) + title!: string + + @Field(() => String, { nullable: false }) + description!: string + + @IsOptional() + @IsNumberString() + @Length(6, 6) + @Field(() => String, { nullable: true }) + invitationCode?: string + + @Field(() => GraphQLISODateTime, { nullable: false }) + startTime!: Date + + @Field(() => GraphQLISODateTime, { nullable: false }) + endTime!: Date + + @Field(() => Boolean, { nullable: false }) + isVisible!: boolean + + @Field(() => Boolean, { nullable: false }) + isRankVisible!: boolean + + @Field(() => Boolean, { nullable: false }) + isJudgeResultVisible!: boolean + + @Field(() => Boolean, { nullable: true }) + enableCopyPaste?: boolean +} + +@InputType() +export class UpdateAssignmentInput { + @Field(() => Int, { nullable: false }) + id!: number + + @Field(() => String, { nullable: true }) + title?: string + + @Field(() => String, { nullable: true }) + description?: string + + @IsOptional() + @IsNumberString() + @Length(6, 6) + @Field(() => String, { nullable: true }) + invitationCode?: string + + @Field(() => GraphQLISODateTime, { nullable: true }) + startTime?: Date + + @Field(() => GraphQLISODateTime, { nullable: true }) + endTime?: Date + + @Field(() => Boolean, { nullable: true }) + isVisible?: boolean + + @Field(() => Boolean, { nullable: true }) + isRankVisible?: boolean + + @Field(() => Boolean, { nullable: true }) + enableCopyPaste?: boolean + + @Field(() => Boolean, { nullable: true }) + isJudgeResultVisible?: boolean +} diff --git a/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts b/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts new file mode 100644 index 0000000000..7fdab246e8 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/assignments-grouped-by-status.output.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql' +import { AssignmentWithScores } from './assignment-with-scores.model' + +@ObjectType() +export class AssignmentsGroupedByStatus { + @Field(() => [AssignmentWithScores]) + upcoming: AssignmentWithScores[] + + @Field(() => [AssignmentWithScores]) + ongoing: AssignmentWithScores[] + + @Field(() => [AssignmentWithScores]) + finished: AssignmentWithScores[] +} diff --git a/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts b/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts new file mode 100644 index 0000000000..ef2463b817 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/duplicated-assignment-response.output.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql' +import { Type } from 'class-transformer' +import { + Assignment, + AssignmentProblem, + AssignmentRecord +} from '@admin/@generated' + +@ObjectType() +export class DuplicatedAssignmentResponse { + @Field(() => Assignment) + assignment: Assignment + + @Field(() => [AssignmentProblem]) + problems: AssignmentProblem[] + + @Field(() => [AssignmentRecord]) + @Type(() => AssignmentRecord) + records: AssignmentRecord[] +} diff --git a/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts b/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts new file mode 100644 index 0000000000..b46557bee4 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/problem-score.input.ts @@ -0,0 +1,11 @@ +import { Field, InputType, Int } from '@nestjs/graphql' +import { IntScoreScalar } from '@admin/problem/scalar/int-score.scalar' + +@InputType() +export class AssignmentProblemScoreInput { + @Field(() => Int) + problemId: number + + @Field(() => IntScoreScalar) + score: number +} diff --git a/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts b/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts new file mode 100644 index 0000000000..6b96e98d50 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/publicizing-request.model.ts @@ -0,0 +1,13 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class AssignmentPublicizingRequest { + @Field(() => Int) + assignmentId: number + + @Field(() => Int) + userId: number + + @Field(() => String) + expireTime: Date +} diff --git a/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts b/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts new file mode 100644 index 0000000000..0b493fa8fe --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/publicizing-response.output.ts @@ -0,0 +1,10 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class AssignmentPublicizingResponse { + @Field(() => Int) + assignmentId: number + + @Field(() => Boolean) + isAccepted: boolean +} diff --git a/apps/backend/apps/admin/src/assignment/model/score-summary.ts b/apps/backend/apps/admin/src/assignment/model/score-summary.ts new file mode 100644 index 0000000000..fdf50443c7 --- /dev/null +++ b/apps/backend/apps/admin/src/assignment/model/score-summary.ts @@ -0,0 +1,64 @@ +import { Field, Float, Int, ObjectType } from '@nestjs/graphql' + +@ObjectType() +export class UserAssignmentScoreSummary { + @Field(() => Int) + submittedProblemCount: number + + @Field(() => Int) + totalProblemCount: number + + @Field(() => Float) + userAssignmentScore: number + + @Field(() => Int) + assignmentPerfectScore: number + + @Field(() => [AssignmentProblemScore]) + problemScores: AssignmentProblemScore[] +} + +@ObjectType() +export class UserAssignmentScoreSummaryWithUserInfo { + @Field(() => Int) + userId: number + + @Field(() => String) + username: string + + @Field(() => String) + studentId: string + + @Field(() => String, { nullable: true }) + realName: string + + @Field(() => String) + major: string + + @Field(() => Int) + submittedProblemCount: number + + @Field(() => Int) + totalProblemCount: number + + @Field(() => Float) + userAssignmentScore: number + + @Field(() => Int) + assignmentPerfectScore: number + + @Field(() => [AssignmentProblemScore]) + problemScores: AssignmentProblemScore[] +} + +@ObjectType() +class AssignmentProblemScore { + @Field(() => Int) + problemId: number + + @Field(() => Float) + score: number + + @Field(() => Int) + maxScore: number +} diff --git a/apps/backend/apps/client/src/announcement/announcement.controller.ts b/apps/backend/apps/client/src/announcement/announcement.controller.ts index d53d21a0b9..82038e4885 100644 --- a/apps/backend/apps/client/src/announcement/announcement.controller.ts +++ b/apps/backend/apps/client/src/announcement/announcement.controller.ts @@ -6,8 +6,11 @@ import { Query } from '@nestjs/common' import { AuthNotNeededIfOpenSpace } from '@libs/auth' -import { EntityNotExistException } from '@libs/exception' -import { GroupIDPipe, IDValidationPipe, RequiredIntPipe } from '@libs/pipe' +import { + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' +import { GroupIDPipe, IDValidationPipe } from '@libs/pipe' import { AnnouncementService } from './announcement.service' @Controller('announcement') @@ -20,24 +23,42 @@ export class AnnouncementController { @Get() async getAnnouncements( @Query('problemId', IDValidationPipe) problemId: number | null, - @Query('contestId', RequiredIntPipe) contestId: number, - @Query('groupId', GroupIDPipe) groupId: number + @Query('contestId', IDValidationPipe) contestId: number | null, + @Query('assignmentId', IDValidationPipe) assignmentId: number | null, + @Query('groupId', GroupIDPipe) + groupId: number ) { try { + if (!!contestId === !!assignmentId) { + throw new UnprocessableDataException( + 'Either contestId or assignmentId must be provided, but not both.' + ) + } if (problemId) { return await this.announcementService.getProblemAnnouncements( - problemId, contestId, + assignmentId, + problemId, groupId ) } else { - return await this.announcementService.getContestAnnouncements( - contestId, - groupId - ) + if (contestId) { + return await this.announcementService.getContestAnnouncements( + contestId, + groupId + ) + } else { + return await this.announcementService.getAssignmentAnnouncements( + assignmentId!, + groupId + ) + } } } catch (error) { - if (error instanceof EntityNotExistException) { + if ( + error instanceof EntityNotExistException || + error instanceof UnprocessableDataException + ) { throw error.convert2HTTPException() } this.logger.error(error) diff --git a/apps/backend/apps/client/src/announcement/announcement.service.spec.ts b/apps/backend/apps/client/src/announcement/announcement.service.spec.ts index c841456a51..be12cf43fb 100644 --- a/apps/backend/apps/client/src/announcement/announcement.service.spec.ts +++ b/apps/backend/apps/client/src/announcement/announcement.service.spec.ts @@ -54,12 +54,13 @@ describe('AnnouncementService', () => { describe('getProblemAnnouncements', () => { it('should return problem announcements', async () => { - const res = await service.getProblemAnnouncements(1, 1, 1) + const res = await service.getProblemAnnouncements(1, null, 1, 1) expect(res) .excluding(['createTime', 'updateTime', 'content']) .to.deep.equal([ { - id: 6, + id: 16, + assignmentId: null, contestId: 1, problemId: 1 } @@ -74,16 +75,40 @@ describe('AnnouncementService', () => { .excluding(['createTime', 'updateTime', 'content']) .to.deep.equal([ { - id: 6, + id: 16, + assignmentId: null, contestId: 1, problemId: 0 }, { - id: 1, + id: 11, + assignmentId: null, contestId: 1, problemId: null } ]) }) }) + + describe('getAssignmentAnnouncements', () => { + it('should return multiple assignment announcements', async () => { + const res = await service.getAssignmentAnnouncements(1, 1) + expect(res) + .excluding(['createTime', 'updateTime', 'content']) + .to.deep.equal([ + { + id: 6, + assignmentId: 1, + contestId: null, + problemId: 0 + }, + { + id: 1, + assignmentId: 1, + contestId: null, + problemId: null + } + ]) + }) + }) }) diff --git a/apps/backend/apps/client/src/announcement/announcement.service.ts b/apps/backend/apps/client/src/announcement/announcement.service.ts index ec1e325dab..3f4818c707 100644 --- a/apps/backend/apps/client/src/announcement/announcement.service.ts +++ b/apps/backend/apps/client/src/announcement/announcement.service.ts @@ -34,20 +34,62 @@ export class AnnouncementService { }) } - async getProblemAnnouncements( - contestId: number, - problemId: number, + async getAssignmentAnnouncements( + assignmentId: number, groupId: number ): Promise { - return await this.prisma.announcement.findMany({ - where: { - problemId, - contest: { - id: contestId, + const { assignmentProblem, announcement } = + await this.prisma.assignment.findUniqueOrThrow({ + where: { + id: assignmentId, groupId + }, + select: { + assignmentProblem: true, + announcement: { + orderBy: { updateTime: 'desc' } + } } - }, - orderBy: { updateTime: 'desc' } + }) + + return announcement.map((announcement) => { + if (announcement.problemId !== null) { + announcement.problemId = assignmentProblem.find( + (problem) => announcement.problemId === problem.problemId + )!.order + } + return announcement }) } + + async getProblemAnnouncements( + contestId: number | null, + assignmentId: number | null, + problemId: number, + groupId: number + ): Promise { + if (contestId) { + return await this.prisma.announcement.findMany({ + where: { + problemId, + contest: { + id: contestId, + groupId + } + }, + orderBy: { updateTime: 'desc' } + }) + } else { + return await this.prisma.announcement.findMany({ + where: { + problemId, + assignment: { + id: assignmentId!, + groupId + } + }, + orderBy: { updateTime: 'desc' } + }) + } + } } diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index d418fd7524..9239969ec3 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -15,6 +15,7 @@ import { StorageModule } from '@libs/storage' import { AnnouncementModule } from './announcement/announcement.module' import { AppController } from './app.controller' import { AppService } from './app.service' +import { AssignmentModule } from './assignment/assignment.module' import { AuthModule } from './auth/auth.module' import { ContestModule } from './contest/contest.module' import { EmailModule } from './email/email.module' @@ -49,6 +50,7 @@ import { WorkbookModule } from './workbook/workbook.module' EmailModule, AnnouncementModule, StorageModule, + AssignmentModule, LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot() ], diff --git a/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts b/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts new file mode 100644 index 0000000000..78a0a9c63c --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.controller.spec.ts @@ -0,0 +1,25 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { expect } from 'chai' +import { RolesService } from '@libs/auth' +import { AssignmentController } from './assignment.controller' +import { AssignmentService } from './assignment.service' + +describe('AssignmentController', () => { + let controller: AssignmentController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssignmentController], + providers: [ + { provide: AssignmentService, useValue: {} }, + { provide: RolesService, useValue: {} } + ] + }).compile() + + controller = module.get(AssignmentController) + }) + + it('should be defined', () => { + expect(controller).to.be.ok + }) +}) diff --git a/apps/backend/apps/client/src/assignment/assignment.controller.ts b/apps/backend/apps/client/src/assignment/assignment.controller.ts new file mode 100644 index 0000000000..90a9463b14 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.controller.ts @@ -0,0 +1,207 @@ +import { + Controller, + InternalServerErrorException, + NotFoundException, + Param, + Post, + Req, + Get, + Query, + Logger, + DefaultValuePipe, + Delete +} from '@nestjs/common' +import { Prisma } from '@prisma/client' +import { + AuthNotNeededIfOpenSpace, + AuthenticatedRequest, + UserNullWhenAuthFailedIfOpenSpace +} from '@libs/auth' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { + CursorValidationPipe, + GroupIDPipe, + IDValidationPipe, + RequiredIntPipe +} from '@libs/pipe' +import { AssignmentService } from './assignment.service' + +@Controller('assignment') +export class AssignmentController { + private readonly logger = new Logger(AssignmentController.name) + + constructor(private readonly assignmentService: AssignmentService) {} + + @Get('ongoing-upcoming') + @AuthNotNeededIfOpenSpace() + async getOngoingUpcomingAssignments( + @Query('groupId', GroupIDPipe) groupId: number + ) { + try { + return await this.assignmentService.getAssignmentsByGroupId(groupId) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('ongoing-upcoming-with-registered') + async getOngoingUpcomingAssignmentsWithRegistered( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number + ) { + try { + return await this.assignmentService.getAssignmentsByGroupId( + groupId, + req.user.id + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('finished') + @UserNullWhenAuthFailedIfOpenSpace() + async getFinishedAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('cursor', CursorValidationPipe) cursor: number | null, + @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) + take: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getFinishedAssignmentsByGroupId( + req.user?.id, + cursor, + take, + groupId, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-finished') + async getRegisteredFinishedAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('cursor', CursorValidationPipe) cursor: number | null, + @Query('take', new DefaultValuePipe(10), new RequiredIntPipe('take')) + take: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getRegisteredFinishedAssignments( + cursor, + take, + groupId, + req.user.id, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get('registered-ongoing-upcoming') + async getRegisteredOngoingUpcomingAssignments( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Query('search') search?: string + ) { + try { + return await this.assignmentService.getRegisteredOngoingUpcomingAssignments( + groupId, + req.user.id, + search + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Get(':id') + @UserNullWhenAuthFailedIfOpenSpace() + async getAssignment( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', new RequiredIntPipe('id')) id: number + ) { + try { + return await this.assignmentService.getAssignment( + id, + groupId, + req.user?.id + ) + } catch (error) { + if (error instanceof EntityNotExistException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException() + } + } + + @Post(':id/participation') + async createAssignmentRecord( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', IDValidationPipe) assignmentId: number, + @Query('invitationCode') invitationCode?: string + ) { + try { + return await this.assignmentService.createAssignmentRecord( + assignmentId, + req.user.id, + invitationCode, + groupId + ) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.name === 'NotFoundError' + ) { + throw new NotFoundException(error.message) + } else if (error instanceof ConflictFoundException) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException(error.message) + } + } + + // unregister only for upcoming Assignment + @Delete(':id/participation') + async deleteAssignmentRecord( + @Req() req: AuthenticatedRequest, + @Query('groupId', GroupIDPipe) groupId: number, + @Param('id', IDValidationPipe) assignmentId: number + ) { + try { + return await this.assignmentService.deleteAssignmentRecord( + assignmentId, + req.user.id, + groupId + ) + } catch (error) { + if ( + error instanceof ForbiddenAccessException || + error instanceof EntityNotExistException + ) { + throw error.convert2HTTPException() + } + this.logger.error(error) + throw new InternalServerErrorException(error.message) + } + } +} diff --git a/apps/backend/apps/client/src/assignment/assignment.module.ts b/apps/backend/apps/client/src/assignment/assignment.module.ts new file mode 100644 index 0000000000..915ac4dc41 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { GroupMemberGuard, RolesModule } from '@libs/auth' +import { AssignmentController } from './assignment.controller' +import { AssignmentService } from './assignment.service' + +@Module({ + imports: [RolesModule], + controllers: [AssignmentController], + providers: [ + AssignmentService, + { provide: APP_GUARD, useClass: GroupMemberGuard } + ], + exports: [AssignmentService] +}) +export class AssignmentModule {} diff --git a/apps/backend/apps/client/src/assignment/assignment.service.spec.ts b/apps/backend/apps/client/src/assignment/assignment.service.spec.ts new file mode 100644 index 0000000000..1e1361ccc4 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.service.spec.ts @@ -0,0 +1,473 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { ConfigService } from '@nestjs/config' +import { Test, type TestingModule } from '@nestjs/testing' +import { + Prisma, + type Assignment, + type Group, + type AssignmentRecord +} from '@prisma/client' +import { expect } from 'chai' +import * as dayjs from 'dayjs' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { + PrismaService, + PrismaTestService, + type FlatTransactionClient +} from '@libs/prisma' +import { AssignmentService, type AssignmentResult } from './assignment.service' + +const assignmentId = 1 +const user01Id = 4 +const groupId = 1 + +const now = dayjs() + +const assignment = { + id: assignmentId, + createdById: 1, + groupId, + title: 'title', + description: 'description', + startTime: now.add(-1, 'day').toDate(), + endTime: now.add(1, 'day').toDate(), + isVisible: true, + isJudgeResultVisible: true, + isRankVisible: true, + enableCopyPaste: true, + createTime: now.add(-1, 'day').toDate(), + updateTime: now.add(-1, 'day').toDate(), + group: { + id: groupId, + groupName: 'group' + }, + invitationCode: '123456' +} satisfies Assignment & { + group: Partial +} + +const ongoingAssignments = [ + { + id: assignment.id, + group: assignment.group, + title: assignment.title, + invitationCode: 'test', + isJudgeResultVisible: true, + startTime: now.add(-1, 'day').toDate(), + endTime: now.add(1, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const upcomingAssignments = [ + { + id: assignment.id + 6, + group: assignment.group, + title: assignment.title, + invitationCode: 'test', + isJudgeResultVisible: true, + startTime: now.add(1, 'day').toDate(), + endTime: now.add(2, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const finishedAssignments = [ + { + id: assignment.id + 1, + group: assignment.group, + title: assignment.title, + invitationCode: null, + isJudgeResultVisible: true, + startTime: now.add(-2, 'day').toDate(), + endTime: now.add(-1, 'day').toDate(), + participants: 1, + enableCopyPaste: true + } +] satisfies Partial[] + +const assignments = [ + ...ongoingAssignments, + ...finishedAssignments, + ...upcomingAssignments +] satisfies Partial[] + +describe('AssignmentService', () => { + let service: AssignmentService + let prisma: PrismaTestService + let transaction: FlatTransactionClient + + before(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentService, + PrismaTestService, + { + provide: PrismaService, + useExisting: PrismaTestService + }, + ConfigService, + { + provide: CACHE_MANAGER, + useFactory: () => ({ + set: () => [], + get: () => [] + }) + } + ] + }).compile() + service = module.get(AssignmentService) + prisma = module.get(PrismaTestService) + }) + + beforeEach(async () => { + transaction = await prisma.$begin() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(service as any).prisma = transaction + }) + + afterEach(async () => { + await transaction.$rollback() + }) + + after(async () => { + await prisma.$disconnect() + }) + + it('should be defined', () => { + expect(service).to.be.ok + }) + + describe('getAssignmentsByGroupId', () => { + it('should return ongoing, upcoming assignments when userId is undefined', async () => { + const assignments = await service.getAssignmentsByGroupId(groupId) + expect(assignments.ongoing).to.have.lengthOf(4) + expect(assignments.upcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields when userId is undefined', async () => { + const assignments = await service.getAssignmentsByGroupId(groupId) + expect(assignments.ongoing[0]).to.have.property('title') + expect(assignments.ongoing[0]).to.have.property('startTime') + expect(assignments.ongoing[0]).to.have.property('endTime') + expect(assignments.ongoing[0]).to.have.property('participants') + expect(assignments.ongoing[0].group).to.have.property('id') + expect(assignments.ongoing[0].group).to.have.property('groupName') + expect(assignments.upcoming[0]).to.have.property('title') + expect(assignments.upcoming[0]).to.have.property('startTime') + expect(assignments.upcoming[0]).to.have.property('endTime') + expect(assignments.upcoming[0]).to.have.property('participants') + expect(assignments.upcoming[0].group).to.have.property('id') + expect(assignments.upcoming[0].group).to.have.property('groupName') + }) + + it('should return ongoing, upcoming, registered ongoing, registered upcoming assignments when userId is provided', async () => { + const assignments = await service.getAssignmentsByGroupId( + groupId, + user01Id + ) + expect(assignments.ongoing).to.have.lengthOf(2) + expect(assignments.upcoming).to.have.lengthOf(1) + expect(assignments.registeredOngoing).to.have.lengthOf(2) + expect(assignments.registeredUpcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields when userId is provided', async () => { + const assignments = await service.getAssignmentsByGroupId( + groupId, + user01Id + ) + expect(assignments.ongoing[0]).to.have.property('title') + expect(assignments.ongoing[0]).to.have.property('startTime') + expect(assignments.ongoing[0]).to.have.property('endTime') + expect(assignments.ongoing[0]).to.have.property('participants') + expect(assignments.ongoing[0].group).to.have.property('id') + expect(assignments.ongoing[0].group).to.have.property('groupName') + expect(assignments.upcoming[0]).to.have.property('title') + expect(assignments.upcoming[0]).to.have.property('startTime') + expect(assignments.upcoming[0]).to.have.property('endTime') + expect(assignments.upcoming[0]).to.have.property('participants') + expect(assignments.upcoming[0].group).to.have.property('id') + expect(assignments.upcoming[0].group).to.have.property('groupName') + expect(assignments.registeredOngoing[0]).to.have.property('title') + expect(assignments.registeredOngoing[0]).to.have.property('startTime') + expect(assignments.registeredOngoing[0]).to.have.property('endTime') + expect(assignments.registeredOngoing[0]).to.have.property('participants') + expect(assignments.registeredOngoing[0].group).to.have.property('id') + expect(assignments.registeredOngoing[0].group).to.have.property( + 'groupName' + ) + expect(assignments.registeredUpcoming[0]).to.have.property('title') + expect(assignments.registeredUpcoming[0]).to.have.property('startTime') + expect(assignments.registeredUpcoming[0]).to.have.property('endTime') + expect(assignments.registeredUpcoming[0]).to.have.property('participants') + expect(assignments.registeredUpcoming[0].group).to.have.property('id') + expect(assignments.registeredUpcoming[0].group).to.have.property( + 'groupName' + ) + }) + }) + + describe('getRegisteredOngoingUpcomingAssignments', () => { + it('should return registeredOngoing, registeredUpcoming assignments', async () => { + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id + ) + expect(assignments.registeredOngoing).to.have.lengthOf(2) + expect(assignments.registeredUpcoming).to.have.lengthOf(2) + }) + + it('a assignment should contain following fields', async () => { + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id + ) + expect(assignments.registeredOngoing[0]).to.have.property('title') + expect(assignments.registeredOngoing[0]).to.have.property('startTime') + expect(assignments.registeredOngoing[0]).to.have.property('endTime') + expect(assignments.registeredOngoing[0]).to.have.property('participants') + expect(assignments.registeredOngoing[0].group).to.have.property('id') + expect(assignments.registeredOngoing[0].group).to.have.property( + 'groupName' + ) + expect(assignments.registeredUpcoming[0]).to.have.property('title') + expect(assignments.registeredUpcoming[0]).to.have.property('startTime') + expect(assignments.registeredUpcoming[0]).to.have.property('endTime') + expect(assignments.registeredUpcoming[0]).to.have.property('participants') + expect(assignments.registeredUpcoming[0].group).to.have.property('id') + expect(assignments.registeredUpcoming[0].group).to.have.property( + 'groupName' + ) + }) + + it("shold return assignments whose title contains '신입생'", async () => { + const keyword = '신입생' + const assignments = await service.getRegisteredOngoingUpcomingAssignments( + groupId, + user01Id, + keyword + ) + expect( + assignments.registeredOngoing.map((assignment) => assignment.title) + ).to.deep.equals(['24년도 소프트웨어학과 신입생 입학 과제2']) + }) + }) + + describe('getRegisteredAssignmentIds', async () => { + it("should return an array of assignment's id user01 registered", async () => { + const assignmentIds = await service.getRegisteredAssignmentIds(user01Id) + const registeredAssignmentIds = [1, 3, 5, 7, 9, 11, 13, 15, 17] + assignmentIds.sort((a, b) => a - b) + expect(assignmentIds).to.deep.equal(registeredAssignmentIds) + }) + }) + + describe('getRegisteredFinishedAssignments', async () => { + it('should return only 2 assignments that user01 registered but finished', async () => { + const takeNum = 4 + const assignments = await service.getRegisteredFinishedAssignments( + null, + takeNum, + groupId, + user01Id + ) + expect(assignments.data).to.have.lengthOf(takeNum) + }) + + it('should return a assignment array which starts with id 9', async () => { + const takeNum = 2 + const prevCursor = 11 + const assignments = await service.getRegisteredFinishedAssignments( + prevCursor, + takeNum, + groupId, + user01Id + ) + expect(assignments.data[0].id).to.equals(9) + }) + + it('a assignment should contain following fields', async () => { + const assignments = await service.getRegisteredFinishedAssignments( + null, + 10, + groupId, + user01Id + ) + expect(assignments.data[0]).to.have.property('title') + expect(assignments.data[0]).to.have.property('startTime') + expect(assignments.data[0]).to.have.property('endTime') + expect(assignments.data[0]).to.have.property('participants') + expect(assignments.data[0].group).to.have.property('id') + expect(assignments.data[0].group).to.have.property('groupName') + }) + + it("shold return assignments whose title contains '낮'", async () => { + const keyword = '낮' + const assignments = await service.getRegisteredFinishedAssignments( + null, + 10, + groupId, + user01Id, + keyword + ) + expect( + assignments.data.map((assignment) => assignment.title) + ).to.deep.equals(['소프트의 낮과제']) + }) + }) + + describe('getFinishedAssignmentsByGroupId', () => { + it('should return finished assignments', async () => { + const assignments = await service.getFinishedAssignmentsByGroupId( + null, + null, + 10, + groupId + ) + const assignmentIds = assignments.data + .map((c) => c.id) + .sort((a, b) => a - b) + const finishedAssignmentIds = [6, 7, 8, 9, 10, 11, 12, 13] + expect(assignmentIds).to.deep.equal(finishedAssignmentIds) + }) + }) + + describe('filterOngoing', () => { + it('should return ongoing assignments of the group', () => { + expect(service.filterOngoing(assignments)).to.deep.equal( + ongoingAssignments + ) + }) + }) + + describe('filterUpcoming', () => { + it('should return upcoming assignments of the group', () => { + expect(service.filterUpcoming(assignments)).to.deep.equal( + upcomingAssignments + ) + }) + }) + + describe('getAssignment', () => { + it('should throw error when assignment does not exist', async () => { + await expect( + service.getAssignment(999, groupId, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should return assignment', async () => { + expect(await service.getAssignment(assignmentId, groupId, user01Id)).to.be + .ok + }) + }) + + describe('createAssignmentRecord', () => { + let assignmentRecordId = -1 + const invitationCode = '123456' + const invalidInvitationCode = '000000' + + it('should throw error when the invitation code does not match', async () => { + await expect( + service.createAssignmentRecord(1, user01Id, invalidInvitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should throw error when the assignment does not exist', async () => { + await expect( + service.createAssignmentRecord(999, user01Id, invitationCode) + ).to.be.rejectedWith(Prisma.PrismaClientKnownRequestError) + }) + + it('should throw error when user is participated in assignment again', async () => { + await expect( + service.createAssignmentRecord(assignmentId, user01Id, invitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should throw error when assignment is not ongoing', async () => { + await expect( + service.createAssignmentRecord(8, user01Id, invitationCode) + ).to.be.rejectedWith(ConflictFoundException) + }) + + it('should register to a assignment successfully', async () => { + const assignmentRecord = await service.createAssignmentRecord( + 2, + user01Id, + invitationCode + ) + assignmentRecordId = assignmentRecord.id + expect( + await transaction.assignmentRecord.findUnique({ + where: { id: assignmentRecordId } + }) + ).to.deep.equals(assignmentRecord) + }) + }) + + describe('deleteAssignmentRecord', () => { + let assignmentRecord: AssignmentRecord | { id: number } = { id: -1 } + + afterEach(async () => { + try { + await transaction.assignmentRecord.delete({ + where: { id: assignmentRecord.id } + }) + } catch (error) { + if ( + !( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) + ) { + throw error + } + } + }) + + it('should return deleted assignment record', async () => { + const newlyRegisteringAssignmentId = 16 + assignmentRecord = await transaction.assignmentRecord.create({ + data: { + assignmentId: newlyRegisteringAssignmentId, + userId: user01Id, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + + expect( + await service.deleteAssignmentRecord( + newlyRegisteringAssignmentId, + user01Id + ) + ).to.deep.equal(assignmentRecord) + }) + + it('should throw error when assignment does not exist', async () => { + await expect( + service.deleteAssignmentRecord(999, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should throw error when assignment record does not exist', async () => { + await expect( + service.deleteAssignmentRecord(16, user01Id) + ).to.be.rejectedWith(EntityNotExistException) + }) + + it('should throw error when assignment is ongoing', async () => { + await expect( + service.deleteAssignmentRecord(assignmentId, user01Id) + ).to.be.rejectedWith(ForbiddenAccessException) + }) + }) +}) diff --git a/apps/backend/apps/client/src/assignment/assignment.service.ts b/apps/backend/apps/client/src/assignment/assignment.service.ts new file mode 100644 index 0000000000..1d3ba116f0 --- /dev/null +++ b/apps/backend/apps/client/src/assignment/assignment.service.ts @@ -0,0 +1,516 @@ +import { Injectable } from '@nestjs/common' +import { Prisma, type Assignment } from '@prisma/client' +import { OPEN_SPACE_ID } from '@libs/constants' +import { + ConflictFoundException, + EntityNotExistException, + ForbiddenAccessException +} from '@libs/exception' +import { PrismaService } from '@libs/prisma' + +const assignmentSelectOption = { + id: true, + title: true, + startTime: true, + endTime: true, + group: { select: { id: true, groupName: true } }, + invitationCode: true, + enableCopyPaste: true, + isJudgeResultVisible: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + select: { + assignmentRecord: true + } + } +} satisfies Prisma.AssignmentSelect + +export type AssignmentSelectResult = Prisma.AssignmentGetPayload<{ + select: typeof assignmentSelectOption +}> + +export type AssignmentResult = Omit & { + participants: number +} + +@Injectable() +export class AssignmentService { + constructor(private readonly prisma: PrismaService) {} + + async getAssignmentsByGroupId( + groupId: number, + userId?: T + ): Promise< + T extends undefined | null + ? { + ongoing: AssignmentResult[] + upcoming: AssignmentResult[] + } + : { + registeredOngoing: AssignmentResult[] + registeredUpcoming: AssignmentResult[] + ongoing: AssignmentResult[] + upcoming: AssignmentResult[] + } + > + async getAssignmentsByGroupId(groupId: number, userId: number | null = null) { + const now = new Date() + if (userId == null) { + const assignments = await this.prisma.assignment.findMany({ + where: { + groupId, + endTime: { + gt: now + }, + isVisible: true + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + + const assignmentsWithParticipants: AssignmentResult[] = + this.renameToParticipants(assignments) + + return { + ongoing: this.filterOngoing(assignmentsWithParticipants), + upcoming: this.filterUpcoming(assignmentsWithParticipants) + } + } + + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + + let registeredAssignments: AssignmentSelectResult[] = [] + let restAssignments: AssignmentSelectResult[] = [] + + if (registeredAssignmentIds) { + registeredAssignments = await this.prisma.assignment.findMany({ + where: { + groupId, // TODO: 기획 상 필요한 부분인지 확인하고 삭제 + id: { + in: registeredAssignmentIds + }, + endTime: { + gt: now + } + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + } + + restAssignments = await this.prisma.assignment.findMany({ + where: { + groupId, + isVisible: true, + id: { + notIn: registeredAssignmentIds + }, + endTime: { + gt: now + } + }, + select: assignmentSelectOption, + orderBy: { + endTime: 'asc' + } + }) + + const registeredAssignmentsWithParticipants = this.renameToParticipants( + registeredAssignments + ) + const restAssignmentsWithParticipants = + this.renameToParticipants(restAssignments) + + return { + registeredOngoing: this.filterOngoing( + registeredAssignmentsWithParticipants + ), + registeredUpcoming: this.filterUpcoming( + registeredAssignmentsWithParticipants + ), + ongoing: this.filterOngoing(restAssignmentsWithParticipants), + upcoming: this.filterUpcoming(restAssignmentsWithParticipants) + } + } + + async getRegisteredOngoingUpcomingAssignments( + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + + const ongoingAndUpcomings = await this.prisma.assignment.findMany({ + where: { + groupId, + id: { + in: registeredAssignmentIds + }, + endTime: { + gt: now + }, + title: { + contains: search + } + }, + select: assignmentSelectOption + }) + + const ongoingAndUpcomingsWithParticipants = + this.renameToParticipants(ongoingAndUpcomings) + + return { + registeredOngoing: this.filterOngoing( + ongoingAndUpcomingsWithParticipants + ), + registeredUpcoming: this.filterUpcoming( + ongoingAndUpcomingsWithParticipants + ) + } + } + + async getRegisteredAssignmentIds(userId: number) { + const registeredAssignmentRecords = + await this.prisma.assignmentRecord.findMany({ + where: { + userId + }, + select: { + assignmentId: true + } + }) + + return registeredAssignmentRecords.map((obj) => obj.assignmentId) + } + + async getRegisteredFinishedAssignments( + cursor: number | null, + take: number, + groupId: number, + userId: number, + search?: string + ) { + const now = new Date() + const paginator = this.prisma.getPaginator(cursor) + + const registeredAssignmentIds = + await this.getRegisteredAssignmentIds(userId) + const assignments = await this.prisma.assignment.findMany({ + ...paginator, + take, + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredAssignmentIds + }, + title: { + contains: search + }, + isVisible: true + }, + select: assignmentSelectOption, + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] + }) + + const total = await this.prisma.assignment.count({ + where: { + groupId, + endTime: { + lte: now + }, + id: { + in: registeredAssignmentIds + }, + title: { + contains: search + }, + isVisible: true + } + }) + + return { data: this.renameToParticipants(assignments), total } + } + + async getFinishedAssignmentsByGroupId( + userId: number | null, + cursor: number | null, + take: number, + groupId: number, + search?: string + ) { + const paginator = this.prisma.getPaginator(cursor) + const now = new Date() + + const finished = await this.prisma.assignment.findMany({ + ...paginator, + take, + where: { + endTime: { + lte: now + }, + groupId, + isVisible: true, + title: { + contains: search + } + }, + select: assignmentSelectOption, + orderBy: [{ endTime: 'desc' }, { id: 'desc' }] + }) + + const countRenamedAssignments = this.renameToParticipants(finished) + + const finishedAssignmentWithIsRegistered = await Promise.all( + countRenamedAssignments.map(async (assignment) => { + return { + ...assignment, + // userId가 없거나(로그인 안됨) assignment에 참여중이지 않은 경우 false + isRegistered: + !(await this.prisma.assignmentRecord.findFirst({ + where: { + userId, + assignmentId: assignment.id + } + })) || !userId + ? false + : true + } + }) + ) + + const total = await this.prisma.assignment.count({ + where: { + endTime: { + lte: now + }, + groupId, + isVisible: true, + title: { + contains: search + } + } + }) + + return { + data: finishedAssignmentWithIsRegistered, + total + } + } + + // TODO: participants 대신 _count.assignmentRecord 그대로 사용하는 것 고려해보기 + /** 가독성을 위해 _count.assignmentRecord를 participants로 변경한다. */ + renameToParticipants(assignments: AssignmentSelectResult[]) { + return assignments.map(({ _count: countObject, ...rest }) => ({ + ...rest, + participants: countObject.assignmentRecord + })) + } + + filterOngoing(assignments: AssignmentResult[]) { + const now = new Date() + const ongoingAssignment = assignments + .filter( + (assignment) => assignment.startTime <= now && assignment.endTime > now + ) + .sort((a, b) => a.endTime.getTime() - b.endTime.getTime()) + return ongoingAssignment + } + + filterUpcoming(assignments: AssignmentResult[]) { + const now = new Date() + const upcomingAssignment = assignments + .filter((assignment) => assignment.startTime > now) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()) + return upcomingAssignment + } + + async getAssignment(id: number, groupId = OPEN_SPACE_ID, userId?: number) { + // check if the user has already registered this assignment + // initial value is false + let isRegistered = false + let assignment: Partial + if (userId) { + const hasRegistered = await this.prisma.assignmentRecord.findFirst({ + where: { userId, assignmentId: id } + }) + if (hasRegistered) { + isRegistered = true + } + } + try { + assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { + id, + groupId, + isVisible: true + }, + select: { + ...assignmentSelectOption, + description: true + } + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new EntityNotExistException('Assignment') + } + throw error + } + /* HACK: standings 업데이트 로직 수정 후 삭제 + // get assignment participants ranking using AssignmentRecord + const sortedAssignmentRecordsWithUserDetail = + await this.prisma.assignmentRecord.findMany({ + where: { + assignmentId: id + }, + select: { + user: { + select: { + id: true, + username: true + } + }, + score: true, + totalPenalty: true + }, + orderBy: [ + { + score: 'desc' + }, + { + totalPenalty: 'asc' + } + ] + }) + + const UsersWithStandingDetail = sortedAssignmentRecordsWithUserDetail.map( + (assignmentRecord, index) => ({ + ...assignmentRecord, + standing: index + 1 + }) + ) + */ + // combine assignment and sortedAssignmentRecordsWithUserDetail + + const { invitationCode, ...assignmentDetails } = assignment + const invitationCodeExists = invitationCode != null + return { + ...assignmentDetails, + invitationCodeExists, + isRegistered + } + } + + async createAssignmentRecord( + assignmentId: number, + userId: number, + invitationCode?: string, + groupId = OPEN_SPACE_ID + ) { + const assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { id: assignmentId, groupId }, + select: { + startTime: true, + endTime: true, + groupId: true, + invitationCode: true + } + }) + + if ( + assignment.invitationCode && + assignment.invitationCode !== invitationCode + ) { + throw new ConflictFoundException('Invalid invitation code') + } + + const hasRegistered = await this.prisma.assignmentRecord.findFirst({ + where: { userId, assignmentId } + }) + if (hasRegistered) { + throw new ConflictFoundException('Already participated this assignment') + } + const now = new Date() + if (now >= assignment.endTime) { + throw new ConflictFoundException('Cannot participate ended assignment') + } + + return await this.prisma.assignmentRecord.create({ + data: { assignmentId, userId } + }) + } + + async isVisible(assignmentId: number, groupId: number): Promise { + return !!(await this.prisma.assignment.count({ + where: { + id: assignmentId, + isVisible: true, + groupId + } + })) + } + + async deleteAssignmentRecord( + assignmentId: number, + userId: number, + groupId = OPEN_SPACE_ID + ) { + let assignment + try { + assignment = await this.prisma.assignment.findUniqueOrThrow({ + where: { id: assignmentId, groupId } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('Assignment') + } + } + try { + await this.prisma.assignmentRecord.findFirstOrThrow({ + where: { userId, assignmentId } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('AssignmentRecord') + } + } + const now = new Date() + if (now >= assignment.startTime) { + throw new ForbiddenAccessException( + 'Cannot unregister ongoing or ended assignment' + ) + } + + try { + return await this.prisma.assignmentRecord.delete({ + // eslint-disable-next-line @typescript-eslint/naming-convention + where: { assignmentId_userId: { assignmentId, userId } } + }) + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + throw new EntityNotExistException('AssignmentRecord') + } + } + } +} diff --git a/apps/backend/apps/client/src/submission/mock/submission.mock.ts b/apps/backend/apps/client/src/submission/mock/submission.mock.ts index 0aa6ed8816..acdb9d4561 100644 --- a/apps/backend/apps/client/src/submission/mock/submission.mock.ts +++ b/apps/backend/apps/client/src/submission/mock/submission.mock.ts @@ -20,6 +20,7 @@ export const submissions = [ userIp: '127.0.0.1', problemId: 1, contestId: null, + assignmentId: null, workbookId: null, problem: { problemTestcase: [ @@ -43,6 +44,7 @@ export const submissions = [ userIp: '127.0.0.1', problemId: 1, contestId: null, + assignmentId: null, workbookId: null, problem: { problemTestcase: [ diff --git a/apps/backend/apps/client/src/submission/submission.service.ts b/apps/backend/apps/client/src/submission/submission.service.ts index 88d23d69dd..8d7a9def2f 100644 --- a/apps/backend/apps/client/src/submission/submission.service.ts +++ b/apps/backend/apps/client/src/submission/submission.service.ts @@ -376,6 +376,7 @@ export class SubmissionService { score: 0, userId, userIp: null, + assignmentId: null, contestId: null, workbookId: null, codeSize: null, diff --git a/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql b/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql new file mode 100644 index 0000000000..9a885d10df --- /dev/null +++ b/apps/backend/prisma/migrations/20250108071512_rename_contest_to_assignment/migration.sql @@ -0,0 +1,77 @@ +/* + Warnings: + + - You are about to drop the column `contest_id` on the `announcement` table. All the data in the column will be lost. + - You are about to drop the column `contest_id` on the `submission` table. All the data in the column will be lost. + - You are about to drop the `contest` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `contest_problem` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `contest_record` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `assignment_id` to the `announcement` table without a default value. This is not possible if the table is not empty. + +*/ + +--RENAME TABLE (Contest) +ALTER TABLE contest RENAME TO assignment; + +--RENAME COLUMN +ALTER TABLE contest_record RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE contest_problem RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE announcement RENAME COLUMN contest_id to assignment_id; + +--RENAME COLUMN +ALTER TABLE submission RENAME COLUMN contest_id to assignment_id; + +-- RenamePrimaryKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_pkey" TO "assignment_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_group_id_fkey" TO "assignment_group_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "contest_created_by_id_fkey" TO "assignment_create_by_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment" RENAME CONSTRAINT "assignment_create_by_id_fkey" TO "assignment_created_by_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "announcement" RENAME CONSTRAINT "announcement_contest_id_fkey" TO "announcement_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "submission" RENAME CONSTRAINT "submission_contest_id_fkey" TO "submission_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "contest_problem" RENAME CONSTRAINT "contest_problem_contest_id_fkey" TO "contest_problem_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "contest_record" RENAME CONSTRAINT "contest_record_contest_id_fkey" TO "contest_record_assignment_id_fkey"; + +--RENAME TABLE (ContestProblem) +ALTER TABLE contest_problem RENAME TO assignment_problem; + +-- RenamePrimaryKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_pkey" TO "assignment_problem_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_assignment_id_fkey" TO "assignment_problem_assignment_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_problem" RENAME CONSTRAINT "contest_problem_problem_id_fkey" TO "assignment_problem_problem_id_fkey"; + +--RENAME TABLE (AssignmentRecord) +ALTER TABLE contest_record RENAME TO assignment_record; + +-- RenamePrimaryKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_pkey" TO "assignment_record_pkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_user_id_fkey" TO "assignment_record_user_id_fkey"; + +-- RenameForeignKey +ALTER TABLE "assignment_record" RENAME CONSTRAINT "contest_record_assignment_id_fkey" TO "assignment_record_assignment_id_fkey"; + +-- RenameIndex +ALTER INDEX "contest_record_contest_id_user_id_key" RENAME TO "assignment_record_assignment_id_user_id_key"; diff --git a/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql b/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql new file mode 100644 index 0000000000..c3e5456c39 --- /dev/null +++ b/apps/backend/prisma/migrations/20250108111635_add_contest_tables/migration.sql @@ -0,0 +1,88 @@ +-- AlterTable +ALTER TABLE "announcement" ADD COLUMN "contest_id" INTEGER, +ALTER COLUMN "assignment_id" DROP NOT NULL; + +-- Add Constraint +ALTER TABLE "announcement" +ADD CONSTRAINT "assignmentId_or_contestId_required" +CHECK ( + (contest_id IS NOT NULL AND assignment_id IS NULL) OR + (contest_id IS NULL AND assignment_id IS NOT NULL) +); + +-- AlterTable +ALTER TABLE "submission" ADD COLUMN "contest_id" INTEGER; + +-- CreateTable +CREATE TABLE "contest" ( + "id" SERIAL NOT NULL, + "created_by_id" INTEGER, + "group_id" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "invitation_code" TEXT, + "start_time" TIMESTAMP(3) NOT NULL, + "end_time" TIMESTAMP(3) NOT NULL, + "is_visible" BOOLEAN NOT NULL DEFAULT true, + "is_rank_visible" BOOLEAN NOT NULL DEFAULT true, + "is_judge_result_visible" BOOLEAN NOT NULL DEFAULT true, + "enable_copy_paste" BOOLEAN NOT NULL DEFAULT true, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contest_problem" ( + "order" INTEGER NOT NULL, + "contest_id" INTEGER NOT NULL, + "problem_id" INTEGER NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_problem_pkey" PRIMARY KEY ("contest_id","problem_id") +); + +-- CreateTable +CREATE TABLE "contest_record" ( + "id" SERIAL NOT NULL, + "contest_id" INTEGER NOT NULL, + "user_id" INTEGER, + "accepted_problem_num" INTEGER NOT NULL DEFAULT 0, + "score" INTEGER NOT NULL DEFAULT 0, + "finish_time" TIMESTAMP(3), + "total_penalty" INTEGER NOT NULL DEFAULT 0, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_record_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "contest_record_contest_id_user_id_key" ON "contest_record"("contest_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "contest" ADD CONSTRAINT "contest_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest" ADD CONSTRAINT "contest_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_problem" ADD CONSTRAINT "contest_problem_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_problem" ADD CONSTRAINT "contest_problem_problem_id_fkey" FOREIGN KEY ("problem_id") REFERENCES "problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_record" ADD CONSTRAINT "contest_record_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_record" ADD CONSTRAINT "contest_record_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "announcement" ADD CONSTRAINT "announcement_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "submission" ADD CONSTRAINT "submission_contest_id_fkey" FOREIGN KEY ("contest_id") REFERENCES "contest"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index b5daf5c7ec..bcf93075e8 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -50,17 +50,19 @@ model User { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - userProfile UserProfile? - userGroup UserGroup[] - notice Notice[] - problem Problem[] - contest Contest[] - contestRecord ContestRecord[] - workbook Workbook[] - submission Submission[] - useroauth UserOAuth? - CodeDraft CodeDraft[] - Image Image[] + userProfile UserProfile? + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + assignmentRecord AssignmentRecord[] + workbook Workbook[] + submission Submission[] + useroauth UserOAuth? + CodeDraft CodeDraft[] + Image Image[] + contest Contest[] + contestRecord ContestRecord[] @@map("user") } @@ -116,11 +118,12 @@ model Group { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - userGroup UserGroup[] - notice Notice[] - problem Problem[] - contest Contest[] - workbook Workbook[] + userGroup UserGroup[] + notice Notice[] + problem Problem[] + assignment Assignment[] + workbook Workbook[] + contest Contest[] @@map("group") } @@ -186,13 +189,14 @@ model Problem { createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") - problemTestcase ProblemTestcase[] - problemTag ProblemTag[] - contestProblem ContestProblem[] - workbookProblem WorkbookProblem[] - submission Submission[] - announcement Announcement[] - codeDraft CodeDraft[] + problemTestcase ProblemTestcase[] + problemTag ProblemTag[] + assignmentProblem AssignmentProblem[] + workbookProblem WorkbookProblem[] + contestProblem ContestProblem[] + submission Submission[] + announcement Announcement[] + codeDraft CodeDraft[] @@map("problem") } @@ -260,6 +264,67 @@ model Tag { @@map("tag") } +model Assignment { + id Int @id @default(autoincrement()) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + createdById Int? @map("created_by_id") + group Group @relation(fields: [groupId], references: [id]) + groupId Int @map("group_id") + title String + description String + invitationCode String? @map("invitation_code") + startTime DateTime @map("start_time") + endTime DateTime @map("end_time") + isVisible Boolean @default(true) @map("is_visible") + isRankVisible Boolean @default(true) @map("is_rank_visible") + isJudgeResultVisible Boolean @default(true) @map("is_judge_result_visible") // 이 Assignment에 포함된 문제의 Judge Result를 사용자에게 보여줄지 말지 결정합니다. + enableCopyPaste Boolean @default(true) @map("enable_copy_paste") // 이 Assignment에 포함된 문제의 코드 에디터에서 복사-붙여넣기를 허용합니다. + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + assignmentProblem AssignmentProblem[] + assignmentRecord AssignmentRecord[] + submission Submission[] + announcement Announcement[] + + @@map("assignment") +} + +model AssignmentProblem { + order Int + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int @map("assignment_id") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") + // 각 문제의 점수 (비율 아님) + score Int @default(0) + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@id([assignmentId, problemId]) + // @@unique([assignmentId, problemId]) + @@map("assignment_problem") +} + +model AssignmentRecord { + id Int @id @default(autoincrement()) + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int @map("assignment_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + acceptedProblemNum Int @default(0) @map("accepted_problem_num") + score Int @default(0) + // finishTime: Pariticipant가 가장 최근에 AC를 받은 시각 + finishTime DateTime? @map("finish_time") + // totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter + totalPenalty Int @default(0) @map("total_penalty") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@unique([assignmentId, userId]) + @@map("assignment_record") +} + model Contest { id Int @id @default(autoincrement()) createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) @@ -302,20 +367,6 @@ model ContestProblem { @@map("contest_problem") } -model Announcement { - id Int @id @default(autoincrement()) - content String - contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int @map("contest_id") - problem Problem? @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int? @map("problem_id") - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") - - @@unique([id]) - @@map("announcement") -} - model ContestRecord { id Int @id @default(autoincrement()) contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) @@ -335,6 +386,24 @@ model ContestRecord { @@map("contest_record") } +model Announcement { + id Int @id @default(autoincrement()) + content String + // Assignment와 Contest 둘 중 하나만 설정되어 있어야 합니다 (XOR) + // 아닐시 DB단에서 에러 반환, 20250108111635_add_contest_tables migration 참고 + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int? @map("assignment_id") + contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int? @map("contest_id") + problem Problem? @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int? @map("problem_id") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + + @@unique([id]) + @@map("announcement") +} + model Workbook { id Int @id @default(autoincrement()) createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) @@ -368,31 +437,33 @@ model WorkbookProblem { } model Submission { - id Int @id @default(autoincrement()) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - userId Int? @map("user_id") - userIp String? @map("user_ip") - problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + userIp String? @map("user_ip") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) // Todo : // Regardless problem deletion, Keeping submission is positive for ranking or something .... - problemId Int @map("problem_id") - contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int? @map("contest_id") - workbook Workbook? @relation(fields: [workbookId], references: [id]) - workbookId Int? @map("workbook_id") + problemId Int @map("problem_id") + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + assignmentId Int? @map("assignment_id") + contest Contest? @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int? @map("contest_id") + workbook Workbook? @relation(fields: [workbookId], references: [id]) + workbookId Int? @map("workbook_id") /// code item structure /// { /// "id": number, /// "text": string, /// "locked": boolean /// } - code Json[] - codeSize Int? @map("code_size") - language Language - result ResultStatus - score Int @default(0) /// 100점 만점 기준 점수 - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + code Json[] + codeSize Int? @map("code_size") + language Language + result ResultStatus + score Int @default(0) /// 100점 만점 기준 점수 + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") submissionResult SubmissionResult[] @@ -425,7 +496,7 @@ enum ResultStatus { OutputLimitExceeded ServerError SegmentationFaultError - Blind // isJudgeResultVisible == False로 설정된 contest의 채점 결과를 반환할 때 사용 + Blind // isJudgeResultVisible == False로 설정된 Assignment/Contest의 채점 결과를 반환할 때 사용 } model CodeDraft { diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 98fe7396f4..700710000e 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -8,12 +8,14 @@ import { type User, type Problem, type Tag, - type Contest, + type Assignment, type Workbook, type Submission, type ProblemTestcase, type Announcement, type CodeDraft, + type AssignmentRecord, + type Contest, type ContestRecord } from '@prisma/client' import { hash } from 'argon2' @@ -35,6 +37,10 @@ let privateGroup: Group const users: User[] = [] const problems: Problem[] = [] let problemTestcases: ProblemTestcase[] = [] +const assignments: Assignment[] = [] +const endedAssignments: Assignment[] = [] +const ongoingAssignments: Assignment[] = [] +const upcomingAssignments: Assignment[] = [] const contests: Contest[] = [] const endedContests: Contest[] = [] const ongoingContests: Contest[] = [] @@ -42,7 +48,8 @@ const upcomingContests: Contest[] = [] const workbooks: Workbook[] = [] const privateWorkbooks: Workbook[] = [] const submissions: Submission[] = [] -const announcements: Announcement[] = [] +const assignmentAnnouncements: Announcement[] = [] +const contestAnnouncements: Announcement[] = [] const createUsers = async () => { // create super admin user @@ -659,7 +666,7 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: '', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -688,7 +695,7 @@ const createProblems = async () => { timeLimit: 2000, memoryLimit: 512, source: 'Canadian Computing Competition(CCC) 2012 Junior 2번', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -717,7 +724,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'Canadian Computing Competition(CCC) 2013 Junior 2번', - visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingContests[0].endTime + visibleLockTime: new Date('2028-01-01T23:59:59.000Z') //ongoingAssignments[0].endTime } }) ) @@ -746,7 +753,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'USACO 2012 US Open Bronze 1번', - visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedContests[0].endTime + visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedAssignments[0].endTime } }) ) @@ -775,7 +782,7 @@ const createProblems = async () => { timeLimit: 1000, memoryLimit: 128, source: 'ICPC Regionals NCPC 2009 B번', - visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedContests[0].endTime + visibleLockTime: new Date('2024-01-01T23:59:59.000Z') //endedAssignments[0].endTime } }) ) @@ -832,7 +839,7 @@ const createProblems = async () => { hint: '', timeLimit: 2000, memoryLimit: 512, - source: 'COCI 2019/2020 Contest #3 2번', + source: 'COCI 2019/2020 Assignment #3 2번', visibleLockTime: MIN_DATE } }) @@ -1061,7 +1068,7 @@ const createContests = async () => { // Finished Contests { data: { - title: 'Long Time Ago Contest', + title: 'Long Time Ago Assignment', description: '

이 대회는 오래 전에 끝났어요

', createdById: superAdminUser.id, groupId: publicGroup.id, @@ -1188,7 +1195,7 @@ const createContests = async () => { // Upcoming Contests { data: { - title: 'Future Contest', + title: 'Future Assignment', description: '

이 대회는 언젠가 열리겠죠...?

', createdById: superAdminUser.id, groupId: publicGroup.id, @@ -1218,7 +1225,7 @@ const createContests = async () => { data: { title: '2024 스꾸딩 프로그래밍 대회', description: - '

이 대회는 언젠가 열리겠죠...? isVisible이 false인 contest입니다

', + '

이 대회는 언젠가 열리겠죠...? isVisible이 false인 assignment입니다

', createdById: superAdminUser.id, groupId: publicGroup.id, startTime: new Date('3024-01-01T00:00:00.000Z'), @@ -1284,6 +1291,347 @@ const createContests = async () => { // TODO: add records and ranks } +const createAssignments = async () => { + const assignmentData: { + data: { + title: string + description: string + createdById: number + groupId: number + startTime: Date + endTime: Date + isVisible: boolean + isRankVisible: boolean + invitationCode: string | null + enableCopyPaste: boolean + } + }[] = [ + // Ongoing Assignments + { + data: { + title: 'SKKU Coding Platform 모의과제', + description: `

+ 대통령은 내란 또는 외환의 죄를 범한 경우를 제외하고는 재직중 형사상의 소추를 + 받지 아니한다. 모든 국민은 자기의 행위가 아닌 친족의 행위로 인하여 불이익한 + 처우를 받지 아니한다. +

+ +

+ 위원은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니한다. + 대통령은 국무회의의 의장이 되고, 국무총리는 부의장이 된다. 모든 국민은 헌법과 + 법률이 정한 법관에 의하여 법률에 의한 재판을 받을 권리를 가진다. +

+ +

+ 국회의원은 현행범인인 경우를 제외하고는 회기중 국회의 동의없이 체포 또는 + 구금되지 아니한다. 헌법재판소의 장은 국회의 동의를 얻어 재판관중에서 대통령이 + 임명한다. +

+ +

+ 국가는 지역간의 균형있는 발전을 위하여 지역경제를 육성할 의무를 진다. 제3항의 + 승인을 얻지 못한 때에는 그 처분 또는 명령은 그때부터 효력을 상실한다. 이 경우 + 그 명령에 의하여 개정 또는 폐지되었던 법률은 그 명령이 승인을 얻지 못한 때부터 + 당연히 효력을 회복한다. +

+ +

+ 모든 국민은 신체의 자유를 가진다. 누구든지 법률에 의하지 아니하고는 + 체포·구속·압수·수색 또는 심문을 받지 아니하며, 법률과 적법한 절차에 의하지 + 아니하고는 처벌·보안처분 또는 강제노역을 받지 아니한다. +

`, + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제1', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제2', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 소프트웨어학과 신입생 입학 과제3', + description: '

이 과제는 현재 진행 중입니다 !

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '24년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 현재 진행 중입니다 ! (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('2024-01-01T00:00:00.000Z'), + endTime: new Date('2028-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + // Finished Assignments + { + data: { + title: 'Long Time Ago Assignment', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '23년도 소프트웨어학과 신입생 입학 과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 아침과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 낮과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 밤과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2023 SKKU 프로그래밍 과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 오전과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '소프트의 오후과제', + description: '

이 과제는 오래 전에 끝났어요

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: false, + invitationCode: null, + enableCopyPaste: true + } + }, + { + data: { + title: '23년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 오래 전에 끝났어요 (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('2023-01-01T00:00:00.000Z'), + endTime: new Date('2024-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + }, + // Upcoming Assignments + { + data: { + title: 'Future Assignment', + description: '

이 과제는 언젠가 열리겠죠...?

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2024 SKKU 프로그래밍 과제', + description: '

이 과제는 언젠가 열리겠죠...?

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '2024 스꾸딩 프로그래밍 과제', + description: + '

이 과제는 언젠가 열리겠죠...? isVisible이 false인 assignment입니다

', + createdById: superAdminUser.id, + groupId: publicGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: false, + isRankVisible: true, + invitationCode: '123456', + enableCopyPaste: true + } + }, + { + data: { + title: '25년도 아늑배 스파게티 코드 만들기 과제', + description: '

이 과제는 언젠가 열리겠죠...? (private group)

', + createdById: superAdminUser.id, + groupId: privateGroup.id, + startTime: new Date('3024-01-01T00:00:00.000Z'), + endTime: new Date('3025-01-01T23:59:59.000Z'), + isVisible: true, + isRankVisible: true, + invitationCode: null, + enableCopyPaste: true + } + } + ] + + const now = new Date() + for (const obj of assignmentData) { + const assignment = await prisma.assignment.create(obj) + assignments.push(assignment) + if (now < obj.data.startTime) { + upcomingAssignments.push(assignment) + } else if (obj.data.endTime < now) { + endedAssignments.push(assignment) + } else { + ongoingAssignments.push(assignment) + } + } + + // add problems to ongoing assignment + for (const problem of problems.slice(0, 3)) { + await prisma.assignmentProblem.create({ + data: { + order: problem.id - 1, + assignmentId: ongoingAssignments[0].id, + problemId: problem.id, + score: problem.id * 10 + } + }) + } + + // add problems to finished assignment + for (const problem of problems.slice(3, 5)) { + await prisma.assignmentProblem.create({ + data: { + order: problem.id - 1, + assignmentId: endedAssignments[0].id, + problemId: problem.id + } + }) + } + + // TODO: add records and ranks +} + const createWorkbooks = async () => { for (let i = 1; i <= 3; i++) { workbooks.push( @@ -1334,7 +1682,7 @@ const createSubmissions = async () => { data: { userId: users[0].id, problemId: problems[0].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1373,7 +1721,7 @@ int main(void) { data: { userId: users[1].id, problemId: problems[1].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1411,7 +1759,7 @@ int main(void) { data: { userId: users[2].id, problemId: problems[2].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1447,7 +1795,7 @@ int main(void) { data: { userId: users[3].id, problemId: problems[3].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1481,7 +1829,7 @@ int main(void) { data: { userId: users[4].id, problemId: problems[4].id, - contestId: ongoingContests[0].id, + assignmentId: ongoingAssignments[0].id, code: [ { id: 1, @@ -1588,11 +1936,40 @@ int main(void) { } const createAnnouncements = async () => { + // For Assignments + for (let i = 0; i < 5; ++i) { + assignmentAnnouncements.push( + await prisma.announcement.create({ + data: { + content: `Announcement(assignment)_0_${i}`, + assignmentId: ongoingAssignments[i].id + } + }) + ) + } + + for (let i = 0; i < 5; ++i) { + assignmentAnnouncements.push( + await prisma.announcement.create({ + data: { + content: `Announcement(assignment)_1_${i}... +아래 내용은 한글 Lorem Ipsum으로 생성된 내용입니다! 별 의미 없어요. +모든 국민은 신속한 재판을 받을 권리를 가진다. 형사피고인은 상당한 이유가 없는 한 지체없이 공개재판을 받을 권리를 가진다. +법관은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니하며, 징계처분에 의하지 아니하고는 정직·감봉 기타 불리한 처분을 받지 아니한다. +일반사면을 명하려면 국회의 동의를 얻어야 한다. 연소자의 근로는 특별한 보호를 받는다.`, + assignmentId: ongoingAssignments[i].id, + problemId: problems[i].id + } + }) + ) + } + + // For Contests for (let i = 0; i < 5; ++i) { - announcements.push( + contestAnnouncements.push( await prisma.announcement.create({ data: { - content: `Announcement_0_${i}`, + content: `Announcement(contest)_0_${i}`, contestId: ongoingContests[i].id } }) @@ -1600,10 +1977,10 @@ const createAnnouncements = async () => { } for (let i = 0; i < 5; ++i) { - announcements.push( + contestAnnouncements.push( await prisma.announcement.create({ data: { - content: `Announcement_1_${i}... + content: `Announcement(contest)_1_${i}... 아래 내용은 한글 Lorem Ipsum으로 생성된 내용입니다! 별 의미 없어요. 모든 국민은 신속한 재판을 받을 권리를 가진다. 형사피고인은 상당한 이유가 없는 한 지체없이 공개재판을 받을 권리를 가진다. 법관은 탄핵 또는 금고 이상의 형의 선고에 의하지 아니하고는 파면되지 아니하며, 징계처분에 의하지 아니하고는 정직·감봉 기타 불리한 처분을 받지 아니한다. @@ -1672,6 +2049,50 @@ const createCodeDrafts = async () => { return codeDrafts } +const createAssignmentRecords = async () => { + const assignmentRecords: AssignmentRecord[] = [] + // group 1 users + const group1Users = await prisma.userGroup.findMany({ + where: { + groupId: 1 + } + }) + for (const user of group1Users) { + const assignmentRecord = await prisma.assignmentRecord.create({ + data: { + userId: user.userId, + assignmentId: 1, + acceptedProblemNum: 0, + totalPenalty: 0 + } + }) + assignmentRecords.push(assignmentRecord) + } + + // upcoming assignment에 참가한 User 1의 assignment register를 un-register하는 기능과, + // registered upcoming, ongoing, finished assignment를 조회하는 기능을 확인하기 위함 + const user01Id = 4 + for ( + let assignmentId = 3; + assignmentId <= assignments.length; + assignmentId += 2 + ) { + assignmentRecords.push( + await prisma.assignmentRecord.create({ + data: { + userId: user01Id, + assignmentId, + acceptedProblemNum: 0, + score: 0, + totalPenalty: 0 + } + }) + ) + } + + return assignmentRecords +} + const createContestRecords = async () => { const contestRecords: ContestRecord[] = [] // group 1 users @@ -1717,11 +2138,13 @@ const main = async () => { await createGroups() await createNotices() await createProblems() + await createAssignments() await createContests() await createWorkbooks() await createSubmissions() await createAnnouncements() await createCodeDrafts() + await createAssignmentRecords() await createContestRecords() } diff --git a/collection/admin/Assignment/Create Assignment/Succeed.bru b/collection/admin/Assignment/Create Assignment/Succeed.bru new file mode 100644 index 0000000000..73b927538c --- /dev/null +++ b/collection/admin/Assignment/Create Assignment/Succeed.bru @@ -0,0 +1,48 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + createAssignment( + groupId: 2 + input: { + title: "New Assignment Group 2" + description: "thisisnewassignment" + startTime: "2024-01-01" + endTime: "2030-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + isJudgeResultVisible: true + invitationCode: "123456" + } + ) { + id + title + description + enableCopyPaste + invitationCode + isJudgeResultVisible + } + } + +} + +assert { + res.body.data.createAssignment: isDefined +} + +docs { + # Create Assignment + --- + - 주어진 Group에 새로운 Assignment를 생성합니다. +} diff --git a/collection/admin/Assignment/Create Assignment/[ERR] End Time Earlier than Start Time.bru b/collection/admin/Assignment/Create Assignment/[ERR] End Time Earlier than Start Time.bru new file mode 100644 index 0000000000..9f76de8154 --- /dev/null +++ b/collection/admin/Assignment/Create Assignment/[ERR] End Time Earlier than Start Time.bru @@ -0,0 +1,38 @@ +meta { + name: [ERR] End Time Earlier than Start Time + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + createAssignment( + groupId: 2 + input: { + title: "New Assignment Group 2" + description: "thisisnewassignment" + startTime: "2030-01-01" + endTime: "2020-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + invitationCode:"123456" + isJudgeResultVisible: true + } + ) { + id + title + description + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Create Assignment/[ERR] Nonexistent Group.bru b/collection/admin/Assignment/Create Assignment/[ERR] Nonexistent Group.bru new file mode 100644 index 0000000000..eea47152b8 --- /dev/null +++ b/collection/admin/Assignment/Create Assignment/[ERR] Nonexistent Group.bru @@ -0,0 +1,38 @@ +meta { + name: [ERR] Nonexistent Group + type: graphql + seq: 3 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + createAssignment( + groupId: 99999 + input: { + title: "New Assignment Group 2" + description: "thisisnewassignment" + startTime: "2024-01-01" + endTime: "2030-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + invitationCode:"123456" + isJudgeResultVisible: true + } + ) { + id + title + description + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Create Publicizing Request/Succeed.bru b/collection/admin/Assignment/Create Publicizing Request/Succeed.bru new file mode 100644 index 0000000000..77a50cfb20 --- /dev/null +++ b/collection/admin/Assignment/Create Publicizing Request/Succeed.bru @@ -0,0 +1,35 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation CreatePublicizingRequest($assignmentId: Int!, $groupId: Int!){ + createPublicizingRequest( + assignmentId: $assignmentId, + groupId: $groupId + ) { + assignmentId + expireTime + userId + } + } +} + +body:graphql:vars { + { + "assignmentId": 1, + "groupId": 1 + } +} + +assert { + res.body.data.createPublicizingRequest: isDefined +} diff --git a/collection/admin/Assignment/Create Publicizing Request/[ERR] Duplicated Publicizing Request.bru b/collection/admin/Assignment/Create Publicizing Request/[ERR] Duplicated Publicizing Request.bru new file mode 100644 index 0000000000..0aa2b3cd90 --- /dev/null +++ b/collection/admin/Assignment/Create Publicizing Request/[ERR] Duplicated Publicizing Request.bru @@ -0,0 +1,28 @@ +meta { + name: [ERR] Duplicated Publicizing Request + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + createPublicizingRequest( + assignmentId: 3 + groupId: 2 + ) { + assignmentId + expireTime + userId + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Create Publicizing Request/[ERR] Request Publicizing to OpenSpace.bru b/collection/admin/Assignment/Create Publicizing Request/[ERR] Request Publicizing to OpenSpace.bru new file mode 100644 index 0000000000..b466ffc8ab --- /dev/null +++ b/collection/admin/Assignment/Create Publicizing Request/[ERR] Request Publicizing to OpenSpace.bru @@ -0,0 +1,24 @@ +meta { + name: [ERR] Request Publicizing to OpenSpace + type: graphql + seq: 3 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + createPublicizingRequest( + assignmentId: 3 + groupId: 1 + ) { + assignmentId + expireTime + userId + } + } +} diff --git a/collection/admin/Assignment/Delete Assignment/Succeed.bru b/collection/admin/Assignment/Delete Assignment/Succeed.bru new file mode 100644 index 0000000000..9263fd1ae6 --- /dev/null +++ b/collection/admin/Assignment/Delete Assignment/Succeed.bru @@ -0,0 +1,36 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + deleteAssignment( + assignmentId: 3 + groupId: 2 + ) { + id + title + description + startTime + endTime + } + } +} + +assert { + res.body.data.deleteAssignment: isDefined +} + +docs { + # Delete Assignment + --- + - Assignment를 삭제하고 삭제된 Assignment를 반환합니다. +} diff --git a/collection/admin/Assignment/Delete Assignment/[ERR] Nonexistent Assignment.bru b/collection/admin/Assignment/Delete Assignment/[ERR] Nonexistent Assignment.bru new file mode 100644 index 0000000000..077cd08cf1 --- /dev/null +++ b/collection/admin/Assignment/Delete Assignment/[ERR] Nonexistent Assignment.bru @@ -0,0 +1,30 @@ +meta { + name: [ERR] Nonexistent Assignment + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + deleteAssignment( + assignmentId: 99999 + groupId: 2 + ) { + id + title + description + startTime + endTime + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Duplicate Assignment/Succeed.bru b/collection/admin/Assignment/Duplicate Assignment/Succeed.bru new file mode 100644 index 0000000000..6507ad5460 --- /dev/null +++ b/collection/admin/Assignment/Duplicate Assignment/Succeed.bru @@ -0,0 +1,53 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + duplicateAssignment( + groupId: 1, + assignmentId: 1 + ) { + assignment { + id + invitationCode + isRankVisible + isVisible + description + endTime + startTime + title + } + problems { + problemId + assignmentId + order + + } + records { + id + userId + score + } + } + } +} + +assert { + res.body.data.duplicateAssignment: isDefined +} + +docs { + # Duplicate Assignment + --- + - 선택한 Assignment를 기반으로 새로운 Assignment를 생성합니다. + - 등록된 User, AssignmentProblems도 복제됩니다. +} diff --git a/collection/admin/Assignment/Get Assignment Score Summaries/Succeed.bru b/collection/admin/Assignment/Get Assignment Score Summaries/Succeed.bru new file mode 100644 index 0000000000..d31c4f3a9c --- /dev/null +++ b/collection/admin/Assignment/Get Assignment Score Summaries/Succeed.bru @@ -0,0 +1,52 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetAssignmentScoreSummaries($assignmentId: Int!, $take: Int, $cursor: Int) { + getAssignmentScoreSummaries(assignmentId:$assignmentId, take: $take, cursor: $cursor) { + userId + username + studentId + realName + major + submittedProblemCount + totalProblemCount + userAssignmentScore + assignmentPerfectScore + problemScores { + problemId + score + maxScore + } + } + } +} + +body:graphql:vars { + { + "assignmentId": 49 + // "searchingName": "lee" + } +} + +docs { + ## Get Assignment Submission Summaries of Users + + * Assignment에 참여한 User와, 점수 요약을 함께 불러옵니다. + * Assignment Overall 페이지의 Participants 탭의 정보 + * https://github.com/skkuding/codedang/pull/2029 + + #### 필요 인자 + | `assignmentId` | `searchingName?` | `take?` | `cursor?` | + |----------|--------|----------|------------| + | 불러올 Assignment의 id | 필터링할 User의 realName(없으면 모든 유저) | Pagination 구현을 위함(default: 10) | Pagination 구현을 위함(default: null) | +} diff --git a/collection/admin/Assignment/Get Assignment Score Summary of User/Succeed.bru b/collection/admin/Assignment/Get Assignment Score Summary of User/Succeed.bru new file mode 100644 index 0000000000..ccd0c6a9e8 --- /dev/null +++ b/collection/admin/Assignment/Get Assignment Score Summary of User/Succeed.bru @@ -0,0 +1,74 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query getAssignmentSubmissionSummariesByUserId($assignmentId: Int!, $userId: Int!, $take: Int!) { + getAssignmentSubmissionSummaryByUserId(assignmentId: $assignmentId, userId: $userId, take: $take) { + scoreSummary { + assignmentPerfectScore + problemScores { + problemId + score + } + submittedProblemCount + totalProblemCount + userAssignmentScore + } + submissions { + assignmentId + problemTitle + studentId + username + submissionResult + language + submissionTime + codeSize + problemId + ip + order + id + } + } + } + +} + +body:graphql:vars { + { + "assignmentId": 1, + "userId": 4, + "take": 500 + // "problemId": 1 + } +} + +docs { + ## Get Assignment Score Summary of User + 유저의 특정 Assignment에 대한 점수 요약을 반환합니다. + + Assignment Overall 페이지에서 특정 유저를 선택했을 때 사용 + https://github.com/skkuding/codedang/pull/1894 + + - submittedProblemCount + - 제출된 문제의 개수(정답 여부와 관계 없음) + - totalProblemCount + - 전체 문제의 개수 + - userAssignmentScore + - 해당 Assignment에서 User가 획득한 총 점수 + - assignmentPerfectScore + - Assignment의 만점 + - problemScores + - 각 문제에서 획득한 점수를 담고 있는 배열 (100점 만점 기준) + - 속성 + - problemId + - score +} diff --git a/collection/admin/Assignment/Get Assignment/NOT_FOUND.bru b/collection/admin/Assignment/Get Assignment/NOT_FOUND.bru new file mode 100644 index 0000000000..fd8404f3c2 --- /dev/null +++ b/collection/admin/Assignment/Get Assignment/NOT_FOUND.bru @@ -0,0 +1,32 @@ +meta { + name: NOT_FOUND + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetAssignment($assignmentId: Int!) { + getAssignment(assignmentId: $assignmentId) { + id + title + participants + } + } +} + +body:graphql:vars { + { + "assignmentId": 99999 + } +} + +assert { + res.body.errors[0].message: eq No Assignment found + res.body.errors[0].extensions.code: eq NOT_FOUND +} diff --git a/collection/admin/Assignment/Get Assignment/Succeed.bru b/collection/admin/Assignment/Get Assignment/Succeed.bru new file mode 100644 index 0000000000..769a5747f6 --- /dev/null +++ b/collection/admin/Assignment/Get Assignment/Succeed.bru @@ -0,0 +1,40 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetAssignment($assignmentId: Int!) { + getAssignment(assignmentId: $assignmentId) { + id + title + participants + } + } +} + +body:graphql:vars { + { + "assignmentId": 1 + } +} + +assert { + res.body.data.getAssignment: isDefined +} + +docs { + ## Get Problem + 참가자수 정보를 포함한 Problem 정보를 가져옵니다. + + ### Error Cases + #### NOT_FOUND + 존재하는 assignmentId를 전달해야 합니다. +} diff --git a/collection/admin/Assignment/Get Assignments/Succeed.bru b/collection/admin/Assignment/Get Assignments/Succeed.bru new file mode 100644 index 0000000000..d38a71f77a --- /dev/null +++ b/collection/admin/Assignment/Get Assignments/Succeed.bru @@ -0,0 +1,39 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query { + getAssignments( + groupId: 1 + cursor: 1 + take: 10 + ) { + id + title + description + startTime + endTime + participants + } + } +} + +assert { + res.body.data.getAssignments: isDefined +} + +docs { + # Get Assignments + --- + - 주어진 Group에 속한 Assignment들을 반환합니다. + - `cursor`을 생략하면 처음부터 `take`만큼의 Assignment들을 반환합니다. +} diff --git a/collection/admin/Assignment/Get Belonged Assignments by Problem ID/Succeed.bru b/collection/admin/Assignment/Get Belonged Assignments by Problem ID/Succeed.bru new file mode 100644 index 0000000000..6802a92e33 --- /dev/null +++ b/collection/admin/Assignment/Get Belonged Assignments by Problem ID/Succeed.bru @@ -0,0 +1,55 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query GetAssignmentsByProblemId($problemId: Int!) { + getAssignmentsByProblemId(problemId: $problemId) { + upcoming { + id + title + problemScore + totalScore + } + ongoing { + id + title + problemScore + totalScore + } + finished { + id + title + problemScore + totalScore + } + } + } +} + +body:graphql:vars { + { + "problemId": 1 + } +} + +assert { + res.body.data.getAssignmentsByProblemId: isDefined +} + +docs { + ## Get Assignments By Problem ID + Problem ID를 가지고 Assignments정보를 가져옵니다. + Ongoing, Upcoming, Finished로 Group된 Assignments를 반환합니다. + + ### Error Cases + - Problem ID에 해당하는 Problem이 없거나, 해당 Problem ID에 해당하는 Belonged Assignment가 0개인 경우 Problem or Assignment Problem Does not exist 에러를 반환합니다. +} diff --git a/collection/admin/Assignment/Get Publicizing Requests/Succeed.bru b/collection/admin/Assignment/Get Publicizing Requests/Succeed.bru new file mode 100644 index 0000000000..76ba5c73fb --- /dev/null +++ b/collection/admin/Assignment/Get Publicizing Requests/Succeed.bru @@ -0,0 +1,31 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query { + getPublicizingRequests { + assignmentId + userId + expireTime + } + } +} + +assert { + res.body.data.getPublicizingRequests: isDefined +} + +docs { + # Get Publicizing Requests + --- + - Expire Time이 도래하지 않은 Publicizing Request들을 반환합니다. +} diff --git a/collection/admin/Assignment/Handle Publicizing Request/Succeed.bru b/collection/admin/Assignment/Handle Publicizing Request/Succeed.bru new file mode 100644 index 0000000000..f20eb2b554 --- /dev/null +++ b/collection/admin/Assignment/Handle Publicizing Request/Succeed.bru @@ -0,0 +1,35 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + handlePublicizingRequest( + assignmentId: 3 + isAccepted:true + ) { + assignmentId + isAccepted + } + } +} + +assert { + res.body.data.handlePublicizingRequest: isDefined +} + +docs { + # Handle Publicizing Requests + --- + - 주어진 Assignment의 Publicizing Request를 수락하거나, 거절합니다. + - `isAccept`가 `true`인 경우 수락, `isAccept`가 `false`인 경우 거절합니다. + - 존재하지 않는 Assignment이거나, Publicizing Request되지 않은 Assignment인 경우 에러를 반환합니다. +} diff --git a/collection/admin/Assignment/Handle Publicizing Request/[ERR] Nonexistent Publicizing Request.bru b/collection/admin/Assignment/Handle Publicizing Request/[ERR] Nonexistent Publicizing Request.bru new file mode 100644 index 0000000000..7297fc3d71 --- /dev/null +++ b/collection/admin/Assignment/Handle Publicizing Request/[ERR] Nonexistent Publicizing Request.bru @@ -0,0 +1,27 @@ +meta { + name: [ERR] Nonexistent Publicizing Request + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + handlePublicizingRequest( + assignmentId: 99999 + isAccepted:true + ) { + assignmentId + isAccepted + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Import Problems to Assignment/Succeed.bru b/collection/admin/Assignment/Import Problems to Assignment/Succeed.bru new file mode 100644 index 0000000000..c73c6b13e0 --- /dev/null +++ b/collection/admin/Assignment/Import Problems to Assignment/Succeed.bru @@ -0,0 +1,55 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + importProblemsToAssignment( + groupId: 1 + assignmentId: 3 + problemIdsWithScore: [ + { + problemId: 7, + score: 20 + }, + { + problemId: 8, + score: 30 + }, + { + problemId: 9, + score: 50 + } + ] + ) { + assignmentId + problemId + createTime + updateTime + order + score + } + } +} + +assert { + res.body.data.importProblemsToAssignment: isDefined +} + +docs { + # Import Problems + --- + - Group에 속해있는 문제들을 Assignment에 import합니다. + - `problemIdsWithScore`로 전달된 Problem들은 `groupId`로 전달된 Group에 속해 있는 경우에만 Assignment에 import됩니다. 그렇지 않은 경우, 무시됩니다. + - 이미 Assignment에 Problem이 속해있는 경우, 해당 Problem ID는 무시됩니다. + - 존재하지 않는 `problemId`를 전달하는 경우에도 해당 Problem Id는 무시됩니다. + - `score` 속성으로 Assignment에서 해당 Problem이 갖게 될 점수를 설정할 수 있습니다. +} diff --git a/collection/admin/Assignment/Import Problems to Assignment/[ERR] Nonexistent Assignment.bru b/collection/admin/Assignment/Import Problems to Assignment/[ERR] Nonexistent Assignment.bru new file mode 100644 index 0000000000..c9cd35422f --- /dev/null +++ b/collection/admin/Assignment/Import Problems to Assignment/[ERR] Nonexistent Assignment.bru @@ -0,0 +1,32 @@ +meta { + name: [ERR] Nonexistent Assignment + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + importProblemsToAssignment( + groupId: 1 + assignmentId: 99999 + problemIds: [7, 8] + ) { + assignmentId + problemId + createTime + updateTime + order + score + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Remove Problems From Assignment/Succeed.bru b/collection/admin/Assignment/Remove Problems From Assignment/Succeed.bru new file mode 100644 index 0000000000..cd2aa04ac6 --- /dev/null +++ b/collection/admin/Assignment/Remove Problems From Assignment/Succeed.bru @@ -0,0 +1,39 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + removeProblemsFromAssignment( + groupId: 1 + assignmentId: 3 + problemIds: [7] + ) { + assignmentId + problemId + createTime + updateTime + order + score + } + } +} + +assert { + res.body.data.removeProblemsFromAssignment: isDefined +} + +docs { + # remove Problems + + - Assignment에 포함된 문제들을 remove합니다.. + - 입력된 problemId, assignmentId, groupId의 조합과 일치하는 문제가 없을 경우 무시됩니다. +} diff --git a/collection/admin/Assignment/Remove Problems From Assignment/[ERR] Nonexistent Assignment.bru b/collection/admin/Assignment/Remove Problems From Assignment/[ERR] Nonexistent Assignment.bru new file mode 100644 index 0000000000..de77c2ff17 --- /dev/null +++ b/collection/admin/Assignment/Remove Problems From Assignment/[ERR] Nonexistent Assignment.bru @@ -0,0 +1,32 @@ +meta { + name: [ERR] Nonexistent Assignment + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + removeProblemsFromAssignment( + groupId: 1 + assignmentId: 99999 + problemIds: [7, 8] + ) { + assignmentId + problemId + createTime + updateTime + order + score + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Update Assignment/Succeed.bru b/collection/admin/Assignment/Update Assignment/Succeed.bru new file mode 100644 index 0000000000..fadc5d8bce --- /dev/null +++ b/collection/admin/Assignment/Update Assignment/Succeed.bru @@ -0,0 +1,44 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + updateAssignment( + groupId: 1 + input: { + id: 3 + title: "Updated Assignment Group 2" + description: "thisisupdatedassignment" + startTime: "2024-01-01" + endTime: "2030-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + isJudgeResultVisible: true + } + ) { + id + title + description + } + } +} + +assert { + res.body.data.updateAssignment: isDefined +} + +docs { + # Update Assignment + --- + - Assignment를 update합니다. +} diff --git a/collection/admin/Assignment/Update Assignment/[404] Nonexistent Assignment.bru b/collection/admin/Assignment/Update Assignment/[404] Nonexistent Assignment.bru new file mode 100644 index 0000000000..76084b2d19 --- /dev/null +++ b/collection/admin/Assignment/Update Assignment/[404] Nonexistent Assignment.bru @@ -0,0 +1,38 @@ +meta { + name: [404] Nonexistent Assignment + type: graphql + seq: 3 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + updateAssignment( + groupId: 2 + input: { + id: 99999 + title: "Updated Assignment Group 2" + description: "thisisupdatedassignment" + startTime: "2024-01-01" + endTime: "2030-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + isJudgeResultVisible: true + } + ) { + id + title + description + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/admin/Assignment/Update Assignment/[422] End Time Earlier than Start Time.bru b/collection/admin/Assignment/Update Assignment/[422] End Time Earlier than Start Time.bru new file mode 100644 index 0000000000..da22c13970 --- /dev/null +++ b/collection/admin/Assignment/Update Assignment/[422] End Time Earlier than Start Time.bru @@ -0,0 +1,38 @@ +meta { + name: [422] End Time Earlier than Start Time + type: graphql + seq: 2 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + mutation { + updateAssignment( + groupId: 2 + input: { + id: 3 + title: "Updated Assignment Group 2" + description: "thisisupdatedassignment" + startTime: "2030-01-01" + endTime: "2020-01-01" + isVisible: true + isRankVisible: true + enableCopyPaste: true + isJudgeResultVisible: true + } + ) { + id + title + description + } + } +} + +assert { + res.body.errors: isDefined +} diff --git a/collection/client/Assignment/Get assignment by ID/Succeed.bru b/collection/client/Assignment/Get assignment by ID/Succeed.bru new file mode 100644 index 0000000000..9dc9475db2 --- /dev/null +++ b/collection/client/Assignment/Get assignment by ID/Succeed.bru @@ -0,0 +1,58 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/1?groupId=1 + body: none + auth: none +} + +params:query { + groupId: 1 +} + +assert { + res.status: eq 200 + res("id"): isNumber + res("title"): isString + res("startTime"): isString + res("endTime"): isString + res("group.id"): isNumber + res("group.groupName"): isString + res("invitationCodeExists"): isBoolean + res("description"): isString + res("_count.assignmentRecord"): isNumber + res("isRegistered"): isBoolean + res("enableCopyPaste"): isBoolean + res("isJudgeResultVisible"): isBoolean +} + +script:pre-request { + await require("./login").loginUser2nd(req); +} + +docs { + ## Get Assignment by ID + + 하나의 대회 정보와 Assignment 참여자 정보, 로그인한 유저가 해당 Assignment에 참여가능한지 여부에 대한 정보를 가져옵니다. (로그인 하지 않은 유저는 Open Space의 Assignment 정보만 볼 수 있고, `isRegistered`는 항상 `false`) + ### Path + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |id|Integer|Assignment(과제) ID| + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId|Integer|대회가 속한 Group ID| + + ### Error Case + + #### [404] Assignment does not exist + + 존재하지 않는 AssignmentID를 Path로 주면 오류가 납니다. +} diff --git a/collection/client/Assignment/Get assignment by ID/[404] Assignment does not exist.bru b/collection/client/Assignment/Get assignment by ID/[404] Assignment does not exist.bru new file mode 100644 index 0000000000..18d069c167 --- /dev/null +++ b/collection/client/Assignment/Get assignment by ID/[404] Assignment does not exist.bru @@ -0,0 +1,22 @@ +meta { + name: [404] Assignment does not exist + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/assignment/999999 + body: none + auth: none +} + +assert { + res.status: eq 404 + res("message"): eq Assignment does not exist + res("error"): eq Not Found + res("statusCode"): eq 404 +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Get finished assignment/Succeed.bru b/collection/client/Assignment/Get finished assignment/Succeed.bru new file mode 100644 index 0000000000..a7eea6921d --- /dev/null +++ b/collection/client/Assignment/Get finished assignment/Succeed.bru @@ -0,0 +1,50 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/finished + body: none + auth: none +} + +params:query { + ~take: 10 + ~cursor: 1 + ~groupId: 1 + ~search: 소 +} + +assert { + res.status: eq 200 + res("data")[0].id: isNumber + res("data")[0].title: isString + res("data")[0].startTime: isString + res("data")[0].endTime: isString + res("data")[0].group.id: isNumber + res("data")[0].group.groupName: isString + res("data")[0].participants: isNumber + res("data")[0].isJudgeResultVisible: isBoolean +} + +docs { + ## Add Finished Assignments + + 종료된 과제들을 가져옵니다. + + pagination이 가능하며, 제목 검색 기능을 포함합니다. + + 각 과제에 유저가 등록되어있는지를 표시하는 `isRegistered` 필드가 함께 반환합니다. (로그인되어있지 않은 경우 모두 false) + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |take |Integer|가져올 과제 개수 (default: 10)| + |cursor|Integer|cursor 값 다음의 ID를 가진 과제들을 반환| + |groupId |Integer|과제가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| + +} diff --git a/collection/client/Assignment/Get ongoing & upcoming assignments (logged in)/Succeed.bru b/collection/client/Assignment/Get ongoing & upcoming assignments (logged in)/Succeed.bru new file mode 100644 index 0000000000..0b40278577 --- /dev/null +++ b/collection/client/Assignment/Get ongoing & upcoming assignments (logged in)/Succeed.bru @@ -0,0 +1,43 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/ongoing-upcoming-with-registered + body: none + auth: none +} + +assert { + res("ongoing")[0].id: isNumber + res("ongoing")[0].title: isString + res("ongoing")[0].startTime: isString + res("ongoing")[0].endTime: isString + res("ongoing")[0].group.id: isNumber + res("ongoing")[0].group.groupName: isString + res.status: eq 200 + res("ongoing")[0].participants: isNumber + res("registeredOngoing"): isDefined + res("registeredUpcoming"): isDefined + res("ongoing")[0].isJudgeResultVisible: isBoolean +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Get Ongoing & Upcoming assignments (logged in) + + 종료되지 않은 과제 중 현재 로그인한 유저가 등록한 과제와 등록하지 않은 과제를 구분하여 반환합니다. 고로 로그인이 필요합니다. + + 유저가 등록한 과제 정보는 `registeredOngoing`, `registeredUpcoming` 으로, 나머지는 `ongoing`, `upcoming` 으로 분류되어 반환됩니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId |Integer|과제가 속한 Group ID (default: 1)| +} diff --git a/collection/client/Assignment/Get ongoing & upcoming assignments/Succeed.bru b/collection/client/Assignment/Get ongoing & upcoming assignments/Succeed.bru new file mode 100644 index 0000000000..94825e727a --- /dev/null +++ b/collection/client/Assignment/Get ongoing & upcoming assignments/Succeed.bru @@ -0,0 +1,42 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/ongoing-upcoming + body: none + auth: none +} + +params:query { + ~groupId: 1 +} + +assert { + res.status: eq 200 + res("ongoing")[0].id: isNumber + res("ongoing")[0].title: isString + res("ongoing")[0].startTime: isString + res("ongoing")[0].endTime: isString + res("ongoing")[0].group.id: isNumber + res("ongoing")[0].group.groupName: isString + res("ongoing")[0].participants: isNumber + res("ongoing"): isDefined + res("upcoming"): isDefined + res("ongoing")[0].isJudgeResultVisible: isBoolean +} + +docs { + ## Get Ongoing & Upcoming Assignments + + 아직 종료되지 않은 과제들을 가져옵니다. 로그인은 필요하지 않으며, groupId에 해당하는 ongoing 과제와 upcoming 과제를 배열로 가져옵니다. pagination은 수행하지 않습니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId |Integer|과제가 속한 Group ID (default: 1)| + +} diff --git a/collection/client/Assignment/Get registered finished assignments/Succeed.bru b/collection/client/Assignment/Get registered finished assignments/Succeed.bru new file mode 100644 index 0000000000..f83b717996 --- /dev/null +++ b/collection/client/Assignment/Get registered finished assignments/Succeed.bru @@ -0,0 +1,51 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/registered-finished + body: none + auth: none +} + +params:query { + ~groupId: 2 + ~cursor: 1 + ~take: 5 + ~search: 밤 +} + +assert { + res.status: eq 200 + res.body.data[0].id: isNumber + res.body.data[0].title: isString + res.body.data[0].startTime: isString + res.body.data[0].endTime: isString + res.body.data[0].group.id: isNumber + res.body.data[0].group.groupName: isString + res.body.data[0].participants: isNumber + res.body.data[0].isJudgeResultVisible: isBoolean +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Get Registered Finished Assignments + + 사용자가 참가 신청을 한 종료된 과제들을 가져옵니다. + + pagination과 제목 검색이 가능합니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |take |Integer|가져올 과제 개수 (default: 10)| + |cursor|Integer|cursor 값 다음의 ID를 가진 과제들을 반환| + |groupId |Integer|과제가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| +} diff --git a/collection/client/Assignment/Get registered ongoing & upcoming assignments/Succeed.bru b/collection/client/Assignment/Get registered ongoing & upcoming assignments/Succeed.bru new file mode 100644 index 0000000000..5854e535ed --- /dev/null +++ b/collection/client/Assignment/Get registered ongoing & upcoming assignments/Succeed.bru @@ -0,0 +1,44 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/assignment/registered-ongoing-upcoming + body: none + auth: none +} + +params:query { + ~groupId: 2 + ~search: hello +} + +assert { + res("registeredOngoing"): isDefined + res("registeredUpcoming"): isDefined +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Get Registered Ongoing & Upcoming Assignments + + 사용자가 참가 신청을 한 Ongoing, Upcoming 과제들을 가져옵니다. + + 로그인이 필요하며, pagination을 수행하지 않습니다. + + 제목 검색 기능을 수행합니다. + + Get ongoing&upcoming assignments (logged in) API와 다른 점은 검색이 가능하다는 점입니다. + + ### Query + + | 이름 | 타입 | 설명 | + |-----|-----|-----| + |groupId |Integer|과제가 속한 Group ID (default: 1)| + |search|String|title을 기준으로 검색할 키워드. 포함하지 않으면 검색을 수행하지 않음| +} diff --git a/collection/client/Assignment/Participate group assignment/Succeed.bru b/collection/client/Assignment/Participate group assignment/Succeed.bru new file mode 100644 index 0000000000..fc232338b3 --- /dev/null +++ b/collection/client/Assignment/Participate group assignment/Succeed.bru @@ -0,0 +1,32 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/assignment/1/participation?groupId=1&invitationCode=123456 + body: none + auth: none +} + +params:query { + groupId: 1 + invitationCode: 123456 +} + +assert { + res.status: eq 201 +} + +script:pre-request { + await require("./login").loginUser(req); + // TODO: remove participation +} + +docs { + # Participate Group Contest + --- + - Url param으로 주어진 group ID와 assignment ID에 해당하는 assignment에 참여합니다. + - invitationCode가 존재하는 Contest의 경우 6자리의 invitationCode를 URL Query로 전달해야 합니다. +} diff --git a/collection/client/Assignment/Participate group assignment/[404] Nonexistent assignment.bru b/collection/client/Assignment/Participate group assignment/[404] Nonexistent assignment.bru new file mode 100644 index 0000000000..5f265dcee8 --- /dev/null +++ b/collection/client/Assignment/Participate group assignment/[404] Nonexistent assignment.bru @@ -0,0 +1,24 @@ +meta { + name: [404] Nonexistent assignment + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/assignment/999999/participation?groupId=2 + body: none + auth: none +} + +params:query { + groupId: 2 +} + +assert { + res.status: eq 404 + res("message"): eq No Assignment found +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Participate group assignment/[409] Already participated.bru b/collection/client/Assignment/Participate group assignment/[409] Already participated.bru new file mode 100644 index 0000000000..e4d6d51a6a --- /dev/null +++ b/collection/client/Assignment/Participate group assignment/[409] Already participated.bru @@ -0,0 +1,40 @@ +meta { + name: [409] Already participated + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/assignment/1/participation?groupId=1&invitationCode=123456 + body: none + auth: none +} + +params:query { + groupId: 1 + invitationCode: 123456 +} + +assert { + res.status: eq 409 + res("message"): eq Already participated this assignment +} + +script:pre-request { + const axios = require("axios") + + await require("./login").loginUser(req); + + const baseUrl = bru.getEnvVar("baseUrl"); + const authorization = bru.getVar("jwtToken"); + + try { + await axios.post( + baseUrl + "/group/1/assignment/1/participation", + {}, + { + headers: { authorization } + } + ) + } catch (error) {} +} diff --git a/collection/client/Assignment/Participate group assignment/[409] Ended assignment.bru b/collection/client/Assignment/Participate group assignment/[409] Ended assignment.bru new file mode 100644 index 0000000000..6691a6c452 --- /dev/null +++ b/collection/client/Assignment/Participate group assignment/[409] Ended assignment.bru @@ -0,0 +1,25 @@ +meta { + name: [409] Ended assignment + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/assignment/2/participation?groupId=1&invitationCode=123456 + body: none + auth: none +} + +params:query { + groupId: 1 + invitationCode: 123456 +} + +assert { + res.status: eq 409 + res("message"): eq Cannot participate ended assignment +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Participate group assignment/[409] Invalid Invitation Code.bru b/collection/client/Assignment/Participate group assignment/[409] Invalid Invitation Code.bru new file mode 100644 index 0000000000..4e52b375e2 --- /dev/null +++ b/collection/client/Assignment/Participate group assignment/[409] Invalid Invitation Code.bru @@ -0,0 +1,16 @@ +meta { + name: [409] Invalid Invitation Code + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/assignment/1/participation?groupId=1&invitationCode=112233 + body: none + auth: none +} + +params:query { + groupId: 1 + invitationCode: 112233 +} diff --git a/collection/client/Assignment/Unregister Assignment/403: Cannot unregister ongoing or ended assignment.bru b/collection/client/Assignment/Unregister Assignment/403: Cannot unregister ongoing or ended assignment.bru new file mode 100644 index 0000000000..b09538c0c5 --- /dev/null +++ b/collection/client/Assignment/Unregister Assignment/403: Cannot unregister ongoing or ended assignment.bru @@ -0,0 +1,26 @@ +meta { + name: 403: Cannot unregister ongoing or ended assignment + type: http + seq: 4 +} + +delete { + url: {{baseUrl}}/assignment/1/participation?groupId=1 + body: none + auth: none +} + +params:query { + groupId: 1 +} + +assert { + res.status: eq 403 + res.body.message: eq Cannot unregister ongoing or ended assignment + res.body.error: eq Forbidden + res.body.statusCode: eq 403 +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Unregister Assignment/404: No Assignment found.bru b/collection/client/Assignment/Unregister Assignment/404: No Assignment found.bru new file mode 100644 index 0000000000..510ea224f6 --- /dev/null +++ b/collection/client/Assignment/Unregister Assignment/404: No Assignment found.bru @@ -0,0 +1,26 @@ +meta { + name: 404: No Assignment found + type: http + seq: 2 +} + +delete { + url: {{baseUrl}}/assignment/9999/participation?groupId=1 + body: none + auth: none +} + +params:query { + groupId: 1 +} + +assert { + res.status: eq 404 + res.body.message: eq Assignment does not exist + res.body.error: eq Not Found + res.body.statusCode: eq 404 +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Unregister Assignment/404: No AssignmentRecord found.bru b/collection/client/Assignment/Unregister Assignment/404: No AssignmentRecord found.bru new file mode 100644 index 0000000000..20107b1a77 --- /dev/null +++ b/collection/client/Assignment/Unregister Assignment/404: No AssignmentRecord found.bru @@ -0,0 +1,26 @@ +meta { + name: 404: No AssignmentRecord found + type: http + seq: 3 +} + +delete { + url: {{baseUrl}}/assignment/2/participation?groupId=1 + body: none + auth: none +} + +params:query { + groupId: 1 +} + +assert { + res.status: eq 404 + res.body.message: eq AssignmentRecord does not exist + res.body.error: eq Not Found + res.body.statusCode: eq 404 +} + +script:pre-request { + await require("./login").loginUser(req); +} diff --git a/collection/client/Assignment/Unregister Assignment/Succeed.bru b/collection/client/Assignment/Unregister Assignment/Succeed.bru new file mode 100644 index 0000000000..84d50cbe3e --- /dev/null +++ b/collection/client/Assignment/Unregister Assignment/Succeed.bru @@ -0,0 +1,60 @@ +meta { + name: Succeed + type: http + seq: 1 +} + +delete { + url: {{baseUrl}}/assignment/15/participation?groupId=1 + body: none + auth: none +} + +params:query { + groupId: 1 +} + +assert { + res.status: eq 200 + res.body.id: isNumber + res.body.assignmentId: isNumber + res.body.acceptedProblemNum: isNumber + res.body.totalPenalty: isNumber + res.body.createTime: isString + res.body.updateTime: isString +} + +script:pre-request { + await require("./login").loginUser(req); +} + +docs { + ## Unregister Upcoming Assignment + + Upcoming Assignment에 한하여, User가 해당 Assignment를 Unregister합니다. + + ### Path + Path URI로 `:id` 값이 옵니다. + 해당 id 값은 `assignmentId`입니다. + + ### Query + |이름|타입|설명| + |--|--|--| + |groupId|Integer|Assignment가 속한 GroupID입니다. (default: OPENSPACE_ID)| + + ### Error Case + + #### 404: No Assignment found + + Path URI로 존재하지 않는 `assignmentId`를 주거나 `assignmentId`가 속하지 않은 `groupId`를 Query Parameter의 Argument로 줄 경우, No Assignment found Error가 발생합니다. + + #### 404: No AssignmentRecord found + + 현재 `userId`와 `assignmentId`를 기반으로 AssignmentRecord Table에서 두 field를 갖고 있는 레코드를 검색합니다. + 그렇지만 이에 해당하는 레코드가 없는 경우 상기 에러를 반환합니다. + + #### 403: Cannot unregister ongoing or ended assignment + + Assignment가 ongoing 혹은 ended 상태이면, unregister를 할 수 없습니다. + +} diff --git a/collection/client/Contest/Get contest by ID/Succeed.bru b/collection/client/Contest/Get contest by ID/Succeed.bru index 14c385ecb1..d7cd7e46c5 100644 --- a/collection/client/Contest/Get contest by ID/Succeed.bru +++ b/collection/client/Contest/Get contest by ID/Succeed.bru @@ -5,14 +5,13 @@ meta { } get { - url: {{baseUrl}}/contest/1?groupId=1&problemI=1 + url: {{baseUrl}}/contest/1?groupId=1 body: none auth: none } -query { +params:query { groupId: 1 - problemI: 1 } assert {