feat(scripts/data-resources): merge tree + add parent_path filter

- Backend: GET /api/v1/data-resources accepts parent_path; LIKE
  '{ws_id}/%/{escaped}/%' AND NOT LIKE '{ws_id}/%/{escaped}/%/%' on
  StorageObjects.object_key (workspace-wide, escapes _ and %, mirrors
  list_scripts parent_path semantics). 13 new tests in
  test_resources.py (helper unit / SQL compile / SQLite behavioral).
- Frontend: listResources gains parentPath arg, propagated through
  WorkspaceBoundApi + AuthContext binding. WorkspaceTreeGroup title
  count and ScriptExplorer header count now include dataResources.
  memberScriptGroups backfills data-only owners so users with only
  data resources still render a group. loadDataResources accepts an
  optional parentPath, default empty preserves prior behavior.
This commit is contained in:
tao.chen
2026-08-21 12:36:43 +08:00
parent a9b682c1a1
commit 48fe49df3b
7 changed files with 296 additions and 9 deletions
+36 -1
View File
@@ -28,7 +28,7 @@ from backend.dependencies import (
database_session,
request_context,
)
from backend.scripts import _escape_like_pattern
from backend.scripts import _escape_like_pattern, normalize_user_path
from backend.schemas import (
CompleteResourceUploadRequest,
CreateResourceUploadRequest,
@@ -47,6 +47,23 @@ from backend.services.storage import (
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
def _build_list_resources_descendant_prefix(parent_path: str) -> str:
"""Return the escaped materialized-path prefix for direct children
of ``parent_path`` against ``StorageObjects.object_key``.
Data resources are workspace-wide (no per-user scoping at the API
level). The full object_key is ``{ws_id}/{user_id}/{jupyter_path}``;
we filter on object_key with the pattern ``{ws_id}/%/{parent_path}``
so any owner whose jupyter_accessible_path starts with parent_path
matches. LIKE wildcards in parent_path are escaped; the ``%`` between
``{ws_id}/`` and the escaped parent is an intentional SQL wildcard
matching the ``owner_user_id`` segment across all owners.
"""
normalized = normalize_user_path(parent_path)
escaped = _escape_like_pattern(normalized)
return f"{escaped}/" if escaped else ""
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。
@@ -364,6 +381,7 @@ async def bind_resource(
# 列出当前工作区可见的数据资源,可按可见性或关键字筛选。
@router.get("")
async def list_resources(
parent_path: str = Query(default="", max_length=1024),
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
visibility: str | None = Query(default=None),
@@ -383,6 +401,23 @@ async def list_resources(
)
.order_by(DataResources.updated_at.desc())
)
if parent_path:
# ``parent_path`` scopes to DIRECT children of that jupyter path
# (matching /api/v1/scripts). Data resources are workspace-wide, so
# the middle ``%`` is an intentional wildcard that matches the
# ``owner_user_id`` segment across all owners. The parent's ``_`` /
# ``%`` are escaped so sibling folders (e.g. ``fooXbar``) don't leak.
descendant_prefix = _build_list_resources_descendant_prefix(parent_path)
statement = statement.where(
StorageObjects.object_key.like(
f"{context.workspace.workspace_id}/%/{descendant_prefix}%",
escape="\\",
),
~StorageObjects.object_key.like(
f"{context.workspace.workspace_id}/%/{descendant_prefix}%/%",
escape="\\",
),
)
# 2026-08-11: 临时取消"用户间目录互相不可见"约束
# 列表接口现在返回 workspace 内全部 active 资源(不再按 owner / visibility 过滤)。
# 还原: 删除下面这段注释,恢复原来的 if not context.is_admin: ... 块。