feat: Add bulk test case import functionality with dedicated API endpoint and frontend interface.
This commit is contained in:
parent
1906e220d0
commit
c1ac522574
@ -15,6 +15,8 @@ from app.schemas.problem import (
|
|||||||
SampleTestResponse,
|
SampleTestResponse,
|
||||||
TestCaseCreate,
|
TestCaseCreate,
|
||||||
TestCaseResponse,
|
TestCaseResponse,
|
||||||
|
BulkTestCaseImport,
|
||||||
|
BulkImportResult,
|
||||||
)
|
)
|
||||||
from app.dependencies import get_current_user, get_current_admin
|
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.delete(test_case)
|
||||||
await db.commit()
|
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]
|
||||||
|
)
|
||||||
|
|||||||
@ -10,6 +10,35 @@ class TestCaseCreate(BaseModel):
|
|||||||
order_index: int = 0
|
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):
|
class TestCaseResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
input: str
|
input: str
|
||||||
|
|||||||
@ -18,14 +18,23 @@ export default function ProblemTestsPage() {
|
|||||||
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
const [testCases, setTestCases] = useState<TestCase[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [successMessage, setSuccessMessage] = useState("");
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [showBulkImport, setShowBulkImport] = useState(false);
|
||||||
const [editingTest, setEditingTest] = useState<TestCase | null>(null);
|
const [editingTest, setEditingTest] = useState<TestCase | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
input: "",
|
input: "",
|
||||||
expected_output: "",
|
expected_output: "",
|
||||||
is_sample: false,
|
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);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -49,6 +58,7 @@ export default function ProblemTestsPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
setSuccessMessage("");
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -92,10 +102,40 @@ export default function ProblemTestsPage() {
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
|
setShowBulkImport(false);
|
||||||
setEditingTest(null);
|
setEditingTest(null);
|
||||||
setFormData({ input: "", expected_output: "", is_sample: false });
|
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) {
|
if (authLoading || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
@ -131,13 +171,21 @@ export default function ProblemTestsPage() {
|
|||||||
<p className="text-muted-foreground mt-1">{problem.title}</p>
|
<p className="text-muted-foreground mt-1">{problem.title}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showForm && (
|
{!showForm && !showBulkImport && (
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => setShowForm(true)}
|
<button
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition"
|
onClick={() => setShowBulkImport(true)}
|
||||||
>
|
className="px-4 py-2 border border-primary text-primary rounded-lg font-medium hover:bg-primary/10 transition"
|
||||||
Добавить тест
|
>
|
||||||
</button>
|
Массовый импорт
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition"
|
||||||
|
>
|
||||||
|
Добавить тест
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -147,6 +195,137 @@ export default function ProblemTestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="p-4 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg mb-4">
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBulkImport && (
|
||||||
|
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Массовый импорт тестов</h2>
|
||||||
|
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg text-sm">
|
||||||
|
<h3 className="font-semibold mb-2">Формат:</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground mb-3">
|
||||||
|
<li>Тесты разделяются символом <code className="px-1 py-0.5 bg-muted rounded text-xs">===</code></li>
|
||||||
|
<li>Вход и выход разделяются символом <code className="px-1 py-0.5 bg-muted rounded text-xs">---</code></li>
|
||||||
|
<li>Поддерживается как однострочный, так и многострочный ввод/вывод</li>
|
||||||
|
</ul>
|
||||||
|
<div className="bg-muted p-3 rounded font-mono text-xs">
|
||||||
|
<div className="text-muted-foreground mb-1"># Пример:</div>
|
||||||
|
<pre>{`1 2
|
||||||
|
---
|
||||||
|
3
|
||||||
|
===
|
||||||
|
5
|
||||||
|
10
|
||||||
|
---
|
||||||
|
15
|
||||||
|
===
|
||||||
|
100 200
|
||||||
|
---
|
||||||
|
300`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleBulkImport} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Текст с тестами
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={bulkImportData.text}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBulkImportData({ ...bulkImportData, text: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||||
|
placeholder={`1 2\n---\n3\n===\n5 10\n---\n15`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Разделитель тестов
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bulkImportData.test_delimiter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBulkImportData({ ...bulkImportData, test_delimiter: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">
|
||||||
|
Разделитель входа/выхода
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bulkImportData.io_delimiter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBulkImportData({ ...bulkImportData, io_delimiter: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="mark_first_as_sample"
|
||||||
|
checked={bulkImportData.mark_first_as_sample}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBulkImportData({ ...bulkImportData, mark_first_as_sample: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<label htmlFor="mark_first_as_sample" className="text-sm">
|
||||||
|
Первые тесты как примеры
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{bulkImportData.mark_first_as_sample && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm">Кол-во:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={bulkImportData.sample_count}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBulkImportData({ ...bulkImportData, sample_count: parseInt(e.target.value) || 1 })
|
||||||
|
}
|
||||||
|
className="w-16 px-2 py-1 border border-input rounded bg-background focus:outline-none focus:ring-2 focus:ring-ring text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-6 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? "Импорт..." : "Импортировать"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetBulkImport}
|
||||||
|
className="px-6 py-2 border border-border rounded-lg font-medium hover:bg-muted transition"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
|
<div className="mb-8 p-6 border border-border rounded-lg bg-muted/30">
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
<h2 className="text-xl font-semibold mb-4">
|
||||||
|
|||||||
@ -296,6 +296,25 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bulkImportTestCases(
|
||||||
|
problemId: number,
|
||||||
|
data: {
|
||||||
|
text: string;
|
||||||
|
test_delimiter?: string;
|
||||||
|
io_delimiter?: string;
|
||||||
|
mark_first_as_sample?: boolean;
|
||||||
|
sample_count?: number;
|
||||||
|
}
|
||||||
|
): Promise<{ created_count: number; test_cases: TestCase[] }> {
|
||||||
|
return this.request<{ created_count: number; test_cases: TestCase[] }>(
|
||||||
|
`/api/problems/${problemId}/test-cases/bulk`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Alias methods
|
// Alias methods
|
||||||
async getContestProblems(contestId: number): Promise<ProblemListItem[]> {
|
async getContestProblems(contestId: number): Promise<ProblemListItem[]> {
|
||||||
return this.getProblemsByContest(contestId);
|
return this.getProblemsByContest(contestId);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user