diff --git a/backend/tests/test_chat_agent.py b/backend/tests/test_chat_agent.py index c720c7b..cfadc73 100644 --- a/backend/tests/test_chat_agent.py +++ b/backend/tests/test_chat_agent.py @@ -214,6 +214,12 @@ def test_duplicate_scan_requires_teacher_file_choice(environment, db): generated_action = db.get(PendingAction, generated["pending_action"]["id"]) assert generated_action.preview["requires_edit"] is True assert all(file["status"] == "ignored" for file in generated_action.payload["projects"][0]["files"]) + with pytest.raises(HTTPException) as blocked: + confirm(db, conversation, generated_action.id) + assert blocked.value.status_code == 409 + with pytest.raises(HTTPException) as invalid_selection: + prepare(db, conversation, "import", generated_action.payload, teacher_edited=True) + assert invalid_selection.value.status_code == 400 proposed = prepare(db, conversation, "import", payload) assert proposed.preview["requires_edit"] is True with pytest.raises(HTTPException): diff --git a/backend/wisegrade/actions.py b/backend/wisegrade/actions.py index bd46d95..c8542b9 100644 --- a/backend/wisegrade/actions.py +++ b/backend/wisegrade/actions.py @@ -6,6 +6,7 @@ from pathlib import Path from uuid import uuid4 from fastapi import HTTPException +from pydantic import ValidationError from sqlalchemy import select from . import services @@ -149,7 +150,12 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher raise HTTPException(400, "整批 Excel 导出须在未限定学生项目的会话中准备") if kind == "import": from .api import ImportConfirm - data = ImportConfirm.model_validate(payload) + try: + data = ImportConfirm.model_validate(payload, context={ + "allow_unselected_import_draft": not teacher_edited}) + except ValidationError as exc: + messages = [error["msg"] for error in exc.errors(include_input=False, include_context=False)] + raise HTTPException(400, "导入资料尚未满足确认条件:" + ";".join(messages)) from exc payload = data.model_dump() require_scope(db, conversation, version_id=data.course_version_id) if teacher_edited and any(item.student_no == "00000" or item.name == "待确认" or item.topic == "待确认" for item in data.projects): @@ -164,9 +170,10 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher scanned.append(match) if match["status"] != "recognized" or match["warnings"] or any(f["status"] != "recognized" for f in match["files"]): ambiguous.append(item.relative_dir) + unselected = any(not any(file.status != "ignored" for file in item.files) for item in data.projects) preview = {"course_version_id": data.course_version_id, "projects": scanned, "selection": payload["projects"], "ambiguous": ambiguous, - "requires_edit": bool(ambiguous) and not teacher_edited} + "requires_edit": (bool(ambiguous) or unselected) and not teacher_edited} elif kind == "course_ingest": require_scope(db, conversation, version_id=payload["course_version_id"]) preview = {"course_version_id": payload["course_version_id"], "templates": diff --git a/backend/wisegrade/api.py b/backend/wisegrade/api.py index 0a23666..7341ba2 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, field_validator, model_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -79,9 +79,13 @@ class ProjectConfirm(BaseModel): return value.strip() if isinstance(value, str) else value @model_validator(mode="after") - def validate_selection(self): + def validate_selection(self, info: ValidationInfo): selected = [file for file in self.files if file.status != "ignored"] if not selected: + # Only the chat preview builder supplies this context. HTTP imports, + # edited previews and final execution always use strict validation. + if (info.context or {}).get("allow_unselected_import_draft"): + return self raise ValueError("至少选择1份资料") categories = [file.category for file in selected] if any(category not in CATEGORIES for category in categories):