Skip to content

Commit

Permalink
🎨 moving folders to workspaces (ITISFoundation#6851)
Browse files Browse the repository at this point in the history
  • Loading branch information
matusdrobuliak66 authored Dec 3, 2024
1 parent 6a7b073 commit 59aeb99
Show file tree
Hide file tree
Showing 29 changed files with 918 additions and 176 deletions.
15 changes: 15 additions & 0 deletions api/specs/web-server/_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
FoldersListQueryParams,
FoldersPathParams,
)
from simcore_service_webserver.folders._workspaces_handlers import (
_FolderWorkspacesPathParams,
)

router = APIRouter(
prefix=f"/{API_VTAG}",
Expand Down Expand Up @@ -97,3 +100,15 @@ async def delete_folder(
_path: Annotated[FoldersPathParams, Depends()],
):
...


@router.post(
"/folders/{folder_id}/workspaces/{workspace_id}:move",
status_code=status.HTTP_204_NO_CONTENT,
summary="Move folder to the workspace",
tags=["workspaces"],
)
async def move_folder_to_workspace(
_path: Annotated[_FolderWorkspacesPathParams, Depends()],
):
...
6 changes: 3 additions & 3 deletions api/specs/web-server/_projects_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
)


@router.put(
"/projects/{project_id}/workspaces/{workspace_id}",
@router.post(
"/projects/{project_id}/workspaces/{workspace_id}:move",
status_code=status.HTTP_204_NO_CONTENT,
summary="Move project to the workspace",
)
async def replace_project_workspace(
async def move_project_to_workspace(
_path: Annotated[_ProjectWorkspacesPathParams, Depends()],
):
...
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
from typing import Annotated, Any, Literal, TypeAlias

from models_library.folders import FolderID
from models_library.utils._original_fastapi_encoders import jsonable_encoder
from models_library.workspaces import WorkspaceID
from pydantic import BeforeValidator, ConfigDict, Field, HttpUrl, field_validator
from pydantic import (
BeforeValidator,
ConfigDict,
Field,
HttpUrl,
PlainSerializer,
field_validator,
)

from ..api_schemas_long_running_tasks.tasks import TaskGet
from ..basic_types import LongTruncatedStr, ShortTruncatedStr
Expand Down Expand Up @@ -130,12 +138,22 @@ class ProjectPatch(InputSchema):
name: ShortTruncatedStr | None = Field(default=None)
description: LongTruncatedStr | None = Field(default=None)
thumbnail: Annotated[
HttpUrl | None, BeforeValidator(empty_str_to_none_pre_validator)
HttpUrl | None,
BeforeValidator(empty_str_to_none_pre_validator),
PlainSerializer(lambda x: str(x) if x is not None else None),
] = Field(default=None)
access_rights: dict[GroupIDStr, AccessRights] | None = Field(default=None)
classifiers: list[ClassifierID] | None = Field(default=None)
dev: dict | None = Field(default=None)
ui: StudyUI | None = Field(default=None)
ui: Annotated[
StudyUI | None,
BeforeValidator(empty_str_to_none_pre_validator),
PlainSerializer(
lambda obj: jsonable_encoder(
obj, exclude_unset=True, by_alias=False
) # For the sake of backward compatibility
),
] = Field(default=None)
quality: dict[str, Any] | None = Field(default=None)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async def create_project(
for group_id, permissions in _access_rights.items():
await update_or_insert_project_group(
app,
new_project["uuid"],
project_id=new_project["uuid"],
group_id=int(group_id),
read=permissions["read"],
write=permissions["write"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import aiopg.sa
import arrow
from dask_task_models_library.container_tasks.protocol import ContainerEnvsDict
from models_library.api_schemas_catalog.services import ServiceGet
from models_library.api_schemas_clusters_keeper.ec2_instances import EC2InstanceTypeGet
from models_library.api_schemas_directorv2.services import (
NodeRequirements,
Expand Down Expand Up @@ -89,7 +90,7 @@ async def _get_service_details(
node.version,
product_name,
)
obj: ServiceMetaDataPublished = ServiceMetaDataPublished(**service_details)
obj: ServiceMetaDataPublished = ServiceGet(**service_details)
return obj


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,29 @@ def _mocked_service_resources(request) -> httpx.Response:
def _mocked_services_details(
request, service_key: str, service_version: str
) -> httpx.Response:
assert "json_schema_extra" in ServiceGet.model_config
assert isinstance(ServiceGet.model_config["json_schema_extra"], dict)
assert isinstance(
ServiceGet.model_config["json_schema_extra"]["examples"], list
)
assert isinstance(
ServiceGet.model_config["json_schema_extra"]["examples"][0], dict
)
data_published = fake_service_details.model_copy(
update={
"key": urllib.parse.unquote(service_key),
"version": service_version,
}
).model_dump(by_alias=True)
data = {
**ServiceGet.model_config["json_schema_extra"]["examples"][0],
**data_published,
}
payload = ServiceGet.model_validate(data)
return httpx.Response(
200,
json=jsonable_encoder(
fake_service_details.model_copy(
update={
"key": urllib.parse.unquote(service_key),
"version": service_version,
}
),
payload,
by_alias=True,
),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -578,11 +578,6 @@ qx.Class.define("osparc.dashboard.StudyBrowser", {
const data = e.getData();
const destWorkspaceId = data["workspaceId"];
const destFolderId = data["folderId"];
if (destWorkspaceId !== currentWorkspaceId) {
const msg = this.tr("Moving folders to Shared Workspaces are coming soon");
osparc.FlashMessenger.getInstance().logAs(msg, "WARNING");
return;
}
const moveFolder = () => {
Promise.all([
this.__moveFolderToWorkspace(folderId, destWorkspaceId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,8 @@ qx.Class.define("osparc.data.Resources", {
url: statics.API + "/projects/{studyId}/folders/{folderId}"
},
moveToWorkspace: {
method: "PUT",
url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}"
method: "POST",
url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}:move"
},
}
},
Expand Down Expand Up @@ -342,8 +342,8 @@ qx.Class.define("osparc.data.Resources", {
url: statics.API + "/folders/{folderId}"
},
moveToWorkspace: {
method: "PUT",
url: statics.API + "/folders/{folderId}/folders/{workspaceId}"
method: "POST",
url: statics.API + "/folders/{folderId}/folders/{workspaceId}:move"
},
trash: {
method: "POST",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2944,6 +2944,59 @@ paths:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Service Unavailable
/v0/folders/{folder_id}/workspaces/{workspace_id}:move:
post:
tags:
- folders
- workspaces
summary: Move folder to the workspace
operationId: move_folder_to_workspace
parameters:
- name: folder_id
in: path
required: true
schema:
type: integer
exclusiveMinimum: true
title: Folder Id
minimum: 0
- name: workspace_id
in: path
required: true
schema:
anyOf:
- type: integer
exclusiveMinimum: true
minimum: 0
- type: 'null'
title: Workspace Id
responses:
'204':
description: Successful Response
'404':
content:
application/json:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Not Found
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Forbidden
'409':
content:
application/json:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Conflict
'503':
content:
application/json:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Service Unavailable
/v0/tasks:
get:
tags:
Expand Down Expand Up @@ -4706,13 +4759,13 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Envelope_WalletGet_'
/v0/projects/{project_id}/workspaces/{workspace_id}:
put:
/v0/projects/{project_id}/workspaces/{workspace_id}:move:
post:
tags:
- projects
- workspaces
summary: Move project to the workspace
operationId: replace_project_workspace
operationId: move_project_to_workspace
parameters:
- name: project_id
in: path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import logging
from datetime import datetime
from typing import Any, Final, cast
from typing import Final, cast

import sqlalchemy as sa
from aiohttp import web
Expand All @@ -33,6 +33,7 @@
from simcore_postgres_database.utils_workspaces_sql import (
create_my_workspace_access_rights_subquery,
)
from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncConnection
from sqlalchemy.orm import aliased
Expand All @@ -43,18 +44,9 @@

_logger = logging.getLogger(__name__)


class UnSet:
...


_unset: Final = UnSet()


def as_dict_exclude_unset(**params) -> dict[str, Any]:
return {k: v for k, v in params.items() if not isinstance(v, UnSet)}


_SELECTION_ARGS = (
folders_v2.c.folder_id,
folders_v2.c.name,
Expand Down Expand Up @@ -324,6 +316,8 @@ async def update(
parent_folder_id: FolderID | None | UnSet = _unset,
trashed_at: datetime | None | UnSet = _unset,
trashed_explicitly: bool | UnSet = _unset,
workspace_id: WorkspaceID | None | UnSet = _unset,
user_id: UserID | None | UnSet = _unset,
) -> FolderDB:
"""
Batch/single patch of folder/s
Expand All @@ -334,6 +328,8 @@ async def update(
parent_folder_id=parent_folder_id,
trashed_at=trashed_at,
trashed_explicitly=trashed_explicitly,
workspace_id=workspace_id,
user_id=user_id,
)

query = (
Expand Down Expand Up @@ -467,6 +463,60 @@ async def get_projects_recursively_only_if_user_is_owner(
return [ProjectID(row[0]) async for row in result]


async def get_all_folders_and_projects_ids_recursively(
app: web.Application,
connection: AsyncConnection | None = None,
*,
folder_id: FolderID,
private_workspace_user_id_or_none: UserID | None,
product_name: ProductName,
) -> tuple[list[FolderID], list[ProjectID]]:
"""
The purpose of this function is to retrieve all projects within the provided folder ID.
"""

async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:

# Step 1: Define the base case for the recursive CTE
base_query = select(
folders_v2.c.folder_id, folders_v2.c.parent_folder_id
).where(
(folders_v2.c.folder_id == folder_id) # <-- specified folder id
& (folders_v2.c.product_name == product_name)
)
folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True)

# Step 2: Define the recursive case
folder_alias = aliased(folders_v2)
recursive_query = select(
folder_alias.c.folder_id, folder_alias.c.parent_folder_id
).select_from(
folder_alias.join(
folder_hierarchy_cte,
folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id,
)
)

# Step 3: Combine base and recursive cases into a CTE
folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query)

# Step 4: Execute the query to get all descendants
final_query = select(folder_hierarchy_cte)
result = await conn.stream(final_query)
# list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)]
folder_ids = [item.folder_id async for item in result]

query = select(projects_to_folders.c.project_uuid).where(
(projects_to_folders.c.folder_id.in_(folder_ids))
& (projects_to_folders.c.user_id == private_workspace_user_id_or_none)
)

result = await conn.stream(query)
project_ids = [ProjectID(row.project_uuid) async for row in result]

return folder_ids, project_ids


async def get_folders_recursively(
app: web.Application,
connection: AsyncConnection | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@
null_or_none_str_to_none_validator,
)
from models_library.workspaces import WorkspaceID
from pydantic import BeforeValidator, ConfigDict, Field
from servicelib.request_keys import RQT_USERID_KEY
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field

from .._constants import RQ_PRODUCT_KEY
from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -88,3 +87,12 @@ class FolderSearchQueryParams(

class FolderTrashQueryParams(RemoveQueryParams):
...


class _FolderWorkspacesPathParams(BaseModel):
folder_id: FolderID
workspace_id: Annotated[
WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator)
] = Field(default=None)

model_config = ConfigDict(extra="forbid")
Loading

0 comments on commit 59aeb99

Please sign in to comment.