Skip to content

Commit 652265e

Browse files
fregataaclaude
andcommitted
feat(BA-5737): add project-scoped VFolder create/delete GraphQL mutations
Add createProjectVfolderV2, deleteProjectVfolderV2, and bulkDeleteProjectVfoldersV2 mutations so that project admin flows have a consistent project-scoped GraphQL surface alongside projectVfolders. - New VFolderScopeAction subclasses (PROJECT scope) in actions/vfolder_in_project.py for create, delete, and bulk-delete. - Service methods: create_in_project delegates to create_v2 with project_id; delete_in_project / bulk_delete_in_project verify vfolder.group == project_id before trashing. - ScopeActionProcessor wiring with scope_rbac_validators — superadmin bypasses; regular users without explicit project grants get 403. - Adapter methods, GQL resolvers (NEXT_RELEASE_VERSION), and schema registration. - Unit tests for service methods (mocked repo) and component tests exercising real ScopeActionRBACValidator against DB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 93f4929 commit 652265e

10 files changed

Lines changed: 899 additions & 0 deletions

File tree

src/ai/backend/manager/api/adapters/vfolder.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@
125125
from ai.backend.manager.services.vfolder.actions.upload_session_v2 import (
126126
CreateUploadSessionV2Action,
127127
)
128+
from ai.backend.manager.services.vfolder.actions.vfolder_in_project import (
129+
BulkDeleteVFoldersInProjectAction,
130+
CreateVFolderInProjectAction,
131+
DeleteVFolderInProjectAction,
132+
)
128133
from ai.backend.manager.services.vfolder.actions.vfolder_v2 import (
129134
DeleteVFolderV2Action,
130135
PurgeVFolderV2Action,
@@ -361,6 +366,64 @@ async def create(self, input: CreateVFolderInput) -> CreateVFolderPayload:
361366
result = await self._processors.vfolder.create_vfolder_v2.wait_for_complete(action)
362367
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))
363368

369+
async def create_in_project(
370+
self,
371+
project_id: UUID,
372+
input: CreateVFolderInput,
373+
) -> CreateVFolderPayload:
374+
"""Create a vfolder owned by ``project_id``.
375+
376+
Uses ``CreateVFolderInProjectAction`` which is PROJECT-scoped so the
377+
caller must hold CREATE permission on the project.
378+
"""
379+
me = current_user()
380+
if me is None:
381+
raise UnreachableError("User context is not available")
382+
action = CreateVFolderInProjectAction(
383+
project_id=project_id,
384+
user_id=me.user_id,
385+
domain_name=me.domain_name,
386+
name=input.name,
387+
host=input.host,
388+
usage_mode=VFolderUsageMode(input.usage_mode.value),
389+
permission=VFolderPermission(input.permission.value),
390+
cloneable=input.cloneable,
391+
)
392+
result = await self._processors.vfolder.create_vfolder_in_project.wait_for_complete(action)
393+
return CreateVFolderPayload(vfolder=self._vfolder_data_to_node(result.vfolder))
394+
395+
async def delete_in_project(
396+
self,
397+
project_id: UUID,
398+
vfolder_id: UUID,
399+
) -> DeleteVFolderPayload:
400+
"""Soft-delete a vfolder belonging to ``project_id``.
401+
402+
Uses ``DeleteVFolderInProjectAction`` which is PROJECT-scoped so the
403+
caller must hold DELETE permission on the project.
404+
"""
405+
action = DeleteVFolderInProjectAction(
406+
project_id=project_id,
407+
vfolder_id=vfolder_id,
408+
)
409+
await self._processors.vfolder.delete_vfolder_in_project.wait_for_complete(action)
410+
return DeleteVFolderPayload(id=vfolder_id)
411+
412+
async def bulk_delete_in_project(
413+
self,
414+
project_id: UUID,
415+
input: BulkDeleteVFoldersInput,
416+
) -> BulkDeleteVFoldersPayload:
417+
"""Soft-delete multiple vfolders that all belong to ``project_id``."""
418+
action = BulkDeleteVFoldersInProjectAction(
419+
project_id=project_id,
420+
vfolder_ids=list(input.ids),
421+
)
422+
result = await self._processors.vfolder.bulk_delete_vfolders_in_project.wait_for_complete(
423+
action
424+
)
425+
return BulkDeleteVFoldersPayload(deleted_count=len(result.deleted_vfolder_ids))
426+
364427
async def create_upload_session(
365428
self, vfolder_id: UUID, input: CreateUploadSessionInput
366429
) -> CreateUploadSessionPayload:

src/ai/backend/manager/api/gql/schema.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,13 @@
412412
)
413413
from .vfolder_v2 import (
414414
admin_vfolders_v2,
415+
bulk_delete_project_vfolders_v2,
415416
bulk_delete_vfolders_v2,
416417
bulk_purge_vfolders_v2,
417418
clone_vfolder_v2,
419+
create_project_vfolder_v2,
418420
create_vfolder_v2,
421+
delete_project_vfolder_v2,
419422
delete_vfolder_v2,
420423
deploy_vfolder_v2,
421424
my_vfolders,
@@ -845,6 +848,9 @@ class Mutation:
845848
deploy_vfolder_v2 = deploy_vfolder_v2
846849
bulk_delete_vfolders_v2 = bulk_delete_vfolders_v2
847850
bulk_purge_vfolders_v2 = bulk_purge_vfolders_v2
851+
create_project_vfolder_v2 = create_project_vfolder_v2
852+
delete_project_vfolder_v2 = delete_project_vfolder_v2
853+
bulk_delete_project_vfolders_v2 = bulk_delete_project_vfolders_v2
848854
clone_vfolder_v2 = clone_vfolder_v2
849855
vfolder_list_files_v2 = vfolder_list_files_v2
850856
vfolder_mkdir_v2 = vfolder_mkdir_v2

src/ai/backend/manager/api/gql/vfolder_v2/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
from .resolver import (
88
admin_vfolders_v2,
9+
bulk_delete_project_vfolders_v2,
910
bulk_delete_vfolders_v2,
1011
bulk_purge_vfolders_v2,
1112
clone_vfolder_v2,
13+
create_project_vfolder_v2,
1214
create_vfolder_v2,
15+
delete_project_vfolder_v2,
1316
delete_vfolder_v2,
1417
deploy_vfolder_v2,
1518
my_vfolders,
@@ -45,9 +48,12 @@
4548
"project_vfolders",
4649
"vfolder_v2",
4750
# Mutations
51+
"bulk_delete_project_vfolders_v2",
4852
"bulk_delete_vfolders_v2",
4953
"bulk_purge_vfolders_v2",
54+
"create_project_vfolder_v2",
5055
"create_vfolder_v2",
56+
"delete_project_vfolder_v2",
5157
"delete_vfolder_v2",
5258
"deploy_vfolder_v2",
5359
"purge_vfolder_v2",

src/ai/backend/manager/api/gql/vfolder_v2/resolver/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""VFolder GraphQL resolver package."""
22

33
from .mutation import (
4+
bulk_delete_project_vfolders_v2,
45
bulk_delete_vfolders_v2,
56
bulk_purge_vfolders_v2,
67
clone_vfolder_v2,
8+
create_project_vfolder_v2,
79
create_vfolder_v2,
10+
delete_project_vfolder_v2,
811
delete_vfolder_v2,
912
deploy_vfolder_v2,
1013
purge_vfolder_v2,
@@ -24,9 +27,12 @@
2427
"project_vfolders",
2528
"vfolder_v2",
2629
# Mutations
30+
"bulk_delete_project_vfolders_v2",
2731
"bulk_delete_vfolders_v2",
2832
"bulk_purge_vfolders_v2",
33+
"create_project_vfolder_v2",
2934
"create_vfolder_v2",
35+
"delete_project_vfolder_v2",
3036
"delete_vfolder_v2",
3137
"deploy_vfolder_v2",
3238
"purge_vfolder_v2",

src/ai/backend/manager/api/gql/vfolder_v2/resolver/mutation.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from strawberry import Info
88

9+
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
910
from ai.backend.manager.api.gql.decorators import BackendAIGQLMeta, gql_mutation
1011
from ai.backend.manager.api.gql.types import StrawberryGQLContext
1112
from ai.backend.manager.api.gql.vfolder_v2.types.mutations import (
@@ -227,6 +228,65 @@ async def bulk_delete_vfolders_v2(
227228
return BulkDeleteVFoldersPayloadGQL.from_pydantic(payload)
228229

229230

231+
@gql_mutation(
232+
BackendAIGQLMeta(
233+
added_version=NEXT_RELEASE_VERSION,
234+
description=(
235+
"Create a virtual folder owned by the specified project. "
236+
"Requires project-scoped CREATE permission."
237+
),
238+
)
239+
) # type: ignore[misc]
240+
async def create_project_vfolder_v2(
241+
info: Info[StrawberryGQLContext],
242+
project_id: UUID,
243+
input: CreateVFolderInputGQL,
244+
) -> CreateVFolderPayloadGQL:
245+
"""Create a new virtual folder scoped to a project."""
246+
payload = await info.context.adapters.vfolder.create_in_project(project_id, input.to_pydantic())
247+
return CreateVFolderPayloadGQL.from_pydantic(payload)
248+
249+
250+
@gql_mutation(
251+
BackendAIGQLMeta(
252+
added_version=NEXT_RELEASE_VERSION,
253+
description=(
254+
"Soft-delete a virtual folder that belongs to the specified project. "
255+
"Requires project-scoped DELETE permission."
256+
),
257+
)
258+
) # type: ignore[misc]
259+
async def delete_project_vfolder_v2(
260+
info: Info[StrawberryGQLContext],
261+
project_id: UUID,
262+
vfolder_id: UUID,
263+
) -> DeleteVFolderPayloadGQL:
264+
"""Soft-delete a project-owned virtual folder."""
265+
payload = await info.context.adapters.vfolder.delete_in_project(project_id, vfolder_id)
266+
return DeleteVFolderPayloadGQL.from_pydantic(payload)
267+
268+
269+
@gql_mutation(
270+
BackendAIGQLMeta(
271+
added_version=NEXT_RELEASE_VERSION,
272+
description=(
273+
"Soft-delete multiple virtual folders that all belong to the specified project. "
274+
"Requires project-scoped DELETE permission."
275+
),
276+
)
277+
) # type: ignore[misc]
278+
async def bulk_delete_project_vfolders_v2(
279+
info: Info[StrawberryGQLContext],
280+
project_id: UUID,
281+
input: BulkDeleteVFoldersInputGQL,
282+
) -> BulkDeleteVFoldersPayloadGQL:
283+
"""Soft-delete multiple project-owned virtual folders."""
284+
payload = await info.context.adapters.vfolder.bulk_delete_in_project(
285+
project_id, input.to_pydantic()
286+
)
287+
return BulkDeleteVFoldersPayloadGQL.from_pydantic(payload)
288+
289+
230290
@gql_mutation(
231291
BackendAIGQLMeta(
232292
added_version="26.4.2",

0 commit comments

Comments
 (0)