diff --git a/apps/backend/apps/admin/src/contest/contest.resolver.ts b/apps/backend/apps/admin/src/contest/contest.resolver.ts index 90aa2310b2..63a1430e43 100644 --- a/apps/backend/apps/admin/src/contest/contest.resolver.ts +++ b/apps/backend/apps/admin/src/contest/contest.resolver.ts @@ -14,8 +14,14 @@ import { EntityNotExistException, UnprocessableDataException } from '@libs/exception' -import { CursorValidationPipe, GroupIDPipe, RequiredIntPipe } from '@libs/pipe' +import { + CursorValidationPipe, + GroupIDPipe, + IDValidationPipe, + RequiredIntPipe +} from '@libs/pipe' import { ContestService } from './contest.service' +import { ContestSubmissionSummaryForUser } from './model/contest-submission-summary-for-user.model' import { ContestWithParticipants } from './model/contest-with-participants.model' import { CreateContestInput } from './model/contest.input' import { UpdateContestInput } from './model/contest.input' @@ -224,6 +230,35 @@ export class ContestResolver { } } + @Query(() => ContestSubmissionSummaryForUser) + async getContestSubmissionSummaryByUserId( + @Args('contestId', { type: () => Int }, IDValidationPipe) contestId: 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 + ) { + try { + return await this.contestService.getContestSubmissionSummaryByUserId( + take, + contestId, + userId, + problemId, + cursor + ) + } catch (error) { + this.logger.error(error) + throw new InternalServerErrorException() + } + } + @Mutation(() => DuplicatedContestResponse) async duplicateContest( @Args('groupId', { type: () => Int }, GroupIDPipe) groupId: number, diff --git a/apps/backend/apps/admin/src/contest/contest.service.spec.ts b/apps/backend/apps/admin/src/contest/contest.service.spec.ts index f0c0dfbb19..8a9601c94d 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.spec.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.spec.ts @@ -4,6 +4,7 @@ import { ContestProblem, Group, ContestRecord } from '@generated' import { Problem } from '@generated' import { Contest } 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' @@ -141,6 +142,41 @@ const contestProblem: ContestProblem = { updateTime: faker.date.past() } +const submissionsWithProblemTitleAndUsername = { + id: 1, + userId: 1, + userIp: '127.0.0.1', + problemId: 1, + contestId: 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: PublicizingRequest = { contestId, userId, @@ -181,6 +217,7 @@ const db = { contestProblem: { create: stub().resolves(ContestProblem), findMany: stub().resolves([ContestProblem]), + findFirstOrThrow: stub().resolves(ContestProblem), findFirst: stub().resolves(ContestProblem) }, contestRecord: { @@ -195,6 +232,12 @@ const db = { 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 newContestProblem = await db.contestProblem.create() @@ -358,6 +401,38 @@ describe('ContestService', () => { }) }) + // describe('getContestSubmissionSummaryByUserId', () => { + // it('should return contest submission summaries', async () => { + // const res = await service.getContestSubmissionSummaryByUserId(10, 1, 1, 1) + + // expect(res.submissions).to.deep.equal([ + // { + // contestId: 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('duplicateContest', () => { // db['$transaction'] = stub().callsFake(async () => { // const newContest = await db.contest.create() diff --git a/apps/backend/apps/admin/src/contest/contest.service.ts b/apps/backend/apps/admin/src/contest/contest.service.ts index d7dd341151..8961dd6263 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.ts @@ -490,6 +490,59 @@ export class ContestService { return contestProblems } + async getContestSubmissionSummaryByUserId( + take: number, + contestId: 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, + contestId, + problemId: problemId ?? undefined + }, + include: { + problem: { + select: { + title: true + } + }, + user: { + select: { + username: true, + studentId: true + } + } + } + }) + + const mappedSubmission = submissions.map((submission) => { + return { + contestId: submission.contestId, + 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 + } + }) + + const scoreSummary = await this.getContestScoreSummary(userId, contestId) + + return { + scoreSummary, + submissions: mappedSubmission + } + } + /** * Duplicate contest with contest problems and users who participated in the contest * Not copied: submission @@ -608,15 +661,23 @@ export class ContestService { } }) ]) - - if (!submissions.length) { - throw new EntityNotExistException('Submissions') - } else if (!contestProblems.length) { + if (!contestProblems.length) { throw new EntityNotExistException('ContestProblems') } else if (!contestRecord) { throw new EntityNotExistException('contestRecord') } - + if (!submissions.length) { + return { + submittedProblemCount: 0, + totalProblemCount: contestProblems.length, + userContestScore: 0, + contestPerfectScore: contestProblems.reduce( + (total, { score }) => total + score, + 0 + ), + problemScores: [] + } + } // 하나의 Problem에 대해 여러 개의 Submission이 존재한다면, 마지막에 제출된 Submission만을 점수 계산에 반영함 const latestSubmissions: { [problemId: string]: { diff --git a/apps/backend/apps/admin/src/contest/model/contest-submission-summary-for-user.model.ts b/apps/backend/apps/admin/src/contest/model/contest-submission-summary-for-user.model.ts new file mode 100644 index 0000000000..598df5c5ae --- /dev/null +++ b/apps/backend/apps/admin/src/contest/model/contest-submission-summary-for-user.model.ts @@ -0,0 +1,45 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Language, ResultStatus } from '@admin/@generated' +import { UserContestScoreSummary } from './score-summary' + +@ObjectType({ description: 'ContestSubmissionSummaryForUser' }) +export class ContestSubmissionSummaryForUser { + @Field(() => UserContestScoreSummary, { nullable: false }) + scoreSummary: UserContestScoreSummary + + @Field(() => [ContestSubmissionSummaryForOne], { nullable: false }) + submissions: ContestSubmissionSummaryForOne[] +} + +/** + * 특정 User의 특정 Contest에 대한 Submission 정보 (!== model SubmissionResult) + */ +@ObjectType({ description: 'ContestSubmissionSummaryForOne' }) +export class ContestSubmissionSummaryForOne { + @Field(() => Int, { nullable: false }) + contestId: 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 +} diff --git a/collection/admin/Contest/Get Contest Submission Summaries of User/Succeed.bru b/collection/admin/Contest/Get Contest Submission Summaries of User/Succeed.bru new file mode 100644 index 0000000000..bf9fd6158c --- /dev/null +++ b/collection/admin/Contest/Get Contest Submission Summaries of User/Succeed.bru @@ -0,0 +1,48 @@ +meta { + name: Succeed + type: graphql + seq: 1 +} + +post { + url: {{gqlUrl}} + body: graphql + auth: none +} + +body:graphql { + query getContestSubmissionSummariesByUserId($contestId: Int!, $userId: Int!) { + getContestSubmissionSummaryByUserId(contestId: $contestId, userId: $userId) { + scoreSummary { + contestPerfectScore + problemScores { + problemId + score + } + submittedProblemCount + totalProblemCount + userContestScore + } + submissions { + contestId + problemTitle + studentId + username + submissionResult + language + submissionTime + codeSize + ip + } + } + } + +} + +body:graphql:vars { + { + "contestId": 1, + "userId": 4 + // "problemId": 1 + } +}