fix: delete bug
This commit is contained in:
@@ -484,10 +484,14 @@ queued ──→ running ──┬─→ succeeded
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` |
|
| `POST` | `/api/v1/data-resources/uploads` | 创建上传会话,返回 `upload_id` + `upload_path` |
|
||||||
| `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) |
|
| `PUT` | `/api/v1/data-resources/uploads/{upload_id}` | 上传字节(请求体即文件内容) |
|
||||||
|
| `POST` | `/api/v1/data-resources/uploads/{upload_id}/bind` | 绑定已完成上传为数据资源 |
|
||||||
| `GET` | `/api/v1/data-resources` | 列表(workspace 范围) |
|
| `GET` | `/api/v1/data-resources` | 列表(workspace 范围) |
|
||||||
| `GET` | `/api/v1/data-resources/{id}` | 详情 |
|
| `GET` | `/api/v1/data-resources/{id}` | 详情 |
|
||||||
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
|
| `POST` | `/api/v1/data-resources/{id}/download-url` | 生成 presigned GET URL |
|
||||||
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
|
| `DELETE` | `/api/v1/data-resources/{id}` | 软删 |
|
||||||
|
|
||||||
|
**同名冲突**:同一 `workspace` 内、同一目录(`target_path` 相等)、同一 `owner` 下,`resource_name` 重复提交绑定返回 `409`。不同目录或不同 `owner` 允许重名。重新绑定同一 `upload_id`(`storage_object_id` 已落库)走 idempotent 复用路径,不视为冲突。
|
||||||
|
|
||||||
字节归档到 trash bucket(`settings.s3_trash_bucket`)。
|
字节归档到 trash bucket(`settings.s3_trash_bucket`)。
|
||||||
|
|
||||||
请求示例(上传):`POST /api/v1/data-resources/uploads`
|
请求示例(上传):`POST /api/v1/data-resources/uploads`
|
||||||
|
|||||||
@@ -48,6 +48,24 @@ def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> s
|
|||||||
return os.path.relpath(resource_relative, start=script_dir)
|
return os.path.relpath(resource_relative, start=script_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_directory(
|
||||||
|
object_key: str,
|
||||||
|
workspace_id: str,
|
||||||
|
owner_user_id: str,
|
||||||
|
) -> str:
|
||||||
|
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。
|
||||||
|
|
||||||
|
object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``;
|
||||||
|
不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。
|
||||||
|
"""
|
||||||
|
prefix = f"{workspace_id}/{owner_user_id}/"
|
||||||
|
if not object_key.startswith(prefix):
|
||||||
|
return ""
|
||||||
|
tail = object_key[len(prefix):]
|
||||||
|
directory, _, _ = tail.rpartition("/")
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
def resource_payload(
|
def resource_payload(
|
||||||
resource: DataResources,
|
resource: DataResources,
|
||||||
storage_object: StorageObjects,
|
storage_object: StorageObjects,
|
||||||
@@ -242,18 +260,41 @@ async def bind_resource(
|
|||||||
item.visibility = payload.visibility
|
item.visibility = payload.visibility
|
||||||
# (description lives on DataResources, not on StorageObjects.)
|
# (description lives on DataResources, not on StorageObjects.)
|
||||||
|
|
||||||
existing_active = await session.scalar(
|
# 同名查重按「owner + 目录 + 名称」维度:目录从 object_key 解析,
|
||||||
select(DataResources).where(
|
# 不同目录、不同 owner 均允许重名。
|
||||||
DataResources.workspace_id == context.workspace.workspace_id,
|
new_directory = resource_directory(
|
||||||
DataResources.resource_name == payload.resource_name,
|
item.object_key,
|
||||||
DataResources.status == "active",
|
context.workspace.workspace_id,
|
||||||
)
|
context.user.user_id,
|
||||||
)
|
)
|
||||||
if existing_active is not None:
|
same_name_rows = (
|
||||||
raise HTTPException(
|
await session.execute(
|
||||||
status.HTTP_409_CONFLICT,
|
select(DataResources, StorageObjects)
|
||||||
"a data resource with this name already exists in this workspace",
|
.join(
|
||||||
|
StorageObjects,
|
||||||
|
StorageObjects.storage_object_id
|
||||||
|
== DataResources.storage_object_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
DataResources.workspace_id == context.workspace.workspace_id,
|
||||||
|
DataResources.owner_user_id == context.user.user_id,
|
||||||
|
DataResources.resource_name == payload.resource_name,
|
||||||
|
DataResources.status == "active",
|
||||||
|
DataResources.storage_object_id != item.storage_object_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
).all()
|
||||||
|
for existing_resource, existing_object in same_name_rows:
|
||||||
|
existing_directory = resource_directory(
|
||||||
|
existing_object.object_key,
|
||||||
|
existing_resource.workspace_id,
|
||||||
|
existing_resource.owner_user_id,
|
||||||
|
)
|
||||||
|
if existing_directory == new_directory:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"a data resource with this name already exists in this directory",
|
||||||
|
)
|
||||||
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(DataResources).where(
|
select(DataResources).where(
|
||||||
|
|||||||
+207
-50
@@ -10,81 +10,218 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from backend.resources import compute_jupyter_relative_path, resource_payload
|
from backend.resources import (
|
||||||
|
compute_jupyter_relative_path,
|
||||||
|
resource_directory,
|
||||||
|
resource_payload,
|
||||||
|
)
|
||||||
from backend.services.storage import _safe_file_name, _safe_path_segment
|
from backend.services.storage import _safe_file_name, _safe_path_segment
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
_BIND_WS = "01WS0000000000000000000A"
|
||||||
async def test_bind_resource_rejects_duplicate_active_name() -> None:
|
_BIND_USER = "01USR0000000000000000000A"
|
||||||
"""Two binds with the same resource_name but different upload_ids raise 409."""
|
|
||||||
from backend.resources import bind_resource
|
|
||||||
|
|
||||||
class _BindSessionMock:
|
|
||||||
def __init__(self, existing_active=None):
|
|
||||||
self._existing_active = existing_active
|
|
||||||
self._calls = 0
|
|
||||||
|
|
||||||
async def scalar(self, _stmt):
|
class _ExecuteResult:
|
||||||
self._calls += 1
|
def __init__(self, rows):
|
||||||
# 1st scalar: UploadSessions lookup; 2nd: active name clash check.
|
self._rows = rows
|
||||||
if self._calls == 1:
|
|
||||||
return SimpleNamespace(
|
|
||||||
upload_id="01UPL0000000000000000000B",
|
|
||||||
storage_object_id="01OBJ0000000000000000000B",
|
|
||||||
workspace_id="01WS0000000000000000000A",
|
|
||||||
user_id="01USR0000000000000000000A",
|
|
||||||
)
|
|
||||||
return self._existing_active
|
|
||||||
|
|
||||||
async def get(self, _model, _pk):
|
def all(self):
|
||||||
return _make_storage_object("01WS0000000000000000000A/01USR0000000000000000000A/data.csv")
|
return self._rows
|
||||||
|
|
||||||
def add(self, _obj):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def flush(self):
|
class _BindSessionMock:
|
||||||
pass
|
"""Mocked session for bind_resource.
|
||||||
|
|
||||||
async def refresh(self, _obj, attribute_names=None):
|
``new_object_key`` is the StorageObjects row of the upload being bound;
|
||||||
_obj.created_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
``same_name_rows`` are (DataResources, StorageObjects) candidates already
|
||||||
_obj.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
in the workspace with the same resource_name.
|
||||||
|
"""
|
||||||
|
|
||||||
context = SimpleNamespace(
|
def __init__(self, new_object_key: str, same_name_rows=(), reused_resource=None):
|
||||||
|
self._new_object_key = new_object_key
|
||||||
|
self._same_name_rows = list(same_name_rows)
|
||||||
|
self._reused_resource = reused_resource
|
||||||
|
self._scalar_calls = 0
|
||||||
|
|
||||||
|
async def scalar(self, _stmt):
|
||||||
|
self._scalar_calls += 1
|
||||||
|
# 1st scalar: UploadSessions lookup; 2nd: storage_object_id reuse check.
|
||||||
|
if self._scalar_calls == 1:
|
||||||
|
return SimpleNamespace(
|
||||||
|
upload_id="01UPL0000000000000000000B",
|
||||||
|
storage_object_id="01OBJ0000000000000000000B",
|
||||||
|
workspace_id=_BIND_WS,
|
||||||
|
user_id=_BIND_USER,
|
||||||
|
)
|
||||||
|
if self._scalar_calls == 2 and self._reused_resource is not None:
|
||||||
|
return self._reused_resource
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, _model, _pk):
|
||||||
|
return _make_storage_object(self._new_object_key)
|
||||||
|
|
||||||
|
async def execute(self, _stmt):
|
||||||
|
# 模拟 SQL 的 owner 过滤与 self-exclusion:只返回属于当前用户且
|
||||||
|
# 不是当前 upload 已绑定对象的同名候选。
|
||||||
|
rows = [
|
||||||
|
(resource, storage_object)
|
||||||
|
for resource, storage_object in self._same_name_rows
|
||||||
|
if resource.owner_user_id == _BIND_USER
|
||||||
|
and resource.storage_object_id != "01OBJ0000000000000000000B"
|
||||||
|
]
|
||||||
|
return _ExecuteResult(rows)
|
||||||
|
|
||||||
|
def add(self, _obj):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def refresh(self, _obj, attribute_names=None):
|
||||||
|
_obj.created_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||||
|
_obj.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_context():
|
||||||
|
return SimpleNamespace(
|
||||||
request_id="01REQ0000000000000000000A",
|
request_id="01REQ0000000000000000000A",
|
||||||
user=SimpleNamespace(user_id="01USR0000000000000000000A"),
|
user=SimpleNamespace(user_id=_BIND_USER),
|
||||||
workspace=SimpleNamespace(workspace_id="01WS0000000000000000000A"),
|
workspace=SimpleNamespace(workspace_id=_BIND_WS),
|
||||||
)
|
)
|
||||||
payload = SimpleNamespace(
|
|
||||||
|
|
||||||
|
def _bind_payload():
|
||||||
|
return SimpleNamespace(
|
||||||
resource_name="data.csv", description=None, visibility="private"
|
resource_name="data.csv", description=None, visibility="private"
|
||||||
)
|
)
|
||||||
request = MagicMock()
|
|
||||||
|
|
||||||
# First bind succeeds: no active resource with this name yet.
|
|
||||||
session1 = _BindSessionMock(existing_active=None)
|
|
||||||
await bind_resource(
|
|
||||||
upload_id="01UPL0000000000000000000A",
|
|
||||||
payload=payload,
|
|
||||||
request=request,
|
|
||||||
context=context,
|
|
||||||
session=session1,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Second bind with same resource_name fails because an active row exists.
|
@pytest.mark.asyncio
|
||||||
session2 = _BindSessionMock(
|
async def test_bind_resource_rejects_duplicate_name_in_same_directory() -> None:
|
||||||
existing_active=SimpleNamespace(resource_id="01RES0000000000000000000A")
|
"""Same resource_name in the same directory raises 409."""
|
||||||
|
from backend.resources import bind_resource
|
||||||
|
|
||||||
|
existing_rows = [
|
||||||
|
(
|
||||||
|
_make_resource(_BIND_WS, _BIND_USER),
|
||||||
|
_make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
session = _BindSessionMock(
|
||||||
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
||||||
|
same_name_rows=existing_rows,
|
||||||
)
|
)
|
||||||
with pytest.raises(Exception) as exc_info:
|
with pytest.raises(Exception) as exc_info:
|
||||||
await bind_resource(
|
await bind_resource(
|
||||||
upload_id="01UPL0000000000000000000B",
|
upload_id="01UPL0000000000000000000B",
|
||||||
payload=payload,
|
payload=_bind_payload(),
|
||||||
request=request,
|
request=MagicMock(),
|
||||||
context=context,
|
context=_bind_context(),
|
||||||
session=session2,
|
session=session,
|
||||||
)
|
)
|
||||||
assert exc_info.value.status_code == 409
|
assert exc_info.value.status_code == 409
|
||||||
assert "already exists" in exc_info.value.detail
|
assert "already exists" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bind_resource_allows_same_name_in_different_directory() -> None:
|
||||||
|
"""Same resource_name in a different directory binds successfully."""
|
||||||
|
from backend.resources import bind_resource
|
||||||
|
|
||||||
|
existing_rows = [
|
||||||
|
(
|
||||||
|
_make_resource(_BIND_WS, _BIND_USER),
|
||||||
|
_make_storage_object(f"{_BIND_WS}/{_BIND_USER}/data.csv"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
session = _BindSessionMock(
|
||||||
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/subdir/data.csv",
|
||||||
|
same_name_rows=existing_rows,
|
||||||
|
)
|
||||||
|
result = await bind_resource(
|
||||||
|
upload_id="01UPL0000000000000000000B",
|
||||||
|
payload=_bind_payload(),
|
||||||
|
request=MagicMock(),
|
||||||
|
context=_bind_context(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result["data"]["resource_name"] == "data.csv"
|
||||||
|
assert result["data"]["jupyter_accessible_path"] == "subdir/data.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bind_resource_allows_same_name_when_workspace_empty() -> None:
|
||||||
|
"""No same-name rows at all: bind succeeds (root directory)."""
|
||||||
|
from backend.resources import bind_resource
|
||||||
|
|
||||||
|
session = _BindSessionMock(
|
||||||
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
||||||
|
)
|
||||||
|
result = await bind_resource(
|
||||||
|
upload_id="01UPL0000000000000000000A",
|
||||||
|
payload=_bind_payload(),
|
||||||
|
request=MagicMock(),
|
||||||
|
context=_bind_context(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result["data"]["resource_name"] == "data.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bind_resource_allows_same_name_for_different_owner() -> None:
|
||||||
|
"""其他用户在同目录下的同名资源不阻塞当前用户的绑定。"""
|
||||||
|
from backend.resources import bind_resource
|
||||||
|
|
||||||
|
other_user = "01USR0000000000000000000B"
|
||||||
|
existing_rows = [
|
||||||
|
(
|
||||||
|
_make_resource(_BIND_WS, other_user),
|
||||||
|
_make_storage_object(f"{_BIND_WS}/{other_user}/data.csv"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
session = _BindSessionMock(
|
||||||
|
new_object_key=f"{_BIND_WS}/{_BIND_USER}/data.csv",
|
||||||
|
same_name_rows=existing_rows,
|
||||||
|
)
|
||||||
|
result = await bind_resource(
|
||||||
|
upload_id="01UPL0000000000000000000C",
|
||||||
|
payload=_bind_payload(),
|
||||||
|
request=MagicMock(),
|
||||||
|
context=_bind_context(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result["data"]["resource_name"] == "data.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bind_resource_allows_rebinding_same_storage_object() -> None:
|
||||||
|
"""重新绑定同一 upload_id 应走 idempotent 复用路径,不触发 409。"""
|
||||||
|
from backend.resources import bind_resource
|
||||||
|
|
||||||
|
new_object_key = f"{_BIND_WS}/{_BIND_USER}/data.csv"
|
||||||
|
existing_resource = _make_resource(_BIND_WS, _BIND_USER)
|
||||||
|
existing_resource.resource_name = "data.csv"
|
||||||
|
existing_resource.storage_object_id = "01OBJ0000000000000000000B"
|
||||||
|
existing_object = _make_storage_object(new_object_key)
|
||||||
|
existing_object.storage_object_id = "01OBJ0000000000000000000B"
|
||||||
|
same_name_rows = [(existing_resource, existing_object)]
|
||||||
|
session = _BindSessionMock(
|
||||||
|
new_object_key=new_object_key,
|
||||||
|
same_name_rows=same_name_rows,
|
||||||
|
reused_resource=existing_resource,
|
||||||
|
)
|
||||||
|
result = await bind_resource(
|
||||||
|
upload_id="01UPL0000000000000000000B",
|
||||||
|
payload=_bind_payload(),
|
||||||
|
request=MagicMock(),
|
||||||
|
context=_bind_context(),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert result["meta"]["reused"] is True
|
||||||
|
assert result["data"]["resource_name"] == "data.csv"
|
||||||
|
|
||||||
|
|
||||||
def test_safe_path_segment_cleans_special_characters():
|
def test_safe_path_segment_cleans_special_characters():
|
||||||
assert _safe_path_segment("train") == "train"
|
assert _safe_path_segment("train") == "train"
|
||||||
assert _safe_path_segment("train v1") == "train_v1"
|
assert _safe_path_segment("train v1") == "train_v1"
|
||||||
@@ -171,6 +308,26 @@ def test_compute_jupyter_relative_path_for_legacy_and_new_paths():
|
|||||||
assert compute_jupyter_relative_path("exp.ipynb", "data.csv") == "data.csv"
|
assert compute_jupyter_relative_path("exp.ipynb", "data.csv") == "data.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_directory_parses_object_key():
|
||||||
|
ws = "01WS00000000000000000000A"
|
||||||
|
user = "01USR000000000000000000A"
|
||||||
|
# 根目录文件:目录为 ""。
|
||||||
|
assert resource_directory(f"{ws}/{user}/data.csv", ws, user) == ""
|
||||||
|
# 子目录 / 多级目录。
|
||||||
|
assert resource_directory(f"{ws}/{user}/sub/data.csv", ws, user) == "sub"
|
||||||
|
assert (
|
||||||
|
resource_directory(f"{ws}/{user}/train/v1/data.csv", ws, user)
|
||||||
|
== "train/v1"
|
||||||
|
)
|
||||||
|
# 旧版 .resources 布局:目录为 ".resources"。
|
||||||
|
assert (
|
||||||
|
resource_directory(f"{ws}/{user}/.resources/data.csv", ws, user)
|
||||||
|
== ".resources"
|
||||||
|
)
|
||||||
|
# 不匹配 ws/user 前缀的键按根目录处理。
|
||||||
|
assert resource_directory("other-bucket-key.csv", ws, user) == ""
|
||||||
|
|
||||||
|
|
||||||
def test_data_resources_model_allows_duplicate_storage_object_reference() -> None:
|
def test_data_resources_model_allows_duplicate_storage_object_reference() -> None:
|
||||||
"""With uk_data_resources_object dropped, no unique index covers
|
"""With uk_data_resources_object dropped, no unique index covers
|
||||||
storage_object_id, so multiple DataResources rows may reference the
|
storage_object_id, so multiple DataResources rows may reference the
|
||||||
|
|||||||
Reference in New Issue
Block a user