diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma
index 2e88403..5185271 100644
--- a/web/prisma/schema.prisma
+++ b/web/prisma/schema.prisma
@@ -94,7 +94,7 @@ model ActiveTeam {
model Contest {
id Int @id @default(autoincrement())
- name String
+ name String @unique
teams Team[] @relation("TeamContestRelation")
problems Problem[] @relation("ProblemContestRelation")
activeTeams ActiveTeam[]
diff --git a/web/src/routes/admin/contests/+page.svelte b/web/src/routes/admin/contests/+page.svelte
index 62ff4f6..b7b6335 100644
--- a/web/src/routes/admin/contests/+page.svelte
+++ b/web/src/routes/admin/contests/+page.svelte
@@ -11,7 +11,8 @@
diff --git a/web/src/routes/admin/contests/import/+page.server.ts b/web/src/routes/admin/contests/import/+page.server.ts
new file mode 100644
index 0000000..6cdb215
--- /dev/null
+++ b/web/src/routes/admin/contests/import/+page.server.ts
@@ -0,0 +1,167 @@
+import { db } from '$lib/server/prisma';
+import { Language, SubmissionState } from '@prisma/client';
+import type { Actions, PageServerLoad } from './$types';
+import { fail, redirect } from '@sveltejs/kit';
+import { genPassword } from '../../teams/util';
+
+export const load = (async () => { }) satisfies PageServerLoad;
+
+export type ContestImportData = {
+ Name: string,
+ Problems: ProblemImportData[],
+ Teams: TeamImportData[],
+ Submissions: SubmissionImportData[]
+}
+
+export type ProblemImportData = {
+ ProblemName: string,
+ ShortName: string,
+ SampleInput: string,
+ SampleOutput: string,
+ RealInput: string,
+ RealOutput: string
+}
+
+export type TeamImportData = {
+ TeamName: string
+}
+
+export type SubmissionImportData = {
+ TeamName: string,
+ ProblemShortName: string,
+ State: string,
+ SubmitTime: number,
+ TeamOutput: string,
+ Code: string | null,
+ Language: "Java" | "C#" | "C++" | null
+}
+
+export const actions = {
+ default: async ({ request }) => {
+ let parsedContest: ContestImportData;
+ let includeSubmissions: boolean;
+
+ try {
+ const formData = await request.formData();
+ const contestJson = formData.get('jsonText')?.toString();
+ if (!contestJson) {
+ return fail(400, { message: "Could not get json text" });
+ }
+
+ parsedContest = JSON.parse(contestJson);
+ includeSubmissions = formData.get('includeSubmissions')?.toString() == "on";
+ }
+ catch (err) {
+ return fail(400, { message: "Could not parse contest data: " + err?.toString() });
+ }
+
+ try {
+ let contestStart: Date | null = null;
+ let hasSubmissions = false;
+
+ if (includeSubmissions && parsedContest.Submissions.length > 0) {
+ hasSubmissions = true;
+
+ const maxSubmitTimeMinutes = Math.max(...parsedContest.Submissions.map(s => s.SubmitTime));
+ const now = new Date();
+ contestStart = new Date(now.getTime() - maxSubmitTimeMinutes * 60 * 1000);
+ }
+
+ // Single transaction
+ await db.contest.create({
+ data: {
+ name: parsedContest.Name,
+ startTime: contestStart,
+ teams: {
+ connectOrCreate: parsedContest.Teams.map(team => ({
+ where: { name: team.TeamName },
+ create: {
+ name: team.TeamName,
+ password: genPassword(),
+ language: inferTeamLanguage(parsedContest, team) ?? Language.Java
+ }
+ })),
+ },
+ problems: {
+ connectOrCreate: parsedContest.Problems.map(problem => ({
+ where: {
+ friendlyName: problem.ProblemName,
+ pascalName: problem.ShortName,
+ sampleInput: problem.SampleInput,
+ sampleOutput: problem.SampleOutput,
+ realInput: problem.RealInput,
+ realOutput: problem.RealOutput,
+ },
+ create: {
+ friendlyName: problem.ProblemName,
+ pascalName: problem.ShortName,
+ sampleInput: problem.SampleInput,
+ sampleOutput: problem.SampleOutput,
+ realInput: problem.RealInput,
+ realOutput: problem.RealOutput
+ }
+ })),
+ },
+ submissions: {
+ create: hasSubmissions
+ ? parsedContest.Submissions.toSorted((a, b) => a.SubmitTime - b.SubmitTime).map(submission => ({
+ createdAt: dateFromContestMinutes(contestStart!, submission.SubmitTime),
+ gradedAt: dateFromContestMinutes(contestStart!, submission.SubmitTime + 1),
+ state: convertSubmissionState(submission),
+ actualOutput: submission.TeamOutput,
+ commitHash: "",
+ problem: {
+ connect: {
+ pascalName: submission.ProblemShortName
+ }
+ },
+ team: {
+ connect: {
+ name: submission.TeamName
+ }
+ }
+ }))
+ : []
+ }
+ }
+ });
+ } catch (err) {
+ return fail(400, { message: "Error updating database: " + err?.toString() });
+ }
+
+ return redirect(303, "/admin/contests");
+ }
+} satisfies Actions;
+
+function convertSubmissionState(submission: SubmissionImportData): SubmissionState {
+ switch (submission.State) {
+ case "Correct":
+ return SubmissionState.Correct;
+ case "Incorrect":
+ return SubmissionState.Incorrect;
+ default:
+ return SubmissionState.InReview;
+ }
+}
+
+function inferTeamLanguage(parsedContest: ContestImportData, team: TeamImportData): Language | null {
+ const submissionWithCode = parsedContest.Submissions.find(s => s.TeamName == team.TeamName && s.Code != null);
+ if (!submissionWithCode) {
+ return null;
+ }
+
+ switch (submissionWithCode.Language) {
+ case "Java":
+ return Language.Java;
+ case "C#":
+ return Language.CSharp;
+ case "C++":
+ return Language.CPP;
+ default:
+ throw new Error("Unrecognized language: " + submissionWithCode.Language);
+ }
+}
+
+function dateFromContestMinutes(contestStart: Date, minutesFromStart: number): Date {
+ return new Date(contestStart.getTime() + minutesFromStart * 60 * 1000);
+}
\ No newline at end of file
diff --git a/web/src/routes/admin/contests/import/+page.svelte b/web/src/routes/admin/contests/import/+page.svelte
new file mode 100644
index 0000000..dd553bc
--- /dev/null
+++ b/web/src/routes/admin/contests/import/+page.svelte
@@ -0,0 +1,77 @@
+
+
+
+ Import Contest
+
+
+
Import Contest
+
+
+
+