From 225c33633ad02e4eb6c90a17b3e363bf8f9f26c1 Mon Sep 17 00:00:00 2001 From: Harry Date: Fri, 23 Jan 2026 14:34:16 +0800 Subject: [PATCH] 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. --- api/controllers/console/app/app_asset.py | 91 ++++ api/core/app/entities/app_asset_entities.py | 41 +- api/core/app_bundle/source_zip_extractor.py | 4 +- api/services/app_asset_service.py | 456 +++++++++++------- .../sandbox/sandbox_provider_service.py | 2 +- 5 files changed, 403 insertions(+), 191 deletions(-) diff --git a/api/controllers/console/app/app_asset.py b/api/controllers/console/app/app_asset.py index e71c4a83c2..3501fc0fcb 100644 --- a/api/controllers/console/app/app_asset.py +++ b/api/controllers/console/app/app_asset.py @@ -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//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//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() diff --git a/api/core/app/entities/app_asset_entities.py b/api/core/app/entities/app_asset_entities.py index a3c62c5211..32376fd2c0 100644 --- a/api/core/app/entities/app_asset_entities.py +++ b/api/core/app/entities/app_asset_entities.py @@ -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, ) diff --git a/api/core/app_bundle/source_zip_extractor.py b/api/core/app_bundle/source_zip_extractor.py index d5bb6a4cac..16c44864ee 100644 --- a/api/core/app_bundle/source_zip_extractor.py +++ b/api/core/app_bundle/source_zip_extractor.py @@ -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) diff --git a/api/services/app_asset_service.py b/api/services/app_asset_service.py index da6f93b49c..e215fc2b55 100644 --- a/api/services/app_asset_service.py +++ b/api/services/app_asset_service.py @@ -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 diff --git a/api/services/sandbox/sandbox_provider_service.py b/api/services/sandbox/sandbox_provider_service.py index 83268c5f5a..387f0a8f14 100644 --- a/api/services/sandbox/sandbox_provider_service.py +++ b/api/services/sandbox/sandbox_provider_service.py @@ -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,