diff --git a/api/app/api/v1/atp_models.py b/api/app/api/v1/atp_models.py index d3aa094..9a5305f 100644 --- a/api/app/api/v1/atp_models.py +++ b/api/app/api/v1/atp_models.py @@ -111,15 +111,9 @@ def delete_atp_model_endpoint( _: CurrentUser = Depends(require_permission("atp.manage")), db: Session = Depends(get_db), ) -> dict[str, bool]: - deleted, version_count = delete_model(db, model_id) + deleted = delete_model(db, model_id) if not deleted: - item = get_model_by_id(db, model_id) - if not item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Model has {version_count} versions, delete versions first", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found") return {"success": True} diff --git a/api/app/services/atp_model_service.py b/api/app/services/atp_model_service.py index 09addee..6c4dafd 100644 --- a/api/app/services/atp_model_service.py +++ b/api/app/services/atp_model_service.py @@ -50,6 +50,9 @@ def _fire_and_forget(coro: Any) -> None: try: loop = asyncio.get_running_loop() except RuntimeError: + close = getattr(coro, "close", None) + if callable(close): + close() return loop.create_task(coro) @@ -442,19 +445,15 @@ def update_model( ) -def delete_model(db: Session, model_id: str) -> tuple[bool, int]: +def delete_model(db: Session, model_id: str) -> bool: item = get_model_by_id(db, model_id) if not item: - return False, 0 - - version_count = int(db.scalar(select(func.count()).select_from(AtpModelVersion).where(AtpModelVersion.model_id == model_id)) or 0) - if version_count > 0: - return False, version_count + return False db.delete(item) db.commit() _publish_change("model.deleted", {"action": "deleted", "model_id": model_id}) - return True, 0 + return True def list_model_versions( diff --git a/api/tests/test_atp_model_service.py b/api/tests/test_atp_model_service.py new file mode 100644 index 0000000..c37431d --- /dev/null +++ b/api/tests/test_atp_model_service.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker + +from app.core.database import Base +from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun +from app.services.atp_model_service import delete_model + + +def _build_sessionmaker(): + engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all( + bind=engine, + tables=[ + AtpModel.__table__, + AtpModelVersion.__table__, + AtpSimulationRun.__table__, + ], + ) + return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False) + + +def test_delete_model_cascades_hidden_versions_and_runs() -> None: + testing_session = _build_sessionmaker() + session: Session = testing_session() + try: + model = AtpModel( + code="ATP-001", + name="示例模型", + source_type="atp", + status="enabled", + latest_version_no=1, + active_version_no=1, + ) + session.add(model) + session.flush() + + version = AtpModelVersion( + model_id=model.id, + version_no=1, + status="released", + atp_text="sample", + content_hash="hash-v1", + ) + session.add(version) + session.flush() + + session.add( + AtpSimulationRun( + model_id=model.id, + version_id=version.id, + status="success", + engine_mode="native", + timeout_seconds=60, + ) + ) + session.commit() + + assert delete_model(session, model.id) is True + + assert session.execute(select(AtpModel).where(AtpModel.id == model.id)).scalar_one_or_none() is None + assert session.execute(select(AtpModelVersion).where(AtpModelVersion.model_id == model.id)).scalar_one_or_none() is None + assert session.execute(select(AtpSimulationRun).where(AtpSimulationRun.model_id == model.id)).scalar_one_or_none() is None + finally: + session.close() diff --git a/web/src/app/admin/fl-analysis/page.tsx b/web/src/app/admin/fl-analysis/page.tsx index 25a211c..a7dfa46 100644 --- a/web/src/app/admin/fl-analysis/page.tsx +++ b/web/src/app/admin/fl-analysis/page.tsx @@ -30,8 +30,6 @@ import type { AtpEngineStatusResponse, AtpModelListResponse, AtpModelSummary, - AtpModelVersionListResponse, - AtpModelVersionSummary, FlAnalysisJobDetail, FlAnalysisJobListResponse, FlAnalysisJobSummary, @@ -47,7 +45,6 @@ type CreateJobFormValues = { job_type: "normal" | "tongtiao" | "risk"; external_adapter: "placeholder" | "wine" | "atp"; atp_model_id: string; - atp_version_id: string; current_waveform: "heidler" | "double_slope" | "double_exponential"; flashover_method: "guideline" | "intersection" | "leader_development"; altitude_correction: "none" | "formula1" | "formula2"; @@ -154,7 +151,6 @@ const CREATE_JOB_DEFAULTS: CreateJobFormValues = { job_type: "normal", external_adapter: "placeholder", atp_model_id: "", - atp_version_id: "", current_waveform: "heidler", flashover_method: "intersection", altitude_correction: "none", @@ -414,7 +410,6 @@ export default function AdminFlAnalysisPage() { const selectedLineId = Form.useWatch("line_id", createJobForm); const selectedCreateJobType = Form.useWatch("job_type", createJobForm) ?? CREATE_JOB_DEFAULTS.job_type; const selectedExternalAdapter = Form.useWatch("external_adapter", createJobForm) ?? CREATE_JOB_DEFAULTS.external_adapter; - const selectedAtpModelId = Form.useWatch("atp_model_id", createJobForm) ?? ""; const canRead = hasPermission("line.read") || hasPermission("line.manage"); const canManage = hasPermission("line.manage") || hasPermission("tower.manage"); @@ -469,18 +464,6 @@ export default function AdminFlAnalysisPage() { }, }); - const atpVersionsQuery = useQuery({ - queryKey: ["/api/v1/atp/models/versions", selectedAtpModelId], - enabled: !!user && canReadAtp && !!selectedAtpModelId, - queryFn: async () => { - const response = await fetchWithAuth(`/api/v1/atp/models/${selectedAtpModelId}/versions?limit=200&offset=0`); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as AtpModelVersionListResponse; - }, - }); - const selectedJob = useMemo(() => { if (!selectedJobId) { return jobsQuery.data?.items[0] ?? null; @@ -543,6 +526,7 @@ export default function AdminFlAnalysisPage() { }, [jobsQuery.data?.items, selectedJob]); const atpModels = useMemo(() => atpModelsQuery.data?.items ?? [], [atpModelsQuery.data]); + const selectedAtpModelId = Form.useWatch("atp_model_id", createJobForm) ?? ""; const selectedAtpModel = useMemo( () => atpModels.find((item) => item.id === selectedAtpModelId) ?? null, [atpModels, selectedAtpModelId], @@ -568,24 +552,6 @@ export default function AdminFlAnalysisPage() { createJobForm.setFieldValue("atp_model_id", atpModels[0].id); }, [atpModels, createJobForm, selectedCreateJobType, selectedExternalAdapter]); - useEffect(() => { - if (!["atp", "wine"].includes(selectedExternalAdapter)) { - return; - } - const versions = atpVersionsQuery.data?.items ?? []; - if (!versions.length) { - return; - } - const currentVersionId = createJobForm.getFieldValue("atp_version_id"); - if (currentVersionId && versions.some((item) => item.id === currentVersionId)) { - return; - } - const preferredVersion = - versions.find((item) => item.version_no === selectedAtpModel?.active_version_no) - ?? versions[0]; - createJobForm.setFieldValue("atp_version_id", preferredVersion.id); - }, [atpVersionsQuery.data?.items, createJobForm, selectedAtpModel?.active_version_no, selectedExternalAdapter]); - async function invalidateFlAnalysisQueries(): Promise { await queryClient.invalidateQueries({ predicate: (query) => @@ -679,7 +645,6 @@ export default function AdminFlAnalysisPage() { if (values.external_adapter !== "placeholder") { payload.adapter_config_json = { model_id: values.atp_model_id, - version_id: values.atp_version_id || undefined, }; } } @@ -1385,7 +1350,7 @@ export default function AdminFlAnalysisPage() { /> ) : null} {externalAdapterActive ? ( -
+
- -