diff --git a/backend/tests/test_import_validation.py b/backend/tests/test_import_validation.py new file mode 100644 index 0000000..19487d9 --- /dev/null +++ b/backend/tests/test_import_validation.py @@ -0,0 +1,36 @@ +import pytest +from pydantic import ValidationError + +from wisegrade.api import ImportConfirm, ProjectConfirm + + +def project(**changes): + return {"relative_dir": "test/student", "student_no": "9000000001", "name": "学生甲", "topic": "机器人项目", + "files": [{"relative_path": "test/student/最终报告.pdf", "category": "final_report", "sha256": "a" * 64}], **changes} + + +def test_confirm_normalizes_identity_before_validating(): + result = ProjectConfirm.model_validate(project(student_no=" 9000000001 ", name=" 学生甲 ", topic=" 机器人项目 ")) + assert (result.student_no, result.name, result.topic) == ("9000000001", "学生甲", "机器人项目") + + +@pytest.mark.parametrize("changes", [ + {"name": " "}, {"topic": " "}, {"student_no": "9000000001"}, {"files": []}, + {"files": [{"relative_path": "test/student/a.pdf", "category": "unknown", "sha256": "a" * 64}]}, + {"files": [{"relative_path": "test/student/a.pdf", "category": "final_report", "sha256": "a" * 64, "status": "ignored"}]}, + {"files": [{"relative_path": f"test/student/{i}.pdf", "category": "final_report", "sha256": "a" * 64} for i in range(2)]}, +]) +def test_confirm_rejects_invalid_identity_or_selection(changes): + with pytest.raises(ValidationError): + ProjectConfirm.model_validate(project(**changes)) + + +def test_empty_import_is_rejected(): + with pytest.raises(ValidationError): + ImportConfirm(course_version_id=1, projects=[]) + + +def test_ignored_unknown_material_does_not_block_valid_selection(): + data = project() + data["files"].append({"relative_path": "test/student/foreign.pdf", "category": "unknown", "sha256": "b" * 64, "status": "ignored"}) + assert len(ProjectConfirm.model_validate(data).files) == 2 diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index 0c65344..0a23666 100644 --- a/backend/wisegrade/api.py +++ b/backend/wisegrade/api.py @@ -3,7 +3,7 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -67,16 +67,33 @@ class FileConfirm(BaseModel): class ProjectConfirm(BaseModel): relative_dir: str - student_no: str = Field(pattern=r"^\d{5,20}$") + student_no: str = Field(pattern=r"^[0-9]{5,20}$") name: str = Field(min_length=1) topic: str = Field(min_length=1) direction: str = "" - files: list[FileConfirm] + files: list[FileConfirm] = Field(min_length=1) + + @field_validator("student_no", "name", "topic", "direction", mode="before") + @classmethod + def trim_identity(cls, value): + return value.strip() if isinstance(value, str) else value + + @model_validator(mode="after") + def validate_selection(self): + selected = [file for file in self.files if file.status != "ignored"] + if not selected: + raise ValueError("至少选择1份资料") + categories = [file.category for file in selected] + if any(category not in CATEGORIES for category in categories): + raise ValueError("请选择有效材料类型") + if len(set(categories)) != len(categories): + raise ValueError("每类材料只允许选择1份,其余请设为忽略") + return self class ImportConfirm(BaseModel): course_version_id: int - projects: list[ProjectConfirm] + projects: list[ProjectConfirm] = Field(min_length=1) class BatchInput(BaseModel):