From 9e6ea3bdc9c46a224ad34bbd452a07913c9e0143 Mon Sep 17 00:00:00 2001 From: ymin431 Date: Mon, 2 Nov 2026 13:11:14 +0900 Subject: [PATCH 1/9] =?UTF-8?q?#838=20=EB=AC=B8=EC=A0=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=9D=90=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From ae6e3fe7ac5584fe7c93b04f127628692f12d563 Mon Sep 17 00:00:00 2001 From: ymin431 Date: Mon, 2 Nov 2026 13:47:35 +0900 Subject: [PATCH 2/9] =?UTF-8?q?#839=20=EB=AC=B8=EC=A0=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/admin/components/Simditor.vue | 49 +++++++++++++++++++ .../src/pages/admin/views/problem/Problem.vue | 38 ++++++++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/admin/components/Simditor.vue b/frontend/src/pages/admin/components/Simditor.vue index 2c1eee5c..2e1bfc51 100644 --- a/frontend/src/pages/admin/components/Simditor.vue +++ b/frontend/src/pages/admin/components/Simditor.vue @@ -43,11 +43,16 @@ export default { type: String, default: "", }, + deferUpload: { + type: Boolean, + default: false, + }, }, data() { return { editor: null, currentValue: this.value, + pendingImages: {}, } }, mounted() { @@ -69,6 +74,21 @@ export default { // span: ['color'] }, }) + if (this.deferUpload) { + const uploader = this.editor.uploader + uploader._xhrUpload = (file) => { + const blobUrl = URL.createObjectURL(file.obj) + this.pendingImages[blobUrl] = file.obj + setTimeout(() => { + uploader.trigger("uploadprogress", [file, file.size, file.size]) + uploader.trigger("uploadsuccess", [ + file, + { success: true, file_path: blobUrl }, + ]) + uploader.trigger("uploadcomplete", [file, ""]) + }) + } + } this.editor.on("valuechanged", (e, src) => { this.currentValue = this.editor.getValue() }) @@ -92,6 +112,35 @@ export default { } }, }, + methods: { + async uploadPendingImages() { + let html = this.editor.getValue() + for (const [blobUrl, file] of Object.entries(this.pendingImages)) { + if (!html.includes(blobUrl)) continue + const form = new FormData() + form.append("image", file) + const res = await fetch("/api/admin/upload_image/", { + method: "POST", + body: form, + credentials: "same-origin", + }) + const data = await res.json() + if (!data.success) throw new Error(data.msg) + html = html.split(blobUrl).join(data.file_path) + } + this.revokeAll() + this.editor.setValue(html) + this.currentValue = html + return html + }, + revokeAll() { + Object.keys(this.pendingImages).forEach(URL.revokeObjectURL) + this.pendingImages = {} + }, + }, + beforeDestroy() { + this.revokeAll() + }, } diff --git a/frontend/src/pages/admin/views/problem/Problem.vue b/frontend/src/pages/admin/views/problem/Problem.vue index 37270512..5cf274db 100644 --- a/frontend/src/pages/admin/views/problem/Problem.vue +++ b/frontend/src/pages/admin/views/problem/Problem.vue @@ -110,7 +110,11 @@ >*{{ $t("m.Description") }} - +
@@ -118,7 +122,11 @@ >*{{ $t("m.Input_Description") }} - +
@@ -126,7 +134,11 @@ >*{{ $t("m.Output_Description") }} - +
@@ -217,7 +229,11 @@ - + @@ -944,7 +960,7 @@ export default { }, ) }, - submit() { + async submit() { if (this.problem.field == null) { this.$error("Field is required") return @@ -1016,6 +1032,18 @@ export default { if (funcName === "editContestProblem") { this.problem.contest_id = this.contest.id } + try { + this.problem.description = + await this.$refs.descEditor.uploadPendingImages() + this.problem.input_description = + await this.$refs.inputDescEditor.uploadPendingImages() + this.problem.output_description = + await this.$refs.outputDescEditor.uploadPendingImages() + this.problem.hint = await this.$refs.hintEditor.uploadPendingImages() + } catch (e) { + this.$error("Image upload failed: " + e.message) + return + } api[funcName](this.problem) .then((res) => { if ( From 4f384003a033135726ff4f95e96899b862a55d2c Mon Sep 17 00:00:00 2001 From: ymin431 Date: Mon, 2 Nov 2026 13:58:08 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=8C=80=ED=9A=8C,=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=20=EC=97=90=EB=94=94=ED=84=B0=EB=8F=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/admin/components/Simditor.vue | 30 ++++++++----------- .../src/pages/admin/views/contest/Contest.vue | 11 +++++-- .../admin/views/general/Announcement.vue | 14 +++++++-- .../src/pages/admin/views/problem/Problem.vue | 14 ++------- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/admin/components/Simditor.vue b/frontend/src/pages/admin/components/Simditor.vue index 2e1bfc51..0a7dbdd0 100644 --- a/frontend/src/pages/admin/components/Simditor.vue +++ b/frontend/src/pages/admin/components/Simditor.vue @@ -43,10 +43,6 @@ export default { type: String, default: "", }, - deferUpload: { - type: Boolean, - default: false, - }, }, data() { return { @@ -74,20 +70,18 @@ export default { // span: ['color'] }, }) - if (this.deferUpload) { - const uploader = this.editor.uploader - uploader._xhrUpload = (file) => { - const blobUrl = URL.createObjectURL(file.obj) - this.pendingImages[blobUrl] = file.obj - setTimeout(() => { - uploader.trigger("uploadprogress", [file, file.size, file.size]) - uploader.trigger("uploadsuccess", [ - file, - { success: true, file_path: blobUrl }, - ]) - uploader.trigger("uploadcomplete", [file, ""]) - }) - } + const uploader = this.editor.uploader + uploader._xhrUpload = (file) => { + const blobUrl = URL.createObjectURL(file.obj) + this.pendingImages[blobUrl] = file.obj + setTimeout(() => { + uploader.trigger("uploadprogress", [file, file.size, file.size]) + uploader.trigger("uploadsuccess", [ + file, + { success: true, file_path: blobUrl }, + ]) + uploader.trigger("uploadcomplete", [file, ""]) + }) } this.editor.on("valuechanged", (e, src) => { this.currentValue = this.editor.getValue() diff --git a/frontend/src/pages/admin/views/contest/Contest.vue b/frontend/src/pages/admin/views/contest/Contest.vue index 1516dee4..9630c607 100644 --- a/frontend/src/pages/admin/views/contest/Contest.vue +++ b/frontend/src/pages/admin/views/contest/Contest.vue @@ -19,7 +19,7 @@ *{{ $t("m.ContestDescription") }} - + @@ -247,9 +247,16 @@ export default { } }, methods: { - saveContest() { + async saveContest() { let funcName = this.$route.name === "edit-contest" ? "editContest" : "createContest" + try { + this.contest.description = + await this.$refs.descEditor.uploadPendingImages() + } catch (e) { + this.$error("Image upload failed: " + e.message) + return + } let data = Object.assign({}, this.contest) let ranges = [] for (let v of data.allowed_ip_ranges) { diff --git a/frontend/src/pages/admin/views/general/Announcement.vue b/frontend/src/pages/admin/views/general/Announcement.vue index 1ca9ccf7..221214db 100644 --- a/frontend/src/pages/admin/views/general/Announcement.vue +++ b/frontend/src/pages/admin/views/general/Announcement.vue @@ -143,7 +143,10 @@ - +
@@ -271,9 +274,16 @@ export default { }, // 提交编辑 // 默认传入MouseEvent - submitAnnouncement(data = undefined) { + async submitAnnouncement(data = undefined) { let funcName if (!data.title) { + try { + this.announcement.content = + await this.$refs.contentEditor.uploadPendingImages() + } catch (e) { + this.$error("Image upload failed: " + e.message) + return + } data = { id: this.currentAnnouncementId, title: this.announcement.title, diff --git a/frontend/src/pages/admin/views/problem/Problem.vue b/frontend/src/pages/admin/views/problem/Problem.vue index 5cf274db..9b4a70e5 100644 --- a/frontend/src/pages/admin/views/problem/Problem.vue +++ b/frontend/src/pages/admin/views/problem/Problem.vue @@ -110,11 +110,7 @@ >*{{ $t("m.Description") }} - +
@@ -124,7 +120,6 @@ >
@@ -136,7 +131,6 @@ >
@@ -229,11 +223,7 @@ - + From 8e8723b63ead37708549b4795cc1990b5180489b Mon Sep 17 00:00:00 2001 From: ymin431 Date: Mon, 2 Nov 2026 14:04:56 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=EC=A0=80=EC=9E=A5=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=95=84=EC=88=98=EA=B0=92=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/admin/views/contest/Contest.vue | 18 ++++++++++++++++++ .../pages/admin/views/general/Announcement.vue | 8 ++++++++ .../src/pages/admin/views/problem/Problem.vue | 12 ++++++++++++ 3 files changed, 38 insertions(+) diff --git a/frontend/src/pages/admin/views/contest/Contest.vue b/frontend/src/pages/admin/views/contest/Contest.vue index 9630c607..82b3ed27 100644 --- a/frontend/src/pages/admin/views/contest/Contest.vue +++ b/frontend/src/pages/admin/views/contest/Contest.vue @@ -250,6 +250,24 @@ export default { async saveContest() { let funcName = this.$route.name === "edit-contest" ? "editContest" : "createContest" + if (!this.contest.title) { + this.$error("Title is required") + return + } + if (!this.contest.description) { + this.$error("Description is required") + return + } + if (!this.contest.start_time || !this.contest.end_time) { + this.$error("Start time and end time are required") + return + } + if ( + new Date(this.contest.start_time) >= new Date(this.contest.end_time) + ) { + this.$error("Start time must occur earlier than end time") + return + } try { this.contest.description = await this.$refs.descEditor.uploadPendingImages() diff --git a/frontend/src/pages/admin/views/general/Announcement.vue b/frontend/src/pages/admin/views/general/Announcement.vue index 221214db..f2715ebc 100644 --- a/frontend/src/pages/admin/views/general/Announcement.vue +++ b/frontend/src/pages/admin/views/general/Announcement.vue @@ -277,6 +277,14 @@ export default { async submitAnnouncement(data = undefined) { let funcName if (!data.title) { + if (!this.announcement.title) { + this.$error("Title is required") + return + } + if (!this.announcement.content) { + this.$error("Content is required") + return + } try { this.announcement.content = await this.$refs.contentEditor.uploadPendingImages() diff --git a/frontend/src/pages/admin/views/problem/Problem.vue b/frontend/src/pages/admin/views/problem/Problem.vue index 9b4a70e5..a60e87f1 100644 --- a/frontend/src/pages/admin/views/problem/Problem.vue +++ b/frontend/src/pages/admin/views/problem/Problem.vue @@ -951,6 +951,18 @@ export default { ) }, async submit() { + if (!this.problem.title) { + this.$error("Title is required") + return + } + if ( + !this.problem.description || + !this.problem.input_description || + !this.problem.output_description + ) { + this.$error("Description, input and output description are required") + return + } if (this.problem.field == null) { this.$error("Field is required") return From a38fa9f7e47949c3bbc0fd25b0d324f7f8cb43ed Mon Sep 17 00:00:00 2001 From: ymin431 Date: Mon, 2 Nov 2026 20:08:12 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A4=91=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=9C=A0=EC=8B=A4=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/admin/components/Simditor.vue | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/admin/components/Simditor.vue b/frontend/src/pages/admin/components/Simditor.vue index 0a7dbdd0..f068379f 100644 --- a/frontend/src/pages/admin/components/Simditor.vue +++ b/frontend/src/pages/admin/components/Simditor.vue @@ -49,6 +49,8 @@ export default { editor: null, currentValue: this.value, pendingImages: {}, + uploadedPaths: {}, + uploadingPromise: null, } }, mounted() { @@ -107,29 +109,49 @@ export default { }, }, methods: { - async uploadPendingImages() { - let html = this.editor.getValue() - for (const [blobUrl, file] of Object.entries(this.pendingImages)) { - if (!html.includes(blobUrl)) continue - const form = new FormData() - form.append("image", file) - const res = await fetch("/api/admin/upload_image/", { - method: "POST", - body: form, - credentials: "same-origin", + uploadPendingImages() { + if (!this.uploadingPromise) { + this.uploadingPromise = this.uploadImagesInBody().finally(() => { + this.uploadingPromise = null }) - const data = await res.json() - if (!data.success) throw new Error(data.msg) - html = html.split(blobUrl).join(data.file_path) } - this.revokeAll() - this.editor.setValue(html) - this.currentValue = html - return html + return this.uploadingPromise + }, + async uploadImagesInBody() { + const done = new Set() + let blobUrls + while ( + (blobUrls = Object.keys(this.pendingImages).filter( + (url) => !done.has(url) && this.editor.getValue().includes(url), + )).length + ) { + for (const blobUrl of blobUrls) { + if (!this.uploadedPaths[blobUrl]) { + const form = new FormData() + form.append("image", this.pendingImages[blobUrl]) + const res = await fetch("/api/admin/upload_image/", { + method: "POST", + body: form, + credentials: "same-origin", + }) + const data = await res.json() + if (!data.success) throw new Error(data.msg) + this.uploadedPaths[blobUrl] = data.file_path + } + this.editor.body + .find("img") + .filter((_, img) => img.getAttribute("src") === blobUrl) + .attr("src", this.uploadedPaths[blobUrl]) + done.add(blobUrl) + this.currentValue = this.editor.getValue() + } + } + return this.editor.getValue() }, revokeAll() { Object.keys(this.pendingImages).forEach(URL.revokeObjectURL) this.pendingImages = {} + this.uploadedPaths = {} }, }, beforeDestroy() { From b0e30b16298bcb2db3d9e4642b59c60a0589712a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A0=EB=AF=BC?= <132874504+ymin431@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:34:32 +0900 Subject: [PATCH 6/9] =?UTF-8?q?#840=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=A7=80=EC=A0=95=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=A0=81=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#852)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/admin/components/Simditor.vue | 7 +++++++ frontend/src/pages/admin/views/contest/Contest.vue | 5 ++++- frontend/src/pages/admin/views/problem/Problem.vue | 5 ++++- .../ProblemDetailFlexibleContainer.vue | 1 - 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/admin/components/Simditor.vue b/frontend/src/pages/admin/components/Simditor.vue index f068379f..3dca5a38 100644 --- a/frontend/src/pages/admin/components/Simditor.vue +++ b/frontend/src/pages/admin/components/Simditor.vue @@ -161,3 +161,10 @@ export default { + + diff --git a/frontend/src/pages/admin/views/contest/Contest.vue b/frontend/src/pages/admin/views/contest/Contest.vue index 82b3ed27..5744cc47 100644 --- a/frontend/src/pages/admin/views/contest/Contest.vue +++ b/frontend/src/pages/admin/views/contest/Contest.vue @@ -541,7 +541,10 @@ export default { /deep/ .simditor { border-radius: 8px; border: 1px solid #dcdfe6; - overflow: hidden; + + .simditor-wrapper { + border-radius: 8px; + } .simditor-toolbar { border-radius: 8px 8px 0 0; diff --git a/frontend/src/pages/admin/views/problem/Problem.vue b/frontend/src/pages/admin/views/problem/Problem.vue index a60e87f1..06111b3b 100644 --- a/frontend/src/pages/admin/views/problem/Problem.vue +++ b/frontend/src/pages/admin/views/problem/Problem.vue @@ -2153,7 +2153,10 @@ export default { /deep/ .simditor { border-radius: 8px; border: 1px solid #dcdfe6; - overflow: hidden; + + .simditor-wrapper { + border-radius: 8px; + } .simditor-toolbar { border-radius: 8px 8px 0 0; diff --git a/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/ProblemDetailFlexibleContainer.vue b/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/ProblemDetailFlexibleContainer.vue index cfe7c44f..ce5e5178 100644 --- a/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/ProblemDetailFlexibleContainer.vue +++ b/frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/ProblemDetailFlexibleContainer.vue @@ -559,7 +559,6 @@ export default defineComponent({ /deep/ img { display: inline-block; - width: auto; max-width: ~"min(100%, 640px)"; height: auto; vertical-align: middle; From aa2662db76e65baf62e0fcbc59511872a3385763 Mon Sep 17 00:00:00 2001 From: ymin431 Date: Tue, 3 Nov 2026 11:28:24 +0900 Subject: [PATCH 7/9] =?UTF-8?q?#838=20=EB=AC=B8=EC=A0=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20zip=EC=97=90=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/problem/tests.py | 85 ++++++++++++++++++++++++++++++++++ backend/problem/views/admin.py | 50 ++++++++++++++++++-- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/backend/problem/tests.py b/backend/problem/tests.py index f096ad95..2c5d3b2d 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -1,13 +1,16 @@ import copy import hashlib +import io import json import os +import re import shutil from datetime import timedelta from zipfile import ZipFile import requests from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import DatabaseError from django.utils import timezone @@ -182,6 +185,88 @@ def test_upload_test_case_zip(self): self.assertEqual(f.read(), name + "\n" + name + "\n" + "end") +class ImportProblemAPITest(APITestCase): + + def setUp(self): + self.create_school_fixtures(college_id=1, college_name="Test", department_id=1, department_name="Test") + self.url = self.reverse("import_problem_api") + self.create_super_admin() + + def tearDown(self): + for problem in Problem.objects.all(): + for path in re.findall(rf"{settings.UPLOAD_PREFIX}/[\w.]+", problem.description + problem.hint): + os.remove(os.path.join(settings.UPLOAD_DIR, os.path.basename(path))) + + def problem_json(self, display_id, description, hint=""): + return { + "display_id": display_id, "title": f"title-{display_id}", + "description": {"format": "html", "value": description}, + "input_description": {"format": "html", "value": "input"}, + "output_description": {"format": "html", "value": "output"}, + "hint": {"format": "html", "value": hint}, + "test_case_score": None, "time_limit": 1000, "memory_limit": 256, + "samples": [{"input": "1", "output": "1"}], "template": {}, "spj": None, + "rule_type": "ACM", "source": "", "field": 0, "difficulty": "Mid", "answers": [], "tags": ["tag"], + } + + def import_zip(self, problems): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as f: + for problem_dir, (info, images) in problems.items(): + f.writestr(f"{problem_dir}/problem.json", json.dumps(info)) + f.writestr(f"{problem_dir}/testcase/1.in", "1") + f.writestr(f"{problem_dir}/testcase/1.out", "1") + for name, content in images.items(): + f.writestr(f"{problem_dir}/images/{name}", content) + file = SimpleUploadedFile("problems.zip", buffer.getvalue(), content_type="application/zip") + return self.client.post(self.url, data={"file": file}, format="multipart") + + def read_upload(self, path): + with open(os.path.join(settings.UPLOAD_DIR, os.path.basename(path)), "rb") as f: + return f.read() + + def test_import_images_in_zip(self): + resp = self.import_zip({ + "1": (self.problem_json("p1", ''), {"1.png": b"first"}), + "2": (self.problem_json("p2", '', ""), + {"1.png": b"second", "b.jpg": b"hint", "unused.png": b"unused"}), + }) + self.assertSuccess(resp) + self.assertEqual(resp.data["data"]["import_count"], 2) + + pattern = rf"{settings.UPLOAD_PREFIX}/\w+\.(?:png|jpg)" + first = Problem.objects.get(_id="p1") + second = Problem.objects.get(_id="p2") + self.assertNotIn("images/", first.description + second.description + second.hint) + self.assertIn('width="50"', second.description) + self.assertEqual(self.read_upload(re.search(pattern, first.description).group()), b"first") + self.assertEqual(self.read_upload(re.search(pattern, second.description).group()), b"second") + self.assertEqual(self.read_upload(re.search(pattern, second.hint).group()), b"hint") + + def test_missing_image_is_rejected(self): + resp = self.import_zip({"1": (self.problem_json("p1", ''), {"1.png": b"x"})}) + self.assertFailed(resp, "title-p1: images/2.png 파일이 zip에 없습니다") + self.assertFalse(Problem.objects.exists()) + + def test_unsupported_image_is_rejected(self): + resp = self.import_zip({"1": (self.problem_json("p1", ''), {"a.svg": b"x"})}) + self.assertFailed(resp, "title-p1: images/a.svg 는 지원하지 않는 이미지 형식입니다") + self.assertFalse(Problem.objects.exists()) + + def test_failure_rolls_back_all_problems_and_files(self): + uploads = set(os.listdir(settings.UPLOAD_DIR)) + test_cases = set(os.listdir(settings.TEST_CASE_DIR)) + resp = self.import_zip({ + "1": (self.problem_json("p1", ''), {"1.png": b"x"}), + "2": (self.problem_json("p2", ''), {"1.png": b"x"}), + "3": (self.problem_json("p3", ''), {}), + }) + self.assertFailed(resp, "title-p3: images/missing.png 파일이 zip에 없습니다") + self.assertFalse(Problem.objects.exists()) + self.assertEqual(set(os.listdir(settings.UPLOAD_DIR)), uploads) + self.assertEqual(set(os.listdir(settings.TEST_CASE_DIR)), test_cases) + + class ProblemAdminAPITest(APITestCase): def setUp(self): diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index 1127c47b..4fca4b9d 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -1,6 +1,7 @@ import hashlib import json import os +import re import shutil import tempfile import zipfile @@ -644,8 +645,47 @@ def get(self, request): class ImportProblemAPI(CSRFExemptAPIView, TestCaseZipProcessor): request_parsers = () + image_src_pattern = re.compile(r"""(]*?\bsrc\s*=\s*)(["'])(images/[^"']+)\2""", re.IGNORECASE) + image_fields = ("description", "input_description", "output_description", "hint") + image_suffixes = (".gif", ".jpg", ".jpeg", ".bmp", ".png") + + def collect_images(self, problem_info, problem_dir, name_list): + images = {} + for field in self.image_fields: + for _, _, src in self.image_src_pattern.findall(problem_info[field]["value"]): + if f"{problem_dir}/{src}" not in name_list: + raise APIError(f"{problem_info['title']}: {src} 파일이 zip에 없습니다") + if os.path.splitext(src)[-1].lower() not in self.image_suffixes: + raise APIError(f"{problem_info['title']}: {src} 는 지원하지 않는 이미지 형식입니다") + images[src] = f"{problem_dir}/{src}" + return images + + def save_images(self, zip_file, problem_info, images): + urls = {} + for src, path in images.items(): + name = rand_str(10) + os.path.splitext(src)[-1].lower() + target_path = os.path.join(settings.UPLOAD_DIR, name) + self.created_files.append(target_path) + with zip_file.open(path) as source, open(target_path, "wb") as target: + shutil.copyfileobj(source, target) + urls[src] = f"{settings.UPLOAD_PREFIX}/{name}" + for field in self.image_fields: + problem_info[field]["value"] = self.image_src_pattern.sub( + lambda m: f"{m.group(1)}{m.group(2)}{urls[m.group(3)]}{m.group(2)}", problem_info[field]["value"]) def post(self, request): + self.created_files = [] + try: + return self.import_problems(request) + except Exception: + for path in self.created_files: + if os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + elif os.path.exists(path): + os.remove(path) + raise + + def import_problems(self, request): form = UploadProblemForm(request.POST, request.FILES) if form.is_valid(): file = form.cleaned_data["file"] @@ -671,30 +711,34 @@ def post(self, request): for problem_dir in problem_dirs: json_path = f"{problem_dir}/problem.json" if json_path not in name_list: - return self.error(f"problem.json not found in directory: {problem_dir}") + raise APIError(f"problem.json not found in directory: {problem_dir}") with zip_file.open(json_path) as f: problem_info = json.load(f) serializer = ImportProblemSerializer(data=problem_info) if not serializer.is_valid(): - return self.error(f"Invalid problem format, error is {serializer.errors}") + raise APIError(f"Invalid problem format, error is {serializer.errors}") else: problem_info = serializer.data for item in problem_info["template"].keys(): if item not in SysOptions.language_names: - return self.error(f"Unsupported language {item}") + raise APIError(f"Unsupported language {item}") problem_info["display_id"] = problem_info["display_id"][:24] for k, v in problem_info["template"].items(): problem_info["template"][k] = build_problem_template(v["prepend"], v["template"], v["append"]) + images = self.collect_images(problem_info, problem_dir, name_list) + spj = problem_info["spj"] is not None rule_type = problem_info["rule_type"] test_case_score = problem_info["test_case_score"] # process test case _, test_case_id = self.process_zip(tmp_file, spj=spj, dir=f"{problem_dir}/testcase/") + self.created_files.append(os.path.join(settings.TEST_CASE_DIR, test_case_id)) + self.save_images(zip_file, problem_info, images) problem_obj = Problem.objects.create( _id=problem_info["display_id"], From 508b9502c4d6d9a94a9fb542ba69637dc7e179a3 Mon Sep 17 00:00:00 2001 From: ymin431 Date: Tue, 3 Nov 2026 13:40:58 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?HTML=20=ED=8C=8C=EC=84=9C=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/problem/tests.py | 15 ++++++++++++ backend/problem/views/admin.py | 42 ++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/backend/problem/tests.py b/backend/problem/tests.py index 2c5d3b2d..f29be8a4 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -243,6 +243,21 @@ def test_import_images_in_zip(self): self.assertEqual(self.read_upload(re.search(pattern, second.description).group()), b"second") self.assertEqual(self.read_upload(re.search(pattern, second.hint).group()), b"hint") + def test_import_images_with_unusual_html(self): + resp = self.import_zip({ + "1": (self.problem_json("p1", '

x

\n\na > b', + 'c'), + {"a.png": b"a", "b.png": b"b", "a&b.png": b"amp"}), + }) + self.assertSuccess(resp) + + problem = Problem.objects.get(_id="p1") + pattern = rf"{settings.UPLOAD_PREFIX}/\w+\.png" + self.assertNotIn("images/", problem.description + problem.hint) + self.assertEqual([self.read_upload(path) for path in re.findall(pattern, problem.description)], [b"a", b"b"]) + self.assertEqual(self.read_upload(re.search(pattern, problem.hint).group()), b"amp") + self.assertIn('alt="a > b"', problem.description) + def test_missing_image_is_rejected(self): resp = self.import_zip({"1": (self.problem_json("p1", ''), {"1.png": b"x"})}) self.assertFailed(resp, "title-p1: images/2.png 파일이 zip에 없습니다") diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index 4fca4b9d..0b867c47 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -1,10 +1,11 @@ import hashlib +import html import json import os -import re import shutil import tempfile import zipfile +from html.parser import HTMLParser from wsgiref.util import FileWrapper from django.conf import settings @@ -643,16 +644,40 @@ def get(self, request): return resp +class ImageTagParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.tags = [] + + def handle_starttag(self, tag, attrs): + if tag == "img": + self.tags.append((self.getpos(), self.get_starttag_text(), attrs)) + + class ImportProblemAPI(CSRFExemptAPIView, TestCaseZipProcessor): request_parsers = () - image_src_pattern = re.compile(r"""(]*?\bsrc\s*=\s*)(["'])(images/[^"']+)\2""", re.IGNORECASE) image_fields = ("description", "input_description", "output_description", "hint") image_suffixes = (".gif", ".jpg", ".jpeg", ".bmp", ".png") + def find_image_tags(self, value): + parser = ImageTagParser() + parser.feed(value) + parser.close() + line_starts = [0] + for line in value.split("\n")[:-1]: + line_starts.append(line_starts[-1] + len(line) + 1) + tags = [] + for (lineno, offset), text, attrs in parser.tags: + src = dict(attrs).get("src") or "" + if src.startswith("images/"): + start = line_starts[lineno - 1] + offset + tags.append((start, start + len(text), text, attrs, src)) + return tags + def collect_images(self, problem_info, problem_dir, name_list): images = {} for field in self.image_fields: - for _, _, src in self.image_src_pattern.findall(problem_info[field]["value"]): + for *_, src in self.find_image_tags(problem_info[field]["value"]): if f"{problem_dir}/{src}" not in name_list: raise APIError(f"{problem_info['title']}: {src} 파일이 zip에 없습니다") if os.path.splitext(src)[-1].lower() not in self.image_suffixes: @@ -670,8 +695,15 @@ def save_images(self, zip_file, problem_info, images): shutil.copyfileobj(source, target) urls[src] = f"{settings.UPLOAD_PREFIX}/{name}" for field in self.image_fields: - problem_info[field]["value"] = self.image_src_pattern.sub( - lambda m: f"{m.group(1)}{m.group(2)}{urls[m.group(3)]}{m.group(2)}", problem_info[field]["value"]) + value = problem_info[field]["value"] + for start, end, text, attrs, src in reversed(self.find_image_tags(value)): + attrs_html = "".join( + f" {key}" if key != "src" and val is None + else f' {key}="{html.escape(urls[src] if key == "src" else val)}"' + for key, val in attrs) + closing = " />" if text.rstrip().endswith("/>") else ">" + value = f"{value[:start]} Date: Tue, 3 Nov 2026 16:43:02 +0900 Subject: [PATCH 9/9] =?UTF-8?q?#841=20=EB=AC=B8=EC=A0=9C=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=98=A4=EB=A5=98=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EC=B2=B4=ED=99=94=20(#853)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #841 문제 가져오기 오류 메시지 구체화 * fix: SPJ 문제 점수 검증 및 테스트케이스 누락 검사 수정 --- backend/problem/tests.py | 174 ++++++++- backend/problem/views/admin.py | 354 ++++++++++++++---- .../admin/components/ImportErrorReport.vue | 163 ++++++++ .../admin/views/problem/ImportAndExport.vue | 57 ++- 4 files changed, 656 insertions(+), 92 deletions(-) create mode 100644 frontend/src/pages/admin/components/ImportErrorReport.vue diff --git a/backend/problem/tests.py b/backend/problem/tests.py index f29be8a4..2d79e6ad 100644 --- a/backend/problem/tests.py +++ b/backend/problem/tests.py @@ -210,17 +210,32 @@ def problem_json(self, display_id, description, hint=""): } def import_zip(self, problems): + files = {} + for problem_dir, (info, images) in problems.items(): + files[f"{problem_dir}/problem.json"] = json.dumps(info) + files[f"{problem_dir}/testcase/1.in"] = "1" + files[f"{problem_dir}/testcase/1.out"] = "1" + for name, content in images.items(): + files[f"{problem_dir}/images/{name}"] = content + return self.import_files(files) + + def import_files(self, files): buffer = io.BytesIO() with ZipFile(buffer, "w") as f: - for problem_dir, (info, images) in problems.items(): - f.writestr(f"{problem_dir}/problem.json", json.dumps(info)) - f.writestr(f"{problem_dir}/testcase/1.in", "1") - f.writestr(f"{problem_dir}/testcase/1.out", "1") - for name, content in images.items(): - f.writestr(f"{problem_dir}/images/{name}", content) - file = SimpleUploadedFile("problems.zip", buffer.getvalue(), content_type="application/zip") + for name, content in files.items(): + f.writestr(name, content) + return self.upload(buffer.getvalue()) + + def upload(self, content): + file = SimpleUploadedFile("problems.zip", content, content_type="application/zip") return self.client.post(self.url, data={"file": file}, format="multipart") + def import_errors(self, resp): + self.assertFailed(resp) + self.assertFalse(Problem.objects.exists()) + return [(error["dir"], error["location"], error["field"], error["message"], error["line"]) + for error in resp.data["data"]["errors"]] + def read_upload(self, path): with open(os.path.join(settings.UPLOAD_DIR, os.path.basename(path)), "rb") as f: return f.read() @@ -258,15 +273,141 @@ def test_import_images_with_unusual_html(self): self.assertEqual(self.read_upload(re.search(pattern, problem.hint).group()), b"amp") self.assertIn('alt="a > b"', problem.description) - def test_missing_image_is_rejected(self): - resp = self.import_zip({"1": (self.problem_json("p1", ''), {"1.png": b"x"})}) - self.assertFailed(resp, "title-p1: images/2.png 파일이 zip에 없습니다") - self.assertFalse(Problem.objects.exists()) + def test_image_errors(self): + resp = self.import_zip({ + "1": (self.problem_json("p1", ''), {"a.svg": b"x"}), + }) + self.assertEqual(self.import_errors(resp), [ + ("1", "images", "description.value", "images/2.png 파일이 없습니다", 1), + ("1", "images", "description.value", "images/a.svg는 지원하지 않는 형식입니다 (gif, jpg, jpeg, bmp, png)", 1), + ]) + errors = resp.data["data"]["errors"] + self.assertEqual(errors[0]["title"], "title-p1") + self.assertEqual(errors[0]["detail"], "") + + def test_zip_level_errors(self): + cases = [ + (self.client.post(self.url, data={}, format="multipart"), "업로드할 zip 파일을 선택해주세요"), + (self.upload(b"not a zip"), "올바른 zip 파일이 아닙니다"), + (self.import_files({"readme.txt": "x"}), "zip에 problem.json이 없습니다"), + (self.import_files({"problem.json": "{}"}), "problem.json은 문제 폴더 안에 있어야 합니다 (예: 1/problem.json)"), + ] + for resp, message in cases: + self.assertEqual(self.import_errors(resp), [("", "zip", "", message, None)]) + self.assertEqual(resp.data["data"]["message"], message) + + def test_dirs_without_problem_json(self): + resp = self.import_files({ + "1/problem.json": json.dumps(self.problem_json("p1", "")), + "1/testcase/1.in": "1", "1/testcase/1.out": "1", + "2/testcase/1.in": "1", + "3/readme.txt": "x", + "__MACOSX/1/._problem.json": "x", + }) + self.assertEqual(self.import_errors(resp), [ + ("2", "problem.json", "", "problem.json 파일이 없습니다", None), + ("3", "problem.json", "", "problem.json 파일이 없습니다", None), + ]) + resp = self.import_files({"export/1/testcase/1.in": "1", "export/2/testcase/1.in": "1"}) + self.assertEqual([error[0] for error in self.import_errors(resp)], ["export/1", "export/2"]) + + def test_collects_errors_from_all_problems(self): + invalid = self.problem_json("p2", "") + invalid["time_limit"] = 0 + del invalid["samples"] + language = self.problem_json("p3", "") + language["template"] = {"Brainfuck": {"prepend": "", "template": "", "append": ""}} + resp = self.import_files({ + "1/problem.json": "{\n,", + "2/problem.json": json.dumps(invalid, indent=2), + "3/problem.json": json.dumps(language), + "10/problem.json": json.dumps(self.problem_json("p10", "")), + "10/testcase/1.in": "1", + "10/testcase/1.out": "1", + }) + self.assertEqual(self.import_errors(resp), [ + ("1", "problem.json", "", "JSON 문법 오류: 키는 큰따옴표로 감싸야 합니다 (마지막 쉼표가 남아 있는지 확인해주세요)", 2), + ("2", "problem.json", "time_limit", "이 값이 1보다 크거나 같은지 확인하세요.", 21), + ("2", "problem.json", "samples", "이 필드는 필수 항목입니다.", None), + ("3", "problem.json", "template.Brainfuck", "지원하지 않는 언어입니다 (Brainfuck)", 1), + ("3", "testcase", "", "테스트케이스가 없습니다 (1.in, 1.out부터 필요)", None), + ]) + errors = resp.data["data"]["errors"] + self.assertEqual(resp.data["data"]["message"], "3개 문제에서 5개 오류가 발생했습니다") + self.assertEqual(errors[1]["title"], "title-p2") + self.assertEqual(errors[0]["detail"], " 1 | {\n> 2 | ,\n | ^") + self.assertEqual(errors[1]["detail"], "") + self.assertEqual(errors[0]["column"], 1) + self.assertEqual(errors[2]["detail"], "") + + def spj_problem_json(self, display_id): + info = self.problem_json(display_id, "") + info["rule_type"] = "OI" + info["spj"] = {"code": "int main() { return 0; }", "language": "C"} + info["test_case_score"] = [{"score": 50, "input_name": "1.in", "output_name": "-"}, + {"score": 50, "input_name": "2.in", "output_name": "-"}] + return info + + def test_import_oi_spj_problem(self): + resp = self.import_files({ + "1/problem.json": json.dumps(self.spj_problem_json("p1")), + "1/testcase/1.in": "1", "1/testcase/2.in": "2", + }) + self.assertSuccess(resp) + problem = Problem.objects.get(_id="p1") + self.assertTrue(problem.spj) + self.assertEqual(problem.total_score, 100) - def test_unsupported_image_is_rejected(self): - resp = self.import_zip({"1": (self.problem_json("p1", ''), {"a.svg": b"x"})}) - self.assertFailed(resp, "title-p1: images/a.svg 는 지원하지 않는 이미지 형식입니다") - self.assertFalse(Problem.objects.exists()) + def test_spj_ignores_out_files(self): + resp = self.import_files({ + "1/problem.json": json.dumps(self.spj_problem_json("p1")), + "1/testcase/1.in": "1", "1/testcase/2.in": "2", "1/testcase/5.out": "5", + }) + self.assertSuccess(resp) + + def test_large_test_case_number(self): + resp = self.import_files({ + "1/problem.json": json.dumps(self.problem_json("p1", "")), + "1/testcase/1.in": "1", "1/testcase/1.out": "1", "1/testcase/1000000000.in": "x", + }) + self.assertEqual(self.import_errors(resp), [ + ("1", "testcase", "", "2.in, 2.out, 3.in, 3.out, 4.in, 4.out, 5.in, 5.out, 6.in, 6.out 외 1999999987개 파일이 없습니다", + None), + ]) + + def test_test_case_errors(self): + oi = self.problem_json("p2", "") + oi["rule_type"] = "OI" + scored = self.problem_json("p3", "") + scored["rule_type"] = "OI" + scored["test_case_score"] = [{"score": 10, "input_name": "1.in", "output_name": "2.out"}] + resp = self.import_files({ + "1/problem.json": json.dumps(self.problem_json("p1", "")), + "1/testcase/1.in": "1", "1/testcase/1.out": "1", "1/testcase/2.in": "2", + "1/testcase/4.in": "4", "1/testcase/4.out": "4", + "2/problem.json": json.dumps(oi), + "2/testcase/1.in": "1", "2/testcase/1.out": "1", + "3/problem.json": json.dumps(scored), + "3/testcase/1.in": "1", "3/testcase/1.out": "1", + }) + self.assertEqual(self.import_errors(resp), [ + ("1", "testcase", "", "2.out, 3.in, 3.out 파일이 없습니다", None), + ("2", "problem.json", "test_case_score", "OI 문제는 테스트케이스별 점수가 필요합니다", 1), + ("3", "problem.json", "test_case_score[0].output_name", "testcase 폴더에 2.out 파일이 없습니다", 1), + ]) + + def test_duplicate_display_id(self): + self.assertSuccess(self.import_zip({"1": (self.problem_json("p1", ""), {})})) + resp = self.import_zip({ + "1": (self.problem_json("p1", ""), {}), + "2": (self.problem_json("p2", ""), {}), + "3": (self.problem_json("p2", ""), {}), + }) + self.assertFailed(resp) + self.assertEqual(Problem.objects.count(), 1) + errors = [(error["dir"], error["message"]) for error in resp.data["data"]["errors"]] + self.assertEqual(errors, [("1", "Problem ID 'p1'인 문제가 이미 있습니다"), + ("3", "2 폴더의 문제와 Problem ID 'p2'가 겹칩니다")]) def test_failure_rolls_back_all_problems_and_files(self): uploads = set(os.listdir(settings.UPLOAD_DIR)) @@ -276,8 +417,7 @@ def test_failure_rolls_back_all_problems_and_files(self): "2": (self.problem_json("p2", ''), {"1.png": b"x"}), "3": (self.problem_json("p3", ''), {}), }) - self.assertFailed(resp, "title-p3: images/missing.png 파일이 zip에 없습니다") - self.assertFalse(Problem.objects.exists()) + self.assertEqual(self.import_errors(resp), [("3", "images", "description.value", "images/missing.png 파일이 없습니다", 1)]) self.assertEqual(set(os.listdir(settings.UPLOAD_DIR)), uploads) self.assertEqual(set(os.listdir(settings.TEST_CASE_DIR)), test_cases) diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index 0b867c47..5af525fa 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -2,10 +2,13 @@ import html import json import os +import re import shutil import tempfile import zipfile from html.parser import HTMLParser +from itertools import islice +from json.decoder import scanstring from wsgiref.util import FileWrapper from django.conf import settings @@ -13,6 +16,7 @@ from django.db.models import Q from django.http import StreamingHttpResponse, FileResponse from django.shortcuts import get_object_or_404 +from django.utils import translation from account.decorators import problem_permission_required, ensure_created_by from contest.models import Contest, ContestStatus @@ -658,6 +662,7 @@ class ImportProblemAPI(CSRFExemptAPIView, TestCaseZipProcessor): request_parsers = () image_fields = ("description", "input_description", "output_description", "hint") image_suffixes = (".gif", ".jpg", ".jpeg", ".bmp", ".png") + missing_test_case_limit = 10 def find_image_tags(self, value): parser = ImageTagParser() @@ -674,17 +679,176 @@ def find_image_tags(self, value): tags.append((start, start + len(text), text, attrs, src)) return tags + json_error_messages = { + "Expecting value": "값이 와야 합니다", + "Expecting ',' delimiter": "쉼표(,)가 빠졌습니다", + "Expecting ':' delimiter": "콜론(:)이 빠졌습니다", + "Expecting property name enclosed in double quotes": "키는 큰따옴표로 감싸야 합니다 (마지막 쉼표가 남아 있는지 확인해주세요)", + "Unterminated string starting at": "문자열이 닫히지 않았습니다", + "Invalid control character at": "문자열 안에 줄바꿈이나 탭 같은 제어 문자가 있습니다", + "Invalid \\escape": "잘못된 이스케이프 문자입니다", + "Extra data": "JSON이 끝난 뒤에 내용이 더 있습니다", + } + + def fail(self, message): + raise APIError({"message": message, "errors": [self.error_item("", "", "zip", message)]}) + + def error_item(self, problem_dir, title, location, message, field="", line=None, column=None, detail=""): + return {"dir": problem_dir, "title": title, "location": location, "field": field, "message": message, + "line": line, "column": column, "detail": detail} + + def add_error(self, problem_dir, title, location, message, **kwargs): + self.errors.append(self.error_item(problem_dir, title, location, message, **kwargs)) + + def add_field_error(self, problem_dir, title, path, message, location="problem.json", target=None): + field = "".join(f"[{key}]" if isinstance(key, int) else f".{key}" for key in path).lstrip(".") + pos = self.find_json_position(self.problem_text, path) + if pos is not None and target and self.problem_text.find(target, pos) != -1: + pos = self.problem_text.find(target, pos) + line = None if pos is None else self.json_location(self.problem_text, pos)[0] + self.add_error(problem_dir, title, location, message, field=field, line=line) + + def flatten_errors(self, errors, path=()): + if isinstance(errors, dict): + for key, value in errors.items(): + yield from self.flatten_errors(value, path + (key,)) + elif errors and all(isinstance(item, str) for item in errors): + yield path, errors[0] + else: + for index, value in enumerate(errors): + if value: + yield from self.flatten_errors(value, path + (index,)) + + def find_json_position(self, text, path): + decoder = json.JSONDecoder() + whitespace = re.compile(r"\s*") + + def skip(pos): + return whitespace.match(text, pos).end() + + pos = skip(0) + found = None + for key in path: + if text[pos] == "{" and isinstance(key, str): + pos = skip(pos + 1) + while text[pos] == '"': + name, pos = scanstring(text, pos + 1) + pos = skip(skip(pos) + 1) + if name == key: + break + pos = skip(decoder.raw_decode(text, pos)[1]) + if text[pos] == ",": + pos = skip(pos + 1) + else: + return found + elif text[pos] == "[" and isinstance(key, int): + pos = skip(pos + 1) + for _ in range(key): + if text[pos] == "]": + return found + pos = skip(decoder.raw_decode(text, pos)[1]) + if text[pos] == ",": + pos = skip(pos + 1) + if text[pos] == "]": + return found + else: + return found + found = pos + return found + + def code_snippet(self, text, lineno, colno, width=100): + lines = text.split("\n") + start, end = max(lineno - 3, 0), min(lineno + 2, len(lines)) + digits = len(str(end)) + snippet = [] + for number in range(start + 1, end + 1): + line = lines[number - 1] + offset = 0 + if len(line) > width: + center = colno - 1 if number == lineno else 0 + offset = max(min(center - width // 2, len(line) - width), 0) + line = ("…" if offset else "") + line[offset:offset + width] + ("…" if offset + width < len(line) else "") + snippet.append(f"{'>' if number == lineno else ' '} {number:>{digits}} | {line}") + if number == lineno: + snippet.append(f" {' ' * digits} | {' ' * (colno - 1 - offset + (1 if offset else 0))}^") + return "\n".join(snippet) + + def json_location(self, text, pos): + lineno = text.count("\n", 0, pos) + 1 + colno = pos - text.rfind("\n", 0, pos) + return lineno, colno + def collect_images(self, problem_info, problem_dir, name_list): images = {} for field in self.image_fields: for *_, src in self.find_image_tags(problem_info[field]["value"]): if f"{problem_dir}/{src}" not in name_list: - raise APIError(f"{problem_info['title']}: {src} 파일이 zip에 없습니다") - if os.path.splitext(src)[-1].lower() not in self.image_suffixes: - raise APIError(f"{problem_info['title']}: {src} 는 지원하지 않는 이미지 형식입니다") - images[src] = f"{problem_dir}/{src}" + self.add_field_error(problem_dir, problem_info["title"], (field, "value"), + f"{src} 파일이 없습니다", location="images", target=src) + elif os.path.splitext(src)[-1].lower() not in self.image_suffixes: + suffixes = ", ".join(suffix[1:] for suffix in self.image_suffixes) + self.add_field_error(problem_dir, problem_info["title"], (field, "value"), + f"{src}는 지원하지 않는 형식입니다 ({suffixes})", location="images", + target=src) + else: + images[src] = f"{problem_dir}/{src}" return images + def check_test_cases(self, problem_info, problem_dir, name_list): + prefix = f"{problem_dir}/testcase/" + spj = problem_info["spj"] is not None + suffixes = (".in",) if spj else (".in", ".out") + names = {name[len(prefix):] for name in name_list if name.startswith(prefix)} + if "1.in" not in names: + required = "1.in" if spj else "1.in, 1.out" + self.add_error(problem_dir, problem_info["title"], "testcase", f"테스트케이스가 없습니다 ({required}부터 필요)") + return set() + numbers = sorted({int(name.split(".")[0]) for name in names + if re.fullmatch(r"\d+\.(in|out)", name) and name.endswith(suffixes)}) + + def missing_names(): + previous = 0 + for number in numbers: + for gap in range(previous + 1, number): + for suffix in suffixes: + yield f"{gap}{suffix}" + for suffix in suffixes: + if f"{number}{suffix}" not in names: + yield f"{number}{suffix}" + previous = number + + missing_count = sum(f"{number}{suffix}" not in names for number in numbers for suffix in suffixes) + missing_count += (numbers[-1] - len(numbers)) * len(suffixes) + if missing_count: + shown = list(islice(missing_names(), self.missing_test_case_limit)) + rest = f" 외 {missing_count - len(shown)}개" if missing_count > len(shown) else "" + self.add_error(problem_dir, problem_info["title"], "testcase", f"{', '.join(shown)}{rest} 파일이 없습니다") + return names + + def check_test_case_score(self, problem_info, problem_dir, test_cases): + if problem_info["rule_type"] != ProblemRuleType.OI: + return + if not problem_info["test_case_score"]: + self.add_field_error(problem_dir, problem_info["title"], ("test_case_score",), + "OI 문제는 테스트케이스별 점수가 필요합니다") + return + keys = ("input_name",) if problem_info["spj"] is not None else ("input_name", "output_name") + for index, item in enumerate(problem_info["test_case_score"]): + for key in keys: + if test_cases and item[key] not in test_cases: + self.add_field_error(problem_dir, problem_info["title"], ("test_case_score", index, key), + f"testcase 폴더에 {item[key]} 파일이 없습니다") + + def check_display_id(self, problem_info, problem_dir, display_ids): + display_id = problem_info["display_id"] + if display_id in display_ids: + self.add_field_error(problem_dir, problem_info["title"], ("display_id",), + f"{display_ids[display_id]} 폴더의 문제와 Problem ID '{display_id}'가 겹칩니다") + elif Problem.objects.filter(_id=display_id).exists(): + self.add_field_error(problem_dir, problem_info["title"], ("display_id",), + f"Problem ID '{display_id}'인 문제가 이미 있습니다") + display_ids.setdefault(display_id, problem_dir) + def save_images(self, zip_file, problem_info, images): urls = {} for src, path in images.items(): @@ -726,11 +890,20 @@ def import_problems(self, request): for chunk in file: f.write(chunk) else: - return self.error("Upload failed") + self.fail("업로드할 zip 파일을 선택해주세요") + + try: + zip_file = zipfile.ZipFile(tmp_file, "r") + except zipfile.BadZipFile: + self.fail("올바른 zip 파일이 아닙니다") count = 0 - with zipfile.ZipFile(tmp_file, "r") as zip_file: + self.errors = [] + display_ids = {} + with zip_file: name_list = zip_file.namelist() + if "problem.json" in name_list: + self.fail("problem.json은 문제 폴더 안에 있어야 합니다 (예: 1/problem.json)") # 공백이 있는 경우도 처리할 수 있도록 수정 problem_dirs = set() @@ -738,72 +911,119 @@ def import_problems(self, request): parts = name.split('/') if len(parts) >= 2 and parts[-1] == 'problem.json': problem_dirs.add('/'.join(parts[:-1])) + missing_dirs = self.find_dirs_without_problem_json(name_list, problem_dirs) + if not problem_dirs and not missing_dirs: + self.fail("zip에 problem.json이 없습니다") + for problem_dir in missing_dirs: + self.add_error(problem_dir, "", "problem.json", "problem.json 파일이 없습니다") with transaction.atomic(): - for problem_dir in problem_dirs: - json_path = f"{problem_dir}/problem.json" - if json_path not in name_list: - raise APIError(f"problem.json not found in directory: {problem_dir}") - - with zip_file.open(json_path) as f: - problem_info = json.load(f) - serializer = ImportProblemSerializer(data=problem_info) - if not serializer.is_valid(): - raise APIError(f"Invalid problem format, error is {serializer.errors}") - else: - problem_info = serializer.data - for item in problem_info["template"].keys(): - if item not in SysOptions.language_names: - raise APIError(f"Unsupported language {item}") - - problem_info["display_id"] = problem_info["display_id"][:24] - for k, v in problem_info["template"].items(): - problem_info["template"][k] = build_problem_template(v["prepend"], v["template"], - v["append"]) - - images = self.collect_images(problem_info, problem_dir, name_list) - - spj = problem_info["spj"] is not None - rule_type = problem_info["rule_type"] - test_case_score = problem_info["test_case_score"] - - # process test case - _, test_case_id = self.process_zip(tmp_file, spj=spj, dir=f"{problem_dir}/testcase/") - self.created_files.append(os.path.join(settings.TEST_CASE_DIR, test_case_id)) - self.save_images(zip_file, problem_info, images) - - problem_obj = Problem.objects.create( - _id=problem_info["display_id"], - title=problem_info["title"], - description=problem_info["description"]["value"], - input_description=problem_info["input_description"]["value"], - output_description=problem_info["output_description"]["value"], - hint=problem_info["hint"]["value"], - test_case_score=test_case_score if test_case_score else [], - time_limit=problem_info["time_limit"], - memory_limit=problem_info["memory_limit"], - samples=problem_info["samples"], - template=problem_info["template"], - rule_type=problem_info["rule_type"], - source=problem_info["source"], - spj=spj, - spj_code=problem_info["spj"]["code"] if spj else None, - spj_language=problem_info["spj"]["language"] if spj else None, - spj_version=rand_str(8) if spj else "", - languages=SysOptions.language_names, - created_by=request.user, - visible=True, - field=problem_info["field"], - difficulty=get_difficulty(problem_info["difficulty"]), - total_score=sum( - item["score"] for item in test_case_score) if rule_type == ProblemRuleType.OI else 0, - test_case_id=test_case_id) - for tag_name in problem_info["tags"]: - tag_obj, _ = ProblemTag.objects.get_or_create(name=tag_name) - problem_obj.tags.add(tag_obj) + for problem_dir in sorted(problem_dirs, key=natural_sort_key): + if self.import_problem(request, zip_file, tmp_file, problem_dir, name_list, display_ids): count += 1 + if self.errors: + problem_count = len({error["dir"] for error in self.errors}) + raise APIError({"message": f"{problem_count}개 문제에서 {len(self.errors)}개 오류가 발생했습니다", + "errors": self.errors}) return self.success({"import_count": count}) + def find_dirs_without_problem_json(self, name_list, problem_dirs): + dirs = set() + for name in name_list: + parts = name.split("/") + if parts[0] == "__MACOSX": + continue + for index, part in enumerate(parts[:-1]): + if part in ("testcase", "images"): + dirs.add("/".join(parts[:index])) + break + else: + if len(parts) >= 2: + dirs.add(parts[0]) + return sorted((d for d in dirs if d and d not in problem_dirs + and not any(p.startswith(d + "/") for p in problem_dirs)), key=natural_sort_key) + + def import_problem(self, request, zip_file, tmp_file, problem_dir, name_list, display_ids): + try: + with zip_file.open(f"{problem_dir}/problem.json") as f: + text = f.read().decode("utf-8-sig") + except UnicodeDecodeError: + self.add_error(problem_dir, "", "problem.json", "UTF-8로 인코딩된 파일이 아닙니다") + return False + try: + raw_info = json.loads(text) + except json.JSONDecodeError as e: + reason = self.json_error_messages.get(e.msg, e.msg) + self.add_error(problem_dir, "", "problem.json", f"JSON 문법 오류: {reason}", line=e.lineno, + column=e.colno, detail=self.code_snippet(text, e.lineno, e.colno)) + return False + + self.problem_text = text + title = raw_info.get("title") if isinstance(raw_info, dict) else None + title = title if isinstance(title, str) else "" + serializer = ImportProblemSerializer(data=raw_info) + with translation.override("ko"): + valid = serializer.is_valid() + if not valid: + for path, message in self.flatten_errors(serializer.errors): + self.add_field_error(problem_dir, title, path, str(message)) + return False + + problem_info = serializer.data + for item in problem_info["template"].keys(): + if item not in SysOptions.language_names: + self.add_field_error(problem_dir, title, ("template", item), f"지원하지 않는 언어입니다 ({item})") + + problem_info["display_id"] = problem_info["display_id"][:24] + for k, v in problem_info["template"].items(): + problem_info["template"][k] = build_problem_template(v["prepend"], v["template"], v["append"]) + + test_cases = self.check_test_cases(problem_info, problem_dir, name_list) + self.check_test_case_score(problem_info, problem_dir, test_cases) + images = self.collect_images(problem_info, problem_dir, name_list) + self.check_display_id(problem_info, problem_dir, display_ids) + if self.errors: + return False + + spj = problem_info["spj"] is not None + rule_type = problem_info["rule_type"] + test_case_score = problem_info["test_case_score"] + + # process test case + _, test_case_id = self.process_zip(tmp_file, spj=spj, dir=f"{problem_dir}/testcase/") + self.created_files.append(os.path.join(settings.TEST_CASE_DIR, test_case_id)) + self.save_images(zip_file, problem_info, images) + + problem_obj = Problem.objects.create( + _id=problem_info["display_id"], + title=problem_info["title"], + description=problem_info["description"]["value"], + input_description=problem_info["input_description"]["value"], + output_description=problem_info["output_description"]["value"], + hint=problem_info["hint"]["value"], + test_case_score=test_case_score if test_case_score else [], + time_limit=problem_info["time_limit"], + memory_limit=problem_info["memory_limit"], + samples=problem_info["samples"], + template=problem_info["template"], + rule_type=problem_info["rule_type"], + source=problem_info["source"], + spj=spj, + spj_code=problem_info["spj"]["code"] if spj else None, + spj_language=problem_info["spj"]["language"] if spj else None, + spj_version=rand_str(8) if spj else "", + languages=SysOptions.language_names, + created_by=request.user, + visible=True, + field=problem_info["field"], + difficulty=get_difficulty(problem_info["difficulty"]), + total_score=sum(item["score"] for item in test_case_score) if rule_type == ProblemRuleType.OI else 0, + test_case_id=test_case_id) + for tag_name in problem_info["tags"]: + tag_obj, _ = ProblemTag.objects.get_or_create(name=tag_name) + problem_obj.tags.add(tag_obj) + return True + class FPSProblemImport(CSRFExemptAPIView): request_parsers = () diff --git a/frontend/src/pages/admin/components/ImportErrorReport.vue b/frontend/src/pages/admin/components/ImportErrorReport.vue new file mode 100644 index 00000000..b48304d5 --- /dev/null +++ b/frontend/src/pages/admin/components/ImportErrorReport.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/frontend/src/pages/admin/views/problem/ImportAndExport.vue b/frontend/src/pages/admin/views/problem/ImportAndExport.vue index 33e85fab..7b947a1f 100644 --- a/frontend/src/pages/admin/views/problem/ImportAndExport.vue +++ b/frontend/src/pages/admin/views/problem/ImportAndExport.vue @@ -75,8 +75,8 @@ :limit="3" :on-change="onFile1Change" :auto-upload="false" - :on-success="uploadSucceeded" - :on-error="uploadFailed" + :on-success="(response) => uploadSucceeded('QDU', response)" + :on-error="(err) => uploadFailed('QDU', err)" > {{ $t("m.Button_Upload") }} + @@ -106,8 +111,8 @@ :limit="3" :on-change="onFile2Change" :auto-upload="false" - :on-success="uploadSucceeded" - :on-error="uploadFailed" + :on-success="(response) => uploadSucceeded('FPS', response)" + :on-error="(err) => uploadFailed('FPS', err)" > {{ $t("m.Button_Upload") }} +