diff --git a/backend/app/routers/problems.py b/backend/app/routers/problems.py index bcca656..814492c 100644 --- a/backend/app/routers/problems.py +++ b/backend/app/routers/problems.py @@ -15,6 +15,8 @@ from app.schemas.problem import ( SampleTestResponse, TestCaseCreate, TestCaseResponse, + BulkTestCaseImport, + BulkImportResult, ) from app.dependencies import get_current_user, get_current_admin @@ -286,3 +288,107 @@ async def delete_test_case( await db.delete(test_case) await db.commit() + + +@router.post("/{problem_id}/test-cases/bulk", response_model=BulkImportResult) +async def bulk_import_test_cases( + problem_id: int, + import_data: BulkTestCaseImport, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """ + Bulk import test cases from text format. + + Format example: + ``` + 1 2 + --- + 3 + === + 5 10 + --- + 15 + ``` + + Supports both single-line and multi-line input/output. + """ + # Check if problem exists + result = await db.execute(select(Problem).where(Problem.id == problem_id)) + problem = result.scalar_one_or_none() + if not problem: + raise HTTPException(status_code=404, detail="Problem not found") + + # Get current max order_index + result = await db.execute( + select(TestCase.order_index) + .where(TestCase.problem_id == problem_id) + .order_by(TestCase.order_index.desc()) + .limit(1) + ) + max_order = result.scalar() or -1 + + # Parse the text + text = import_data.text.strip() + if not text: + raise HTTPException(status_code=400, detail="Empty text provided") + + # Split by test delimiter + test_blocks = text.split(import_data.test_delimiter) + created_tests = [] + + for i, block in enumerate(test_blocks): + block = block.strip() + if not block: + continue + + # Split by IO delimiter + parts = block.split(import_data.io_delimiter) + if len(parts) != 2: + raise HTTPException( + status_code=400, + detail=f"Test #{i + 1}: Expected exactly one '{import_data.io_delimiter}' delimiter separating input and output, found {len(parts) - 1}" + ) + + input_data = parts[0].strip() + output_data = parts[1].strip() + + if not input_data: + raise HTTPException( + status_code=400, + detail=f"Test #{i + 1}: Empty input" + ) + if not output_data: + raise HTTPException( + status_code=400, + detail=f"Test #{i + 1}: Empty output" + ) + + # Determine if this should be a sample + is_sample = import_data.mark_first_as_sample and i < import_data.sample_count + + max_order += 1 + test_case = TestCase( + problem_id=problem_id, + input=input_data, + expected_output=output_data, + is_sample=is_sample, + points=0, + order_index=max_order, + ) + db.add(test_case) + created_tests.append(test_case) + + if not created_tests: + raise HTTPException(status_code=400, detail="No valid test cases found in text") + + await db.commit() + + # Refresh all to get IDs + for test in created_tests: + await db.refresh(test) + + return BulkImportResult( + created_count=len(created_tests), + test_cases=[TestCaseResponse.model_validate(t) for t in created_tests] + ) diff --git a/backend/app/schemas/problem.py b/backend/app/schemas/problem.py index 7aa665c..243021f 100644 --- a/backend/app/schemas/problem.py +++ b/backend/app/schemas/problem.py @@ -10,6 +10,35 @@ class TestCaseCreate(BaseModel): order_index: int = 0 +class BulkTestCaseImport(BaseModel): + """ + Bulk import test cases from text. + + Format: + - Tests separated by test_delimiter (default: ===) + - Input and output separated by io_delimiter (default: ---) + + Example: + 1 2 + --- + 3 + === + 5 10 + --- + 15 + """ + text: str + test_delimiter: str = "===" + io_delimiter: str = "---" + mark_first_as_sample: bool = True # Mark first N tests as samples + sample_count: int = 1 # How many tests to mark as samples + + +class BulkImportResult(BaseModel): + created_count: int + test_cases: list["TestCaseResponse"] + + class TestCaseResponse(BaseModel): id: int input: str diff --git a/frontend/src/app/admin/contests/[id]/problems/[problemId]/tests/page.tsx b/frontend/src/app/admin/contests/[id]/problems/[problemId]/tests/page.tsx index 1873831..a1d911c 100644 --- a/frontend/src/app/admin/contests/[id]/problems/[problemId]/tests/page.tsx +++ b/frontend/src/app/admin/contests/[id]/problems/[problemId]/tests/page.tsx @@ -18,14 +18,23 @@ export default function ProblemTestsPage() { const [testCases, setTestCases] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); const [showForm, setShowForm] = useState(false); + const [showBulkImport, setShowBulkImport] = useState(false); const [editingTest, setEditingTest] = useState(null); const [formData, setFormData] = useState({ input: "", expected_output: "", is_sample: false, }); + const [bulkImportData, setBulkImportData] = useState({ + text: "", + test_delimiter: "===", + io_delimiter: "---", + mark_first_as_sample: true, + sample_count: 1, + }); const [isSaving, setIsSaving] = useState(false); useEffect(() => { @@ -49,6 +58,7 @@ export default function ProblemTestsPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + setSuccessMessage(""); setIsSaving(true); try { @@ -92,10 +102,40 @@ export default function ProblemTestsPage() { const resetForm = () => { setShowForm(false); + setShowBulkImport(false); setEditingTest(null); setFormData({ input: "", expected_output: "", is_sample: false }); }; + const resetBulkImport = () => { + setShowBulkImport(false); + setBulkImportData({ + text: "", + test_delimiter: "===", + io_delimiter: "---", + mark_first_as_sample: true, + sample_count: 1, + }); + }; + + const handleBulkImport = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccessMessage(""); + setIsSaving(true); + + try { + const result = await api.bulkImportTestCases(problemId, bulkImportData); + setTestCases((prev) => [...prev, ...result.test_cases]); + setSuccessMessage(`Успешно добавлено тестов: ${result.created_count}`); + resetBulkImport(); + } catch (err) { + setError(err instanceof Error ? err.message : "Ошибка импорта"); + } finally { + setIsSaving(false); + } + }; + if (authLoading || isLoading) { return (
@@ -131,13 +171,21 @@ export default function ProblemTestsPage() {

{problem.title}

)}
- {!showForm && ( - + {!showForm && !showBulkImport && ( +
+ + +
)} @@ -147,6 +195,137 @@ export default function ProblemTestsPage() { )} + {successMessage && ( +
+ {successMessage} +
+ )} + + {showBulkImport && ( +
+

Массовый импорт тестов

+ +
+

Формат:

+
    +
  • Тесты разделяются символом ===
  • +
  • Вход и выход разделяются символом ---
  • +
  • Поддерживается как однострочный, так и многострочный ввод/вывод
  • +
+
+
# Пример:
+
{`1 2
+---
+3
+===
+5
+10
+---
+15
+===
+100 200
+---
+300`}
+
+
+ +
+
+ +