diff --git a/backend/problem/tests.py b/backend/problem/tests.py index f096ad95..2d79e6ad 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,243 @@ 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): + 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 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() + + 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_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_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_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)) + 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.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) + + class ProblemAdminAPITest(APITestCase): def setUp(self): diff --git a/backend/problem/views/admin.py b/backend/problem/views/admin.py index 1127c47b..5af525fa 100644 --- a/backend/problem/views/admin.py +++ b/backend/problem/views/admin.py @@ -1,9 +1,14 @@ import hashlib +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 @@ -11,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 @@ -642,10 +648,240 @@ 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_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() + 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 + + 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: + 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(): + 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: + 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]}= 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: - return self.error(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}") - 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}") - - 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"]) - - 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/") - - 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/components/Simditor.vue b/frontend/src/pages/admin/components/Simditor.vue index 2c1eee5c..3dca5a38 100644 --- a/frontend/src/pages/admin/components/Simditor.vue +++ b/frontend/src/pages/admin/components/Simditor.vue @@ -48,6 +48,9 @@ export default { return { editor: null, currentValue: this.value, + pendingImages: {}, + uploadedPaths: {}, + uploadingPromise: null, } }, mounted() { @@ -69,6 +72,19 @@ export default { // span: ['color'] }, }) + 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,7 +108,63 @@ export default { } }, }, + methods: { + uploadPendingImages() { + if (!this.uploadingPromise) { + this.uploadingPromise = this.uploadImagesInBody().finally(() => { + this.uploadingPromise = null + }) + } + 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() { + this.revokeAll() + }, } + + diff --git a/frontend/src/pages/admin/views/contest/Contest.vue b/frontend/src/pages/admin/views/contest/Contest.vue index 1516dee4..5744cc47 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,34 @@ export default { } }, methods: { - saveContest() { + 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() + } 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) { @@ -516,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/general/Announcement.vue b/frontend/src/pages/admin/views/general/Announcement.vue index 1ca9ccf7..f2715ebc 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,24 @@ export default { }, // 提交编辑 // 默认传入MouseEvent - submitAnnouncement(data = undefined) { + 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() + } 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/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") }} +