mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 14:14:17 +08:00
feat(app_asset): add batch upload and file upload URL generation
- Introduced `GetUploadUrlPayload` and `BatchUploadPayload` models for handling file uploads. - Implemented `AppAssetFileUploadUrlResource` for generating pre-signed upload URLs. - Added `AppAssetBatchUploadResource` to support batch creation of asset nodes from a tree structure. - Enhanced `AppAssetService` with methods for obtaining upload URLs and batch creation of assets. - Removed checksum handling from file creation to streamline the process.
This commit is contained in:
parent
a522327662
commit
225c33633a
@ -10,6 +10,7 @@ from controllers.console.app.error import (
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from core.app.entities.app_asset_entities import BatchUploadNode
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import App
|
||||
from models.model import AppMode
|
||||
@ -47,6 +48,26 @@ class CreateFilePayload(BaseModel):
|
||||
return v or None
|
||||
|
||||
|
||||
class GetUploadUrlPayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
size: int = Field(..., ge=0)
|
||||
parent_id: str | None = None
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def strip_name(cls, v: str) -> str:
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
@field_validator("parent_id", mode="before")
|
||||
@classmethod
|
||||
def empty_to_none(cls, v: str | None) -> str | None:
|
||||
return v or None
|
||||
|
||||
|
||||
class BatchUploadPayload(BaseModel):
|
||||
children: list[BatchUploadNode] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class UpdateFileContentPayload(BaseModel):
|
||||
content: str
|
||||
|
||||
@ -69,6 +90,9 @@ def reg(cls: type[BaseModel]) -> None:
|
||||
|
||||
reg(CreateFolderPayload)
|
||||
reg(CreateFilePayload)
|
||||
reg(GetUploadUrlPayload)
|
||||
reg(BatchUploadNode)
|
||||
reg(BatchUploadPayload)
|
||||
reg(UpdateFileContentPayload)
|
||||
reg(RenameNodePayload)
|
||||
reg(MoveNodePayload)
|
||||
@ -256,3 +280,70 @@ class AppAssetFileDownloadUrlResource(Resource):
|
||||
return {"download_url": download_url}
|
||||
except ServiceNodeNotFoundError:
|
||||
raise AppAssetNodeNotFoundError()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<string:app_id>/assets/files/upload")
|
||||
class AppAssetFileUploadUrlResource(Resource):
|
||||
@console_ns.expect(console_ns.models[GetUploadUrlPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = GetUploadUrlPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
node, upload_url = AppAssetService.get_file_upload_url(
|
||||
app_model, current_user.id, payload.name, payload.size, payload.parent_id
|
||||
)
|
||||
return {"node": node.model_dump(), "upload_url": upload_url}, 201
|
||||
except AppAssetParentNotFoundError:
|
||||
raise AppAssetNodeNotFoundError()
|
||||
except ServicePathConflictError:
|
||||
raise AppAssetPathConflictError()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<string:app_id>/assets/batch-upload")
|
||||
class AppAssetBatchUploadResource(Resource):
|
||||
@console_ns.expect(console_ns.models[BatchUploadPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Create nodes from tree structure and return upload URLs.
|
||||
|
||||
Input:
|
||||
{
|
||||
"children": [
|
||||
{"name": "folder1", "node_type": "folder", "children": [
|
||||
{"name": "file1.txt", "node_type": "file", "size": 1024}
|
||||
]},
|
||||
{"name": "root.txt", "node_type": "file", "size": 512}
|
||||
]
|
||||
}
|
||||
|
||||
Output:
|
||||
{
|
||||
"children": [
|
||||
{"id": "xxx", "name": "folder1", "node_type": "folder", "children": [
|
||||
{"id": "yyy", "name": "file1.txt", "node_type": "file", "size": 1024, "upload_url": "..."}
|
||||
]},
|
||||
{"id": "zzz", "name": "root.txt", "node_type": "file", "size": 512, "upload_url": "..."}
|
||||
]
|
||||
}
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = BatchUploadPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
result_children = AppAssetService.batch_create_from_tree(
|
||||
app_model, current_user.id, payload.children
|
||||
)
|
||||
return {"children": [child.model_dump() for child in result_children]}, 201
|
||||
except AppAssetParentNotFoundError:
|
||||
raise AppAssetNodeNotFoundError()
|
||||
except ServicePathConflictError:
|
||||
raise AppAssetPathConflictError()
|
||||
|
||||
@ -20,16 +20,13 @@ class AppAssetNode(BaseModel):
|
||||
order: int = Field(default=0, description="Sort order within parent folder, lower values first")
|
||||
extension: str = Field(default="", description="File extension without dot, empty for folders")
|
||||
size: int = Field(default=0, description="File size in bytes, 0 for folders")
|
||||
checksum: str = Field(default="", description="SHA-256 checksum of file content, empty for folders")
|
||||
|
||||
@classmethod
|
||||
def create_folder(cls, node_id: str, name: str, parent_id: str | None = None) -> AppAssetNode:
|
||||
return cls(id=node_id, node_type=AssetNodeType.FOLDER, name=name, parent_id=parent_id)
|
||||
|
||||
@classmethod
|
||||
def create_file(
|
||||
cls, node_id: str, name: str, parent_id: str | None = None, size: int = 0, checksum: str = ""
|
||||
) -> AppAssetNode:
|
||||
def create_file(cls, node_id: str, name: str, parent_id: str | None = None, size: int = 0) -> AppAssetNode:
|
||||
return cls(
|
||||
id=node_id,
|
||||
node_type=AssetNodeType.FILE,
|
||||
@ -37,7 +34,6 @@ class AppAssetNode(BaseModel):
|
||||
parent_id=parent_id,
|
||||
extension=name.rsplit(".", 1)[-1] if "." in name else "",
|
||||
size=size,
|
||||
checksum=checksum,
|
||||
)
|
||||
|
||||
|
||||
@ -48,10 +44,39 @@ class AppAssetNodeView(BaseModel):
|
||||
path: str = Field(description="Full path from root, e.g. '/folder/file.txt'")
|
||||
extension: str = Field(default="", description="File extension without dot")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
checksum: str = Field(default="", description="SHA-256 checksum of file content")
|
||||
children: list[AppAssetNodeView] = Field(default_factory=list, description="Child nodes for folders")
|
||||
|
||||
|
||||
class BatchUploadNode(BaseModel):
|
||||
"""Structure for batch upload_url tree nodes, used for both input and output."""
|
||||
|
||||
name: str
|
||||
node_type: AssetNodeType
|
||||
size: int = 0
|
||||
children: list[BatchUploadNode] = []
|
||||
id: str | None = None
|
||||
upload_url: str | None = None
|
||||
|
||||
def to_app_asset_nodes(self, parent_id: str | None = None) -> list[AppAssetNode]:
|
||||
"""
|
||||
Generate IDs and convert to AppAssetNode list.
|
||||
Mutates self to set id field.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
self.id = str(uuid4())
|
||||
nodes: list[AppAssetNode] = []
|
||||
|
||||
if self.node_type == AssetNodeType.FOLDER:
|
||||
nodes.append(AppAssetNode.create_folder(self.id, self.name, parent_id))
|
||||
for child in self.children:
|
||||
nodes.extend(child.to_app_asset_nodes(self.id))
|
||||
else:
|
||||
nodes.append(AppAssetNode.create_file(self.id, self.name, parent_id, self.size))
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
class TreeNodeNotFoundError(Exception):
|
||||
"""Tree internal: node not found"""
|
||||
|
||||
@ -192,12 +217,11 @@ class AppAssetFileTree(BaseModel):
|
||||
self.nodes.append(node)
|
||||
return node
|
||||
|
||||
def update(self, node_id: str, size: int, checksum: str) -> AppAssetNode:
|
||||
def update(self, node_id: str, size: int) -> AppAssetNode:
|
||||
node = self.get(node_id)
|
||||
if not node or node.node_type != AssetNodeType.FILE:
|
||||
raise TreeNodeNotFoundError(node_id)
|
||||
node.size = size
|
||||
node.checksum = checksum
|
||||
return node
|
||||
|
||||
def rename(self, node_id: str, new_name: str) -> AppAssetNode:
|
||||
@ -284,7 +308,6 @@ class AppAssetFileTree(BaseModel):
|
||||
path=path,
|
||||
extension=node.extension,
|
||||
size=node.size,
|
||||
checksum=node.checksum,
|
||||
children=child_views,
|
||||
)
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
@ -77,8 +76,7 @@ class SourceZipExtractor:
|
||||
parent_path = file.path.rsplit("/", 1)[0] if "/" in file.path else None
|
||||
parent_id = path_to_node_id.get(parent_path) if parent_path else None
|
||||
|
||||
checksum = hashlib.sha256(file.content).hexdigest()
|
||||
node = AppAssetNode.create_file(node_id, name, parent_id, len(file.content), checksum)
|
||||
node = AppAssetNode.create_file(node_id, name, parent_id, len(file.content))
|
||||
tree.add(node)
|
||||
|
||||
storage_key = storage_key_fn(tenant_id, app_id, node_id)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
@ -8,6 +7,7 @@ from core.app.entities.app_asset_entities import (
|
||||
AppAssetFileTree,
|
||||
AppAssetNode,
|
||||
AssetNodeType,
|
||||
BatchUploadNode,
|
||||
TreeNodeNotFoundError,
|
||||
TreeParentNotFoundError,
|
||||
TreePathConflictError,
|
||||
@ -34,9 +34,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppAssetService:
|
||||
MAX_PREVIEW_CONTENT_SIZE = 5 * 1024 * 1024 # 1MB
|
||||
MAX_PREVIEW_CONTENT_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
_PRESIGN_CACHE_TTL_BUFFER_SECONDS = 300
|
||||
_PRESIGN_CACHE_MIN_TTL_SECONDS = 60
|
||||
_LOCK_TIMEOUT_SECONDS = 60
|
||||
|
||||
@staticmethod
|
||||
def _lock(app_id: str):
|
||||
return redis_client.lock(f"app_asset:lock:{app_id}", timeout=AppAssetService._LOCK_TIMEOUT_SECONDS)
|
||||
|
||||
@staticmethod
|
||||
def _draft_download_cache_key(storage_key: str) -> str:
|
||||
@ -198,25 +203,27 @@ class AppAssetService:
|
||||
name: str,
|
||||
parent_id: str | None = None,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
node = AppAssetNode.create_folder(str(uuid4()), name, parent_id)
|
||||
node = AppAssetNode.create_folder(str(uuid4()), name, parent_id)
|
||||
|
||||
try:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
try:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
return node
|
||||
return node
|
||||
|
||||
# FIXME(Mairuis): migrate to get_file_upload_url / get_file_upload_urls API
|
||||
@staticmethod
|
||||
def create_file(
|
||||
app_model: App,
|
||||
@ -225,37 +232,37 @@ class AppAssetService:
|
||||
content: bytes,
|
||||
parent_id: str | None = None,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
node_id = str(uuid4())
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
node = AppAssetNode.create_file(node_id, name, parent_id, len(content), checksum)
|
||||
node_id = str(uuid4())
|
||||
node = AppAssetNode.create_file(node_id, name, parent_id, len(content))
|
||||
|
||||
try:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
try:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
storage.save(storage_key, content)
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
storage.save(storage_key, content)
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
cache_key = AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache([cache_key])
|
||||
cache_key = AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache([cache_key])
|
||||
|
||||
return node
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def get_file_content(app_model: App, account_id: str, node_id: str) -> bytes:
|
||||
@ -274,6 +281,7 @@ class AppAssetService:
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
return storage.load_once(storage_key)
|
||||
|
||||
# FIXME(Mairuis): migrate to presigned upload API
|
||||
@staticmethod
|
||||
def update_file_content(
|
||||
app_model: App,
|
||||
@ -281,105 +289,23 @@ class AppAssetService:
|
||||
node_id: str,
|
||||
content: bytes,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
checksum = hashlib.sha256(content).hexdigest()
|
||||
try:
|
||||
node = tree.update(node_id, len(content))
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
|
||||
try:
|
||||
node = tree.update(node_id, len(content), checksum)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
storage.save(storage_key, content)
|
||||
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
storage.save(storage_key, content)
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
cache_key = AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache([cache_key])
|
||||
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def rename_node(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
node_id: str,
|
||||
new_name: str,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
old_node = tree.get(node_id)
|
||||
old_extension = old_node.extension if old_node else None
|
||||
|
||||
try:
|
||||
node = tree.rename(node_id, new_name)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
if node.node_type == AssetNodeType.FILE:
|
||||
cache_keys: list[str] = []
|
||||
if old_extension is not None:
|
||||
old_storage_key = (
|
||||
AssetPaths.build_resolved_file(app_model.tenant_id, app_model.id, assets.id, node.id)
|
||||
if old_extension == "md"
|
||||
else AssetPaths.draft_file(app_model.tenant_id, app_model.id, node.id)
|
||||
)
|
||||
cache_keys.append(old_storage_key)
|
||||
cache_keys.append(
|
||||
AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache(list(set(cache_keys)))
|
||||
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def move_node(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
node_id: str,
|
||||
new_parent_id: str | None,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
try:
|
||||
node = tree.move(node_id, new_parent_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
if node.node_type == AssetNodeType.FILE:
|
||||
cache_key = AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
@ -388,7 +314,90 @@ class AppAssetService:
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache([cache_key])
|
||||
|
||||
return node
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def rename_node(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
node_id: str,
|
||||
new_name: str,
|
||||
) -> AppAssetNode:
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
old_node = tree.get(node_id)
|
||||
old_extension = old_node.extension if old_node else None
|
||||
|
||||
try:
|
||||
node = tree.rename(node_id, new_name)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
if node.node_type == AssetNodeType.FILE:
|
||||
cache_keys: list[str] = []
|
||||
if old_extension is not None:
|
||||
old_storage_key = (
|
||||
AssetPaths.build_resolved_file(app_model.tenant_id, app_model.id, assets.id, node.id)
|
||||
if old_extension == "md"
|
||||
else AssetPaths.draft_file(app_model.tenant_id, app_model.id, node.id)
|
||||
)
|
||||
cache_keys.append(old_storage_key)
|
||||
cache_keys.append(
|
||||
AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache(list(set(cache_keys)))
|
||||
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def move_node(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
node_id: str,
|
||||
new_parent_id: str | None,
|
||||
) -> AppAssetNode:
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
try:
|
||||
node = tree.move(node_id, new_parent_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
if node.node_type == AssetNodeType.FILE:
|
||||
cache_key = AppAssetService._draft_storage_key_for_node(
|
||||
app_model.tenant_id,
|
||||
app_model.id,
|
||||
assets.id,
|
||||
node,
|
||||
)
|
||||
AppAssetService._clear_draft_download_cache([cache_key])
|
||||
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def reorder_node(
|
||||
@ -397,52 +406,54 @@ class AppAssetService:
|
||||
node_id: str,
|
||||
after_node_id: str | None,
|
||||
) -> AppAssetNode:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id=account_id)
|
||||
tree = assets.asset_tree
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id=account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
try:
|
||||
node = tree.reorder(node_id, after_node_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
try:
|
||||
node = tree.reorder(node_id, after_node_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
return node
|
||||
return node
|
||||
|
||||
@staticmethod
|
||||
def delete_node(app_model: App, account_id: str, node_id: str) -> None:
|
||||
with Session(db.engine) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
target_ids = [node_id] + tree.get_descendant_ids(node_id)
|
||||
target_nodes = [tree.get(nid) for nid in target_ids]
|
||||
cache_keys = [
|
||||
AppAssetService._draft_storage_key_for_node(app_model.tenant_id, app_model.id, assets.id, node)
|
||||
for node in target_nodes
|
||||
if node is not None and node.node_type == AssetNodeType.FILE
|
||||
]
|
||||
target_ids = [node_id] + tree.get_descendant_ids(node_id)
|
||||
target_nodes = [tree.get(nid) for nid in target_ids]
|
||||
cache_keys = [
|
||||
AppAssetService._draft_storage_key_for_node(app_model.tenant_id, app_model.id, assets.id, node)
|
||||
for node in target_nodes
|
||||
if node is not None and node.node_type == AssetNodeType.FILE
|
||||
]
|
||||
|
||||
try:
|
||||
removed_ids = tree.remove(node_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
|
||||
for nid in removed_ids:
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, nid)
|
||||
try:
|
||||
storage.delete(storage_key)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete storage file %s", storage_key, exc_info=True)
|
||||
removed_ids = tree.remove(node_id)
|
||||
except TreeNodeNotFoundError as e:
|
||||
raise AppAssetNodeNotFoundError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
for nid in removed_ids:
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, nid)
|
||||
try:
|
||||
storage.delete(storage_key)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete storage file %s", storage_key, exc_info=True)
|
||||
|
||||
AppAssetService._clear_draft_download_cache(cache_keys)
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
AppAssetService._clear_draft_download_cache(cache_keys)
|
||||
|
||||
@staticmethod
|
||||
def publish(session: Session, app_model: App, account_id: str, workflow_id: str) -> AppAssets:
|
||||
@ -528,10 +539,99 @@ class AppAssetService:
|
||||
account_id: str,
|
||||
new_tree: AppAssetFileTree,
|
||||
) -> AppAssets:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
assets.asset_tree = new_tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
assets.asset_tree = new_tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
return assets
|
||||
return assets
|
||||
|
||||
@staticmethod
|
||||
def get_file_upload_url(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
name: str,
|
||||
size: int,
|
||||
parent_id: str | None = None,
|
||||
expires_in: int = 3600,
|
||||
) -> tuple[AppAssetNode, str]:
|
||||
"""
|
||||
Create a file node with metadata and return a pre-signed upload URL.
|
||||
|
||||
The file metadata is saved immediately. If the user doesn't upload,
|
||||
the download will fail when the file is accessed.
|
||||
|
||||
Returns:
|
||||
tuple of (node, upload_url)
|
||||
"""
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
node_id = str(uuid4())
|
||||
node = AppAssetNode.create_file(node_id, name, parent_id, size)
|
||||
|
||||
try:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node_id)
|
||||
presign_storage = FilePresignStorage(storage.storage_runner)
|
||||
upload_url = presign_storage.get_upload_url(storage_key, expires_in)
|
||||
|
||||
return node, upload_url
|
||||
|
||||
@staticmethod
|
||||
def batch_create_from_tree(
|
||||
app_model: App,
|
||||
account_id: str,
|
||||
input_children: list[BatchUploadNode],
|
||||
expires_in: int = 3600,
|
||||
) -> list[BatchUploadNode]:
|
||||
if not input_children:
|
||||
return []
|
||||
|
||||
new_nodes: list[AppAssetNode] = []
|
||||
for child in input_children:
|
||||
new_nodes.extend(child.to_app_asset_nodes(None))
|
||||
|
||||
with AppAssetService._lock(app_model.id):
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
|
||||
tree = assets.asset_tree
|
||||
|
||||
try:
|
||||
for node in new_nodes:
|
||||
tree.add(node)
|
||||
except TreeParentNotFoundError as e:
|
||||
raise AppAssetParentNotFoundError(str(e)) from e
|
||||
except TreePathConflictError as e:
|
||||
raise AppAssetPathConflictError(str(e)) from e
|
||||
|
||||
assets.asset_tree = tree
|
||||
assets.updated_by = account_id
|
||||
session.commit()
|
||||
|
||||
presign_storage = FilePresignStorage(storage.storage_runner)
|
||||
|
||||
def fill_urls(node: BatchUploadNode) -> None:
|
||||
if node.node_type == AssetNodeType.FILE and node.id:
|
||||
storage_key = AssetPaths.draft_file(app_model.tenant_id, app_model.id, node.id)
|
||||
node.upload_url = presign_storage.get_upload_url(storage_key, expires_in)
|
||||
for child in node.children:
|
||||
fill_urls(child)
|
||||
|
||||
for child in input_children:
|
||||
fill_urls(child)
|
||||
|
||||
return input_children
|
||||
|
||||
@ -195,7 +195,7 @@ class SandboxProviderService:
|
||||
)
|
||||
if not system_configed:
|
||||
raise ValueError(
|
||||
f"No system default provider configured for tenant {tenant_id} and provider type {tenant_configed.provider_type}"
|
||||
f"No system default provider configured for provider type {tenant_configed.provider_type}"
|
||||
)
|
||||
return SandboxProviderEntity(
|
||||
id=tenant_configed.id,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user