Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎨 moving folders to workspaces #6851

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
496bba5
api and handlers
matusdrobuliak66 Nov 27, 2024
fcd0701
db
matusdrobuliak66 Nov 27, 2024
29f67de
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 27, 2024
33b48e6
open api specs
matusdrobuliak66 Nov 27, 2024
f9c7c0b
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 28, 2024
dc092c2
adding unit tests
matusdrobuliak66 Nov 28, 2024
3f8e8b1
adding unit tests
matusdrobuliak66 Nov 28, 2024
e6908f0
refactor project DB
matusdrobuliak66 Nov 28, 2024
57b63b1
adding transaction
matusdrobuliak66 Nov 28, 2024
5e8e5e3
open api specs
matusdrobuliak66 Nov 28, 2024
f45879c
final cleanup
matusdrobuliak66 Nov 28, 2024
1ce4f4e
final cleanup
matusdrobuliak66 Nov 28, 2024
b7ee5cf
final cleanup
matusdrobuliak66 Nov 28, 2024
470716f
open api specs
matusdrobuliak66 Nov 28, 2024
e2d205e
open api specs
matusdrobuliak66 Nov 28, 2024
4b87ce3
review @pcrespov
matusdrobuliak66 Nov 29, 2024
0ac8e2d
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
0d71046
review @pcrespov
matusdrobuliak66 Nov 29, 2024
96d9b93
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
7061af7
review @pcrespov
matusdrobuliak66 Nov 29, 2024
b77602c
open api specs
matusdrobuliak66 Nov 29, 2024
6f43ade
Merge branch 'move-folder-between-workspaced' of github.com:matusdrob…
matusdrobuliak66 Nov 29, 2024
4e4675f
frontend changes
matusdrobuliak66 Nov 29, 2024
c37f3dc
clieaning
matusdrobuliak66 Nov 29, 2024
0fb74d4
frontend changes
matusdrobuliak66 Nov 29, 2024
c2f63e9
fix
matusdrobuliak66 Nov 29, 2024
f2cad55
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
7a9284c
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
6e3ed2a
fix
matusdrobuliak66 Dec 2, 2024
d03a496
fix
matusdrobuliak66 Dec 2, 2024
64758ff
fix
matusdrobuliak66 Dec 2, 2024
4dfa541
fix
matusdrobuliak66 Dec 2, 2024
763586a
fix
matusdrobuliak66 Dec 2, 2024
6e287d9
fix
matusdrobuliak66 Dec 2, 2024
21619d9
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
cd50981
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
5c785d2
fix
matusdrobuliak66 Dec 3, 2024
a9d5436
fix
matusdrobuliak66 Dec 3, 2024
658b167
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 3, 2024
eabeafb
fix
matusdrobuliak66 Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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]]:
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
)
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
Loading