Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class ProjectGet(OutputSchema):
dev: dict | None
permalink: ProjectPermalink = FieldNotRequired()
workspace_id: WorkspaceID | None
folder_id: FolderID | None

_empty_description = validator("description", allow_reuse=True, pre=True)(
none_to_empty_str_pre_validator
Expand Down
6 changes: 6 additions & 0 deletions packages/models-library/src/models_library/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Any, Final, TypeAlias
from uuid import UUID

from models_library.folders import FolderID
from models_library.workspaces import WorkspaceID
from pydantic import BaseModel, ConstrainedStr, Extra, Field, validator

Expand Down Expand Up @@ -179,6 +180,11 @@ class Project(BaseProjectModel):
description="To which workspace project belongs. If None, belongs to private user workspace.",
alias="workspaceId",
)
folder_id: FolderID | None = Field(
default=None,
description="To which folder project belongs. If None, belongs to root folder.",
alias="folderId",
)

class Config:
description = "Document that stores metadata, pipeline and UI setup of a study"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10236,6 +10236,11 @@ components:
exclusiveMinimum: true
type: integer
minimum: 0
folderId:
title: Folderid
exclusiveMinimum: true
type: integer
minimum: 0
ProjectGroupGet:
title: ProjectGroupGet
required:
Expand Down Expand Up @@ -10471,6 +10476,11 @@ components:
exclusiveMinimum: true
type: integer
minimum: 0
folderId:
title: Folderid
exclusiveMinimum: true
type: integer
minimum: 0
ProjectLocked:
title: ProjectLocked
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from ._access_rights_db import get_project_owner
from .db import APP_PROJECT_DBAPI, ProjectDBAPI
from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
from .models import UserProjectAccessRights
from .models import UserProjectAccessRightsWithWorkspace


async def validate_project_ownership(
Expand All @@ -31,7 +31,7 @@ async def get_user_project_access_rights(
project_id: ProjectID,
user_id: UserID,
product_name: ProductName,
) -> UserProjectAccessRights:
) -> UserProjectAccessRightsWithWorkspace:
"""
This function resolves user access rights on the project resource.

Expand All @@ -51,19 +51,31 @@ async def get_user_project_access_rights(
workspace_id=project_db.workspace_id,
product_name=product_name,
)
_user_project_access_rights = UserProjectAccessRights(
uid=user_id,
read=workspace.my_access_rights.read,
write=workspace.my_access_rights.write,
delete=workspace.my_access_rights.delete,
_user_project_access_rights_with_workspace = (
UserProjectAccessRightsWithWorkspace(
uid=user_id,
workspace_id=project_db.workspace_id,
read=workspace.my_access_rights.read,
write=workspace.my_access_rights.write,
delete=workspace.my_access_rights.delete,
)
)
else:
_user_project_access_rights = (
await db.get_pure_project_access_rights_without_workspace(
user_id, project_id
)
)
return _user_project_access_rights
_user_project_access_rights_with_workspace = (
UserProjectAccessRightsWithWorkspace(
uid=user_id,
workspace_id=None,
read=_user_project_access_rights.read,
write=_user_project_access_rights.write,
delete=_user_project_access_rights.delete,
)
)
return _user_project_access_rights_with_workspace


async def has_user_project_access_rights(
Expand Down Expand Up @@ -92,7 +104,7 @@ async def check_user_project_permission(
user_id: UserID,
product_name: ProductName,
permission: PermissionStr = "read",
) -> UserProjectAccessRights:
) -> UserProjectAccessRightsWithWorkspace:
_user_project_access_rights = await get_user_project_access_rights(
app, project_id=project_id, user_id=user_id, product_name=product_name
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,13 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
# Adds permalink
await update_or_pop_permalink_in_project(request, new_project)

# Adds folderId
user_specific_project_data_db = await db.get_user_specific_project_data_db(
project_uuid=new_project["uuid"],
private_workspace_user_id_or_none=user_id if workspace_id is None else None,
)
new_project["folderId"] = user_specific_project_data_db.folder_id

# Overwrite project access rights
if workspace_id:
workspace_db: UserWorkspaceAccessRightsDB = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ async def replace_project(request: web.Request):
reason=f"Project {path_params.project_id} cannot be modified while pipeline is still running."
)

await check_user_project_permission(
user_project_permission = await check_user_project_permission(
request.app,
project_id=path_params.project_id,
user_id=req_ctx.user_id,
Expand Down Expand Up @@ -483,6 +483,16 @@ async def replace_project(request: web.Request):
is_template=False,
app=request.app,
)
# Appends folder ID
user_specific_project_data_db = await db.get_user_specific_project_data_db(
project_uuid=path_params.project_id,
private_workspace_user_id_or_none=(
req_ctx.user_id
if user_project_permission.workspace_id is None
else None
),
)
data["folderId"] = user_specific_project_data_db.folder_id

return web.json_response({"data": data}, dumps=json_dumps)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def convert_to_db_names(project_document_data: dict) -> dict:
exclude_keys = [
"tags",
"prjOwner",
"folderId",
] # No column for tags, prjOwner is a foreign key in db
for key, value in project_document_data.items():
if key not in exclude_keys:
Expand Down
50 changes: 40 additions & 10 deletions services/web/server/src/simcore_service_webserver/projects/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@
ProjectNodeResourcesInsufficientRightsError,
ProjectNotFoundError,
)
from .models import ProjectDB, ProjectDict, UserProjectAccessRights
from .models import (
ProjectDB,
ProjectDict,
UserProjectAccessRightsDB,
UserSpecificProjectDataDB,
)

_logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -398,6 +403,7 @@ async def list_projects( # pylint: disable=too-many-arguments
],
access_rights_subquery.c.access_rights,
projects_to_products.c.product_name,
projects_to_folders.c.folder_id,
)
.select_from(_join_query)
.where(
Expand Down Expand Up @@ -496,13 +502,9 @@ async def get_project(
only_published: bool = False,
only_templates: bool = False,
) -> tuple[ProjectDict, ProjectType]:
"""Returns all projects *owned* by the user

- prj_owner
- Notice that a user can have access to a template but he might not own it
- Notice that a user can have access to a project where he/she has read access

:raises ProjectNotFoundError: project is not assigned to user
"""
This is a legacy function that retrieves the project resource along with additional adjustments.
The `get_project_db` function is now recommended for use when interacting with the projects DB layer.
"""
async with self.engine.acquire() as conn:
project = await self._get_project(
Expand Down Expand Up @@ -553,9 +555,37 @@ async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB:
raise ProjectNotFoundError(project_uuid=project_uuid)
return ProjectDB.from_orm(row)

async def get_user_specific_project_data_db(
self, project_uuid: ProjectID, private_workspace_user_id_or_none: UserID | None
) -> UserSpecificProjectDataDB:
async with self.engine.acquire() as conn:
result = await conn.execute(
sa.select(
*self._SELECTION_PROJECT_DB_ARGS, projects_to_folders.c.folder_id
)
.select_from(
projects.join(
projects_to_folders,
(
(projects_to_folders.c.project_uuid == projects.c.uuid)
& (
projects_to_folders.c.user_id
== private_workspace_user_id_or_none
)
),
isouter=True,
)
)
.where(projects.c.uuid == f"{project_uuid}")
)
row = await result.fetchone()
if row is None:
raise ProjectNotFoundError(project_uuid=project_uuid)
return UserSpecificProjectDataDB.from_orm(row)

async def get_pure_project_access_rights_without_workspace(
self, user_id: UserID, project_uuid: ProjectID
) -> UserProjectAccessRights:
) -> UserProjectAccessRightsDB:
"""
Be careful what you want. You should use `get_user_project_access_rights` to get access rights on the
project. It depends on which context you are in, whether private or shared workspace.
Expand Down Expand Up @@ -597,7 +627,7 @@ async def get_pure_project_access_rights_without_workspace(
raise ProjectInvalidRightsError(
user_id=user_id, project_uuid=project_uuid
)
return UserProjectAccessRights.from_orm(row)
return UserProjectAccessRightsDB.from_orm(row)

async def replace_project(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from aiopg.sa.result import RowProxy
from models_library.basic_types import HttpUrlWithCustomMinLength
from models_library.folders import FolderID
from models_library.projects import ClassifierID, ProjectID
from models_library.projects_ui import StudyUI
from models_library.users import UserID
Expand Down Expand Up @@ -63,12 +64,19 @@ class Config:
)


class UserSpecificProjectDataDB(ProjectDB):
folder_id: FolderID | None

class Config:
orm_mode = True


assert set(ProjectDB.__fields__.keys()).issubset( # nosec
{c.name for c in projects.columns if c.name not in ["access_rights"]}
)


class UserProjectAccessRights(BaseModel):
class UserProjectAccessRightsDB(BaseModel):
uid: UserID
read: bool
write: bool
Expand All @@ -78,6 +86,19 @@ class Config:
orm_mode = True


class UserProjectAccessRightsWithWorkspace(BaseModel):
uid: UserID
workspace_id: WorkspaceID | None # None if it's a private workspace
read: bool
write: bool
delete: bool

class Config:
orm_mode = True


# class UserProjectAccessWithWork

__all__: tuple[str, ...] = (
"ProjectDict",
"ProjectProxy",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,26 @@ async def get_project_for_user(
db = ProjectDBAPI.get_from_app_context(app)

product_name = await db.get_project_product(ProjectID(project_uuid))
await check_user_project_permission(
user_project_access = await check_user_project_permission(
app,
project_id=ProjectID(project_uuid),
user_id=user_id,
product_name=product_name,
permission=cast(PermissionStr, check_permissions),
)
workspace_is_private = user_project_access.workspace_id is None

project, project_type = await db.get_project(
project_uuid,
)

# add folder id to the project base on the user
user_specific_project_data_db = await db.get_user_specific_project_data_db(
project_uuid=ProjectID(project_uuid),
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
)
project["folderId"] = user_specific_project_data_db.folder_id

# adds state if it is not a template
if include_state:
project = await add_project_states_for_user(
Expand Down
1 change: 1 addition & 0 deletions services/web/server/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ async def _creator(
# dynamic
"state",
"permalink",
"folderId",
]

for key in new_project:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ async def test_list_projects(
# template project
project_state = data[0].pop("state")
project_permalink = data[0].pop("permalink")
folder_id = data[0].pop("folderId")

assert data[0] == template_project
assert not ProjectState(
Expand All @@ -232,10 +233,12 @@ async def test_list_projects(
# standard project
project_state = data[1].pop("state")
project_permalink = data[1].pop("permalink", None)
folder_id = data[1].pop("folderId")

assert data[1] == user_project
assert ProjectState(**project_state)
assert project_permalink is None
assert folder_id is None

# GET /v0/projects?type=user
data, *_ = await _list_and_assert_projects(client, expected, {"type": "user"})
Expand All @@ -245,6 +248,7 @@ async def test_list_projects(
# standad project
project_state = data[0].pop("state")
project_permalink = data[0].pop("permalink", None)
folder_id = data[0].pop("folderId")

assert data[0] == user_project
assert not ProjectState(
Expand All @@ -261,6 +265,7 @@ async def test_list_projects(
# template project
project_state = data[0].pop("state")
project_permalink = data[0].pop("permalink")
folder_id = data[0].pop("folderId")

assert data[0] == template_project
assert not ProjectState(
Expand Down
Loading
Loading