diff --git a/.env.development b/.env.development index b60c7aba04..31ff3fb203 100644 --- a/.env.development +++ b/.env.development @@ -39,6 +39,9 @@ MEDIA_SECRET_KEY=skku1234 REDIS_HOST=127.0.0.1 REDIS_PORT=6380 +MINIO_ROOT_USER=skku +MINIO_ROOT_PASSWORD=skku1234 + DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5433/skkuding?schema=public TEST_DATABASE_URL=postgresql://postgres:1234@127.0.0.1:5434/skkuding?schema=public diff --git a/apps/backend/apps/admin/src/admin.module.ts b/apps/backend/apps/admin/src/admin.module.ts index 355dfec8d9..35b87db812 100644 --- a/apps/backend/apps/admin/src/admin.module.ts +++ b/apps/backend/apps/admin/src/admin.module.ts @@ -25,6 +25,7 @@ import { ContestModule } from './contest/contest.module' import { GroupModule } from './group/group.module' import { ProblemModule } from './problem/problem.module' import { StorageModule } from './storage/storage.module' +import { SubmissionModule } from './submission/submission.module' import { UserModule } from './user/user.module' @Module({ @@ -55,6 +56,7 @@ import { UserModule } from './user/user.module' UserModule, AnnouncementModule, NoticeModule, + SubmissionModule, LoggerModule.forRoot(pinoLoggerModuleOption) ], controllers: [AdminController], diff --git a/apps/backend/apps/admin/src/submission/model/contest-submission.model.ts b/apps/backend/apps/admin/src/submission/model/contest-submission.model.ts new file mode 100644 index 0000000000..e3e7eb9afd --- /dev/null +++ b/apps/backend/apps/admin/src/submission/model/contest-submission.model.ts @@ -0,0 +1,32 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql' +import { Language, ResultStatus } from '@admin/@generated' + +@ObjectType({ description: 'contestSubmissionOverall' }) +export class ContestSubmission { + @Field(() => String, { nullable: false }) + title!: string // 문제 title + + @Field(() => String, { nullable: false }) + studentId!: string // 학번 + + @Field(() => String, { nullable: true }) + realname?: string // 실명 + + @Field(() => String, { nullable: false }) + username!: string + + @Field(() => ResultStatus, { nullable: false }) + result!: ResultStatus // Accepted, WrongAnswer ... + + @Field(() => Language, { nullable: false }) + language!: Language + + @Field(() => String, { nullable: false }) + submissionTime!: Date // 제출 시각 + + @Field(() => Int, { nullable: true }) + codeSize: number | null + + @Field(() => String, { nullable: true }) + ip: string | null +} diff --git a/apps/backend/apps/admin/src/submission/model/get-contest-submission.input.ts b/apps/backend/apps/admin/src/submission/model/get-contest-submission.input.ts new file mode 100644 index 0000000000..7f6a8f9b9f --- /dev/null +++ b/apps/backend/apps/admin/src/submission/model/get-contest-submission.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType, Int } from '@nestjs/graphql' + +@InputType() +export class GetContestSubmissionsInput { + @Field(() => Int, { nullable: false }) + contestId!: number + + @Field(() => Int, { nullable: true }) + problemId?: number +} diff --git a/apps/backend/apps/admin/src/submission/submission.module.ts b/apps/backend/apps/admin/src/submission/submission.module.ts new file mode 100644 index 0000000000..77c7e02949 --- /dev/null +++ b/apps/backend/apps/admin/src/submission/submission.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { SubmissionResolver } from './submission.resolver' +import { SubmissionService } from './submission.service' + +@Module({ + providers: [SubmissionResolver, SubmissionService] +}) +export class SubmissionModule {} diff --git a/apps/backend/apps/admin/src/submission/submission.resolver.ts b/apps/backend/apps/admin/src/submission/submission.resolver.ts new file mode 100644 index 0000000000..26a67b72de --- /dev/null +++ b/apps/backend/apps/admin/src/submission/submission.resolver.ts @@ -0,0 +1,37 @@ +import { InternalServerErrorException, Logger } from '@nestjs/common' +import { Args, Int, Query, Resolver } from '@nestjs/graphql' +import { CursorValidationPipe } from '@libs/pipe' +import { Submission } from '@admin/@generated' +import { ContestSubmission } from './model/contest-submission.model' +import { GetContestSubmissionsInput } from './model/get-contest-submission.input' +import { SubmissionService } from './submission.service' + +@Resolver(() => Submission) +export class SubmissionResolver { + private readonly logger = new Logger(SubmissionResolver.name) + constructor(private readonly submissionService: SubmissionService) {} + + @Query(() => [ContestSubmission]) + async getContestSubmissions( + @Args('input', { + nullable: false, + type: () => GetContestSubmissionsInput + }) + input: GetContestSubmissionsInput, + @Args('cursor', { nullable: true, type: () => Int }, CursorValidationPipe) + cursor: number | null, + @Args('take', { nullable: true, defaultValue: 10, type: () => Int }) + take: number + ): Promise { + try { + return await this.submissionService.getContestSubmissions( + input, + take, + cursor + ) + } catch (error) { + this.logger.error(error.error) + throw new InternalServerErrorException() + } + } +} diff --git a/apps/backend/apps/admin/src/submission/submission.service.ts b/apps/backend/apps/admin/src/submission/submission.service.ts new file mode 100644 index 0000000000..06ed277b91 --- /dev/null +++ b/apps/backend/apps/admin/src/submission/submission.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common' +import { PrismaService } from '@libs/prisma' +import type { Language, ResultStatus } from '@admin/@generated' +import type { GetContestSubmissionsInput } from './model/get-contest-submission.input' + +@Injectable() +export class SubmissionService { + constructor(private readonly prisma: PrismaService) {} + + async getContestSubmissions( + input: GetContestSubmissionsInput, + take: number, + cursor: number | null + ) { + const paginator = this.prisma.getPaginator(cursor) + + const { contestId, problemId } = input + const contestSubmissions = await this.prisma.submission.findMany({ + ...paginator, + take, + where: { + contestId, + problemId + }, + include: { + user: { + select: { + id: true, + username: true, + studentId: true, + userProfile: { + select: { + realName: true + } + } + } + }, + problem: { + select: { + title: true + } + } + } + }) + + const results = contestSubmissions.map((c) => { + return { + title: c.problem.title, + studentId: c.user?.studentId ?? 'Unknown', + realname: c.user?.userProfile?.realName ?? 'Unknown', + username: c.user?.username ?? 'Unknown', + result: c.result as ResultStatus, + language: c.language as Language, + submissionTime: c.createTime, + codeSize: c.codeSize ?? null, + ip: c.userIp ?? 'Unknown' + } + }) + + return results + } +} diff --git a/apps/backend/apps/client/src/email/templates/email-auth.hbs b/apps/backend/apps/client/src/email/templates/email-auth.hbs index 40160aba14..faa70d593d 100644 --- a/apps/backend/apps/client/src/email/templates/email-auth.hbs +++ b/apps/backend/apps/client/src/email/templates/email-auth.hbs @@ -64,7 +64,7 @@ {{!-- Logo file is uploaded in https://github.com/skkuding/codedang/issues/1066 --}} { ).to.be.true }) - it('should call handleJudgeError when CompileError or ServerError detected', async () => { + it('should call handleJudgeError when ServerError detected', async () => { const handlerSpy = sandbox.stub(service, 'handleJudgeError').resolves() - + const updateSpy = sandbox + .stub(service, 'updateTestcaseJudgeResult') + .resolves() const serverErrMsg = { - resultCode: 8, + resultCode: 9, submissionId: 1, error: '', judgeResult @@ -189,6 +191,25 @@ describe('SubmissionSubscriptionService', () => { await service.handleJudgerMessage(serverErrMsg) expect(handlerSpy.calledOnceWith(ResultStatus.ServerError, serverErrMsg)) .to.be.true + expect(updateSpy.notCalled).to.be.true + }) + + it('should call handleJudgeError when CompileError detected', async () => { + const handlerSpy = sandbox.stub(service, 'handleJudgeError').resolves() + const updateSpy = sandbox + .stub(service, 'updateTestcaseJudgeResult') + .resolves() + const serverErrMsg = { + resultCode: 6, + submissionId: 1, + error: '', + judgeResult + } + + await service.handleJudgerMessage(serverErrMsg) + expect(handlerSpy.calledOnceWith(ResultStatus.CompileError, serverErrMsg)) + .to.be.true + expect(updateSpy.notCalled).to.be.true }) }) diff --git a/apps/backend/libs/constants/src/submission.constants.ts b/apps/backend/libs/constants/src/submission.constants.ts index 9e1a84c319..c2d42dcfcb 100644 --- a/apps/backend/libs/constants/src/submission.constants.ts +++ b/apps/backend/libs/constants/src/submission.constants.ts @@ -39,8 +39,8 @@ export const Status = (code: number) => { return ResultStatus.CompileError case 7: // TESTCASE_ERROR return ResultStatus.ServerError - case 8: // SERVER_ERROR - return ResultStatus.ServerError + case 8: // Segmentation Fault + return ResultStatus.SegmentationFaultError default: return ResultStatus.ServerError } diff --git a/apps/backend/prisma/migrations/20240819125317_add_segmentation_fault_on_result_status_enum/migration.sql b/apps/backend/prisma/migrations/20240819125317_add_segmentation_fault_on_result_status_enum/migration.sql new file mode 100644 index 0000000000..245d83187b --- /dev/null +++ b/apps/backend/prisma/migrations/20240819125317_add_segmentation_fault_on_result_status_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ResultStatus" ADD VALUE 'SegmentationFaultError'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index af308ec59a..16ac1399b5 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -170,7 +170,7 @@ model Problem { /// "locked": boolean /// }[] /// } - + template Json[] @default([]) languages Language[] timeLimit Int @map("time_limit") // unit: MilliSeconds @@ -435,6 +435,7 @@ enum ResultStatus { MemoryLimitExceeded OutputLimitExceeded ServerError + SegmentationFaultError } model CodeDraft { diff --git a/apps/frontend/app/(main)/_components/ContestCard.tsx b/apps/frontend/app/(main)/_components/ContestCard.tsx index 8380a32984..7e27120af8 100644 --- a/apps/frontend/app/(main)/_components/ContestCard.tsx +++ b/apps/frontend/app/(main)/_components/ContestCard.tsx @@ -46,7 +46,7 @@ export default function ContestCard({ contest }: Props) { )} > -
+
{contest.title}
diff --git a/apps/frontend/app/(main)/_components/Header.tsx b/apps/frontend/app/(main)/_components/Header.tsx index 96ddf37b16..15add14e91 100644 --- a/apps/frontend/app/(main)/_components/Header.tsx +++ b/apps/frontend/app/(main)/_components/Header.tsx @@ -8,7 +8,7 @@ import NavLink from './NavLink' export default async function Header() { const session = await auth() return ( -
+
{/* FIXME: If you uncomment a group tab, you have to remove a pr-20 tailwind class */}
diff --git a/apps/frontend/app/(main)/contest/_components/ContestCardList.tsx b/apps/frontend/app/(main)/contest/_components/ContestCardList.tsx index 76488e51c3..d1953e71d7 100644 --- a/apps/frontend/app/(main)/contest/_components/ContestCardList.tsx +++ b/apps/frontend/app/(main)/contest/_components/ContestCardList.tsx @@ -6,7 +6,7 @@ import { CarouselNext, CarouselPrevious } from '@/components/ui/carousel' -import { fetcher, fetcherWithAuth } from '@/lib/utils' +import { cn, fetcher, fetcherWithAuth } from '@/lib/utils' import type { Contest } from '@/types/type' import type { Route } from 'next' import type { Session } from 'next-auth' @@ -52,33 +52,29 @@ const getRegisteredContests = async () => { ) } -export default async function Contest({ +type ItemsPerSlide = 2 | 3 + +function ContestCardCarousel({ + itemsPerSlide, title, - type, - session + data }: { - type: string + itemsPerSlide: ItemsPerSlide title: string - session?: Session | null + data: Contest[] }) { - const data = ( - session ? await getRegisteredContests() : await getContests() - ).filter( - (contest) => - contest.status.toLowerCase() === 'registered' + type.toLowerCase() || - contest.status.toLowerCase() === type.toLowerCase() - ) - - data.sort((a, b) => +new Date(a.startTime) - +new Date(b.startTime)) + const chunks = [] - const contestChunks = [] - for (let i = 0; i < data.length; i += 3) - contestChunks.push(data.slice(i, i + 3)) + if (itemsPerSlide === 3) { + for (let i = 0; i < data.length; i += 3) chunks.push(data.slice(i, i + 3)) + } else if (itemsPerSlide === 2) { + for (let i = 0; i < data.length; i += 2) chunks.push(data.slice(i, i + 2)) + } - return data.length === 0 ? ( - <> - ) : ( - + return ( +

{title}

@@ -87,13 +83,16 @@ export default async function Contest({
- {contestChunks.map((contestChunk) => ( - - {contestChunk.map((contest) => ( + {chunks.map((chunk) => ( + + {chunk.map((contest) => ( @@ -104,3 +103,32 @@ export default async function Contest({
) } + +export default async function Contest({ + title, + type, + session +}: { + type: string + title: string + session?: Session | null +}) { + const data = ( + session ? await getRegisteredContests() : await getContests() + ).filter( + (contest) => + contest.status.toLowerCase() === 'registered' + type.toLowerCase() || + contest.status.toLowerCase() === type.toLowerCase() + ) + + data.sort((a, b) => +new Date(a.startTime) - +new Date(b.startTime)) + + return data.length === 0 ? ( + <> + ) : ( + <> + + + + ) +} diff --git a/apps/frontend/app/(main)/contest/layout.tsx b/apps/frontend/app/(main)/contest/layout.tsx index 4ec28a4966..ab421b3448 100644 --- a/apps/frontend/app/(main)/contest/layout.tsx +++ b/apps/frontend/app/(main)/contest/layout.tsx @@ -4,7 +4,9 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( <> -
{children}
+
+ {children} +
) } diff --git a/apps/frontend/app/(main)/layout.tsx b/apps/frontend/app/(main)/layout.tsx index 7acd4a1b04..fff7662ae6 100644 --- a/apps/frontend/app/(main)/layout.tsx +++ b/apps/frontend/app/(main)/layout.tsx @@ -9,7 +9,7 @@ export default function MainLayout({ return (
-
+
{children}