[fix]:[FL-215][ATP模型管理去掉版本概念:上传文件直接用于展示]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -9,6 +9,7 @@ from ...schemas.atp_asset import (
|
|||||||
AtpAssetCreateRequest,
|
AtpAssetCreateRequest,
|
||||||
AtpAssetDetail,
|
AtpAssetDetail,
|
||||||
AtpAssetFileListResponse,
|
AtpAssetFileListResponse,
|
||||||
|
AtpAssetFileUploadResponse,
|
||||||
AtpAssetListResponse,
|
AtpAssetListResponse,
|
||||||
AtpAssetReleaseCreateRequest,
|
AtpAssetReleaseCreateRequest,
|
||||||
AtpAssetReleaseDetail,
|
AtpAssetReleaseDetail,
|
||||||
@@ -32,6 +33,7 @@ from ...services.atp_asset_service import (
|
|||||||
get_release_by_id,
|
get_release_by_id,
|
||||||
get_run_detail,
|
get_run_detail,
|
||||||
list_assets,
|
list_assets,
|
||||||
|
list_asset_files,
|
||||||
list_release_files,
|
list_release_files,
|
||||||
list_releases,
|
list_releases,
|
||||||
list_runs,
|
list_runs,
|
||||||
@@ -40,6 +42,7 @@ from ...services.atp_asset_service import (
|
|||||||
serialize_release_detail,
|
serialize_release_detail,
|
||||||
update_asset,
|
update_asset,
|
||||||
update_release,
|
update_release,
|
||||||
|
upload_asset_archive,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
|
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
|
||||||
@@ -88,6 +91,30 @@ def create_atp_asset_endpoint(
|
|||||||
return AtpAssetDetail(**created.model_dump())
|
return AtpAssetDetail(**created.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assets/{asset_id}/files/upload", response_model=AtpAssetFileUploadResponse)
|
||||||
|
def upload_atp_asset_files_endpoint(
|
||||||
|
asset_id: str,
|
||||||
|
archive: UploadFile = File(...),
|
||||||
|
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AtpAssetFileUploadResponse:
|
||||||
|
try:
|
||||||
|
archive_content = archive.file.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
archive.file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return upload_asset_archive(
|
||||||
|
db,
|
||||||
|
asset_id=asset_id,
|
||||||
|
archive_filename=archive.filename or "model.zip",
|
||||||
|
archive_content=archive_content,
|
||||||
|
actor_user_id=current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/assets/{asset_id}", response_model=AtpAssetDetail)
|
@router.get("/assets/{asset_id}", response_model=AtpAssetDetail)
|
||||||
def get_atp_asset_detail(
|
def get_atp_asset_detail(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
@@ -245,6 +272,15 @@ def get_atp_release_files(
|
|||||||
return list_release_files(db, release_id=release_id)
|
return list_release_files(db, release_id=release_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets/{asset_id}/files", response_model=AtpAssetFileListResponse)
|
||||||
|
def get_atp_asset_files(
|
||||||
|
asset_id: str,
|
||||||
|
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> AtpAssetFileListResponse:
|
||||||
|
return list_asset_files(db, asset_id=asset_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse)
|
@router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse)
|
||||||
def get_atp_release_runs(
|
def get_atp_release_runs(
|
||||||
release_id: str,
|
release_id: str,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class AtpAssetSummary(BaseModel):
|
|||||||
active_release_no: int | None = None
|
active_release_no: int | None = None
|
||||||
active_release_id: str | None = None
|
active_release_id: str | None = None
|
||||||
active_release_tag: str | None = None
|
active_release_tag: str | None = None
|
||||||
|
storage_mount_code: str | None = None
|
||||||
|
storage_root_path: str | None = None
|
||||||
release_count: int = 0
|
release_count: int = 0
|
||||||
run_count: int = 0
|
run_count: int = 0
|
||||||
last_run_status: AtpAssetRunStatus | None = None
|
last_run_status: AtpAssetRunStatus | None = None
|
||||||
@@ -151,13 +153,22 @@ class AtpAssetFileEntry(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AtpAssetFileListResponse(BaseModel):
|
class AtpAssetFileListResponse(BaseModel):
|
||||||
release_id: str
|
asset_id: str
|
||||||
|
release_id: str | None = None
|
||||||
storage_mount_code: str
|
storage_mount_code: str
|
||||||
storage_root_path: str
|
storage_root_path: str
|
||||||
items: list[AtpAssetFileEntry]
|
items: list[AtpAssetFileEntry]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class AtpAssetFileUploadResponse(BaseModel):
|
||||||
|
asset_id: str
|
||||||
|
storage_mount_code: str
|
||||||
|
storage_root_path: str
|
||||||
|
uploaded_count: int
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
|
||||||
class AtpAssetRunSummary(BaseModel):
|
class AtpAssetRunSummary(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
asset_id: str
|
asset_id: str
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import time
|
|||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
@@ -29,6 +30,7 @@ from ..schemas.atp_asset import (
|
|||||||
AtpAssetDetail,
|
AtpAssetDetail,
|
||||||
AtpAssetFileEntry,
|
AtpAssetFileEntry,
|
||||||
AtpAssetFileListResponse,
|
AtpAssetFileListResponse,
|
||||||
|
AtpAssetFileUploadResponse,
|
||||||
AtpAssetListResponse,
|
AtpAssetListResponse,
|
||||||
AtpAssetReleaseCreateRequest,
|
AtpAssetReleaseCreateRequest,
|
||||||
AtpAssetReleaseDetail,
|
AtpAssetReleaseDetail,
|
||||||
@@ -65,6 +67,7 @@ VALID_RUNNER_KIND = {"atp", "egm", "hybrid"}
|
|||||||
VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
|
VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
|
||||||
LOG_MAX_CHARS = 200_000
|
LOG_MAX_CHARS = 200_000
|
||||||
ATP_ASSET_RELEASES_ROOT = "/atp-library"
|
ATP_ASSET_RELEASES_ROOT = "/atp-library"
|
||||||
|
ATP_ASSET_FILES_ROOT = "/atp-library/assets"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -355,6 +358,14 @@ def _build_release_storage_root(asset_code: str, release_no: int, voltage_level:
|
|||||||
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}")
|
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_asset_storage_root(asset: AtpAsset) -> str:
|
||||||
|
return normalize_virtual_path(f"{ATP_ASSET_FILES_ROOT}/{asset.id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_storage_mount() -> str:
|
||||||
|
return "main"
|
||||||
|
|
||||||
|
|
||||||
def _write_archive_to_storage(
|
def _write_archive_to_storage(
|
||||||
driver: StorageDriver,
|
driver: StorageDriver,
|
||||||
*,
|
*,
|
||||||
@@ -364,9 +375,9 @@ def _write_archive_to_storage(
|
|||||||
) -> int:
|
) -> int:
|
||||||
filename = (archive_filename or "").strip().lower()
|
filename = (archive_filename or "").strip().lower()
|
||||||
if filename and not filename.endswith(".zip"):
|
if filename and not filename.endswith(".zip"):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包必须是 zip 格式")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包必须是 zip 格式")
|
||||||
if not archive_content:
|
if not archive_content:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包不能为空")
|
||||||
|
|
||||||
driver.ensure_directory(storage_root_path)
|
driver.ensure_directory(storage_root_path)
|
||||||
ensured_directories = {normalize_virtual_path(storage_root_path)}
|
ensured_directories = {normalize_virtual_path(storage_root_path)}
|
||||||
@@ -398,13 +409,41 @@ def _write_archive_to_storage(
|
|||||||
)
|
)
|
||||||
extracted_count += 1
|
extracted_count += 1
|
||||||
except zipfile.BadZipFile as exc:
|
except zipfile.BadZipFile as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Release ZIP 文件损坏: {exc}") from exc
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"ZIP 文件损坏: {exc}") from exc
|
||||||
|
|
||||||
if extracted_count <= 0:
|
if extracted_count <= 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包中没有可导入文件")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包中没有可导入文件")
|
||||||
return extracted_count
|
return extracted_count
|
||||||
|
|
||||||
|
|
||||||
|
def _read_upload_file_bytes(file: Any) -> tuple[str, bytes, str | None]:
|
||||||
|
filename = (getattr(file, "filename", None) or "").strip() or "upload.zip"
|
||||||
|
content_type = getattr(file, "content_type", None)
|
||||||
|
try:
|
||||||
|
content = file.file.read()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
file.file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return filename, content, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _write_archive_to_asset_storage(
|
||||||
|
driver: StorageDriver,
|
||||||
|
*,
|
||||||
|
asset_storage_root: str,
|
||||||
|
archive_filename: str,
|
||||||
|
archive_content: bytes,
|
||||||
|
) -> int:
|
||||||
|
return _write_archive_to_storage(
|
||||||
|
driver,
|
||||||
|
storage_root_path=asset_storage_root,
|
||||||
|
archive_filename=archive_filename,
|
||||||
|
archive_content=archive_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runner_kind_from_tree(tree: StorageTree) -> str:
|
def _resolve_runner_kind_from_tree(tree: StorageTree) -> str:
|
||||||
detected_entry = _auto_detect_entry_file(tree)
|
detected_entry = _auto_detect_entry_file(tree)
|
||||||
detected_egm_subdir = _auto_detect_egm_subdir(tree)
|
detected_egm_subdir = _auto_detect_egm_subdir(tree)
|
||||||
@@ -584,6 +623,8 @@ def serialize_asset(
|
|||||||
active_release_no=item.active_release_no,
|
active_release_no=item.active_release_no,
|
||||||
active_release_id=active_release.id if active_release else None,
|
active_release_id=active_release.id if active_release else None,
|
||||||
active_release_tag=active_release.release_tag if active_release else None,
|
active_release_tag=active_release.release_tag if active_release else None,
|
||||||
|
storage_mount_code=_asset_storage_mount(),
|
||||||
|
storage_root_path=_build_asset_storage_root(item),
|
||||||
release_count=release_count,
|
release_count=release_count,
|
||||||
run_count=run_count,
|
run_count=run_count,
|
||||||
last_run_status=last_run_status, # type: ignore[arg-type]
|
last_run_status=last_run_status, # type: ignore[arg-type]
|
||||||
@@ -838,6 +879,108 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
|
|||||||
return serialize_asset(saved, release_count=0, run_count=0, last_run_status=None, last_run_date=None, active_release=None)
|
return serialize_asset(saved, release_count=0, run_count=0, last_run_status=None, last_run_date=None, active_release=None)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset_archive(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
asset_id: str,
|
||||||
|
archive_filename: str,
|
||||||
|
archive_content: bytes,
|
||||||
|
actor_user_id: str,
|
||||||
|
) -> AtpAssetFileUploadResponse:
|
||||||
|
asset = get_asset_by_id(db, asset_id)
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||||
|
|
||||||
|
storage_mount_code = _asset_storage_mount()
|
||||||
|
storage_root_path = _build_asset_storage_root(asset)
|
||||||
|
mount = _resolve_mount(db, storage_mount_code)
|
||||||
|
driver = _build_driver_or_400(mount)
|
||||||
|
root_parent = _parent_virtual_path(storage_root_path)
|
||||||
|
root_name = PurePosixPath(storage_root_path).name or asset.id
|
||||||
|
staging_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__upload__{uuid4().hex}")
|
||||||
|
backup_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__backup__{uuid4().hex}")
|
||||||
|
backup_exists = False
|
||||||
|
try:
|
||||||
|
uploaded_count = _write_archive_to_asset_storage(
|
||||||
|
driver,
|
||||||
|
asset_storage_root=staging_root_path,
|
||||||
|
archive_filename=archive_filename,
|
||||||
|
archive_content=archive_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver.list_dir(storage_root_path)
|
||||||
|
backup_exists = True
|
||||||
|
except StoragePathNotFoundError:
|
||||||
|
backup_exists = False
|
||||||
|
|
||||||
|
if backup_exists:
|
||||||
|
driver.move_path(
|
||||||
|
storage_root_path,
|
||||||
|
is_dir=True,
|
||||||
|
target_parent_path=root_parent,
|
||||||
|
new_name=PurePosixPath(backup_root_path).name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver.move_path(
|
||||||
|
staging_root_path,
|
||||||
|
is_dir=True,
|
||||||
|
target_parent_path=root_parent,
|
||||||
|
new_name=root_name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if backup_exists:
|
||||||
|
try:
|
||||||
|
driver.move_path(
|
||||||
|
backup_root_path,
|
||||||
|
is_dir=True,
|
||||||
|
target_parent_path=root_parent,
|
||||||
|
new_name=root_name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
driver.delete_path(staging_root_path, is_dir=True, recursive=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if backup_exists:
|
||||||
|
try:
|
||||||
|
driver.delete_path(backup_root_path, is_dir=True, recursive=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = _walk_storage_tree(driver, storage_root_path)
|
||||||
|
except HTTPException:
|
||||||
|
tree = StorageTree(files=[], directories=[], file_paths=set(), dir_paths=set(), max_depth=0)
|
||||||
|
|
||||||
|
asset.active_release_no = None
|
||||||
|
asset.update_user = actor_user_id
|
||||||
|
asset.update_date = utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
_publish_change(
|
||||||
|
"asset.files_uploaded",
|
||||||
|
{
|
||||||
|
"action": "files_uploaded",
|
||||||
|
"asset_id": asset.id,
|
||||||
|
"storage_root_path": storage_root_path,
|
||||||
|
"file_count": len(tree.files),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return AtpAssetFileUploadResponse(
|
||||||
|
asset_id=asset.id,
|
||||||
|
storage_mount_code=storage_mount_code,
|
||||||
|
storage_root_path=storage_root_path,
|
||||||
|
uploaded_count=uploaded_count,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def update_asset(
|
def update_asset(
|
||||||
db: Session,
|
db: Session,
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
@@ -890,6 +1033,12 @@ def delete_asset(db: Session, asset_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Delete physical files for all releases before deleting database records
|
# Delete physical files for all releases before deleting database records
|
||||||
|
try:
|
||||||
|
asset_mount = _resolve_mount(db, _asset_storage_mount())
|
||||||
|
asset_driver = _build_driver_or_400(asset_mount)
|
||||||
|
asset_driver.delete_path(_build_asset_storage_root(item), is_dir=True, recursive=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for release in item.releases:
|
for release in item.releases:
|
||||||
try:
|
try:
|
||||||
mount = _resolve_mount(db, release.storage_mount_code)
|
mount = _resolve_mount(db, release.storage_mount_code)
|
||||||
@@ -1296,6 +1445,7 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo
|
|||||||
]
|
]
|
||||||
merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path))
|
merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path))
|
||||||
return AtpAssetFileListResponse(
|
return AtpAssetFileListResponse(
|
||||||
|
asset_id=release.asset_id,
|
||||||
release_id=release.id,
|
release_id=release.id,
|
||||||
storage_mount_code=release.storage_mount_code,
|
storage_mount_code=release.storage_mount_code,
|
||||||
storage_root_path=release.storage_root_path,
|
storage_root_path=release.storage_root_path,
|
||||||
@@ -1304,6 +1454,70 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_asset_files(db: Session, *, asset_id: str) -> AtpAssetFileListResponse:
|
||||||
|
asset = get_asset_by_id(db, asset_id)
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||||
|
|
||||||
|
storage_mount_code = _asset_storage_mount()
|
||||||
|
storage_root_path = _build_asset_storage_root(asset)
|
||||||
|
mount = _resolve_mount(db, storage_mount_code)
|
||||||
|
driver = _build_driver_or_400(mount)
|
||||||
|
try:
|
||||||
|
tree = _walk_storage_tree(driver, storage_root_path)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
|
fallback_release = db.execute(
|
||||||
|
select(AtpAssetRelease)
|
||||||
|
.options(joinedload(AtpAssetRelease.asset))
|
||||||
|
.where(AtpAssetRelease.asset_id == asset.id)
|
||||||
|
.order_by(AtpAssetRelease.is_active.desc(), AtpAssetRelease.release_no.desc(), AtpAssetRelease.id.desc())
|
||||||
|
).scalars().first()
|
||||||
|
if fallback_release:
|
||||||
|
return list_release_files(db, release_id=fallback_release.id)
|
||||||
|
return AtpAssetFileListResponse(
|
||||||
|
asset_id=asset.id,
|
||||||
|
release_id=None,
|
||||||
|
storage_mount_code=storage_mount_code,
|
||||||
|
storage_root_path=storage_root_path,
|
||||||
|
items=[],
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
items = [
|
||||||
|
AtpAssetFileEntry(
|
||||||
|
relative_path=_relative_from_root(storage_root_path, item.path),
|
||||||
|
name=item.name,
|
||||||
|
is_dir=False,
|
||||||
|
size=item.size,
|
||||||
|
mime_type=item.mime_type,
|
||||||
|
file_role=_infer_asset_file_role(asset, _relative_from_root(storage_root_path, item.path), False),
|
||||||
|
)
|
||||||
|
for item in tree.files
|
||||||
|
]
|
||||||
|
dir_items = [
|
||||||
|
AtpAssetFileEntry(
|
||||||
|
relative_path=path,
|
||||||
|
name=path.split("/")[-1],
|
||||||
|
is_dir=True,
|
||||||
|
size=0,
|
||||||
|
mime_type=None,
|
||||||
|
file_role=_infer_asset_file_role(asset, path, True),
|
||||||
|
)
|
||||||
|
for path in tree.directories
|
||||||
|
]
|
||||||
|
merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path))
|
||||||
|
return AtpAssetFileListResponse(
|
||||||
|
asset_id=asset.id,
|
||||||
|
release_id=None,
|
||||||
|
storage_mount_code=storage_mount_code,
|
||||||
|
storage_root_path=storage_root_path,
|
||||||
|
items=merged_items,
|
||||||
|
total=len(merged_items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None:
|
def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None:
|
||||||
if is_dir:
|
if is_dir:
|
||||||
if release.egm_subdir and relative_path == release.egm_subdir:
|
if release.egm_subdir and relative_path == release.egm_subdir:
|
||||||
@@ -1333,6 +1547,21 @@ def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool)
|
|||||||
return "other"
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_asset_file_role(asset: AtpAsset, relative_path: str, is_dir: bool) -> str | None:
|
||||||
|
if is_dir:
|
||||||
|
return None
|
||||||
|
lower = relative_path.lower()
|
||||||
|
if lower.endswith(".atp"):
|
||||||
|
return "atp"
|
||||||
|
if lower.endswith(".py"):
|
||||||
|
return "script"
|
||||||
|
if lower.endswith(("tpbig.exe", "rjtzl.exe")):
|
||||||
|
return "executable"
|
||||||
|
if lower.endswith((".doc", ".docx", ".txt", ".md", ".pdf")):
|
||||||
|
return "doc"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
def list_runs(
|
def list_runs(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -235,6 +235,80 @@ def test_create_release_from_archive_detects_storage_path_conflict(tmp_path) ->
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_asset_archive_and_list_asset_files(tmp_path) -> None:
|
||||||
|
testing_session = _build_sessionmaker()
|
||||||
|
session: Session = testing_session()
|
||||||
|
try:
|
||||||
|
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
|
||||||
|
asset = atp_asset_service.create_asset(
|
||||||
|
session,
|
||||||
|
AtpAssetCreateRequest(
|
||||||
|
code="ATP-ASSET-DIRECT-UPLOAD",
|
||||||
|
name="直接上传模型",
|
||||||
|
voltage_level="220",
|
||||||
|
tower_type="sihuita",
|
||||||
|
scene_type="raoji3",
|
||||||
|
),
|
||||||
|
actor_user_id="tester",
|
||||||
|
)
|
||||||
|
assert asset is not None
|
||||||
|
|
||||||
|
uploaded = atp_asset_service.upload_asset_archive(
|
||||||
|
session,
|
||||||
|
asset_id=asset.id,
|
||||||
|
archive_filename="model.zip",
|
||||||
|
archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}),
|
||||||
|
actor_user_id="tester",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert uploaded.success is True
|
||||||
|
assert uploaded.storage_root_path == f"/atp-library/assets/{asset.id}"
|
||||||
|
|
||||||
|
files = atp_asset_service.list_asset_files(session, asset_id=asset.id)
|
||||||
|
assert files.asset_id == asset.id
|
||||||
|
assert files.release_id is None
|
||||||
|
assert files.total == 3
|
||||||
|
assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_asset_files_falls_back_to_latest_release(tmp_path) -> None:
|
||||||
|
testing_session = _build_sessionmaker()
|
||||||
|
session: Session = testing_session()
|
||||||
|
try:
|
||||||
|
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
|
||||||
|
asset = atp_asset_service.create_asset(
|
||||||
|
session,
|
||||||
|
AtpAssetCreateRequest(
|
||||||
|
code="ATP-ASSET-LEGACY-FALLBACK",
|
||||||
|
name="旧版本模型",
|
||||||
|
voltage_level="220",
|
||||||
|
tower_type="sihuita",
|
||||||
|
scene_type="raoji3",
|
||||||
|
),
|
||||||
|
actor_user_id="tester",
|
||||||
|
)
|
||||||
|
assert asset is not None
|
||||||
|
|
||||||
|
release = atp_asset_service.create_release_from_archive(
|
||||||
|
session,
|
||||||
|
asset_id=asset.id,
|
||||||
|
release_tag="v1",
|
||||||
|
archive_filename="release.zip",
|
||||||
|
archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}),
|
||||||
|
actor_user_id="tester",
|
||||||
|
)
|
||||||
|
|
||||||
|
files = atp_asset_service.list_asset_files(session, asset_id=asset.id)
|
||||||
|
assert files.asset_id == asset.id
|
||||||
|
assert files.release_id == release.id
|
||||||
|
assert files.total == 3
|
||||||
|
assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def test_list_assets_paginates_after_filtering(tmp_path) -> None:
|
def test_list_assets_paginates_after_filtering(tmp_path) -> None:
|
||||||
testing_session = _build_sessionmaker()
|
testing_session = _build_sessionmaker()
|
||||||
session: Session = testing_session()
|
session: Session = testing_session()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Typography,
|
Typography,
|
||||||
Upload,
|
Upload,
|
||||||
message,
|
|
||||||
type CardProps,
|
type CardProps,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons";
|
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons";
|
||||||
@@ -31,7 +30,7 @@ import { useAuth } from "@/components/auth-provider";
|
|||||||
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
import { useMobileDetection } from "@/hooks/use-mobile-detection";
|
||||||
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
import { useToastFeedback } from "@/hooks/use-toast-feedback";
|
||||||
import { readApiError } from "@/lib/api";
|
import { readApiError } from "@/lib/api";
|
||||||
import type { AtpAssetListResponse, AtpAssetSummary, AtpAssetFileEntry } from "@/types/auth";
|
import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
|
||||||
|
|
||||||
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
|
||||||
|
|
||||||
@@ -229,20 +228,18 @@ export default function AtpModelsPage() {
|
|||||||
const createdAsset = await response.json();
|
const createdAsset = await response.json();
|
||||||
|
|
||||||
if (values.files.length > 0) {
|
if (values.files.length > 0) {
|
||||||
|
const formData = new FormData();
|
||||||
const JSZip = (await import("jszip")).default;
|
const JSZip = (await import("jszip")).default;
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
|
|
||||||
for (const file of values.files) {
|
for (const file of values.files) {
|
||||||
const path = (file as any).webkitRelativePath || file.name;
|
const path = (file as any).webkitRelativePath || file.name;
|
||||||
zip.file(path, file);
|
zip.file(path, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("archive", zipBlob, "model.zip");
|
formData.append("archive", zipBlob, "model.zip");
|
||||||
|
|
||||||
const uploadResponse = await fetchWithAuth(
|
const uploadResponse = await fetchWithAuth(
|
||||||
`/api/v1/atp/assets/${createdAsset.id}/releases/upload`,
|
`/api/v1/atp/assets/${createdAsset.id}/files/upload`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
@@ -536,10 +533,10 @@ export default function AtpModelsPage() {
|
|||||||
|
|
||||||
const filesQueries = useQueries({
|
const filesQueries = useQueries({
|
||||||
queries: assetsInCurrentPath.map((asset) => ({
|
queries: assetsInCurrentPath.map((asset) => ({
|
||||||
queryKey: ["atp-asset-files", asset.id, asset.active_release_id],
|
queryKey: ["atp-asset-files", asset.id],
|
||||||
enabled: Boolean(user && canRead && asset.active_release_id && fileViewPath.length >= 4),
|
enabled: Boolean(user && canRead && fileViewPath.length >= 4),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/releases/${asset.active_release_id}/files`);
|
const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/files`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { assetId: asset.id, items: [] };
|
return { assetId: asset.id, items: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -923,6 +923,8 @@ export type AtpAssetSummary = {
|
|||||||
active_release_no: number | null;
|
active_release_no: number | null;
|
||||||
active_release_id: string | null;
|
active_release_id: string | null;
|
||||||
active_release_tag: string | null;
|
active_release_tag: string | null;
|
||||||
|
storage_mount_code: string | null;
|
||||||
|
storage_root_path: string | null;
|
||||||
release_count: number;
|
release_count: number;
|
||||||
run_count: number;
|
run_count: number;
|
||||||
last_run_status: AtpAssetRunStatus | null;
|
last_run_status: AtpAssetRunStatus | null;
|
||||||
@@ -987,13 +989,22 @@ export type AtpAssetFileEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AtpAssetFileListResponse = {
|
export type AtpAssetFileListResponse = {
|
||||||
release_id: string;
|
asset_id: string;
|
||||||
|
release_id: string | null;
|
||||||
storage_mount_code: string;
|
storage_mount_code: string;
|
||||||
storage_root_path: string;
|
storage_root_path: string;
|
||||||
items: AtpAssetFileEntry[];
|
items: AtpAssetFileEntry[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AtpAssetFileUploadResponse = {
|
||||||
|
asset_id: string;
|
||||||
|
storage_mount_code: string;
|
||||||
|
storage_root_path: string;
|
||||||
|
uploaded_count: number;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type AtpAssetRunSummary = {
|
export type AtpAssetRunSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
asset_id: string;
|
asset_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user