[feat]:[FL-194][删除AtpModel系统,保留AtpAsset]
- 删除AtpModel、AtpModelVersion、AtpSimulationRun模型及相关代码 - 删除/api/v1/atp/models API端点 - 将engine status功能迁移到atp_asset_service - 更新路由和模型注册,移除atp_model引用 - 删除相关测试文件 - 更新fl_analysis_service使用atp_asset_service的_truncate_output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -4,7 +4,6 @@ from .v1.admin import router as admin_router
|
||||
from .v1.admin_files import router as admin_files_router
|
||||
from .v1.ai_chat import router as ai_chat_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.atp_models import router as atp_models_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.documents import router as documents_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
@@ -30,7 +29,6 @@ v1_router.include_router(admin_router)
|
||||
v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(ai_chat_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(atp_models_router)
|
||||
v1_router.include_router(documents_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(scheduled_tasks_router)
|
||||
|
||||
@@ -19,8 +19,8 @@ from ...schemas.atp_asset import (
|
||||
AtpAssetRunListResponse,
|
||||
AtpAssetRunRequest,
|
||||
AtpAssetUpdateRequest,
|
||||
AtpEngineStatusResponse,
|
||||
)
|
||||
from ...schemas.atp_model import AtpEngineStatusResponse
|
||||
from ...services.atp_asset_service import (
|
||||
activate_release,
|
||||
create_asset,
|
||||
@@ -28,6 +28,7 @@ from ...services.atp_asset_service import (
|
||||
create_release_from_archive,
|
||||
delete_asset,
|
||||
get_asset_by_id,
|
||||
get_engine_status,
|
||||
get_release_by_id,
|
||||
get_run_detail,
|
||||
list_assets,
|
||||
@@ -40,7 +41,6 @@ from ...services.atp_asset_service import (
|
||||
update_asset,
|
||||
update_release,
|
||||
)
|
||||
from ...services.atp_model_service import get_engine_status
|
||||
|
||||
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_any_permission, require_enabled_menu_route, require_permission
|
||||
from ...schemas.atp_model import (
|
||||
AtpEngineStatusResponse,
|
||||
AtpModelCreateRequest,
|
||||
AtpModelListResponse,
|
||||
AtpModelSummary,
|
||||
AtpModelUpdateRequest,
|
||||
AtpModelVersionCreateRequest,
|
||||
AtpModelVersionDetail,
|
||||
AtpModelVersionListResponse,
|
||||
AtpModelVersionUpdateRequest,
|
||||
AtpSimulationRunDetail,
|
||||
AtpSimulationRunListResponse,
|
||||
AtpSimulationRunRequest,
|
||||
)
|
||||
from ...services.atp_model_service import (
|
||||
activate_model_version,
|
||||
create_model,
|
||||
create_model_version,
|
||||
delete_model,
|
||||
get_engine_status,
|
||||
get_model_by_id,
|
||||
get_model_run_detail,
|
||||
get_model_version_by_id,
|
||||
list_model_runs,
|
||||
list_model_versions,
|
||||
list_models,
|
||||
run_model_version,
|
||||
serialize_model,
|
||||
serialize_version_detail,
|
||||
update_model,
|
||||
update_model_version,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/atp/models", tags=["atp-models"], dependencies=[Depends(require_enabled_menu_route)])
|
||||
|
||||
|
||||
@router.get("/engine/status", response_model=AtpEngineStatusResponse)
|
||||
def get_atp_engine_status_endpoint(
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
) -> AtpEngineStatusResponse:
|
||||
return get_engine_status()
|
||||
|
||||
|
||||
@router.get("", response_model=AtpModelListResponse)
|
||||
def get_atp_model_list(
|
||||
keyword: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelListResponse:
|
||||
return list_models(db, keyword=keyword, status_filter=status_filter, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("", response_model=AtpModelSummary)
|
||||
def create_atp_model_endpoint(
|
||||
payload: AtpModelCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelSummary:
|
||||
created = create_model(db, payload, actor_user_id=current_user.user.id)
|
||||
if not created:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Model code already exists")
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/{model_id}", response_model=AtpModelSummary)
|
||||
def get_atp_model_detail(
|
||||
model_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelSummary:
|
||||
item = get_model_by_id(db, model_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
|
||||
version_count = int(len(item.versions))
|
||||
run_count = int(len(item.runs))
|
||||
last_run = item.runs[0] if item.runs else None
|
||||
return serialize_model(
|
||||
item,
|
||||
version_count=version_count,
|
||||
run_count=run_count,
|
||||
last_run_status=last_run.status if last_run else None,
|
||||
last_run_date=last_run.create_date if last_run else None,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{model_id}", response_model=AtpModelSummary)
|
||||
def update_atp_model_endpoint(
|
||||
model_id: str,
|
||||
payload: AtpModelUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelSummary:
|
||||
updated = update_model(db, model_id, payload, actor_user_id=current_user.user.id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{model_id}")
|
||||
def delete_atp_model_endpoint(
|
||||
model_id: str,
|
||||
_: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_model(db, model_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/{model_id}/versions", response_model=AtpModelVersionListResponse)
|
||||
def get_atp_model_versions(
|
||||
model_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelVersionListResponse:
|
||||
if not get_model_by_id(db, model_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
return list_model_versions(db, model_id=model_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/{model_id}/versions", response_model=AtpModelVersionDetail)
|
||||
def create_atp_model_version_endpoint(
|
||||
model_id: str,
|
||||
payload: AtpModelVersionCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelVersionDetail:
|
||||
return create_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.get("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail)
|
||||
def get_atp_model_version_detail(
|
||||
model_id: str,
|
||||
version_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelVersionDetail:
|
||||
item = get_model_version_by_id(db, model_id=model_id, version_id=version_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Version not found")
|
||||
return serialize_version_detail(item)
|
||||
|
||||
|
||||
@router.patch("/{model_id}/versions/{version_id}", response_model=AtpModelVersionDetail)
|
||||
def update_atp_model_version_endpoint(
|
||||
model_id: str,
|
||||
version_id: str,
|
||||
payload: AtpModelVersionUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelVersionDetail:
|
||||
return update_model_version(
|
||||
db,
|
||||
model_id=model_id,
|
||||
version_id=version_id,
|
||||
payload=payload,
|
||||
actor_user_id=current_user.user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{model_id}/versions/{version_id}/activate", response_model=AtpModelSummary)
|
||||
def activate_atp_model_version_endpoint(
|
||||
model_id: str,
|
||||
version_id: str,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpModelSummary:
|
||||
return activate_model_version(
|
||||
db,
|
||||
model_id=model_id,
|
||||
version_id=version_id,
|
||||
actor_user_id=current_user.user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{model_id}/runs", response_model=AtpSimulationRunListResponse)
|
||||
def get_atp_model_runs(
|
||||
model_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpSimulationRunListResponse:
|
||||
if not get_model_by_id(db, model_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
return list_model_runs(db, model_id=model_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/{model_id}/runs", response_model=AtpSimulationRunDetail)
|
||||
def run_atp_model_endpoint(
|
||||
model_id: str,
|
||||
payload: AtpSimulationRunRequest,
|
||||
current_user: CurrentUser = Depends(require_any_permission("atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpSimulationRunDetail:
|
||||
return run_model_version(db, model_id=model_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.get("/{model_id}/runs/{run_id}", response_model=AtpSimulationRunDetail)
|
||||
def get_atp_model_run_detail(
|
||||
model_id: str,
|
||||
run_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpSimulationRunDetail:
|
||||
if not get_model_by_id(db, model_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found")
|
||||
return get_model_run_detail(db, model_id=model_id, run_id=run_id)
|
||||
@@ -4,12 +4,11 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import ai_chat, atp_asset, atp_model, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import ai_chat, atp_asset, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"ai_chat",
|
||||
"atp_asset",
|
||||
"atp_model",
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
"document",
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
class AtpModel(Base):
|
||||
__tablename__ = "atp_model"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_atp_model_code"),
|
||||
Index("idx_atp_model_status", "status"),
|
||||
Index("idx_atp_model_source", "source_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(32), default="atpdraw", index=True)
|
||||
description: Mapped[str] = mapped_column(Text(), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="enabled", index=True)
|
||||
tags_json: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
latest_version_no: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active_version_no: Mapped[int | None] = mapped_column(Integer)
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
versions: Mapped[list[AtpModelVersion]] = relationship(
|
||||
"AtpModelVersion",
|
||||
back_populates="model",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AtpModelVersion.version_no.desc()",
|
||||
)
|
||||
runs: Mapped[list[AtpSimulationRun]] = relationship(
|
||||
"AtpSimulationRun",
|
||||
back_populates="model",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AtpSimulationRun.create_date.desc()",
|
||||
)
|
||||
|
||||
|
||||
class AtpModelVersion(Base):
|
||||
__tablename__ = "atp_model_version"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("model_id", "version_no", name="uq_atp_model_version_model_no"),
|
||||
Index("idx_atp_model_version_status", "status"),
|
||||
Index("idx_atp_model_version_model_status", "model_id", "status"),
|
||||
Index("idx_atp_model_version_content_hash", "content_hash"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
model_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_model.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
version_tag: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft", index=True)
|
||||
entry_file: Mapped[str | None] = mapped_column(String(255))
|
||||
change_note: Mapped[str] = mapped_column(Text(), default="")
|
||||
artifact_manifest_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
graph_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
atp_text: Mapped[str] = mapped_column(Text(), default="")
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
model: Mapped[AtpModel] = relationship("AtpModel", back_populates="versions", lazy="selectin")
|
||||
runs: Mapped[list[AtpSimulationRun]] = relationship(
|
||||
"AtpSimulationRun",
|
||||
back_populates="version",
|
||||
lazy="selectin",
|
||||
order_by="AtpSimulationRun.create_date.desc()",
|
||||
)
|
||||
|
||||
|
||||
class AtpSimulationRun(Base):
|
||||
__tablename__ = "atp_simulation_run"
|
||||
__table_args__ = (
|
||||
Index("idx_atp_simulation_run_status", "status"),
|
||||
Index("idx_atp_simulation_run_model", "model_id", "create_date"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
primary_key=True,
|
||||
default=lambda: uuid4().hex,
|
||||
)
|
||||
model_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_model.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
version_id: Mapped[str | None] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_model_version.id", ondelete="SET NULL"),
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
engine_mode: Mapped[str] = mapped_column(String(20), default="wine", index=True)
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
engine_command: Mapped[str | None] = mapped_column(String(1000))
|
||||
working_dir: Mapped[str | None] = mapped_column(String(1000))
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=600)
|
||||
exit_code: Mapped[int | None] = mapped_column(Integer)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
stdout_text: Mapped[str | None] = mapped_column(Text())
|
||||
stderr_text: Mapped[str | None] = mapped_column(Text())
|
||||
error_message: Mapped[str | None] = mapped_column(Text())
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
model: Mapped[AtpModel] = relationship("AtpModel", back_populates="runs", lazy="selectin")
|
||||
version: Mapped[AtpModelVersion | None] = relationship("AtpModelVersion", back_populates="runs", lazy="selectin")
|
||||
@@ -209,3 +209,16 @@ class AtpAssetRunRequest(BaseModel):
|
||||
class AtpAssetReleaseUploadResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
|
||||
|
||||
class AtpEngineStatusResponse(BaseModel):
|
||||
mode: AtpAssetEngineMode
|
||||
available: bool
|
||||
executable_path: str
|
||||
resolved_executable: str | None = None
|
||||
storage_root: str
|
||||
workdir: str
|
||||
default_timeout_seconds: int
|
||||
max_timeout_seconds: int
|
||||
checks: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AtpModelStatus = Literal["enabled", "disabled"]
|
||||
AtpModelSourceType = Literal["atpdraw", "atp", "manual"]
|
||||
AtpModelVersionStatus = Literal["draft", "released", "archived"]
|
||||
AtpSimulationRunStatus = Literal["pending", "running", "success", "failed"]
|
||||
AtpEngineMode = Literal["wine", "native"]
|
||||
|
||||
|
||||
class AtpModelSummary(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
source_type: AtpModelSourceType
|
||||
description: str
|
||||
status: AtpModelStatus
|
||||
tags_json: list[str] = Field(default_factory=list)
|
||||
latest_version_no: int = 0
|
||||
active_version_no: int | None = None
|
||||
version_count: int = 0
|
||||
run_count: int = 0
|
||||
last_run_status: AtpSimulationRunStatus | None = None
|
||||
last_run_date: datetime | None = None
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class AtpModelListResponse(BaseModel):
|
||||
items: list[AtpModelSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpModelCreateRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
source_type: AtpModelSourceType = "atpdraw"
|
||||
description: str = Field(default="", max_length=8000)
|
||||
status: AtpModelStatus = "enabled"
|
||||
tags_json: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
|
||||
class AtpModelUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
source_type: AtpModelSourceType | None = None
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
status: AtpModelStatus | None = None
|
||||
tags_json: list[str] | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class AtpModelVersionSummary(BaseModel):
|
||||
id: str
|
||||
model_id: str
|
||||
version_no: int
|
||||
version_tag: str | None = None
|
||||
status: AtpModelVersionStatus
|
||||
entry_file: str | None = None
|
||||
change_note: str
|
||||
artifact_manifest_json: dict[str, Any] = Field(default_factory=dict)
|
||||
content_hash: str
|
||||
atp_text_size: int
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class AtpModelVersionDetail(AtpModelVersionSummary):
|
||||
atp_text: str
|
||||
graph_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AtpModelVersionListResponse(BaseModel):
|
||||
items: list[AtpModelVersionSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpModelVersionCreateRequest(BaseModel):
|
||||
version_tag: str | None = Field(default=None, max_length=64)
|
||||
status: AtpModelVersionStatus = "released"
|
||||
entry_file: str | None = Field(default=None, max_length=255)
|
||||
change_note: str = Field(default="", max_length=8000)
|
||||
artifact_manifest_json: dict[str, Any] = Field(default_factory=dict)
|
||||
graph_json: dict[str, Any] = Field(default_factory=dict)
|
||||
atp_text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AtpModelVersionUpdateRequest(BaseModel):
|
||||
version_tag: str | None = Field(default=None, max_length=64)
|
||||
status: AtpModelVersionStatus | None = None
|
||||
entry_file: str | None = Field(default=None, max_length=255)
|
||||
change_note: str | None = Field(default=None, max_length=8000)
|
||||
artifact_manifest_json: dict[str, Any] | None = None
|
||||
graph_json: dict[str, Any] | None = None
|
||||
atp_text: str | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class AtpSimulationRunSummary(BaseModel):
|
||||
id: str
|
||||
model_id: str
|
||||
version_id: str | None = None
|
||||
version_no: int | None = None
|
||||
task_id: str | None = None
|
||||
status: AtpSimulationRunStatus
|
||||
engine_mode: AtpEngineMode
|
||||
engine_command: str | None = None
|
||||
working_dir: str | None = None
|
||||
timeout_seconds: int
|
||||
exit_code: int | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int | None = None
|
||||
error_message: str | None = None
|
||||
stdout_size: int = 0
|
||||
stderr_size: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
|
||||
|
||||
class AtpSimulationRunDetail(AtpSimulationRunSummary):
|
||||
stdout_text: str | None = None
|
||||
stderr_text: str | None = None
|
||||
|
||||
|
||||
class AtpSimulationRunListResponse(BaseModel):
|
||||
items: list[AtpSimulationRunSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpSimulationRunRequest(BaseModel):
|
||||
version_id: str | None = None
|
||||
version_no: int | None = Field(default=None, ge=1)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1)
|
||||
extra_args: list[str] = Field(default_factory=list, max_length=32)
|
||||
environment: dict[str, str] = Field(default_factory=dict, max_length=16)
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class AtpEngineStatusResponse(BaseModel):
|
||||
mode: AtpEngineMode
|
||||
available: bool
|
||||
executable_path: str
|
||||
resolved_executable: str | None = None
|
||||
storage_root: str
|
||||
workdir: str
|
||||
default_timeout_seconds: int
|
||||
max_timeout_seconds: int
|
||||
checks: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
@@ -41,8 +41,11 @@ from ..schemas.atp_asset import (
|
||||
AtpAssetRunSummary,
|
||||
AtpAssetSummary,
|
||||
AtpAssetUpdateRequest,
|
||||
AtpEngineStatusResponse,
|
||||
)
|
||||
from .legacy_atp_adapter import build_legacy_atp_status_checks
|
||||
from .push_service import publish_topic
|
||||
from .wine_probe import probe_wine_binary
|
||||
from .storage_driver import (
|
||||
StorageDriver,
|
||||
StorageDriverError,
|
||||
@@ -164,6 +167,85 @@ def _resolve_binary(raw_path: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_storage_root() -> Path:
|
||||
root = Path(settings.atp_storage_root).expanduser()
|
||||
return root.resolve(strict=False)
|
||||
|
||||
|
||||
def _resolve_engine_workdir() -> Path:
|
||||
configured = Path(settings.atp_engine_workdir).expanduser()
|
||||
if configured.is_absolute():
|
||||
return configured.resolve(strict=False)
|
||||
return (_resolve_storage_root() / configured).resolve(strict=False)
|
||||
|
||||
|
||||
def _resolve_wine_engine_executable() -> tuple[str | None, str | None, str | None]:
|
||||
wine_binary = _resolve_binary(settings.wine_binary_path)
|
||||
if not wine_binary:
|
||||
return None, None, "Wine binary not found"
|
||||
|
||||
allowed_root = Path(settings.wine_allowed_root).expanduser().resolve(strict=False)
|
||||
configured = Path(settings.atp_engine_executable).expanduser()
|
||||
if not configured.is_absolute():
|
||||
configured = (allowed_root / configured).resolve(strict=False)
|
||||
else:
|
||||
configured = configured.resolve(strict=False)
|
||||
|
||||
if not configured.is_relative_to(allowed_root):
|
||||
return wine_binary, None, f"ATP engine executable must be inside {allowed_root}"
|
||||
if not configured.exists() or not configured.is_file():
|
||||
return wine_binary, None, f"ATP engine executable not found: {configured}"
|
||||
|
||||
probe = probe_wine_binary(wine_binary)
|
||||
return wine_binary, str(configured), None if probe.available else (probe.error or "Wine binary unavailable")
|
||||
|
||||
|
||||
def _resolve_native_engine_executable() -> tuple[str | None, str | None]:
|
||||
resolved = _resolve_binary(settings.atp_engine_executable)
|
||||
if not resolved:
|
||||
return None, "ATP engine executable not found"
|
||||
return resolved, None
|
||||
|
||||
|
||||
def get_engine_status() -> AtpEngineStatusResponse:
|
||||
mode = _resolve_engine_mode()
|
||||
storage_root = str(_resolve_storage_root())
|
||||
workdir = str(_resolve_engine_workdir())
|
||||
checks = build_legacy_atp_status_checks()
|
||||
|
||||
if mode == "wine":
|
||||
wine_binary, resolved_engine, error = _resolve_wine_engine_executable()
|
||||
available = error is None
|
||||
executable_path = settings.atp_engine_executable.strip()
|
||||
resolved_binary = f"{wine_binary} -> {resolved_engine}" if wine_binary and resolved_engine else wine_binary
|
||||
return AtpEngineStatusResponse(
|
||||
mode="wine",
|
||||
available=available,
|
||||
executable_path=executable_path,
|
||||
resolved_executable=resolved_binary,
|
||||
storage_root=storage_root,
|
||||
workdir=workdir,
|
||||
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
|
||||
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
|
||||
checks=checks,
|
||||
error=error,
|
||||
)
|
||||
|
||||
resolved_engine, error = _resolve_native_engine_executable()
|
||||
return AtpEngineStatusResponse(
|
||||
mode="native",
|
||||
available=error is None,
|
||||
executable_path=settings.atp_engine_executable.strip(),
|
||||
resolved_executable=resolved_engine,
|
||||
storage_root=storage_root,
|
||||
workdir=workdir,
|
||||
default_timeout_seconds=settings.atp_engine_default_timeout_seconds,
|
||||
max_timeout_seconds=settings.atp_engine_max_timeout_seconds,
|
||||
checks=checks,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_mount(db: Session, mount_code: str) -> FileStorageMount:
|
||||
mount = db.execute(
|
||||
select(FileStorageMount)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ from ..schemas.fl_analysis import (
|
||||
FlAnalysisTowerResultListResponse,
|
||||
FlAnalysisTowerResultSummary,
|
||||
)
|
||||
from .atp_model_service import _truncate_output
|
||||
from .atp_asset_service import _truncate_output
|
||||
from .fl_analysis_external import execute_external_waveform_tower_analysis, resolve_external_waveform_job
|
||||
from .legacy_atp_adapter import execute_legacy_atp_tower_analysis, resolve_legacy_atp_job
|
||||
from .fl_analysis_report import build_report_document, build_report_summary_payload
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..core.celery_app import celery_app
|
||||
from ..services.atp_model_service import execute_model_run_job
|
||||
|
||||
|
||||
@celery_app.task(name="app.tasks.atp_model_tasks.execute_atp_model_run_job")
|
||||
def execute_atp_model_run_job(
|
||||
run_id: str,
|
||||
payload_data: dict[str, Any],
|
||||
actor_user_id: str | None,
|
||||
) -> dict[str, str]:
|
||||
execute_model_run_job(
|
||||
run_id=run_id,
|
||||
payload_data=payload_data,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
return {"run_id": run_id, "status": "done"}
|
||||
@@ -1,437 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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.models.elevation import ElevationDataImportJob, ElevationDataset, ElevationFileRecord
|
||||
from app.models.user import User
|
||||
from app.models.wine import WineRun
|
||||
from app.schemas.atp_model import AtpSimulationRunRequest
|
||||
from app.schemas.wine import WineRunRequest
|
||||
from app.services import atp_model_service, elevation_service, wine_service
|
||||
|
||||
|
||||
class _MemoryStorageDriver:
|
||||
def __init__(self) -> None:
|
||||
self.directories: set[str] = set()
|
||||
self.files: dict[str, bytes] = {}
|
||||
|
||||
def ensure_directory(self, path: str) -> None:
|
||||
self.directories.add(path.rstrip("/") or "/")
|
||||
|
||||
def write_file(self, path: str, *, content: bytes, content_type: str | None = None) -> SimpleNamespace:
|
||||
self.files[path] = content
|
||||
parent_path = path.rsplit("/", 1)[0] or "/"
|
||||
return SimpleNamespace(
|
||||
path=path,
|
||||
parent_path=parent_path,
|
||||
name=Path(path).name,
|
||||
is_dir=False,
|
||||
size=len(content),
|
||||
modified_at=None,
|
||||
mime_type=content_type,
|
||||
)
|
||||
|
||||
def list_dir(self, path: str) -> list[SimpleNamespace]:
|
||||
prefix = f"{path.rstrip('/')}/"
|
||||
entries: list[SimpleNamespace] = []
|
||||
for file_path in sorted(self.files):
|
||||
if not file_path.startswith(prefix):
|
||||
continue
|
||||
suffix = file_path[len(prefix):]
|
||||
if "/" in suffix:
|
||||
continue
|
||||
entries.append(
|
||||
SimpleNamespace(
|
||||
path=file_path,
|
||||
parent_path=path,
|
||||
name=Path(file_path).name,
|
||||
is_dir=False,
|
||||
size=len(self.files[file_path]),
|
||||
modified_at=None,
|
||||
mime_type=None,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
def read_file(self, path: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
path=path,
|
||||
name=Path(path).name,
|
||||
content=self.files[path],
|
||||
mime_type=None,
|
||||
)
|
||||
|
||||
def delete_path(self, path: str, *, is_dir: bool, recursive: bool) -> None:
|
||||
normalized = path.rstrip("/")
|
||||
if is_dir:
|
||||
prefix = f"{normalized}/"
|
||||
for file_path in list(self.files):
|
||||
if file_path.startswith(prefix):
|
||||
del self.files[file_path]
|
||||
self.directories = {item for item in self.directories if item != normalized and not item.startswith(prefix)}
|
||||
return
|
||||
self.files.pop(path, None)
|
||||
|
||||
|
||||
def _build_upload(filename: str, content: bytes, content_type: str = "application/octet-stream") -> SimpleNamespace:
|
||||
return SimpleNamespace(filename=filename, file=io.BytesIO(content), content_type=content_type)
|
||||
|
||||
|
||||
def _build_sessionmaker(*tables):
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine, tables=list(tables))
|
||||
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def test_run_model_version_queues_celery_task(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(
|
||||
AtpModel.__table__,
|
||||
AtpModelVersion.__table__,
|
||||
AtpSimulationRun.__table__,
|
||||
)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
model = AtpModel(
|
||||
code="ATP-ASYNC-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.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
atp_model_service,
|
||||
"_dispatch_atp_model_run_task",
|
||||
lambda **_: SimpleNamespace(id="celery-atp-1"),
|
||||
)
|
||||
|
||||
result = atp_model_service.run_model_version(
|
||||
session,
|
||||
model_id=model.id,
|
||||
payload=AtpSimulationRunRequest(version_id=version.id),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert result.status == "pending"
|
||||
assert result.task_id == "celery-atp-1"
|
||||
|
||||
saved = session.execute(select(AtpSimulationRun).where(AtpSimulationRun.id == result.id)).scalar_one()
|
||||
assert saved.task_id == "celery-atp-1"
|
||||
assert saved.status == "pending"
|
||||
assert saved.started_at is None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationDataset.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
dataset = ElevationDataset(
|
||||
code="ELEV-001",
|
||||
name="样例高程集",
|
||||
file_format="csv",
|
||||
mount_code="default",
|
||||
dataset_dir="/elevation/datasets/ELEV-001",
|
||||
file_path="/elevation/datasets/ELEV-001/data.csv",
|
||||
status="active",
|
||||
usage_status="idle",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
|
||||
actor = User(
|
||||
id="actor-1",
|
||||
email="actor@example.com",
|
||||
username="actor",
|
||||
password_hash="hashed",
|
||||
status="active",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
elevation_service,
|
||||
"_dispatch_elevation_dataset_analysis_task",
|
||||
lambda **_: SimpleNamespace(id="elev-task-1"),
|
||||
)
|
||||
|
||||
first = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor)
|
||||
assert first.queued is True
|
||||
assert first.task_id == "elev-task-1"
|
||||
assert first.dataset.analysis_status == "queued"
|
||||
assert first.dataset.terrain_status == "not_supported"
|
||||
|
||||
second = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor)
|
||||
assert second.queued is False
|
||||
assert second.task_id == "elev-task-1"
|
||||
assert second.detail == "分析任务已存在,无需重复提交。"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_queue_dataset_terrain_build_reuses_existing_running_task(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationDataset.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
dataset = ElevationDataset(
|
||||
code="ELEV-TERRAIN-001",
|
||||
name="样例地形集",
|
||||
file_format="tif",
|
||||
mount_code="default",
|
||||
dataset_dir="/elevation/datasets/ELEV-TERRAIN-001",
|
||||
file_path="/elevation/datasets/ELEV-TERRAIN-001/data.tif",
|
||||
status="active",
|
||||
usage_status="idle",
|
||||
terrain_status="pending",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
|
||||
actor = User(
|
||||
id="actor-1",
|
||||
email="actor@example.com",
|
||||
username="actor",
|
||||
password_hash="hashed",
|
||||
status="active",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
elevation_service,
|
||||
"_dispatch_elevation_dataset_terrain_task",
|
||||
lambda **_: SimpleNamespace(id="terrain-task-1"),
|
||||
)
|
||||
|
||||
first = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
|
||||
assert first.queued is True
|
||||
assert first.task_id == "terrain-task-1"
|
||||
assert first.dataset.terrain_status == "pending"
|
||||
|
||||
second = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
|
||||
assert second.queued is False
|
||||
assert second.task_id == "terrain-task-1"
|
||||
assert second.detail == "地形瓦片任务已存在,无需重复提交。"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_file_record_terrain_layer_and_tile_read_from_record_storage(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationFileRecord.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
record = ElevationFileRecord(
|
||||
id="abcdef1234567890abcdef1234567890",
|
||||
file_name="terrain.tif",
|
||||
file_path="/elevation/records/ab/cd/terrain.tif",
|
||||
file_format="tif",
|
||||
file_size=128,
|
||||
mount_code="default",
|
||||
status="active",
|
||||
terrain_status="ready",
|
||||
terrain_root_path="/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890",
|
||||
terrain_url_template="/api/v1/elevation/records/abcdef1234567890abcdef1234567890/terrain/{z}/{x}/{y}.terrain?v=1.0.0",
|
||||
terrain_min_zoom=0,
|
||||
terrain_max_zoom=0,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
|
||||
driver = _MemoryStorageDriver()
|
||||
layer_payload = b'{"tilejson":"2.1.0","format":"heightmap-1.0","version":"1.0.0","scheme":"tms","projection":"EPSG:4326","tiles":["{z}/{x}/{y}.terrain?v=1.0.0"],"minzoom":0,"maxzoom":0}'
|
||||
driver.write_file(
|
||||
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/layer.json",
|
||||
content=layer_payload,
|
||||
content_type="application/json",
|
||||
)
|
||||
driver.write_file(
|
||||
"/elevation/terrain/records/ab/cd/abcdef1234567890abcdef1234567890/0/0/0.terrain",
|
||||
content=b"tile-bytes",
|
||||
content_type="application/octet-stream",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace(code="default"))
|
||||
monkeypatch.setattr(elevation_service, "_build_driver_or_400", lambda *_args, **_kwargs: driver)
|
||||
|
||||
layer = elevation_service.get_file_record_terrain_layer(session, record_id=record.id)
|
||||
tile = elevation_service.get_file_record_terrain_tile(session, record_id=record.id, z=0, x=0, y=0)
|
||||
|
||||
assert layer.maxzoom == 0
|
||||
assert layer.tiles == ["{z}/{x}/{y}.terrain?v=1.0.0"]
|
||||
assert tile == b"tile-bytes"
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_import_dataset_data_files_queue_job_and_worker_keeps_preferred_raster(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(ElevationDataset.__table__, ElevationDataImportJob.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
dataset = ElevationDataset(
|
||||
code="ELEV-IMPORT-001",
|
||||
name="批量导入样例",
|
||||
file_format="csv",
|
||||
mount_code="default",
|
||||
dataset_dir="/elevation/datasets/ELEV-IMPORT-001",
|
||||
file_path="/elevation/datasets/ELEV-IMPORT-001/dataset.csv",
|
||||
status="active",
|
||||
usage_status="idle",
|
||||
sample_count=128,
|
||||
bbox_min_lon=100.0,
|
||||
bbox_max_lon=120.0,
|
||||
bbox_min_lat=20.0,
|
||||
bbox_max_lat=30.0,
|
||||
analysis_task_id="old-task",
|
||||
analysis_status="success",
|
||||
terrain_status="not_supported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.commit()
|
||||
|
||||
actor = User(
|
||||
id="actor-1",
|
||||
email="actor@example.com",
|
||||
username="actor",
|
||||
password_hash="hashed",
|
||||
status="active",
|
||||
)
|
||||
driver = _MemoryStorageDriver()
|
||||
import_calls: list[tuple[str, str | None]] = []
|
||||
analysis_calls: list[tuple[str, str | None]] = []
|
||||
|
||||
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace(code="default"))
|
||||
monkeypatch.setattr(elevation_service, "_build_driver_or_400", lambda *_args, **_kwargs: driver)
|
||||
monkeypatch.setattr(
|
||||
elevation_service,
|
||||
"_dispatch_elevation_dataset_data_import_task",
|
||||
lambda *, import_job_id, actor_user_id: import_calls.append((import_job_id, actor_user_id)) or SimpleNamespace(id="import-task-1"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
elevation_service,
|
||||
"_dispatch_elevation_dataset_analysis_task",
|
||||
lambda *, dataset_id, actor_user_id: analysis_calls.append((dataset_id, actor_user_id)) or SimpleNamespace(id="new-task"),
|
||||
)
|
||||
monkeypatch.setattr(elevation_service, "_publish_elevation_change", lambda *_args, **_kwargs: None)
|
||||
|
||||
first = elevation_service.import_dataset_data_files(
|
||||
session,
|
||||
dataset_id=dataset.id,
|
||||
files=[_build_upload("terrain.img", b"img-bytes", "application/octet-stream")],
|
||||
actor=actor,
|
||||
trigger_analysis=True,
|
||||
)
|
||||
assert first.queued is True
|
||||
assert first.job.task_id == "import-task-1"
|
||||
assert first.job.status == "pending"
|
||||
assert first.job.uploaded_file_count == 1
|
||||
assert first.job.analysis_task_queued is False
|
||||
assert import_calls == [(first.job.id, actor.id)]
|
||||
saved_pending_job = session.get(ElevationDataImportJob, first.job.id)
|
||||
assert saved_pending_job is not None
|
||||
assert saved_pending_job.staged_files_json[0]["filename"] == "terrain.img"
|
||||
assert "content_base64" in saved_pending_job.staged_files_json[0]
|
||||
|
||||
second = elevation_service.import_dataset_data_files(
|
||||
session,
|
||||
dataset_id=dataset.id,
|
||||
files=[_build_upload("points.csv", b"lon,lat,elevation\n1,2,3\n", "text/csv")],
|
||||
actor=actor,
|
||||
trigger_analysis=True,
|
||||
)
|
||||
assert second.queued is False
|
||||
assert second.job.id == first.job.id
|
||||
assert second.detail == "导入任务已存在,无需重复提交。"
|
||||
|
||||
monkeypatch.setattr(elevation_service, "SessionLocal", testing_session)
|
||||
elevation_service.execute_dataset_data_import_job(import_job_id=first.job.id, actor_user_id=actor.id)
|
||||
|
||||
verification = testing_session()
|
||||
try:
|
||||
saved_dataset = verification.get(ElevationDataset, dataset.id)
|
||||
saved_job = verification.get(ElevationDataImportJob, first.job.id)
|
||||
assert saved_dataset is not None
|
||||
assert saved_job is not None
|
||||
assert saved_job.status == "success"
|
||||
assert saved_job.progress_percent == 100
|
||||
assert saved_job.analysis_task_queued is True
|
||||
assert saved_job.analysis_task_id == "new-task"
|
||||
assert saved_job.imported_file_count == 1
|
||||
assert saved_dataset.file_path.endswith("/terrain.img")
|
||||
assert saved_dataset.file_format == "img"
|
||||
assert saved_dataset.analysis_status == "queued"
|
||||
assert saved_dataset.analysis_task_id == "new-task"
|
||||
assert saved_dataset.sample_count == 0
|
||||
assert saved_dataset.bbox_min_lon is None
|
||||
assert saved_dataset.terrain_status == "pending"
|
||||
assert analysis_calls == [(dataset.id, actor.id)]
|
||||
assert set(driver.files) == {
|
||||
"/elevation/datasets/ELEV-IMPORT-001/terrain.img",
|
||||
}
|
||||
finally:
|
||||
verification.close()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_wine_create_run_queues_task_and_worker_records_failure(monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker(WineRun.__table__)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
monkeypatch.setattr(wine_service, "_resolve_binary", lambda: "/usr/bin/wine")
|
||||
monkeypatch.setattr(
|
||||
wine_service,
|
||||
"probe_wine_binary",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(available=True, error=None, version="wine-10.0"),
|
||||
)
|
||||
monkeypatch.setattr(wine_service, "_resolve_executable", lambda _path: Path("/tmp/demo.exe"))
|
||||
monkeypatch.setattr(wine_service, "_resolve_working_dir", lambda _path, _exe: Path("/tmp"))
|
||||
monkeypatch.setattr(
|
||||
wine_service,
|
||||
"_dispatch_wine_run_task",
|
||||
lambda **_: SimpleNamespace(id="wine-task-1"),
|
||||
)
|
||||
|
||||
created = wine_service.create_run(
|
||||
session,
|
||||
payload=WineRunRequest(exe_path="demo.exe", arguments=["/silent"]),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert created.status == "pending"
|
||||
assert created.task_id == "wine-task-1"
|
||||
|
||||
monkeypatch.setattr(wine_service, "SessionLocal", testing_session)
|
||||
monkeypatch.setattr(
|
||||
wine_service.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: SimpleNamespace(returncode=9, stdout="stdout", stderr="stderr"),
|
||||
)
|
||||
|
||||
wine_service.execute_run_job(run_id=created.id, actor_user_id="tester")
|
||||
|
||||
saved = session.execute(select(WineRun).where(WineRun.id == created.id)).scalar_one()
|
||||
session.refresh(saved)
|
||||
assert saved.status == "failed"
|
||||
assert saved.exit_code == 9
|
||||
assert saved.error_message == "Wine process exited with code 9"
|
||||
assert saved.stdout_text == "stdout"
|
||||
assert saved.stderr_text == "stderr"
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1,110 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core import database as core_database
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import Base
|
||||
from app.models.atp_model import AtpModel, AtpModelVersion, AtpSimulationRun
|
||||
from app.schemas.atp_model import AtpSimulationRunRequest
|
||||
from app.services import atp_model_service
|
||||
|
||||
|
||||
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_run_model_version_dry_run_records_worker_command(monkeypatch, tmp_path) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
monkeypatch.setattr(core_database, "SessionLocal", testing_session)
|
||||
monkeypatch.setattr(atp_model_service, "_publish_change", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(atp_model_service, "_resolve_storage_root", lambda: tmp_path / "storage")
|
||||
monkeypatch.setattr(atp_model_service, "_resolve_engine_workdir", lambda: tmp_path / "runs")
|
||||
monkeypatch.setattr(
|
||||
atp_model_service,
|
||||
"_resolve_wine_engine_executable",
|
||||
lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None),
|
||||
)
|
||||
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
model = AtpModel(
|
||||
code="ATP-DRY-001",
|
||||
name="Dry Run ATP",
|
||||
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",
|
||||
entry_file="case.atp",
|
||||
atp_text="BEGIN ATP CASE",
|
||||
content_hash="dry-hash-v1",
|
||||
)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
||||
result = atp_model_service.run_model_version(
|
||||
session,
|
||||
model_id=model.id,
|
||||
payload=AtpSimulationRunRequest(version_id=version.id, dry_run=True),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.engine_command is not None
|
||||
assert result.engine_command.startswith("/usr/bin/wine /tmp/tpbig.exe ")
|
||||
assert result.engine_command.endswith("/case.atp")
|
||||
assert result.working_dir is not None
|
||||
assert result.stdout_text is not None
|
||||
|
||||
saved = session.execute(select(AtpSimulationRun).where(AtpSimulationRun.id == result.id)).scalar_one()
|
||||
assert saved.status == "success"
|
||||
assert saved.exit_code == 0
|
||||
assert saved.error_message is None
|
||||
assert "dry_run" in (saved.stdout_text or "")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_get_engine_status_includes_legacy_asset_checks(monkeypatch, tmp_path) -> None:
|
||||
allowed_root = tmp_path / "wine-root"
|
||||
template_root = allowed_root / "ATP" / "templates"
|
||||
template_root.mkdir(parents=True)
|
||||
(template_root / "EGM").mkdir()
|
||||
(allowed_root / "ATP").mkdir(exist_ok=True)
|
||||
(allowed_root / "ATP" / "tpbig.exe").write_text("binary", encoding="utf-8")
|
||||
(allowed_root / "ATP" / "rjtzl.exe").write_text("binary", encoding="utf-8")
|
||||
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "wine_allowed_root", str(allowed_root))
|
||||
monkeypatch.setattr(settings, "atp_legacy_root", str(allowed_root / "ATP"))
|
||||
monkeypatch.setattr(settings, "atp_template_root", str(template_root))
|
||||
monkeypatch.setattr(settings, "atp_run_root", str(allowed_root / "runs"))
|
||||
monkeypatch.setattr(settings, "atp_tpbig_executable", "ATP/tpbig.exe")
|
||||
monkeypatch.setattr(settings, "atp_rjtzl_executable", "ATP/rjtzl.exe")
|
||||
monkeypatch.setattr(atp_model_service, "_resolve_wine_engine_executable", lambda: ("/usr/bin/wine", "/tmp/tpbig.exe", None))
|
||||
|
||||
result = atp_model_service.get_engine_status()
|
||||
|
||||
assert "legacy_root" in result.checks
|
||||
assert result.checks["legacy_root"]["available"] is True
|
||||
assert result.checks["tpbig_executable"]["available"] is True
|
||||
assert result.checks["rjtzl_executable"]["available"] is True
|
||||
assert result.checks["egm_subdir"]["available"] is True
|
||||
@@ -1,66 +0,0 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user