diff --git a/.github/workflows/ci-testing-pull-request.yml b/.github/workflows/ci-testing-pull-request.yml
index 3d877cb091a..a8c3641b4df 100644
--- a/.github/workflows/ci-testing-pull-request.yml
+++ b/.github/workflows/ci-testing-pull-request.yml
@@ -17,7 +17,7 @@ concurrency:
jobs:
api-specs:
timeout-minutes: 10
- name: "check oas' are up to date"
+ name: "check OAS' are up to date"
runs-on: ubuntu-latest
steps:
- name: setup python environment
@@ -35,11 +35,13 @@ jobs:
run: |
uv venv .venv && source .venv/bin/activate
make openapi-specs
- ./ci/github/helpers/openapi-specs-diff.bash diff \
+ if ! ./ci/github/helpers/openapi-specs-diff.bash diff \
https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/refs/heads/${{ github.event.pull_request.head.ref }} \
- .
+ .; then \
+ echo "::error:: OAS are not up to date. Run 'make openapi-specs' to update them"; exit 1; \
+ fi
- api-server-backwards-compatibility:
+ api-server-oas-breaking:
needs: api-specs
timeout-minutes: 10
name: "api-server backwards compatibility"
@@ -62,11 +64,11 @@ jobs:
https://raw.githubusercontent.com/${{ github.event.pull_request.base.repo.full_name }}/refs/heads/${{ github.event.pull_request.base.ref }}/services/api-server/openapi.json \
/specs/services/api-server/openapi.json
- oas-backwards-compatibility:
+ all-oas-breaking:
needs: api-specs
continue-on-error: true
timeout-minutes: 10
- name: "oas backwards compatibility"
+ name: "OAS backwards compatibility"
runs-on: ubuntu-latest
steps:
- name: setup python environment
diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py
index 9fa015bd7b5..530460c6d8c 100644
--- a/api/specs/web-server/_groups.py
+++ b/api/specs/web-server/_groups.py
@@ -11,17 +11,17 @@
GroupCreate,
GroupGet,
GroupUpdate,
+ GroupUserAdd,
GroupUserGet,
+ GroupUserUpdate,
MyGroupsGet,
)
from models_library.generics import Envelope
from simcore_service_webserver._meta import API_VTAG
-from simcore_service_webserver.groups._handlers import (
- GroupUserAdd,
- GroupUserUpdate,
- _ClassifiersQuery,
- _GroupPathParams,
- _GroupUserPathParams,
+from simcore_service_webserver.groups._common.schemas import (
+ GroupsClassifiersQuery,
+ GroupsPathParams,
+ GroupsUsersPathParams,
)
from simcore_service_webserver.scicrunch.models import ResearchResource, ResourceHit
@@ -58,7 +58,7 @@ async def create_group(_body: GroupCreate):
"/groups/{gid}",
response_model=Envelope[GroupGet],
)
-async def get_group(_path: Annotated[_GroupPathParams, Depends()]):
+async def get_group(_path: Annotated[GroupsPathParams, Depends()]):
"""
Get an organization group
"""
@@ -69,7 +69,7 @@ async def get_group(_path: Annotated[_GroupPathParams, Depends()]):
response_model=Envelope[GroupGet],
)
async def update_group(
- _path: Annotated[_GroupPathParams, Depends()],
+ _path: Annotated[GroupsPathParams, Depends()],
_body: GroupUpdate,
):
"""
@@ -81,7 +81,7 @@ async def update_group(
"/groups/{gid}",
status_code=status.HTTP_204_NO_CONTENT,
)
-async def delete_group(_path: Annotated[_GroupPathParams, Depends()]):
+async def delete_group(_path: Annotated[GroupsPathParams, Depends()]):
"""
Deletes organization groups
"""
@@ -91,7 +91,7 @@ async def delete_group(_path: Annotated[_GroupPathParams, Depends()]):
"/groups/{gid}/users",
response_model=Envelope[list[GroupUserGet]],
)
-async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]):
+async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]):
"""
Gets users in organization groups
"""
@@ -102,11 +102,11 @@ async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]):
status_code=status.HTTP_204_NO_CONTENT,
)
async def add_group_user(
- _path: Annotated[_GroupPathParams, Depends()],
+ _path: Annotated[GroupsPathParams, Depends()],
_body: GroupUserAdd,
):
"""
- Adds a user to an organization group
+ Adds a user to an organization group using their username, user ID, or email (subject to privacy settings)
"""
@@ -115,7 +115,7 @@ async def add_group_user(
response_model=Envelope[GroupUserGet],
)
async def get_group_user(
- _path: Annotated[_GroupUserPathParams, Depends()],
+ _path: Annotated[GroupsUsersPathParams, Depends()],
):
"""
Gets specific user in an organization group
@@ -127,7 +127,7 @@ async def get_group_user(
response_model=Envelope[GroupUserGet],
)
async def update_group_user(
- _path: Annotated[_GroupUserPathParams, Depends()],
+ _path: Annotated[GroupsUsersPathParams, Depends()],
_body: GroupUserUpdate,
):
"""
@@ -140,7 +140,7 @@ async def update_group_user(
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_group_user(
- _path: Annotated[_GroupUserPathParams, Depends()],
+ _path: Annotated[GroupsUsersPathParams, Depends()],
):
"""
Removes a user from an organization group
@@ -157,8 +157,8 @@ async def delete_group_user(
response_model=Envelope[dict[str, Any]],
)
async def get_group_classifiers(
- _path: Annotated[_GroupPathParams, Depends()],
- _query: Annotated[_ClassifiersQuery, Depends()],
+ _path: Annotated[GroupsPathParams, Depends()],
+ _query: Annotated[GroupsClassifiersQuery, Depends()],
):
...
diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py
index c161a7aa69a..cb1904f3bb7 100644
--- a/api/specs/web-server/_users.py
+++ b/api/specs/web-server/_users.py
@@ -7,7 +7,7 @@
from typing import Annotated
from fastapi import APIRouter, Depends, status
-from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate
+from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch
from models_library.api_schemas_webserver.users_preferences import PatchRequestBody
from models_library.generics import Envelope
from models_library.user_preferences import PreferenceIdentifier
@@ -34,7 +34,7 @@
@router.get(
"/me",
- response_model=Envelope[ProfileGet],
+ response_model=Envelope[MyProfileGet],
)
async def get_my_profile():
...
@@ -44,7 +44,7 @@ async def get_my_profile():
"/me",
status_code=status.HTTP_204_NO_CONTENT,
)
-async def update_my_profile(_profile: ProfileUpdate):
+async def update_my_profile(_profile: MyProfilePatch):
...
@@ -54,7 +54,7 @@ async def update_my_profile(_profile: ProfileUpdate):
deprecated=True,
description="Use PATCH instead",
)
-async def replace_my_profile(_profile: ProfileUpdate):
+async def replace_my_profile(_profile: MyProfilePatch):
...
diff --git a/packages/models-library/src/models_library/api_schemas_webserver/_base.py b/packages/models-library/src/models_library/api_schemas_webserver/_base.py
index 718984116c7..948c4c9b3ea 100644
--- a/packages/models-library/src/models_library/api_schemas_webserver/_base.py
+++ b/packages/models-library/src/models_library/api_schemas_webserver/_base.py
@@ -24,7 +24,8 @@ class InputSchemaWithoutCamelCase(BaseModel):
class InputSchema(BaseModel):
model_config = ConfigDict(
- **InputSchemaWithoutCamelCase.model_config, alias_generator=snake_to_camel
+ **InputSchemaWithoutCamelCase.model_config,
+ alias_generator=snake_to_camel,
)
@@ -50,7 +51,7 @@ def data(
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
- **kwargs
+ **kwargs,
)
def data_json(
@@ -67,5 +68,5 @@ def data_json(
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
- **kwargs
+ **kwargs,
)
diff --git a/packages/models-library/src/models_library/api_schemas_webserver/groups.py b/packages/models-library/src/models_library/api_schemas_webserver/groups.py
index 71bbc5ae068..d595447c3d3 100644
--- a/packages/models-library/src/models_library/api_schemas_webserver/groups.py
+++ b/packages/models-library/src/models_library/api_schemas_webserver/groups.py
@@ -1,5 +1,7 @@
from contextlib import suppress
+from typing import Annotated, Any, Self, TypeVar
+from common_library.basic_types import DEFAULT_FACTORY
from pydantic import (
AnyHttpUrl,
AnyUrl,
@@ -12,11 +14,25 @@
model_validator,
)
+from ..basic_types import IDStr
from ..emails import LowerCaseEmailStr
+from ..groups import (
+ AccessRightsDict,
+ Group,
+ GroupMember,
+ StandardGroupCreate,
+ StandardGroupUpdate,
+)
from ..users import UserID
from ..utils.common_validators import create__check_only_one_is_set__root_validator
from ._base import InputSchema, OutputSchema
+S = TypeVar("S", bound=BaseModel)
+
+
+def _rename_keys(source: dict, name_map: dict[str, str]) -> dict[str, Any]:
+ return {name_map.get(k, k): v for k, v in source.items()}
+
class GroupAccessRights(BaseModel):
"""
@@ -26,6 +42,7 @@ class GroupAccessRights(BaseModel):
read: bool
write: bool
delete: bool
+
model_config = ConfigDict(
json_schema_extra={
"examples": [
@@ -45,11 +62,40 @@ class GroupGet(OutputSchema):
default=None, description="url to the group thumbnail"
)
access_rights: GroupAccessRights = Field(..., alias="accessRights")
- inclusion_rules: dict[str, str] = Field(
- default_factory=dict,
- description="Maps user's column and regular expression",
- alias="inclusionRules",
- )
+
+ inclusion_rules: Annotated[
+ dict[str, str],
+ Field(
+ default_factory=dict,
+ alias="inclusionRules",
+ deprecated=True,
+ ),
+ ] = DEFAULT_FACTORY
+
+ @classmethod
+ def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self:
+ # Merges both service models into this schema
+ return cls.model_validate(
+ {
+ **_rename_keys(
+ group.model_dump(
+ include={
+ "gid",
+ "name",
+ "description",
+ "thumbnail",
+ },
+ exclude={"access_rights", "inclusion_rules"},
+ exclude_unset=True,
+ by_alias=False,
+ ),
+ name_map={
+ "name": "label",
+ },
+ ),
+ "access_rights": access_rights,
+ }
+ )
model_config = ConfigDict(
json_schema_extra={
@@ -78,7 +124,6 @@ class GroupGet(OutputSchema):
"label": "SPARCi",
"description": "Stimulating Peripheral Activity to Relieve Conditions",
"thumbnail": "https://placekitten.com/15/15",
- "inclusionRules": {"email": r"@(sparc)+\.(io|com|us)$"},
"accessRights": {"read": True, "write": True, "delete": True},
},
]
@@ -100,12 +145,36 @@ class GroupCreate(InputSchema):
description: str
thumbnail: AnyUrl | None = None
+ def to_model(self) -> StandardGroupCreate:
+ data = _rename_keys(
+ self.model_dump(
+ mode="json",
+ # NOTE: intentionally inclusion_rules are not exposed to the REST api
+ include={"label", "description", "thumbnail"},
+ exclude_unset=True,
+ ),
+ name_map={"label": "name"},
+ )
+ return StandardGroupCreate(**data)
+
class GroupUpdate(InputSchema):
label: str | None = None
description: str | None = None
thumbnail: AnyUrl | None = None
+ def to_model(self) -> StandardGroupUpdate:
+ data = _rename_keys(
+ self.model_dump(
+ mode="json",
+ # NOTE: intentionally inclusion_rules are not exposed to the REST api
+ include={"label", "description", "thumbnail"},
+ exclude_unset=True,
+ ),
+ name_map={"label": "name"},
+ )
+ return StandardGroupUpdate(**data)
+
class MyGroupsGet(OutputSchema):
me: GroupGet
@@ -156,20 +225,42 @@ class MyGroupsGet(OutputSchema):
class GroupUserGet(BaseModel):
- id: str | None = Field(None, description="the user id", coerce_numbers_to_str=True)
- login: LowerCaseEmailStr | None = Field(None, description="the user login email")
- first_name: str | None = Field(None, description="the user first name")
- last_name: str | None = Field(None, description="the user last name")
- gravatar_id: str | None = Field(None, description="the user gravatar id hash")
- gid: str | None = Field(
- None, description="the user primary gid", coerce_numbers_to_str=True
- )
+ # OutputSchema
+
+ # Identifiers
+ id: Annotated[
+ str | None, Field(description="the user id", coerce_numbers_to_str=True)
+ ] = None
+ user_name: Annotated[IDStr, Field(alias="userName")]
+ gid: Annotated[
+ str | None,
+ Field(description="the user primary gid", coerce_numbers_to_str=True),
+ ] = None
+
+ # Private Profile
+ login: Annotated[
+ LowerCaseEmailStr | None,
+ Field(description="the user's email, if privacy settings allows"),
+ ] = None
+ first_name: Annotated[
+ str | None, Field(description="If privacy settings allows")
+ ] = None
+ last_name: Annotated[
+ str | None, Field(description="If privacy settings allows")
+ ] = None
+ gravatar_id: Annotated[
+ str | None, Field(description="the user gravatar id hash", deprecated=True)
+ ] = None
+
+ # Access Rights
access_rights: GroupAccessRights = Field(..., alias="accessRights")
model_config = ConfigDict(
+ populate_by_name=True,
json_schema_extra={
"example": {
"id": "1",
+ "userName": "mrmith",
"login": "mr.smith@matrix.com",
"first_name": "Mr",
"last_name": "Smith",
@@ -181,9 +272,23 @@ class GroupUserGet(BaseModel):
"delete": False,
},
}
- }
+ },
)
+ @classmethod
+ def from_model(cls, user: GroupMember) -> Self:
+ return cls.model_validate(
+ {
+ "id": user.id,
+ "user_name": user.name,
+ "login": user.email,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "gid": user.primary_gid,
+ "access_rights": user.access_rights,
+ }
+ )
+
class GroupUserAdd(InputSchema):
"""
@@ -191,14 +296,25 @@ class GroupUserAdd(InputSchema):
"""
uid: UserID | None = None
- email: LowerCaseEmailStr | None = None
+ user_name: Annotated[IDStr | None, Field(alias="userName")] = None
+ email: Annotated[
+ LowerCaseEmailStr | None,
+ Field(
+ description="Accessible only if the user has opted to share their email in privacy settings"
+ ),
+ ] = None
_check_uid_or_email = model_validator(mode="after")(
- create__check_only_one_is_set__root_validator(["uid", "email"])
+ create__check_only_one_is_set__root_validator(["uid", "email", "user_name"])
)
model_config = ConfigDict(
- json_schema_extra={"examples": [{"uid": 42}, {"email": "foo@email.com"}]}
+ json_schema_extra={
+ "examples": [
+ {"uid": 42},
+ {"email": "foo@email.com"},
+ ]
+ }
)
diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py
index ae7b9f89504..f0dd3d8bcfb 100644
--- a/packages/models-library/src/models_library/api_schemas_webserver/users.py
+++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py
@@ -13,17 +13,17 @@
from ._base import InputSchema, OutputSchema
-class ProfilePrivacyGet(OutputSchema):
+class MyProfilePrivacyGet(OutputSchema):
hide_fullname: bool
hide_email: bool
-class ProfilePrivacyUpdate(InputSchema):
+class MyProfilePrivacyPatch(InputSchema):
hide_fullname: bool | None = None
hide_email: bool | None = None
-class ProfileGet(BaseModel):
+class MyProfileGet(BaseModel):
# WARNING: do not use InputSchema until front-end is updated!
id: UserID
user_name: Annotated[
@@ -45,7 +45,7 @@ class ProfileGet(BaseModel):
),
] = None
- privacy: ProfilePrivacyGet
+ privacy: MyProfilePrivacyGet
preferences: AggregatedPreferences
model_config = ConfigDict(
@@ -77,13 +77,13 @@ def _to_upper_string(cls, v):
return v
-class ProfileUpdate(BaseModel):
+class MyProfilePatch(BaseModel):
# WARNING: do not use InputSchema until front-end is updated!
first_name: FirstNameStr | None = None
last_name: LastNameStr | None = None
user_name: Annotated[IDStr | None, Field(alias="userName")] = None
- privacy: ProfilePrivacyUpdate | None = None
+ privacy: MyProfilePrivacyPatch | None = None
model_config = ConfigDict(
json_schema_extra={
diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py
index 488776b6d8e..e79928574a6 100644
--- a/packages/models-library/src/models_library/groups.py
+++ b/packages/models-library/src/models_library/groups.py
@@ -1,9 +1,13 @@
import enum
-from typing import Final
+from typing import Annotated, Final, NamedTuple, TypeAlias
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from common_library.basic_types import DEFAULT_FACTORY
+from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from pydantic.types import PositiveInt
+from typing_extensions import TypedDict
+from .basic_types import IDStr
+from .users import GroupID, UserID
from .utils.common_validators import create_enums_pre_validator
EVERYONE_GROUP_ID: Final[int] = 1
@@ -25,15 +29,77 @@ class Group(BaseModel):
gid: PositiveInt
name: str
description: str
- group_type: GroupTypeInModel = Field(..., alias="type")
+ group_type: Annotated[GroupTypeInModel, Field(alias="type")]
thumbnail: str | None
+ inclusion_rules: Annotated[
+ dict[str, str],
+ Field(
+ default_factory=dict,
+ ),
+ ] = DEFAULT_FACTORY
+
_from_equivalent_enums = field_validator("group_type", mode="before")(
create_enums_pre_validator(GroupTypeInModel)
)
+ model_config = ConfigDict(populate_by_name=True)
+
+
+class AccessRightsDict(TypedDict):
+ read: bool
+ write: bool
+ delete: bool
+
+
+GroupInfoTuple: TypeAlias = tuple[Group, AccessRightsDict]
+
+
+class GroupsByTypeTuple(NamedTuple):
+ primary: GroupInfoTuple | None
+ standard: list[GroupInfoTuple]
+ everyone: GroupInfoTuple | None
+
+
+class GroupMember(BaseModel):
+ # identifiers
+ id: UserID
+ name: IDStr
+ primary_gid: GroupID
+
+ # private profile
+ email: EmailStr | None
+ first_name: str | None
+ last_name: str | None
+
+ # group access
+ access_rights: AccessRightsDict
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class StandardGroupCreate(BaseModel):
+ name: str
+ description: str | None = None
+ thumbnail: str | None = None
+ inclusion_rules: Annotated[
+ dict[str, str],
+ Field(
+ default_factory=dict,
+ description="Maps user's column and regular expression",
+ ),
+ ] = DEFAULT_FACTORY
+
+
+class StandardGroupUpdate(BaseModel):
+ name: str | None = None
+ description: str | None = None
+ thumbnail: str | None = None
+ inclusion_rules: dict[str, str] | None = None
+
class GroupAtDB(Group):
+ # NOTE: deprecate and use `Group` instead
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
diff --git a/packages/models-library/src/models_library/utils/common_validators.py b/packages/models-library/src/models_library/utils/common_validators.py
index 23cb62739db..d008f87e8cf 100644
--- a/packages/models-library/src/models_library/utils/common_validators.py
+++ b/packages/models-library/src/models_library/utils/common_validators.py
@@ -87,7 +87,9 @@ def null_or_none_str_to_none_validator(value: Any):
return value
-def create__check_only_one_is_set__root_validator(alternative_field_names: list[str]):
+def create__check_only_one_is_set__root_validator(
+ mutually_exclusive_field_names: list[str],
+):
"""Ensure exactly one and only one of the alternatives is set
NOTE: a field is considered here `unset` when it is `not None`. When None
@@ -104,17 +106,16 @@ def create__check_only_one_is_set__root_validator(alternative_field_names: list[
"""
def _validator(cls: type[BaseModel], values):
- assert set(alternative_field_names).issubset(cls.model_fields) # nosec
-
+ assert set(mutually_exclusive_field_names).issubset( # nosec
+ cls.model_fields
+ ), f"Invalid {mutually_exclusive_field_names=} passed in the factory arguments"
got = {
field_name: getattr(values, field_name)
- for field_name in alternative_field_names
+ for field_name in mutually_exclusive_field_names
}
if not functools.reduce(operator.xor, (v is not None for v in got.values())):
- msg = (
- f"Either { 'or'.join(got.keys()) } must be set, but not both. Got {got}"
- )
+ msg = f"Either { ' or '.join(got.keys()) } must be set, but not both. Got {got}"
raise ValueError(msg)
return values
diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py
new file mode 100644
index 00000000000..2381193baeb
--- /dev/null
+++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py
@@ -0,0 +1,34 @@
+"""set privacy_hide_email to false temporarily
+
+Revision ID: 5e27063c3ac9
+Revises: 4d007819e61a
+Create Date: 2024-12-10 15:50:48.024204+00:00
+
+"""
+from alembic import op
+from sqlalchemy.sql import expression
+
+# revision identifiers, used by Alembic.
+revision = "5e27063c3ac9"
+down_revision = "4d007819e61a"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Change the server_default of privacy_hide_email to false
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.alter_column("privacy_hide_email", server_default=expression.false())
+
+ # Reset all to default: Update existing values in the database
+ op.execute("UPDATE users SET privacy_hide_email = false")
+
+
+def downgrade():
+
+ # Revert the server_default of privacy_hide_email to true
+ with op.batch_alter_table("users") as batch_op:
+ batch_op.alter_column("privacy_hide_email", server_default=expression.true())
+
+ # Reset all to default: Revert existing values in the database to true
+ op.execute("UPDATE users SET privacy_hide_email = true")
diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py
index d42568d772f..bdff1293211 100644
--- a/packages/postgres-database/src/simcore_postgres_database/models/users.py
+++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py
@@ -161,7 +161,7 @@ class UserStatus(str, Enum):
"privacy_hide_email",
sa.Boolean,
nullable=False,
- server_default=expression.true(),
+ server_default=expression.false(),
doc="If true, it hides users.email to others",
),
#
diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_products.py b/packages/postgres-database/src/simcore_postgres_database/utils_products.py
index ff87ac1ad00..33e877c21d0 100644
--- a/packages/postgres-database/src/simcore_postgres_database/utils_products.py
+++ b/packages/postgres-database/src/simcore_postgres_database/utils_products.py
@@ -2,6 +2,8 @@
"""
+import warnings
+
import sqlalchemy as sa
from ._protocols import AiopgConnection, DBConnection
@@ -37,36 +39,54 @@ async def get_product_group_id(
return None if group_id is None else _GroupID(group_id)
+async def execute_get_or_create_product_group(conn, product_name: str) -> int:
+ #
+ # NOTE: Separated so it can be used in asyncpg and aiopg environs while both
+ # coexist
+ #
+ group_id: int | None = await conn.scalar(
+ sa.select(products.c.group_id)
+ .where(products.c.name == product_name)
+ .with_for_update(read=True)
+ # a `FOR SHARE` lock: locks changes in the product until transaction is done.
+ # Read might return in None, but it is OK
+ )
+ if group_id is None:
+ group_id = await conn.scalar(
+ groups.insert()
+ .values(
+ name=product_name,
+ description=f"{product_name} product group",
+ type=GroupType.STANDARD,
+ )
+ .returning(groups.c.gid)
+ )
+ assert group_id # nosec
+
+ await conn.execute(
+ products.update()
+ .where(products.c.name == product_name)
+ .values(group_id=group_id)
+ )
+
+ return group_id
+
+
async def get_or_create_product_group(
connection: AiopgConnection, product_name: str
) -> _GroupID:
"""
Returns group_id of a product. Creates it if undefined
"""
+ warnings.warn(
+ f"{__name__}.get_or_create_product_group uses aiopg which has been deprecated in this repo. Please use the asyncpg equivalent version instead"
+ "See https://github.com/ITISFoundation/osparc-simcore/issues/4529",
+ DeprecationWarning,
+ stacklevel=1,
+ )
+
async with connection.begin():
- group_id = await connection.scalar(
- sa.select(products.c.group_id)
- .where(products.c.name == product_name)
- .with_for_update(read=True)
- # a `FOR SHARE` lock: locks changes in the product until transaction is done.
- # Read might return in None, but it is OK
+ group_id = await execute_get_or_create_product_group(
+ connection, product_name=product_name
)
- if group_id is None:
- group_id = await connection.scalar(
- groups.insert()
- .values(
- name=product_name,
- description=f"{product_name} product group",
- type=GroupType.STANDARD,
- )
- .returning(groups.c.gid)
- )
- assert group_id # nosec
-
- await connection.execute(
- products.update()
- .where(products.c.name == product_name)
- .values(group_id=group_id)
- )
-
return _GroupID(group_id)
diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_users.py b/packages/postgres-database/src/simcore_postgres_database/utils_users.py
index 806d950fee5..9026cdd27b4 100644
--- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py
+++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py
@@ -36,6 +36,10 @@ def _generate_random_chars(length=5) -> str:
return "".join(secrets.choice(string.digits) for _ in range(length - 1))
+def generate_alternative_username(username) -> str:
+ return f"{username}_{_generate_random_chars()}"
+
+
class UsersRepo:
@staticmethod
async def new_user(
@@ -61,7 +65,7 @@ async def new_user(
users.insert().values(**data).returning(users.c.id)
)
except UniqueViolation: # noqa: PERF203
- data["name"] = f'{data["name"]}_{_generate_random_chars()}'
+ data["name"] = generate_alternative_username(data["name"])
result = await conn.execute(
sa.select(
diff --git a/packages/postgres-database/tests/test_groups.py b/packages/postgres-database/tests/test_models_groups.py
similarity index 100%
rename from packages/postgres-database/tests/test_groups.py
rename to packages/postgres-database/tests/test_models_groups.py
diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py
new file mode 100644
index 00000000000..0c79aba5622
--- /dev/null
+++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py
@@ -0,0 +1,158 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+"""
+
+ Fixtures for groups
+
+ NOTE: These fixtures are used in integration and unit tests
+"""
+
+
+from collections.abc import AsyncIterator
+from typing import Any
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient
+from models_library.api_schemas_webserver.groups import GroupGet
+from models_library.groups import GroupsByTypeTuple, StandardGroupCreate
+from models_library.users import UserID
+from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
+from simcore_service_webserver.groups._groups_api import (
+ add_user_in_group,
+ create_standard_group,
+ delete_standard_group,
+ list_user_groups_with_read_access,
+)
+
+
+def _groupget_model_dump(group, access_rights) -> dict[str, Any]:
+ return GroupGet.from_model(group, access_rights).model_dump(
+ mode="json", by_alias=True
+ )
+
+
+async def _create_organization(
+ app: web.Application, user_id: UserID, new_group: dict
+) -> dict[str, Any]:
+ group, access_rights = await create_standard_group(
+ app,
+ user_id=user_id,
+ create=StandardGroupCreate.model_validate(new_group),
+ )
+ return _groupget_model_dump(group=group, access_rights=access_rights)
+
+
+#
+# USER'S GROUPS FIXTURES
+#
+
+
+@pytest.fixture
+async def standard_groups_owner(
+ client: TestClient,
+ logged_user: UserInfoDict,
+) -> AsyncIterator[UserInfoDict]:
+ """
+ standard_groups_owner creates TWO organizations and adds logged_user in them
+ """
+
+ assert client.app
+ # create a separate account to own standard groups
+ async with NewUser(
+ {
+ "name": f"{logged_user['name']}_groups_owner",
+ "role": "USER",
+ },
+ client.app,
+ ) as owner_user:
+
+ # creates two groups
+ sparc_group = await _create_organization(
+ app=client.app,
+ user_id=owner_user["id"],
+ new_group={
+ "name": "SPARC",
+ "description": "Stimulating Peripheral Activity to Relieve Conditions",
+ "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png",
+ "inclusion_rules": {"email": r"@(sparc)+\.(io|com)$"},
+ },
+ )
+ team_black_group = await _create_organization(
+ app=client.app,
+ user_id=owner_user["id"],
+ new_group={
+ "name": "team Black",
+ "description": "THE incredible black team",
+ "thumbnail": None,
+ "inclusion_rules": {"email": r"@(black)+\.(io|com)$"},
+ },
+ )
+
+ # adds logged_user to sparc group
+ await add_user_in_group(
+ app=client.app,
+ user_id=owner_user["id"],
+ group_id=sparc_group["gid"],
+ new_by_user_id=logged_user["id"],
+ )
+
+ # adds logged_user to team-black group
+ await add_user_in_group(
+ app=client.app,
+ user_id=owner_user["id"],
+ group_id=team_black_group["gid"],
+ new_by_user_id=logged_user["id"],
+ )
+
+ yield owner_user
+
+ # clean groups
+ await delete_standard_group(
+ client.app, user_id=owner_user["id"], group_id=sparc_group["gid"]
+ )
+ await delete_standard_group(
+ client.app, user_id=owner_user["id"], group_id=team_black_group["gid"]
+ )
+
+
+@pytest.fixture
+async def logged_user_groups_by_type(
+ client: TestClient, logged_user: UserInfoDict, standard_groups_owner: UserInfoDict
+) -> GroupsByTypeTuple:
+ assert client.app
+
+ assert logged_user["id"] != standard_groups_owner["id"]
+
+ groups_by_type = await list_user_groups_with_read_access(
+ client.app, user_id=logged_user["id"]
+ )
+ assert groups_by_type.primary
+ assert groups_by_type.everyone
+ return groups_by_type
+
+
+@pytest.fixture
+def primary_group(
+ logged_user_groups_by_type: GroupsByTypeTuple,
+) -> dict[str, Any]:
+ """`logged_user`'s primary group"""
+ assert logged_user_groups_by_type.primary
+ return _groupget_model_dump(*logged_user_groups_by_type.primary)
+
+
+@pytest.fixture
+def standard_groups(
+ logged_user_groups_by_type: GroupsByTypeTuple,
+) -> list[dict[str, Any]]:
+ """owned by `standard_groups_owner` and shared with `logged_user`"""
+ return [_groupget_model_dump(*sg) for sg in logged_user_groups_by_type.standard]
+
+
+@pytest.fixture
+def all_group(
+ logged_user_groups_by_type: GroupsByTypeTuple,
+) -> dict[str, Any]:
+ assert logged_user_groups_by_type.everyone
+ return _groupget_model_dump(*logged_user_groups_by_type.everyone)
diff --git a/services/api-server/Makefile b/services/api-server/Makefile
index 39672c9764e..82263c83658 100644
--- a/services/api-server/Makefile
+++ b/services/api-server/Makefile
@@ -87,6 +87,7 @@ openapi-diff.md: guard-OPENAPI_JSON_BASE_URL openapi.json ## Diffs against a rem
# SEE https://schemathesis.readthedocs.io/en/stable/index.html
APP_URL:=http://$(get_my_ip).nip.io:8006
+
test-api: ## Runs schemathesis against development server (NOTE: make up-devel first)
@docker run schemathesis/schemathesis:stable run \
"$(APP_URL)/api/v0/openapi.json"
diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json
index ee8d7f9479d..d70ef50e6bc 100644
--- a/services/api-server/openapi.json
+++ b/services/api-server/openapi.json
@@ -5548,6 +5548,7 @@
"urls": {
"items": {
"type": "string",
+ "minLength": 1,
"format": "uri"
},
"type": "array",
@@ -6161,6 +6162,7 @@
},
"download_link": {
"type": "string",
+ "minLength": 1,
"format": "uri",
"title": "Download Link"
}
diff --git a/services/api-server/requirements/_base.txt b/services/api-server/requirements/_base.txt
index 48d12d8b832..a49ef21c613 100644
--- a/services/api-server/requirements/_base.txt
+++ b/services/api-server/requirements/_base.txt
@@ -1,8 +1,8 @@
-aio-pika==9.4.1
+aio-pika==9.5.3
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiocache==0.12.2
+aiocache==0.12.3
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -11,17 +11,19 @@ aiodebug==2.3.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiodocker==0.21.0
+aiodocker==0.24.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiofiles==23.2.1
+aiofiles==24.1.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-aiohttp==3.9.3
+aiohappyeyeballs==2.4.4
+ # via aiohttp
+aiohttp==3.11.10
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -57,25 +59,23 @@ aiopg==1.4.0
# via
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-aiormq==6.8.0
+aiormq==6.8.1
# via aio-pika
aiosignal==1.3.1
# via aiohttp
-alembic==1.13.1
+alembic==1.14.0
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
annotated-types==0.7.0
# via pydantic
-anyio==4.3.0
+anyio==4.7.0
# via
# fast-depends
# faststream
# httpx
# starlette
# watchfiles
-appdirs==1.4.4
- # via pint
arrow==1.3.0
# via
# -r requirements/../../../packages/models-library/requirements/_base.in
@@ -87,16 +87,14 @@ arrow==1.3.0
asgiref==3.8.1
# via opentelemetry-instrumentation-asgi
async-timeout==4.0.3
- # via
- # aiopg
- # asyncpg
-asyncpg==0.29.0
+ # via aiopg
+asyncpg==0.30.0
# via sqlalchemy
-attrs==23.2.0
+attrs==24.2.0
# via
# aiohttp
# jsonschema
-certifi==2024.2.2
+certifi==2024.8.30
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -129,15 +127,16 @@ certifi==2024.2.2
# httpcore
# httpx
# requests
-cffi==1.16.0
+cffi==1.17.1
# via cryptography
-charset-normalizer==3.3.2
+charset-normalizer==3.4.0
# via requests
click==8.1.7
# via
+ # rich-toolkit
# typer
# uvicorn
-cryptography==42.0.5
+cryptography==44.0.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -168,58 +167,59 @@ cryptography==42.0.5
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_base.in
-deprecated==1.2.14
+deprecated==1.2.15
# via
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-semantic-conventions
-dnspython==2.6.1
+dnspython==2.7.0
# via email-validator
-email-validator==2.1.1
+email-validator==2.2.0
# via
# fastapi
# pydantic
-fast-depends==2.4.2
+exceptiongroup==1.2.2
+ # via aio-pika
+fast-depends==2.4.12
# via faststream
-fastapi==0.115.5
+fastapi==0.115.6
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# -r requirements/_base.in
- # prometheus-fastapi-instrumentator
-fastapi-cli==0.0.5
+fastapi-cli==0.0.6
# via fastapi
-fastapi-pagination==0.12.31
+fastapi-pagination==0.12.32
# via -r requirements/_base.in
-faststream==0.5.31
+faststream==0.5.33
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
flexcache==0.3
# via pint
-flexparser==0.3.1
+flexparser==0.4
# via pint
-frozenlist==1.4.1
+frozenlist==1.5.0
# via
# aiohttp
# aiosignal
-googleapis-common-protos==1.65.0
+googleapis-common-protos==1.66.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-greenlet==3.0.3
+greenlet==3.1.1
# via sqlalchemy
-grpcio==1.66.0
+grpcio==1.68.1
# via opentelemetry-exporter-otlp-proto-grpc
h11==0.14.0
# via
# httpcore
# uvicorn
-httpcore==1.0.5
+httpcore==1.0.7
# via httpx
-httptools==0.6.1
+httptools==0.6.4
# via uvicorn
-httpx==0.27.0
+httpx==0.27.2
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -249,21 +249,22 @@ httpx==0.27.0
# -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
+ # -c requirements/./constraints.txt
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# -r requirements/_base.in
# fastapi
-idna==3.6
+idna==3.10
# via
# anyio
# email-validator
# httpx
# requests
# yarl
-importlib-metadata==8.0.0
+importlib-metadata==8.5.0
# via opentelemetry-api
-itsdangerous==2.1.2
+itsdangerous==2.2.0
# via fastapi
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -301,7 +302,7 @@ jsonschema==3.2.0
# -r requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
-mako==1.3.2
+mako==1.3.7
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -334,17 +335,17 @@ mako==1.3.2
# alembic
markdown-it-py==3.0.0
# via rich
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# jinja2
# mako
mdurl==0.1.2
# via markdown-it-py
-multidict==6.0.5
+multidict==6.1.0
# via
# aiohttp
# yarl
-opentelemetry-api==1.27.0
+opentelemetry-api==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -362,19 +363,19 @@ opentelemetry-api==1.27.0
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
# opentelemetry-semantic-conventions
-opentelemetry-exporter-otlp==1.27.0
+opentelemetry-exporter-otlp==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-exporter-otlp-proto-common==1.27.0
+opentelemetry-exporter-otlp-proto-common==1.28.2
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-exporter-otlp-proto-grpc==1.27.0
+opentelemetry-exporter-otlp-proto-grpc==1.28.2
# via opentelemetry-exporter-otlp
-opentelemetry-exporter-otlp-proto-http==1.27.0
+opentelemetry-exporter-otlp-proto-http==1.28.2
# via opentelemetry-exporter-otlp
-opentelemetry-instrumentation==0.48b0
+opentelemetry-instrumentation==0.49b2
# via
# opentelemetry-instrumentation-aiopg
# opentelemetry-instrumentation-asgi
@@ -385,45 +386,46 @@ opentelemetry-instrumentation==0.48b0
# opentelemetry-instrumentation-logging
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
-opentelemetry-instrumentation-aiopg==0.48b0
+opentelemetry-instrumentation-aiopg==0.49b2
# via -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-opentelemetry-instrumentation-asgi==0.48b0
+opentelemetry-instrumentation-asgi==0.49b2
# via opentelemetry-instrumentation-fastapi
-opentelemetry-instrumentation-asyncpg==0.48b0
+opentelemetry-instrumentation-asyncpg==0.49b2
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
-opentelemetry-instrumentation-dbapi==0.48b0
+opentelemetry-instrumentation-dbapi==0.49b2
# via opentelemetry-instrumentation-aiopg
-opentelemetry-instrumentation-fastapi==0.48b0
+opentelemetry-instrumentation-fastapi==0.49b2
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-opentelemetry-instrumentation-httpx==0.48b0
+opentelemetry-instrumentation-httpx==0.49b2
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-opentelemetry-instrumentation-logging==0.48b0
+opentelemetry-instrumentation-logging==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-redis==0.48b0
+opentelemetry-instrumentation-redis==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-requests==0.48b0
+opentelemetry-instrumentation-requests==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-proto==1.27.0
+opentelemetry-proto==1.28.2
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-sdk==1.27.0
+opentelemetry-sdk==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-semantic-conventions==0.48b0
+opentelemetry-semantic-conventions==0.49b2
# via
+ # opentelemetry-instrumentation
# opentelemetry-instrumentation-asgi
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-dbapi
@@ -432,13 +434,13 @@ opentelemetry-semantic-conventions==0.48b0
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
-opentelemetry-util-http==0.48b0
+opentelemetry-util-http==0.49b2
# via
# opentelemetry-instrumentation-asgi
# opentelemetry-instrumentation-fastapi
# opentelemetry-instrumentation-httpx
# opentelemetry-instrumentation-requests
-orjson==3.10.0
+orjson==3.10.12
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -488,37 +490,44 @@ orjson==3.10.0
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in
# -r requirements/_base.in
# fastapi
-packaging==24.0
+packaging==24.2
# via
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
+ # opentelemetry-instrumentation
pamqp==3.3.0
# via aiormq
parse==1.20.2
# via -r requirements/_base.in
-pint==0.24.3
+pint==0.24.4
# via -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-prometheus-client==0.20.0
+platformdirs==4.3.6
+ # via pint
+prometheus-client==0.21.1
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# prometheus-fastapi-instrumentator
-prometheus-fastapi-instrumentator==6.1.0
+prometheus-fastapi-instrumentator==7.0.0
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-protobuf==4.25.4
+propcache==0.2.1
+ # via
+ # aiohttp
+ # yarl
+protobuf==5.29.1
# via
# googleapis-common-protos
# opentelemetry-proto
-psutil==6.0.0
+psutil==6.1.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-psycopg2-binary==2.9.9
+psycopg2-binary==2.9.10
# via
# aiopg
# sqlalchemy
pycparser==2.22
# via cffi
-pydantic==2.10.2
+pydantic==2.10.3
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -583,7 +592,7 @@ pydantic==2.10.2
# pydantic-settings
pydantic-core==2.27.1
# via pydantic
-pydantic-extra-types==2.9.0
+pydantic-extra-types==2.10.0
# via
# -r requirements/../../../packages/common-library/requirements/_base.in
# -r requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/_base.in
@@ -615,9 +624,9 @@ pydantic-settings==2.6.1
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
# fastapi
-pygments==2.17.2
+pygments==2.18.0
# via rich
-pyinstrument==4.6.2
+pyinstrument==5.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -629,9 +638,9 @@ python-dotenv==1.0.1
# via
# pydantic-settings
# uvicorn
-python-multipart==0.0.9
+python-multipart==0.0.19
# via fastapi
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -666,7 +675,7 @@ pyyaml==6.0.1
# -r requirements/_base.in
# fastapi
# uvicorn
-redis==5.0.4
+redis==5.2.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -704,20 +713,21 @@ repro-zipfile==0.3.1
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
requests==2.32.3
# via opentelemetry-exporter-otlp-proto-http
-rich==13.7.1
+rich==13.9.4
# via
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
+ # rich-toolkit
# typer
-setuptools==69.2.0
- # via
- # jsonschema
- # opentelemetry-instrumentation
+rich-toolkit==0.12.0
+ # via fastapi-cli
+setuptools==75.6.0
+ # via jsonschema
shellingham==1.5.4
# via typer
-six==1.16.0
+six==1.17.0
# via
# jsonschema
# python-dateutil
@@ -725,7 +735,7 @@ sniffio==1.3.1
# via
# anyio
# httpx
-sqlalchemy==1.4.52
+sqlalchemy==1.4.54
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -759,7 +769,7 @@ sqlalchemy==1.4.52
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
# aiopg
# alembic
-starlette==0.41.2
+starlette==0.41.3
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -790,22 +800,23 @@ starlette==0.41.2
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# fastapi
-tenacity==8.5.0
+ # prometheus-fastapi-instrumentator
+tenacity==9.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-toolz==0.12.1
+toolz==1.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-tqdm==4.66.2
+tqdm==4.67.1
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-typer==0.12.3
+typer==0.15.1
# via
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/settings-library/requirements/_base.in
@@ -813,13 +824,13 @@ typer==0.12.3
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/_base.in
# fastapi-cli
-types-python-dateutil==2.9.0.20240316
+types-python-dateutil==2.9.0.20241206
# via arrow
typing-extensions==4.12.2
# via
# aiodebug
- # aiodocker
# alembic
+ # anyio
# fastapi
# fastapi-pagination
# faststream
@@ -829,8 +840,10 @@ typing-extensions==4.12.2
# pint
# pydantic
# pydantic-core
+ # pydantic-extra-types
+ # rich-toolkit
# typer
-ujson==5.9.0
+ujson==5.10.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -892,25 +905,26 @@ urllib3==2.2.3
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# requests
-uvicorn==0.29.0
+uvicorn==0.32.1
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# fastapi
# fastapi-cli
-uvloop==0.19.0
+uvloop==0.21.0
# via uvicorn
-watchfiles==0.21.0
+watchfiles==1.0.0
# via uvicorn
-websockets==12.0
+websockets==14.1
# via uvicorn
-wrapt==1.16.0
+wrapt==1.17.0
# via
# deprecated
# opentelemetry-instrumentation
# opentelemetry-instrumentation-aiopg
# opentelemetry-instrumentation-dbapi
+ # opentelemetry-instrumentation-httpx
# opentelemetry-instrumentation-redis
-yarl==1.9.4
+yarl==1.18.3
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/service-library/requirements/_base.in
@@ -919,5 +933,5 @@ yarl==1.9.4
# aio-pika
# aiohttp
# aiormq
-zipp==3.20.1
+zipp==3.21.0
# via importlib-metadata
diff --git a/services/api-server/requirements/_test.txt b/services/api-server/requirements/_test.txt
index e5254193173..a9341530919 100644
--- a/services/api-server/requirements/_test.txt
+++ b/services/api-server/requirements/_test.txt
@@ -1,25 +1,29 @@
-aiohttp==3.9.3
+aiohappyeyeballs==2.4.4
+ # via
+ # -c requirements/_base.txt
+ # aiohttp
+aiohttp==3.11.10
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# aioresponses
-aioresponses==0.7.6
+aioresponses==0.7.7
# via -r requirements/_test.in
aiosignal==1.3.1
# via
# -c requirements/_base.txt
# aiohttp
-alembic==1.13.1
+alembic==1.14.0
# via
# -c requirements/_base.txt
# -r requirements/_test.in
-anyio==4.3.0
+anyio==4.7.0
# via
# -c requirements/_base.txt
# httpx
asgi-lifespan==2.1.0
# via -r requirements/_test.in
-attrs==23.2.0
+attrs==24.2.0
# via
# -c requirements/_base.txt
# aiohttp
@@ -33,28 +37,26 @@ aws-sam-translator==1.55.0
# cfn-lint
aws-xray-sdk==2.14.0
# via moto
-boto3==1.35.25
+boto3==1.35.76
# via
# aws-sam-translator
# moto
-boto3-stubs==1.35.25
- # via types-boto3
-botocore==1.35.25
+botocore==1.35.76
# via
# aws-xray-sdk
# boto3
# moto
# s3transfer
-botocore-stubs==1.35.25
- # via boto3-stubs
-certifi==2024.2.2
+botocore-stubs==1.35.76
+ # via types-boto3
+certifi==2024.8.30
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# httpcore
# httpx
# requests
-cffi==1.16.0
+cffi==1.17.1
# via
# -c requirements/_base.txt
# cryptography
@@ -62,7 +64,7 @@ cfn-lint==0.72.0
# via
# -c requirements/./constraints.txt
# moto
-charset-normalizer==3.3.2
+charset-normalizer==3.4.0
# via
# -c requirements/_base.txt
# requests
@@ -71,9 +73,9 @@ click==8.1.7
# -c requirements/_base.txt
# -r requirements/_test.in
# flask
-coverage==7.6.1
+coverage==7.6.8
# via pytest-cov
-cryptography==42.0.5
+cryptography==44.0.0
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -89,7 +91,7 @@ ecdsa==0.19.0
# moto
# python-jose
# sshpubkeys
-faker==29.0.0
+faker==33.1.0
# via -r requirements/_test.in
flask==2.1.3
# via
@@ -97,14 +99,14 @@ flask==2.1.3
# moto
flask-cors==5.0.0
# via moto
-frozenlist==1.4.1
+frozenlist==1.5.0
# via
# -c requirements/_base.txt
# aiohttp
# aiosignal
-graphql-core==3.2.4
+graphql-core==3.2.5
# via moto
-greenlet==3.0.3
+greenlet==3.1.1
# via
# -c requirements/_base.txt
# sqlalchemy
@@ -112,16 +114,17 @@ h11==0.14.0
# via
# -c requirements/_base.txt
# httpcore
-httpcore==1.0.5
+httpcore==1.0.7
# via
# -c requirements/_base.txt
# httpx
-httpx==0.27.0
+httpx==0.27.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
+ # -c requirements/./constraints.txt
# respx
-idna==3.6
+idna==3.10
# via
# -c requirements/_base.txt
# anyio
@@ -131,11 +134,11 @@ idna==3.6
# yarl
iniconfig==2.0.0
# via pytest
-itsdangerous==2.1.2
+itsdangerous==2.2.0
# via
# -c requirements/_base.txt
# flask
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -151,7 +154,7 @@ jsondiff==2.2.1
# via moto
jsonpatch==1.33
# via cfn-lint
-jsonpickle==3.3.0
+jsonpickle==4.0.0
# via jschema-to-python
jsonpointer==3.0.0
# via jsonpatch
@@ -167,12 +170,12 @@ jsonschema==3.2.0
# openapi-spec-validator
junit-xml==1.9
# via cfn-lint
-mako==1.3.2
+mako==1.3.7
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# alembic
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# -c requirements/_base.txt
# jinja2
@@ -182,12 +185,12 @@ moto==4.0.1
# via
# -c requirements/./constraints.txt
# -r requirements/_test.in
-multidict==6.0.5
+multidict==6.1.0
# via
# -c requirements/_base.txt
# aiohttp
# yarl
-mypy==1.12.0
+mypy==1.13.0
# via sqlalchemy
mypy-extensions==1.0.0
# via mypy
@@ -199,9 +202,10 @@ openapi-spec-validator==0.4.0
# via
# -c requirements/./constraints.txt
# moto
-packaging==24.0
+packaging==24.2
# via
# -c requirements/_base.txt
+ # aioresponses
# pytest
pbr==6.1.0
# via
@@ -209,6 +213,11 @@ pbr==6.1.0
# sarif-om
pluggy==1.5.0
# via pytest
+propcache==0.2.1
+ # via
+ # -c requirements/_base.txt
+ # aiohttp
+ # yarl
pyasn1==0.6.1
# via
# python-jose
@@ -217,17 +226,17 @@ pycparser==2.22
# via
# -c requirements/_base.txt
# cffi
-pyinstrument==4.6.2
+pyinstrument==5.0.0
# via
# -c requirements/_base.txt
# -r requirements/_test.in
-pyparsing==3.1.4
+pyparsing==3.2.0
# via moto
pyrsistent==0.20.0
# via
# -c requirements/_base.txt
# jsonschema
-pytest==8.3.3
+pytest==8.3.4
# via
# -r requirements/_test.in
# pytest-asyncio
@@ -238,7 +247,7 @@ pytest-asyncio==0.23.8
# via
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_test.in
-pytest-cov==5.0.0
+pytest-cov==6.0.0
# via -r requirements/_test.in
pytest-docker==3.1.1
# via -r requirements/_test.in
@@ -256,7 +265,7 @@ python-jose==3.3.0
# via moto
pytz==2024.2
# via moto
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -279,17 +288,17 @@ rsa==4.9
# via
# -c requirements/../../../requirements/constraints.txt
# python-jose
-s3transfer==0.10.2
+s3transfer==0.10.4
# via boto3
sarif-om==1.0.4
# via cfn-lint
-setuptools==69.2.0
+setuptools==75.6.0
# via
# -c requirements/_base.txt
# jsonschema
# moto
# openapi-spec-validator
-six==1.16.0
+six==1.17.0
# via
# -c requirements/_base.txt
# ecdsa
@@ -302,7 +311,7 @@ sniffio==1.3.1
# anyio
# asgi-lifespan
# httpx
-sqlalchemy==1.4.52
+sqlalchemy==1.4.54
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -314,19 +323,21 @@ sshpubkeys==3.3.1
# via moto
types-aiofiles==24.1.0.20240626
# via -r requirements/_test.in
-types-awscrt==0.21.5
+types-awscrt==0.23.3
# via botocore-stubs
-types-boto3==1.0.2
+types-boto3==1.35.76
# via -r requirements/_test.in
-types-s3transfer==0.10.2
- # via boto3-stubs
+types-s3transfer==0.10.4
+ # via types-boto3
typing-extensions==4.12.2
# via
# -c requirements/_base.txt
# alembic
- # boto3-stubs
+ # anyio
+ # faker
# mypy
# sqlalchemy2-stubs
+ # types-boto3
urllib3==2.2.3
# via
# -c requirements/../../../requirements/constraints.txt
@@ -339,13 +350,13 @@ werkzeug==2.1.2
# via
# flask
# moto
-wrapt==1.16.0
+wrapt==1.17.0
# via
# -c requirements/_base.txt
# aws-xray-sdk
-xmltodict==0.13.0
+xmltodict==0.14.2
# via moto
-yarl==1.9.4
+yarl==1.18.3
# via
# -c requirements/_base.txt
# aiohttp
diff --git a/services/api-server/requirements/_tools.txt b/services/api-server/requirements/_tools.txt
index 795564deb10..ba68de2e0b8 100644
--- a/services/api-server/requirements/_tools.txt
+++ b/services/api-server/requirements/_tools.txt
@@ -1,8 +1,8 @@
-astroid==3.3.4
+astroid==3.3.5
# via pylint
-black==24.8.0
+black==24.10.0
# via -r requirements/../../../requirements/devenv.txt
-build==1.2.2
+build==1.2.2.post1
# via pip-tools
bump2version==1.0.1
# via -r requirements/../../../requirements/devenv.txt
@@ -16,32 +16,32 @@ click==8.1.7
# -c requirements/_test.txt
# black
# pip-tools
-dill==0.3.8
+dill==0.3.9
# via pylint
-distlib==0.3.8
+distlib==0.3.9
# via virtualenv
filelock==3.16.1
# via virtualenv
-identify==2.6.1
+identify==2.6.3
# via pre-commit
isort==5.13.2
# via
# -r requirements/../../../requirements/devenv.txt
# pylint
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# -c requirements/_test.txt
# -r requirements/_tools.in
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
# jinja2
mccabe==0.7.0
# via pylint
-mypy==1.12.0
+mypy==1.13.0
# via
# -c requirements/_test.txt
# -r requirements/../../../requirements/devenv.txt
@@ -52,7 +52,7 @@ mypy-extensions==1.0.0
# mypy
nodeenv==1.9.1
# via pre-commit
-packaging==24.0
+packaging==24.2
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
@@ -60,33 +60,34 @@ packaging==24.0
# build
pathspec==0.12.1
# via black
-pip==24.2
+pip==24.3.1
# via pip-tools
pip-tools==7.4.1
# via -r requirements/../../../requirements/devenv.txt
platformdirs==4.3.6
# via
+ # -c requirements/_base.txt
# black
# pylint
# virtualenv
-pre-commit==3.8.0
+pre-commit==4.0.1
# via -r requirements/../../../requirements/devenv.txt
-pylint==3.3.0
+pylint==3.3.2
# via -r requirements/../../../requirements/devenv.txt
-pyproject-hooks==1.1.0
+pyproject-hooks==1.2.0
# via
# build
# pip-tools
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# -c requirements/_test.txt
# pre-commit
# watchdog
-ruff==0.6.7
+ruff==0.8.2
# via -r requirements/../../../requirements/devenv.txt
-setuptools==69.2.0
+setuptools==75.6.0
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
@@ -98,9 +99,9 @@ typing-extensions==4.12.2
# -c requirements/_base.txt
# -c requirements/_test.txt
# mypy
-virtualenv==20.26.5
+virtualenv==20.28.0
# via pre-commit
-watchdog==5.0.2
+watchdog==6.0.0
# via -r requirements/_tools.in
-wheel==0.44.0
+wheel==0.45.1
# via pip-tools
diff --git a/services/api-server/requirements/constraints.txt b/services/api-server/requirements/constraints.txt
index 6247b000156..2991a5be8b4 100644
--- a/services/api-server/requirements/constraints.txt
+++ b/services/api-server/requirements/constraints.txt
@@ -40,3 +40,6 @@ aws-sam-translator<1.56.0
# # aws-sam-translator<1.55.0 (from -c ./constraints.txt (line 32))
# # aws-sam-translator>=1.57.0 (from cfn-lint==0.72.10->-c ./constraints.txt (line 33))
cfn-lint<0.72.1
+
+# due to https://github.com/lundberg/respx/pull/278
+httpx!=0.28.0
diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py
index 9301b5ce42c..c7d5680eb37 100644
--- a/services/api-server/src/simcore_service_api_server/services/webserver.py
+++ b/services/api-server/src/simcore_service_api_server/services/webserver.py
@@ -29,8 +29,10 @@
ProjectInputUpdate,
)
from models_library.api_schemas_webserver.resource_usage import PricingPlanGet
-from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet
-from models_library.api_schemas_webserver.users import ProfileUpdate as WebProfileUpdate
+from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet
+from models_library.api_schemas_webserver.users import (
+ MyProfilePatch as WebProfileUpdate,
+)
from models_library.api_schemas_webserver.wallets import WalletGet
from models_library.generics import Envelope
from models_library.projects import ProjectID
@@ -351,7 +353,7 @@ async def get_projects_w_solver_page(
show_hidden=True,
# WARNING: better way to match jobs with projects (Next PR if this works fine!)
# WARNING: search text has a limit that I needed to increase for the example!
- search=urllib.parse.quote(solver_name, safe=""),
+ search=solver_name,
)
async def get_projects_page(self, *, limit: int, offset: int):
diff --git a/services/api-server/tests/unit/_with_db/test_api_user.py b/services/api-server/tests/unit/_with_db/test_api_user.py
index 24836d1b3cd..93a3bdf8f68 100644
--- a/services/api-server/tests/unit/_with_db/test_api_user.py
+++ b/services/api-server/tests/unit/_with_db/test_api_user.py
@@ -9,7 +9,7 @@
import pytest
import respx
from fastapi import FastAPI
-from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet
+from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet
from respx import MockRouter
from simcore_service_api_server._meta import API_VTAG
from simcore_service_api_server.core.settings import ApplicationSettings
diff --git a/services/static-webserver/client/source/class/osparc/widget/logger/LoggerModel.js b/services/static-webserver/client/source/class/osparc/widget/logger/LoggerModel.js
index a6110b2714c..9cb8b2249de 100644
--- a/services/static-webserver/client/source/class/osparc/widget/logger/LoggerModel.js
+++ b/services/static-webserver/client/source/class/osparc/widget/logger/LoggerModel.js
@@ -113,7 +113,7 @@ qx.Class.define("osparc.widget.logger.LoggerModel", {
newRow["level"] = this.self().getLevelIcon(newRow.logLevel);
newRow["time"] = osparc.utils.Utils.formatTime(newRow.timeStamp, true);
newRow["who"] = newRow.label;
- newRow["msgRich"] = newRow.msg.replaceAll("\n", "
");
+ newRow["msgRich"] = newRow.msg.replace(/\n/g, "
");
this.__rawData.push(newRow);
});
},
diff --git a/services/web/server/VERSION b/services/web/server/VERSION
index a758a09aae5..5c4503b7043 100644
--- a/services/web/server/VERSION
+++ b/services/web/server/VERSION
@@ -1 +1 @@
-0.48.0
+0.49.0
diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg
index 0b6157ef959..0e40e2535ee 100644
--- a/services/web/server/setup.cfg
+++ b/services/web/server/setup.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 0.48.0
+current_version = 0.49.0
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
index db975aa23c3..9a92419e514 100644
--- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
+++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
@@ -2,7 +2,7 @@ openapi: 3.1.0
info:
title: simcore-service-webserver
description: Main service with an interface (http-API & websockets) to the web front-end
- version: 0.48.0
+ version: 0.49.0
servers:
- url: ''
description: webserver
@@ -396,6 +396,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Envelope_list_ApiKeyGet__'
+ '409':
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
'404':
description: Not Found
content:
@@ -421,6 +427,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_'
+ '409':
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
'404':
description: Not Found
content:
@@ -450,6 +462,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Envelope_ApiKeyGet_'
+ '409':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Conflict
'404':
content:
application/json:
@@ -474,6 +492,12 @@ paths:
responses:
'204':
description: Successful Response
+ '409':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Conflict
'404':
content:
application/json:
@@ -610,7 +634,8 @@ paths:
tags:
- groups
summary: Add Group User
- description: Adds a user to an organization group
+ description: Adds a user to an organization group using their username, user
+ ID, or email (subject to privacy settings)
operationId: add_group_user
parameters:
- name: gid
@@ -1117,7 +1142,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/Envelope_ProfileGet_'
+ $ref: '#/components/schemas/Envelope_MyProfileGet_'
put:
tags:
- user
@@ -1128,7 +1153,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/ProfileUpdate'
+ $ref: '#/components/schemas/MyProfilePatch'
required: true
responses:
'204':
@@ -1143,7 +1168,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/ProfileUpdate'
+ $ref: '#/components/schemas/MyProfilePatch'
required: true
responses:
'204':
@@ -6765,6 +6790,11 @@ components:
title: ApiKeyCreateRequest
ApiKeyCreateResponse:
properties:
+ id:
+ type: string
+ maxLength: 100
+ minLength: 1
+ title: Id
displayName:
type: string
minLength: 3
@@ -6777,11 +6807,6 @@ components:
title: Expiration
description: Time delta from creation time to expiration. If None, then
it does not expire.
- id:
- type: string
- maxLength: 100
- minLength: 1
- title: Id
apiBaseUrl:
type: string
title: Apibaseurl
@@ -6793,8 +6818,8 @@ components:
title: Apisecret
type: object
required:
- - displayName
- id
+ - displayName
- apiBaseUrl
- apiKey
- apiSecret
@@ -7965,6 +7990,19 @@ components:
title: Error
type: object
title: Envelope[MyGroupsGet]
+ Envelope_MyProfileGet_:
+ properties:
+ data:
+ anyOf:
+ - $ref: '#/components/schemas/MyProfileGet'
+ - type: 'null'
+ error:
+ anyOf:
+ - {}
+ - type: 'null'
+ title: Error
+ type: object
+ title: Envelope[MyProfileGet]
Envelope_NodeCreated_:
properties:
data:
@@ -8082,19 +8120,6 @@ components:
title: Error
type: object
title: Envelope[PricingUnitGet]
- Envelope_ProfileGet_:
- properties:
- data:
- anyOf:
- - $ref: '#/components/schemas/ProfileGet'
- - type: 'null'
- error:
- anyOf:
- - {}
- - type: 'null'
- title: Error
- type: object
- title: Envelope[ProfileGet]
Envelope_ProjectGet_:
properties:
data:
@@ -9849,7 +9874,7 @@ components:
type: string
type: object
title: Inclusionrules
- description: Maps user's column and regular expression
+ deprecated: true
type: object
required:
- gid
@@ -9887,12 +9912,21 @@ components:
minimum: 0
- type: 'null'
title: Uid
+ userName:
+ anyOf:
+ - type: string
+ maxLength: 100
+ minLength: 1
+ - type: 'null'
+ title: Username
email:
anyOf:
- type: string
format: email
- type: 'null'
title: Email
+ description: Accessible only if the user has opted to share their email
+ in privacy settings
type: object
title: GroupUserAdd
description: "Identify the user with either `email` or `uid` \u2014 only one."
@@ -9904,41 +9938,48 @@ components:
- type: 'null'
title: Id
description: the user id
+ userName:
+ type: string
+ maxLength: 100
+ minLength: 1
+ title: Username
+ gid:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Gid
+ description: the user primary gid
login:
anyOf:
- type: string
format: email
- type: 'null'
title: Login
- description: the user login email
+ description: the user's email, if privacy settings allows
first_name:
anyOf:
- type: string
- type: 'null'
title: First Name
- description: the user first name
+ description: If privacy settings allows
last_name:
anyOf:
- type: string
- type: 'null'
title: Last Name
- description: the user last name
+ description: If privacy settings allows
gravatar_id:
anyOf:
- type: string
- type: 'null'
title: Gravatar Id
description: the user gravatar id hash
- gid:
- anyOf:
- - type: string
- - type: 'null'
- title: Gid
- description: the user primary gid
+ deprecated: true
accessRights:
$ref: '#/components/schemas/GroupAccessRights'
type: object
required:
+ - userName
- accessRights
title: GroupUserGet
example:
@@ -9952,6 +9993,7 @@ components:
id: '1'
last_name: Smith
login: mr.smith@matrix.com
+ userName: mrmith
GroupUserUpdate:
properties:
accessRights:
@@ -10355,6 +10397,136 @@ components:
description: Some foundation
gid: '16'
label: Blue Fundation
+ MyProfileGet:
+ properties:
+ id:
+ type: integer
+ exclusiveMinimum: true
+ title: Id
+ minimum: 0
+ userName:
+ type: string
+ maxLength: 100
+ minLength: 1
+ title: Username
+ description: Unique username identifier
+ first_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: First Name
+ last_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: Last Name
+ login:
+ type: string
+ format: email
+ title: Login
+ role:
+ type: string
+ enum:
+ - ANONYMOUS
+ - GUEST
+ - USER
+ - TESTER
+ - PRODUCT_OWNER
+ - ADMIN
+ title: Role
+ groups:
+ anyOf:
+ - $ref: '#/components/schemas/MyGroupsGet'
+ - type: 'null'
+ gravatar_id:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Gravatar Id
+ deprecated: true
+ expirationDate:
+ anyOf:
+ - type: string
+ format: date
+ - type: 'null'
+ title: Expirationdate
+ description: If user has a trial account, it sets the expiration date, otherwise
+ None
+ privacy:
+ $ref: '#/components/schemas/MyProfilePrivacyGet'
+ preferences:
+ additionalProperties:
+ $ref: '#/components/schemas/Preference'
+ type: object
+ title: Preferences
+ type: object
+ required:
+ - id
+ - userName
+ - login
+ - role
+ - privacy
+ - preferences
+ title: MyProfileGet
+ MyProfilePatch:
+ properties:
+ first_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: First Name
+ last_name:
+ anyOf:
+ - type: string
+ maxLength: 255
+ - type: 'null'
+ title: Last Name
+ userName:
+ anyOf:
+ - type: string
+ maxLength: 100
+ minLength: 1
+ - type: 'null'
+ title: Username
+ privacy:
+ anyOf:
+ - $ref: '#/components/schemas/MyProfilePrivacyPatch'
+ - type: 'null'
+ type: object
+ title: MyProfilePatch
+ example:
+ first_name: Pedro
+ last_name: Crespo
+ MyProfilePrivacyGet:
+ properties:
+ hideFullname:
+ type: boolean
+ title: Hidefullname
+ hideEmail:
+ type: boolean
+ title: Hideemail
+ type: object
+ required:
+ - hideFullname
+ - hideEmail
+ title: MyProfilePrivacyGet
+ MyProfilePrivacyPatch:
+ properties:
+ hideFullname:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Hidefullname
+ hideEmail:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Hideemail
+ type: object
+ title: MyProfilePrivacyPatch
Node-Input:
properties:
key:
@@ -11637,136 +11809,6 @@ components:
- currentCostPerUnit
- default
title: PricingUnitGet
- ProfileGet:
- properties:
- id:
- type: integer
- exclusiveMinimum: true
- title: Id
- minimum: 0
- userName:
- type: string
- maxLength: 100
- minLength: 1
- title: Username
- description: Unique username identifier
- first_name:
- anyOf:
- - type: string
- maxLength: 255
- - type: 'null'
- title: First Name
- last_name:
- anyOf:
- - type: string
- maxLength: 255
- - type: 'null'
- title: Last Name
- login:
- type: string
- format: email
- title: Login
- role:
- type: string
- enum:
- - ANONYMOUS
- - GUEST
- - USER
- - TESTER
- - PRODUCT_OWNER
- - ADMIN
- title: Role
- groups:
- anyOf:
- - $ref: '#/components/schemas/MyGroupsGet'
- - type: 'null'
- gravatar_id:
- anyOf:
- - type: string
- - type: 'null'
- title: Gravatar Id
- deprecated: true
- expirationDate:
- anyOf:
- - type: string
- format: date
- - type: 'null'
- title: Expirationdate
- description: If user has a trial account, it sets the expiration date, otherwise
- None
- privacy:
- $ref: '#/components/schemas/ProfilePrivacyGet'
- preferences:
- additionalProperties:
- $ref: '#/components/schemas/Preference'
- type: object
- title: Preferences
- type: object
- required:
- - id
- - userName
- - login
- - role
- - privacy
- - preferences
- title: ProfileGet
- ProfilePrivacyGet:
- properties:
- hideFullname:
- type: boolean
- title: Hidefullname
- hideEmail:
- type: boolean
- title: Hideemail
- type: object
- required:
- - hideFullname
- - hideEmail
- title: ProfilePrivacyGet
- ProfilePrivacyUpdate:
- properties:
- hideFullname:
- anyOf:
- - type: boolean
- - type: 'null'
- title: Hidefullname
- hideEmail:
- anyOf:
- - type: boolean
- - type: 'null'
- title: Hideemail
- type: object
- title: ProfilePrivacyUpdate
- ProfileUpdate:
- properties:
- first_name:
- anyOf:
- - type: string
- maxLength: 255
- - type: 'null'
- title: First Name
- last_name:
- anyOf:
- - type: string
- maxLength: 255
- - type: 'null'
- title: Last Name
- userName:
- anyOf:
- - type: string
- maxLength: 100
- minLength: 1
- - type: 'null'
- title: Username
- privacy:
- anyOf:
- - $ref: '#/components/schemas/ProfilePrivacyUpdate'
- - type: 'null'
- type: object
- title: ProfileUpdate
- example:
- first_name: Pedro
- last_name: Crespo
ProjectCopyOverride:
properties:
name:
diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py
index 528fed2e3c5..53750a3c27d 100644
--- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py
+++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py
@@ -78,7 +78,7 @@ async def get_new_project_owner_gid(
standard_groups = {} # groups of users, multiple users can be part of this
primary_groups = {} # each individual user has a unique primary group
for other_gid in other_users_access_rights:
- group: Group | None = await get_group_from_gid(app=app, gid=int(other_gid))
+ group: Group | None = await get_group_from_gid(app=app, group_id=int(other_gid))
# only process for users and groups with write access right
if group is None:
diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py
similarity index 100%
rename from services/web/server/src/simcore_service_webserver/groups/_classifiers.py
rename to services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py
diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py
new file mode 100644
index 00000000000..40ce8c41a34
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py
@@ -0,0 +1,112 @@
+import logging
+
+from aiohttp import web
+from servicelib.aiohttp.requests_validation import (
+ parse_request_path_parameters_as,
+ parse_request_query_parameters_as,
+)
+
+from .._meta import API_VTAG
+from ..login.decorators import login_required
+from ..scicrunch.db import ResearchResourceRepository
+from ..scicrunch.errors import ScicrunchError
+from ..scicrunch.models import ResearchResource, ResourceHit
+from ..scicrunch.service_client import SciCrunch
+from ..security.decorators import permission_required
+from ..utils_aiohttp import envelope_json_response
+from ._classifiers_api import GroupClassifierRepository, build_rrids_tree_view
+from ._common.exceptions_handlers import handle_plugin_requests_exceptions
+from ._common.schemas import GroupsClassifiersQuery, GroupsPathParams
+
+_logger = logging.getLogger(__name__)
+
+
+routes = web.RouteTableDef()
+
+
+@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def get_group_classifiers(request: web.Request):
+ try:
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+ query_params: GroupsClassifiersQuery = parse_request_query_parameters_as(
+ GroupsClassifiersQuery, request
+ )
+
+ repo = GroupClassifierRepository(request.app)
+ if not await repo.group_uses_scicrunch(path_params.gid):
+ bundle = await repo.get_classifiers_from_bundle(path_params.gid)
+ return envelope_json_response(bundle)
+
+ # otherwise, build dynamic tree with RRIDs
+ view = await build_rrids_tree_view(
+ request.app, tree_view_mode=query_params.tree_view
+ )
+ except ScicrunchError:
+ view = {}
+
+ return envelope_json_response(view)
+
+
+@routes.get(
+ f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}",
+ name="get_scicrunch_resource",
+)
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def get_scicrunch_resource(request: web.Request):
+ rrid = request.match_info["rrid"]
+ rrid = SciCrunch.validate_identifier(rrid)
+
+ # check if in database first
+ repo = ResearchResourceRepository(request.app)
+ resource: ResearchResource | None = await repo.get_resource(rrid)
+ if not resource:
+ # otherwise, request to scicrunch service
+ scicrunch = SciCrunch.get_instance(request.app)
+ resource = await scicrunch.get_resource_fields(rrid)
+
+ return envelope_json_response(resource.model_dump())
+
+
+@routes.post(
+ f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}",
+ name="add_scicrunch_resource",
+)
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def add_scicrunch_resource(request: web.Request):
+ rrid = request.match_info["rrid"]
+
+ # check if exists
+ repo = ResearchResourceRepository(request.app)
+ resource: ResearchResource | None = await repo.get_resource(rrid)
+ if not resource:
+ # then request scicrunch service
+ scicrunch = SciCrunch.get_instance(request.app)
+ resource = await scicrunch.get_resource_fields(rrid)
+
+ # insert new or if exists, then update
+ await repo.upsert(resource)
+
+ return envelope_json_response(resource.model_dump())
+
+
+@routes.get(
+ f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search",
+ name="search_scicrunch_resources",
+)
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def search_scicrunch_resources(request: web.Request):
+ guess_name = str(request.query["guess_name"]).strip()
+
+ scicrunch = SciCrunch.get_instance(request.app)
+ hits: list[ResourceHit] = await scicrunch.search_resource(guess_name)
+
+ return envelope_json_response([hit.model_dump() for hit in hits])
diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py b/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py
new file mode 100644
index 00000000000..f0b9242fb70
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py
@@ -0,0 +1,59 @@
+import logging
+
+from servicelib.aiohttp import status
+
+from ...exception_handling import (
+ ExceptionToHttpErrorMap,
+ HttpErrorInfo,
+ exception_handling_decorator,
+ to_exceptions_handlers_map,
+)
+from ...scicrunch.errors import InvalidRRIDError, ScicrunchError
+from ...users.exceptions import UserNotFoundError
+from ..exceptions import (
+ GroupNotFoundError,
+ UserAlreadyInGroupError,
+ UserInGroupNotFoundError,
+ UserInsufficientRightsError,
+)
+
+_logger = logging.getLogger(__name__)
+
+
+_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+ UserNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "User {uid} or {email} not found",
+ ),
+ GroupNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Group {gid} not found",
+ ),
+ UserInGroupNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "User not found in group {gid}",
+ ),
+ UserAlreadyInGroupError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "User is already in group {gid}",
+ ),
+ UserInsufficientRightsError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Insufficient rights for {permission} access to group {gid}",
+ ),
+ # scicrunch
+ InvalidRRIDError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "Invalid RRID {rrid}",
+ ),
+ ScicrunchError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "Cannot get RRID since scicrunch.org service is not reachable.",
+ ),
+}
+
+
+handle_plugin_requests_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+)
+# this is one decorator with a single exception handler
diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py
new file mode 100644
index 00000000000..872193aaffe
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py
@@ -0,0 +1,25 @@
+from typing import Literal
+
+from models_library.rest_base import RequestParameters, StrictRequestParameters
+from models_library.users import GroupID, UserID
+from pydantic import Field
+
+from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY
+
+
+class GroupsRequestContext(RequestParameters):
+ user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required]
+ product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required]
+
+
+class GroupsPathParams(StrictRequestParameters):
+ gid: GroupID
+
+
+class GroupsUsersPathParams(StrictRequestParameters):
+ gid: GroupID
+ uid: UserID
+
+
+class GroupsClassifiersQuery(RequestParameters):
+ tree_view: Literal["std"] = "std"
diff --git a/services/web/server/src/simcore_service_webserver/groups/_db.py b/services/web/server/src/simcore_service_webserver/groups/_db.py
deleted file mode 100644
index 3bcee2c6591..00000000000
--- a/services/web/server/src/simcore_service_webserver/groups/_db.py
+++ /dev/null
@@ -1,428 +0,0 @@
-import re
-from typing import Any
-
-import sqlalchemy as sa
-from aiopg.sa import SAConnection
-from aiopg.sa.result import ResultProxy, RowProxy
-from models_library.groups import GroupAtDB
-from models_library.users import GroupID, UserID
-from simcore_postgres_database.errors import UniqueViolation
-from simcore_postgres_database.utils_products import get_or_create_product_group
-from sqlalchemy import and_, literal_column
-from sqlalchemy.dialects.postgresql import insert
-
-from ..db.models import GroupType, groups, user_to_groups, users
-from ..users.exceptions import UserNotFoundError
-from ._users import convert_user_in_group_to_schema
-from ._utils import (
- AccessRightsDict,
- check_group_permissions,
- convert_groups_db_to_schema,
- convert_groups_schema_to_db,
-)
-from .exceptions import (
- GroupNotFoundError,
- UserAlreadyInGroupError,
- UserInGroupNotFoundError,
-)
-
-_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS = AccessRightsDict(
- read=False,
- write=False,
- delete=False,
-)
-
-_DEFAULT_GROUP_READ_ACCESS_RIGHTS = AccessRightsDict(
- read=True,
- write=False,
- delete=False,
-)
-_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS = AccessRightsDict(
- read=True,
- write=True,
- delete=True,
-)
-
-
-async def _get_user_group(
- conn: SAConnection, user_id: UserID, gid: GroupID
-) -> RowProxy:
- result = await conn.execute(
- sa.select(groups, user_to_groups.c.access_rights)
- .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid))
- .where(and_(user_to_groups.c.uid == user_id, user_to_groups.c.gid == gid))
- )
- group = await result.fetchone()
- if not group:
- raise GroupNotFoundError(gid=gid)
- assert isinstance(group, RowProxy) # nosec
- return group
-
-
-async def get_user_from_email(conn: SAConnection, email: str) -> RowProxy:
- result = await conn.execute(sa.select(users).where(users.c.email == email))
- user = await result.fetchone()
- if not user:
- raise UserNotFoundError(email=email)
- assert isinstance(user, RowProxy) # nosec
- return user
-
-
-#
-# USER GROUPS: standard operations
-#
-
-
-async def get_all_user_groups_with_read_access(
- conn: SAConnection, user_id: UserID
-) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
- """
- Returns the user primary group, standard groups and the all group
- """
- primary_group = {}
- user_groups = []
- all_group = {}
-
- query = (
- sa.select(groups, user_to_groups.c.access_rights)
- .select_from(
- user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid),
- )
- .where(user_to_groups.c.uid == user_id)
- )
- row: RowProxy
- async for row in conn.execute(query):
- if row.type == GroupType.EVERYONE:
- assert row.access_rights["read"] # nosec
- all_group = convert_groups_db_to_schema(row)
-
- elif row.type == GroupType.PRIMARY:
- assert row.access_rights["read"] # nosec
- primary_group = convert_groups_db_to_schema(row)
-
- else:
- assert row.type == GroupType.STANDARD # nosec
- # only add if user has read access
- if row.access_rights["read"]:
- user_groups.append(convert_groups_db_to_schema(row))
-
- return (primary_group, user_groups, all_group)
-
-
-async def get_all_user_groups(conn: SAConnection, user_id: UserID) -> list[GroupAtDB]:
- """
- Returns all user groups
- """
- result = await conn.execute(
- sa.select(groups)
- .select_from(
- user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid),
- )
- .where(user_to_groups.c.uid == user_id)
- )
- rows = await result.fetchall() or []
- return [GroupAtDB.model_validate(row) for row in rows]
-
-
-async def get_user_group(
- conn: SAConnection, user_id: UserID, gid: GroupID
-) -> dict[str, str]:
- """
- Gets group gid if user associated to it and has read access
-
- raises GroupNotFoundError
- raises UserInsufficientRightsError
- """
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "read")
- return convert_groups_db_to_schema(group)
-
-
-async def get_product_group_for_user(
- conn: SAConnection, user_id: UserID, product_gid: GroupID
-) -> dict[str, str]:
- """
- Returns product's group if user belongs to it, otherwise it
- raises GroupNotFoundError
- """
- group: RowProxy = await _get_user_group(conn, user_id, product_gid)
- return convert_groups_db_to_schema(group)
-
-
-async def create_user_group(
- conn: SAConnection, user_id: UserID, new_group: dict
-) -> dict[str, Any]:
- result = await conn.execute(
- sa.select(users.c.primary_gid).where(users.c.id == user_id)
- )
- user: RowProxy | None = await result.fetchone()
- if not user:
- raise UserNotFoundError(uid=user_id)
- result = await conn.execute(
- # pylint: disable=no-value-for-parameter
- groups.insert()
- .values(**convert_groups_schema_to_db(new_group))
- .returning(literal_column("*"))
- )
- group: RowProxy | None = await result.fetchone()
- assert group # nosec
-
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- user_to_groups.insert().values(
- uid=user_id,
- gid=group.gid,
- access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS,
- )
- )
- return convert_groups_db_to_schema(
- group, accessRights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS
- )
-
-
-async def update_user_group(
- conn: SAConnection, user_id: UserID, gid: GroupID, new_group_values: dict[str, str]
-) -> dict[str, str]:
- new_values = {
- k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v
- }
-
- group = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "write")
-
- result = await conn.execute(
- # pylint: disable=no-value-for-parameter
- groups.update()
- .values(**new_values)
- .where(groups.c.gid == group.gid)
- .returning(literal_column("*"))
- )
- updated_group = await result.fetchone()
- assert updated_group # nosec
-
- return convert_groups_db_to_schema(updated_group, accessRights=group.access_rights)
-
-
-async def delete_user_group(conn: SAConnection, user_id: UserID, gid: GroupID) -> None:
- group = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "delete")
-
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- groups.delete().where(groups.c.gid == group.gid)
- )
-
-
-#
-# USER GROUPS: Custom operations
-#
-
-
-async def list_users_in_group(
- conn: SAConnection, user_id: UserID, gid: GroupID
-) -> list[dict[str, str]]:
- # first check if the group exists
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "read")
- # now get the list
- query = (
- sa.select(users, user_to_groups.c.access_rights)
- .select_from(users.join(user_to_groups))
- .where(user_to_groups.c.gid == gid)
- )
- users_list = [
- convert_user_in_group_to_schema(row) async for row in conn.execute(query)
- ]
- return users_list
-
-
-async def auto_add_user_to_groups(conn: SAConnection, user: dict) -> None:
- user_id: UserID = user["id"]
-
- # auto add user to the groups with the right rules
- # get the groups where there are inclusion rules and see if they apply
- query = sa.select(groups).where(groups.c.inclusion_rules != {})
- possible_group_ids = set()
- async for row in conn.execute(query):
- inclusion_rules = row[groups.c.inclusion_rules]
- for prop, rule_pattern in inclusion_rules.items():
- if prop not in user:
- continue
- if re.search(rule_pattern, user[prop]):
- possible_group_ids.add(row[groups.c.gid])
-
- # now add the user to these groups if possible
- for gid in possible_group_ids:
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- insert(user_to_groups)
- .values(
- uid=user_id,
- gid=gid,
- access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS,
- )
- .on_conflict_do_nothing() # in case the user was already added
- )
-
-
-async def auto_add_user_to_product_group(
- conn: SAConnection, user_id: UserID, product_name: str
-) -> GroupID:
- product_group_id: GroupID = await get_or_create_product_group(conn, product_name)
-
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- insert(user_to_groups)
- .values(
- uid=user_id,
- gid=product_group_id,
- access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS,
- )
- .on_conflict_do_nothing() # in case the user was already added
- )
- return product_group_id
-
-
-async def is_user_by_email_in_group(
- conn: SAConnection, email: str, group_id: GroupID
-) -> bool:
- user_id = await conn.scalar(
- sa.select(users.c.id)
- .select_from(sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id))
- .where((users.c.email == email) & (user_to_groups.c.gid == group_id))
- )
- return user_id is not None
-
-
-async def add_new_user_in_group(
- conn: SAConnection,
- user_id: UserID,
- gid: GroupID,
- *,
- new_user_id: UserID,
- access_rights: AccessRightsDict | None = None,
-) -> None:
- """
- adds new_user (either by id or email) in group (with gid) owned by user_id
- """
-
- # first check if the group exists
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "write")
-
- # now check the new user exists
- users_count = await conn.scalar(
- sa.select(sa.func.count()).where(users.c.id == new_user_id)
- )
- if not users_count:
- assert new_user_id is not None # nosec
- raise UserInGroupNotFoundError(uid=new_user_id, gid=gid)
-
- # add the new user to the group now
- user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS
- if access_rights:
- user_access_rights.update(access_rights)
-
- try:
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- user_to_groups.insert().values(
- uid=new_user_id, gid=group.gid, access_rights=user_access_rights
- )
- )
- except UniqueViolation as exc:
- raise UserAlreadyInGroupError(
- uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights
- ) from exc
-
-
-async def _get_user_in_group_permissions(
- conn: SAConnection, gid: GroupID, the_user_id_in_group: int
-) -> RowProxy:
- # now get the user
- result = await conn.execute(
- sa.select(users, user_to_groups.c.access_rights)
- .select_from(users.join(user_to_groups, users.c.id == user_to_groups.c.uid))
- .where(and_(user_to_groups.c.gid == gid, users.c.id == the_user_id_in_group))
- )
- the_user: RowProxy | None = await result.fetchone()
- if not the_user:
- raise UserInGroupNotFoundError(uid=the_user_id_in_group, gid=gid)
- return the_user
-
-
-async def get_user_in_group(
- conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int
-) -> dict[str, str]:
- # first check if the group exists
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "read")
- # get the user with its permissions
- the_user: RowProxy = await _get_user_in_group_permissions(
- conn, gid, the_user_id_in_group
- )
- return convert_user_in_group_to_schema(the_user)
-
-
-async def update_user_in_group(
- conn: SAConnection,
- user_id: UserID,
- gid: GroupID,
- the_user_id_in_group: int,
- access_rights: dict,
-) -> dict[str, str]:
- if not access_rights:
- msg = f"Cannot update empty {access_rights}"
- raise ValueError(msg)
-
- # first check if the group exists
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "write")
- # now check the user exists
- the_user: RowProxy = await _get_user_in_group_permissions(
- conn, gid, the_user_id_in_group
- )
- # modify the user access rights
- new_db_values = {"access_rights": access_rights}
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- user_to_groups.update()
- .values(**new_db_values)
- .where(
- and_(
- user_to_groups.c.uid == the_user_id_in_group,
- user_to_groups.c.gid == gid,
- )
- )
- )
- user = dict(the_user)
- user.update(**new_db_values)
- return convert_user_in_group_to_schema(user)
-
-
-async def delete_user_in_group(
- conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int
-) -> None:
- # first check if the group exists
- group: RowProxy = await _get_user_group(conn, user_id, gid)
- check_group_permissions(group, user_id, gid, "write")
- # check the user exists
- await _get_user_in_group_permissions(conn, gid, the_user_id_in_group)
- # delete him/her
- await conn.execute(
- # pylint: disable=no-value-for-parameter
- user_to_groups.delete().where(
- and_(
- user_to_groups.c.uid == the_user_id_in_group,
- user_to_groups.c.gid == gid,
- )
- )
- )
-
-
-async def get_group_from_gid(conn: SAConnection, gid: GroupID) -> GroupAtDB | None:
- row: ResultProxy = await conn.execute(groups.select().where(groups.c.gid == gid))
- result = await row.first()
- if result:
- return GroupAtDB.model_validate(result)
- return None
diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py
new file mode 100644
index 00000000000..9b9e712df54
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py
@@ -0,0 +1,271 @@
+from aiohttp import web
+from models_library.basic_types import IDStr
+from models_library.emails import LowerCaseEmailStr
+from models_library.groups import (
+ AccessRightsDict,
+ Group,
+ GroupMember,
+ GroupsByTypeTuple,
+ StandardGroupCreate,
+ StandardGroupUpdate,
+)
+from models_library.products import ProductName
+from models_library.users import GroupID, UserID
+from pydantic import EmailStr
+
+from ..users.api import get_user
+from . import _groups_db
+from .exceptions import GroupsError
+
+#
+# GROUPS
+#
+
+
+async def get_group_from_gid(app: web.Application, group_id: GroupID) -> Group | None:
+ group_db = await _groups_db.get_group_from_gid(app, group_id=group_id)
+
+ if group_db:
+ return Group.model_construct(**group_db.model_dump())
+ return None
+
+
+#
+# USER GROUPS: groups a user belongs to
+#
+
+
+async def list_user_groups_with_read_access(
+ app: web.Application, *, user_id: UserID
+) -> GroupsByTypeTuple:
+ """
+ Returns the user primary group, standard groups and the all group
+ """
+ # NOTE: Careful! It seems we are filtering out groups, such as Product Groups,
+ # because they do not have read access. I believe this was done because the
+ # frontend did not want to display them.
+ return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id)
+
+
+async def list_user_groups_ids_with_read_access(
+ app: web.Application, *, user_id: UserID
+) -> list[GroupID]:
+ return await _groups_db.get_ids_of_all_user_groups_with_read_access(
+ app, user_id=user_id
+ )
+
+
+async def list_all_user_groups_ids(
+ app: web.Application, *, user_id: UserID
+) -> list[GroupID]:
+ return await _groups_db.get_ids_of_all_user_groups(app, user_id=user_id)
+
+
+async def get_product_group_for_user(
+ app: web.Application, *, user_id: UserID, product_gid: GroupID
+) -> tuple[Group, AccessRightsDict]:
+ """
+ Returns product's group if user belongs to it, otherwise it
+ raises GroupNotFoundError
+ """
+ return await _groups_db.get_product_group_for_user(
+ app, user_id=user_id, product_gid=product_gid
+ )
+
+
+#
+# CRUD operations on groups linked to a user
+#
+
+
+async def create_standard_group(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ create: StandardGroupCreate,
+) -> tuple[Group, AccessRightsDict]:
+ """NOTE: creation/update and deletion restricted to STANDARD groups
+
+ raises GroupNotFoundError
+ raises UserInsufficientRightsError: needs WRITE access
+ """
+ return await _groups_db.create_standard_group(
+ app,
+ user_id=user_id,
+ create=create,
+ )
+
+
+async def get_associated_group(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+) -> tuple[Group, AccessRightsDict]:
+ """NOTE: here it can also be a non-standard group
+
+ raises GroupNotFoundError
+ raises UserInsufficientRightsError: needs READ access
+ """
+ return await _groups_db.get_user_group(app, user_id=user_id, group_id=group_id)
+
+
+async def update_standard_group(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ update: StandardGroupUpdate,
+) -> tuple[Group, AccessRightsDict]:
+ """NOTE: creation/update and deletion restricted to STANDARD groups
+
+ raises GroupNotFoundError
+ raises UserInsufficientRightsError: needs WRITE access
+ """
+
+ return await _groups_db.update_standard_group(
+ app,
+ user_id=user_id,
+ group_id=group_id,
+ update=update,
+ )
+
+
+async def delete_standard_group(
+ app: web.Application, *, user_id: UserID, group_id: GroupID
+) -> None:
+ """NOTE: creation/update and deletion restricted to STANDARD groups
+
+ raises GroupNotFoundError
+ raises UserInsufficientRightsError: needs DELETE access
+ """
+ return await _groups_db.delete_standard_group(
+ app, user_id=user_id, group_id=group_id
+ )
+
+
+#
+# GROUP MEMBERS (= a user with some access-rights to a group)
+#
+
+
+async def list_group_members(
+ app: web.Application, user_id: UserID, group_id: GroupID
+) -> list[GroupMember]:
+ return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=group_id)
+
+
+async def get_group_member(
+ app: web.Application,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: UserID,
+) -> GroupMember:
+
+ return await _groups_db.get_user_in_group(
+ app,
+ user_id=user_id,
+ group_id=group_id,
+ the_user_id_in_group=the_user_id_in_group,
+ )
+
+
+async def update_group_member(
+ app: web.Application,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: UserID,
+ access_rights: AccessRightsDict,
+) -> GroupMember:
+ return await _groups_db.update_user_in_group(
+ app,
+ user_id=user_id,
+ group_id=group_id,
+ the_user_id_in_group=the_user_id_in_group,
+ access_rights=access_rights,
+ )
+
+
+async def delete_group_member(
+ app: web.Application,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: UserID,
+) -> None:
+ return await _groups_db.delete_user_from_group(
+ app,
+ user_id=user_id,
+ group_id=group_id,
+ the_user_id_in_group=the_user_id_in_group,
+ )
+
+
+async def is_user_by_email_in_group(
+ app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID
+) -> bool:
+
+ return await _groups_db.is_user_by_email_in_group(
+ app,
+ email=user_email,
+ group_id=group_id,
+ )
+
+
+async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None:
+ user: dict = await get_user(app, user_id)
+ return await _groups_db.auto_add_user_to_groups(app, user=user)
+
+
+async def auto_add_user_to_product_group(
+ app: web.Application,
+ user_id: UserID,
+ product_name: ProductName,
+) -> GroupID:
+ return await _groups_db.auto_add_user_to_product_group(
+ app, user_id=user_id, product_name=product_name
+ )
+
+
+def _only_one_true(*args):
+ return sum(bool(arg) for arg in args) == 1
+
+
+async def add_user_in_group(
+ app: web.Application,
+ user_id: UserID,
+ group_id: GroupID,
+ *,
+ # identifies
+ new_by_user_id: UserID | None = None,
+ new_by_user_name: IDStr | None = None,
+ new_by_user_email: EmailStr | None = None,
+ # payload
+ access_rights: AccessRightsDict | None = None,
+) -> None:
+ """Adds new_user (either by id or email) in group (with gid) owned by user_id
+
+ Raises:
+ UserInGroupNotFoundError
+ GroupsException
+ """
+ if not _only_one_true(new_by_user_id, new_by_user_name, new_by_user_email):
+ msg = "Invalid method call, required one of these: user id, username or user email, none provided"
+ raise GroupsError(msg=msg)
+
+ if new_by_user_email:
+ user = await _groups_db.get_user_from_email(
+ app, email=new_by_user_email, caller_user_id=user_id
+ )
+ new_by_user_id = user.id
+
+ if not new_by_user_id:
+ msg = "Missing new user in arguments"
+ raise GroupsError(msg=msg)
+
+ return await _groups_db.add_new_user_in_group(
+ app,
+ user_id=user_id,
+ group_id=group_id,
+ new_user_id=new_by_user_id,
+ access_rights=access_rights,
+ )
diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py
new file mode 100644
index 00000000000..570375f3646
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py
@@ -0,0 +1,749 @@
+import re
+from copy import deepcopy
+
+import sqlalchemy as sa
+from aiohttp import web
+from models_library.basic_types import IDStr
+from models_library.groups import (
+ AccessRightsDict,
+ Group,
+ GroupInfoTuple,
+ GroupMember,
+ GroupsByTypeTuple,
+ StandardGroupCreate,
+ StandardGroupUpdate,
+)
+from models_library.users import GroupID, UserID
+from simcore_postgres_database.errors import UniqueViolation
+from simcore_postgres_database.models.groups import GroupType
+from simcore_postgres_database.utils_products import execute_get_or_create_product_group
+from simcore_postgres_database.utils_repos import (
+ pass_or_acquire_connection,
+ transaction_context,
+)
+from sqlalchemy import and_
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.engine.row import Row
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from ..db.models import GroupType, groups, user_to_groups, users
+from ..db.plugin import get_asyncpg_engine
+from ..users.exceptions import UserNotFoundError
+from .exceptions import (
+ GroupNotFoundError,
+ UserAlreadyInGroupError,
+ UserInGroupNotFoundError,
+ UserInsufficientRightsError,
+)
+
+_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS = AccessRightsDict(
+ read=False,
+ write=False,
+ delete=False,
+)
+
+_DEFAULT_GROUP_READ_ACCESS_RIGHTS = AccessRightsDict(
+ read=True,
+ write=False,
+ delete=False,
+)
+_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS = AccessRightsDict(
+ read=True,
+ write=True,
+ delete=True,
+)
+
+_GROUP_COLUMNS = (
+ groups.c.gid,
+ groups.c.name,
+ groups.c.description,
+ groups.c.thumbnail,
+ groups.c.type,
+ groups.c.inclusion_rules,
+ # NOTE: drops timestamps
+)
+
+
+def _row_to_model(group: Row) -> Group:
+ return Group(
+ gid=group.gid,
+ name=group.name,
+ description=group.description,
+ thumbnail=group.thumbnail,
+ group_type=group.type,
+ inclusion_rules=group.inclusion_rules,
+ )
+
+
+def _to_group_info_tuple(group: Row) -> GroupInfoTuple:
+ return (
+ _row_to_model(group),
+ AccessRightsDict(
+ read=group.access_rights["read"],
+ write=group.access_rights["write"],
+ delete=group.access_rights["delete"],
+ ),
+ )
+
+
+def _check_group_permissions(
+ group: Row, user_id: int, gid: int, permission: str
+) -> None:
+ if not group.access_rights[permission]:
+ raise UserInsufficientRightsError(
+ user_id=user_id, gid=gid, permission=permission
+ )
+
+
+async def _get_group_and_access_rights_or_raise(
+ conn: AsyncConnection,
+ *,
+ user_id: UserID,
+ gid: GroupID,
+) -> Row:
+ result = await conn.stream(
+ sa.select(
+ *_GROUP_COLUMNS,
+ user_to_groups.c.access_rights,
+ )
+ .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid))
+ .where((user_to_groups.c.uid == user_id) & (user_to_groups.c.gid == gid))
+ )
+ row = await result.fetchone()
+ if not row:
+ raise GroupNotFoundError(gid=gid)
+ return row
+
+
+#
+# GROUPS
+#
+
+
+async def get_group_from_gid(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ group_id: GroupID,
+) -> Group | None:
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ row = await conn.stream(groups.select().where(groups.c.gid == group_id))
+ result = await row.first()
+ if result:
+ return Group.model_validate(result, from_attributes=True)
+ return None
+
+
+#
+# USER's GROUPS
+#
+
+
+def _list_user_groups_with_read_access_query(*group_selection, user_id: UserID):
+ return (
+ sa.select(*group_selection, user_to_groups.c.access_rights)
+ .select_from(
+ user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid),
+ )
+ .where(
+ (user_to_groups.c.uid == user_id)
+ & (user_to_groups.c.access_rights["read"].astext == "true")
+ )
+ )
+
+
+async def get_all_user_groups_with_read_access(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+) -> GroupsByTypeTuple:
+
+ """
+ Returns the user primary group, standard groups and the all group
+ """
+ primary_group: GroupInfoTuple | None = None
+ standard_groups: list[GroupInfoTuple] = []
+ everyone_group: GroupInfoTuple | None = None
+
+ query = _list_user_groups_with_read_access_query(groups, user_id=user_id)
+
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(query)
+ async for row in result:
+ if row.type == GroupType.EVERYONE:
+ assert row.access_rights["read"] # nosec
+ everyone_group = _to_group_info_tuple(row)
+
+ elif row.type == GroupType.PRIMARY:
+ assert row.access_rights["read"] # nosec
+ primary_group = _to_group_info_tuple(row)
+
+ else:
+ assert row.type == GroupType.STANDARD # nosec
+ # only add if user has read access
+ if row.access_rights["read"]:
+ standard_groups.append(_to_group_info_tuple(row))
+
+ return GroupsByTypeTuple(
+ primary=primary_group, standard=standard_groups, everyone=everyone_group
+ )
+
+
+async def get_ids_of_all_user_groups_with_read_access(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+) -> list[GroupID]:
+ # thin version of `get_all_user_groups_with_read_access`
+
+ query = _list_user_groups_with_read_access_query(groups.c.gid, user_id=user_id)
+
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(query)
+ return [row.gid async for row in result]
+
+
+async def get_all_user_groups(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+) -> list[Group]:
+ """
+ Returns all user's groups
+ """
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(
+ sa.select(*_GROUP_COLUMNS)
+ .select_from(
+ user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid),
+ )
+ .where(user_to_groups.c.uid == user_id)
+ )
+ return [Group.model_validate(row) async for row in result]
+
+
+async def get_ids_of_all_user_groups(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+) -> list[GroupID]:
+ # thin version of `get_all_user_groups`
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(
+ sa.select(
+ groups.c.gid,
+ )
+ .select_from(
+ user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid),
+ )
+ .where(user_to_groups.c.uid == user_id)
+ )
+ return [row.gid async for row in result]
+
+
+async def get_user_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+) -> tuple[Group, AccessRightsDict]:
+ """
+ Gets group gid if user associated to it and has read access
+
+ raises GroupNotFoundError
+ raises UserInsufficientRightsError
+ """
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ row = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(row, user_id, group_id, "read")
+
+ group, access_rights = _to_group_info_tuple(row)
+ return group, access_rights
+
+
+async def get_product_group_for_user(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_gid: GroupID,
+) -> tuple[Group, AccessRightsDict]:
+ """
+ Returns product's group if user belongs to it, otherwise it
+ raises GroupNotFoundError
+ """
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ row = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=product_gid
+ )
+ group, access_rights = _to_group_info_tuple(row)
+ return group, access_rights
+
+
+assert set(StandardGroupCreate.model_fields).issubset({c.name for c in groups.columns})
+
+
+async def create_standard_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ create: StandardGroupCreate,
+) -> tuple[Group, AccessRightsDict]:
+
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ user = await conn.scalar(
+ sa.select(users.c.primary_gid).where(users.c.id == user_id)
+ )
+ if not user:
+ raise UserNotFoundError(uid=user_id)
+
+ result = await conn.stream(
+ # pylint: disable=no-value-for-parameter
+ groups.insert()
+ .values(
+ **create.model_dump(mode="json", exclude_unset=True),
+ type=GroupType.STANDARD,
+ )
+ .returning(*_GROUP_COLUMNS)
+ )
+ row = await result.fetchone()
+ assert row # nosec
+
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ user_to_groups.insert().values(
+ uid=user_id,
+ gid=row.gid,
+ access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS,
+ )
+ )
+
+ group = _row_to_model(row)
+ return group, deepcopy(_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS)
+
+
+assert set(StandardGroupUpdate.model_fields).issubset({c.name for c in groups.columns})
+
+
+async def update_standard_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ update: StandardGroupUpdate,
+) -> tuple[Group, AccessRightsDict]:
+
+ values = update.model_dump(mode="json", exclude_unset=True)
+
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ row = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ assert row.gid == group_id # nosec
+ _check_group_permissions(row, user_id, group_id, "write")
+ access_rights = AccessRightsDict(**row.access_rights) # type: ignore[typeddict-item]
+
+ result = await conn.stream(
+ # pylint: disable=no-value-for-parameter
+ groups.update()
+ .values(**values)
+ .where((groups.c.gid == row.gid) & (groups.c.type == GroupType.STANDARD))
+ .returning(*_GROUP_COLUMNS)
+ )
+ row = await result.fetchone()
+ assert row # nosec
+
+ group = _row_to_model(row)
+ return group, access_rights
+
+
+async def delete_standard_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+) -> None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "delete")
+
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ groups.delete().where(
+ (groups.c.gid == group.gid) & (groups.c.type == GroupType.STANDARD)
+ )
+ )
+
+
+#
+# USERS
+#
+
+
+async def get_user_from_email(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ caller_user_id: UserID,
+ email: str,
+) -> Row:
+ """
+ Raises:
+ UserNotFoundError: if not found or privacy hides email
+
+ """
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(
+ sa.select(users.c.id).where(
+ (users.c.email == email)
+ & (
+ users.c.privacy_hide_email.is_(False)
+ | (users.c.id == caller_user_id)
+ )
+ )
+ )
+ user = await result.fetchone()
+ if not user:
+ raise UserNotFoundError(email=email)
+ return user
+
+
+#
+# GROUP MEMBERS - CRUD
+#
+
+
+def _group_user_cols(caller_user_id: int):
+ return (
+ users.c.id,
+ users.c.name,
+ # privacy settings
+ sa.case(
+ (
+ users.c.privacy_hide_email.is_(True) & (users.c.id != caller_user_id),
+ None,
+ ),
+ else_=users.c.email,
+ ).label("email"),
+ sa.case(
+ (
+ users.c.privacy_hide_fullname.is_(True)
+ & (users.c.id != caller_user_id),
+ None,
+ ),
+ else_=users.c.first_name,
+ ).label("first_name"),
+ sa.case(
+ (
+ users.c.privacy_hide_fullname.is_(True)
+ & (users.c.id != caller_user_id),
+ None,
+ ),
+ else_=users.c.last_name,
+ ).label("last_name"),
+ users.c.primary_gid,
+ )
+
+
+async def _get_user_in_group(
+ conn: AsyncConnection, *, caller_user_id, group_id: GroupID, user_id: int
+) -> Row:
+ # now get the user
+ result = await conn.stream(
+ sa.select(*_group_user_cols(caller_user_id), user_to_groups.c.access_rights)
+ .select_from(
+ users.join(user_to_groups, users.c.id == user_to_groups.c.uid),
+ )
+ .where(and_(user_to_groups.c.gid == group_id, users.c.id == user_id))
+ )
+ row = await result.fetchone()
+ if not row:
+ raise UserInGroupNotFoundError(uid=user_id, gid=group_id)
+ return row
+
+
+async def list_users_in_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+) -> list[GroupMember]:
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ # first check if the group exists
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "read")
+
+ # now get the list
+ query = (
+ sa.select(
+ *_group_user_cols(user_id),
+ user_to_groups.c.access_rights,
+ )
+ .select_from(users.join(user_to_groups))
+ .where(user_to_groups.c.gid == group_id)
+ )
+
+ result = await conn.stream(query)
+ return [GroupMember.model_validate(row) async for row in result]
+
+
+async def get_user_in_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: int,
+) -> GroupMember:
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ # first check if the group exists
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "read")
+
+ # get the user with its permissions
+ the_user = await _get_user_in_group(
+ conn,
+ caller_user_id=user_id,
+ group_id=group_id,
+ user_id=the_user_id_in_group,
+ )
+ return GroupMember.model_validate(the_user)
+
+
+async def update_user_in_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: UserID,
+ access_rights: AccessRightsDict,
+) -> GroupMember:
+ if not access_rights:
+ msg = f"Cannot update empty {access_rights}"
+ raise ValueError(msg)
+
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+
+ # first check if the group exists
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "write")
+
+ # now check the user exists
+ the_user = await _get_user_in_group(
+ conn,
+ caller_user_id=user_id,
+ group_id=group_id,
+ user_id=the_user_id_in_group,
+ )
+
+ # modify the user access rights
+ new_db_values = {"access_rights": access_rights}
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ user_to_groups.update()
+ .values(**new_db_values)
+ .where(
+ and_(
+ user_to_groups.c.uid == the_user_id_in_group,
+ user_to_groups.c.gid == group_id,
+ )
+ )
+ )
+ user = the_user._asdict()
+ user.update(**new_db_values)
+ return GroupMember.model_validate(user)
+
+
+async def delete_user_from_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ the_user_id_in_group: UserID,
+) -> None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ # first check if the group exists
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "write")
+
+ # check the user exists
+ await _get_user_in_group(
+ conn,
+ caller_user_id=user_id,
+ group_id=group_id,
+ user_id=the_user_id_in_group,
+ )
+
+ # delete him/her
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ user_to_groups.delete().where(
+ and_(
+ user_to_groups.c.uid == the_user_id_in_group,
+ user_to_groups.c.gid == group_id,
+ )
+ )
+ )
+
+
+#
+# GROUP MEMBERS - CUSTOM
+#
+
+
+async def is_user_by_email_in_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ email: str,
+ group_id: GroupID,
+) -> bool:
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ user_id = await conn.scalar(
+ sa.select(users.c.id)
+ .select_from(
+ sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id)
+ )
+ .where((users.c.email == email) & (user_to_groups.c.gid == group_id))
+ )
+ return user_id is not None
+
+
+async def add_new_user_in_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ group_id: GroupID,
+ # either user_id or user_name
+ new_user_id: UserID | None = None,
+ new_user_name: IDStr | None = None,
+ access_rights: AccessRightsDict | None = None,
+) -> None:
+ """
+ adds new_user (either by id or email) in group (with gid) owned by user_id
+ """
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ # first check if the group exists
+ group = await _get_group_and_access_rights_or_raise(
+ conn, user_id=user_id, gid=group_id
+ )
+ _check_group_permissions(group, user_id, group_id, "write")
+
+ query = sa.select(sa.func.count())
+ if new_user_id:
+ query = query.where(users.c.id == new_user_id)
+ elif new_user_name:
+ query = query.where(users.c.name == new_user_name)
+ else:
+ msg = "Either user name or id but none provided"
+ raise ValueError(msg)
+
+ # now check the new user exists
+ users_count = await conn.scalar(query)
+ if not users_count:
+ assert new_user_id is not None # nosec
+ raise UserInGroupNotFoundError(uid=new_user_id, gid=group_id)
+
+ # add the new user to the group now
+ user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS
+ if access_rights:
+ user_access_rights.update(access_rights)
+
+ try:
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ user_to_groups.insert().values(
+ uid=new_user_id, gid=group.gid, access_rights=user_access_rights
+ )
+ )
+ except UniqueViolation as exc:
+ raise UserAlreadyInGroupError(
+ uid=new_user_id,
+ gid=group_id,
+ user_id=user_id,
+ access_rights=access_rights,
+ ) from exc
+
+
+async def auto_add_user_to_groups(
+ app: web.Application, connection: AsyncConnection | None = None, *, user: dict
+) -> None:
+
+ user_id: UserID = user["id"]
+
+ # auto add user to the groups with the right rules
+ # get the groups where there are inclusion rules and see if they apply
+ query = sa.select(groups).where(groups.c.inclusion_rules != {})
+ possible_group_ids = set()
+
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(query)
+ async for row in result:
+ inclusion_rules = row[groups.c.inclusion_rules]
+ for prop, rule_pattern in inclusion_rules.items():
+ if prop not in user:
+ continue
+ if re.search(rule_pattern, user[prop]):
+ possible_group_ids.add(row[groups.c.gid])
+
+ # now add the user to these groups if possible
+ for gid in possible_group_ids:
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ insert(user_to_groups)
+ .values(
+ uid=user_id,
+ gid=gid,
+ access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS,
+ )
+ .on_conflict_do_nothing() # in case the user was already added
+ )
+
+
+async def auto_add_user_to_product_group(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: str,
+) -> GroupID:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ product_group_id: GroupID = await execute_get_or_create_product_group(
+ conn, product_name
+ )
+
+ await conn.execute(
+ # pylint: disable=no-value-for-parameter
+ insert(user_to_groups)
+ .values(
+ uid=user_id,
+ gid=product_group_id,
+ access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS,
+ )
+ .on_conflict_do_nothing() # in case the user was already added
+ )
+ return product_group_id
diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py
new file mode 100644
index 00000000000..46131510489
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py
@@ -0,0 +1,253 @@
+import logging
+from contextlib import suppress
+
+from aiohttp import web
+from models_library.api_schemas_webserver.groups import (
+ GroupCreate,
+ GroupGet,
+ GroupUpdate,
+ GroupUserAdd,
+ GroupUserGet,
+ GroupUserUpdate,
+ MyGroupsGet,
+)
+from servicelib.aiohttp import status
+from servicelib.aiohttp.requests_validation import (
+ parse_request_body_as,
+ parse_request_path_parameters_as,
+)
+
+from .._meta import API_VTAG
+from ..login.decorators import login_required
+from ..products.api import Product, get_current_product
+from ..security.decorators import permission_required
+from ..utils_aiohttp import envelope_json_response
+from . import _groups_api
+from ._common.exceptions_handlers import handle_plugin_requests_exceptions
+from ._common.schemas import (
+ GroupsPathParams,
+ GroupsRequestContext,
+ GroupsUsersPathParams,
+)
+from .exceptions import GroupNotFoundError
+
+_logger = logging.getLogger(__name__)
+
+
+routes = web.RouteTableDef()
+
+
+@routes.get(f"/{API_VTAG}/groups", name="list_groups")
+@login_required
+@permission_required("groups.read")
+@handle_plugin_requests_exceptions
+async def list_groups(request: web.Request):
+ """
+ List all groups (organizations, primary, everyone and products) I belong to
+ """
+ product: Product = get_current_product(request)
+ req_ctx = GroupsRequestContext.model_validate(request)
+
+ groups_by_type = await _groups_api.list_user_groups_with_read_access(
+ request.app, user_id=req_ctx.user_id
+ )
+
+ assert groups_by_type.primary
+ assert groups_by_type.everyone
+
+ my_product_group = None
+
+ if product.group_id:
+ with suppress(GroupNotFoundError):
+ # Product is optional
+ my_product_group = await _groups_api.get_product_group_for_user(
+ app=request.app,
+ user_id=req_ctx.user_id,
+ product_gid=product.group_id,
+ )
+
+ my_groups = MyGroupsGet(
+ me=GroupGet.from_model(*groups_by_type.primary),
+ organizations=[GroupGet.from_model(*gi) for gi in groups_by_type.standard],
+ all=GroupGet.from_model(*groups_by_type.everyone),
+ product=GroupGet.from_model(*my_product_group) if my_product_group else None,
+ )
+
+ return envelope_json_response(my_groups)
+
+
+#
+# ORGANIZATION GROUPS
+#
+
+
+@routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group")
+@login_required
+@permission_required("groups.read")
+@handle_plugin_requests_exceptions
+async def get_group(request: web.Request):
+ """Get one group details"""
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+
+ group, access_rights = await _groups_api.get_associated_group(
+ request.app, user_id=req_ctx.user_id, group_id=path_params.gid
+ )
+
+ return envelope_json_response(GroupGet.from_model(group, access_rights))
+
+
+@routes.post(f"/{API_VTAG}/groups", name="create_group")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def create_group(request: web.Request):
+ """Creates a standard group"""
+ req_ctx = GroupsRequestContext.model_validate(request)
+
+ create = await parse_request_body_as(GroupCreate, request)
+
+ group, access_rights = await _groups_api.create_standard_group(
+ request.app,
+ user_id=req_ctx.user_id,
+ create=create.to_model(),
+ )
+
+ created_group = GroupGet.from_model(group, access_rights)
+ return envelope_json_response(created_group, status_cls=web.HTTPCreated)
+
+
+@routes.patch(f"/{API_VTAG}/groups/{{gid}}", name="update_group")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def update_group(request: web.Request):
+ """Updates metadata of a standard group"""
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+ update: GroupUpdate = await parse_request_body_as(GroupUpdate, request)
+
+ group, access_rights = await _groups_api.update_standard_group(
+ request.app,
+ user_id=req_ctx.user_id,
+ group_id=path_params.gid,
+ update=update.to_model(),
+ )
+
+ updated_group = GroupGet.from_model(group, access_rights)
+ return envelope_json_response(updated_group)
+
+
+@routes.delete(f"/{API_VTAG}/groups/{{gid}}", name="delete_group")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def delete_group(request: web.Request):
+ """Deletes a standard group"""
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+
+ await _groups_api.delete_standard_group(
+ request.app, user_id=req_ctx.user_id, group_id=path_params.gid
+ )
+
+ return web.json_response(status=status.HTTP_204_NO_CONTENT)
+
+
+#
+# USERS in ORGANIZATION groupS (i.e. members of an organization)
+#
+
+
+@routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def get_all_group_users(request: web.Request):
+ """Gets users in organization groups"""
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+
+ users_in_group = await _groups_api.list_group_members(
+ request.app, req_ctx.user_id, path_params.gid
+ )
+
+ return envelope_json_response(
+ [GroupUserGet.from_model(user) for user in users_in_group]
+ )
+
+
+@routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def add_group_user(request: web.Request):
+ """
+ Adds a user in an organization group
+ """
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsPathParams, request)
+ added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request)
+
+ await _groups_api.add_user_in_group(
+ request.app,
+ req_ctx.user_id,
+ path_params.gid,
+ new_by_user_id=added.uid,
+ new_by_user_name=added.user_name,
+ new_by_user_email=added.email,
+ )
+
+ return web.json_response(status=status.HTTP_204_NO_CONTENT)
+
+
+@routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def get_group_user(request: web.Request):
+ """
+ Gets specific user in an organization group
+ """
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request)
+
+ user = await _groups_api.get_group_member(
+ request.app, req_ctx.user_id, path_params.gid, path_params.uid
+ )
+
+ return envelope_json_response(GroupUserGet.from_model(user))
+
+
+@routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def update_group_user(request: web.Request):
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request)
+ update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request)
+
+ user = await _groups_api.update_group_member(
+ request.app,
+ user_id=req_ctx.user_id,
+ group_id=path_params.gid,
+ the_user_id_in_group=path_params.uid,
+ access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type]
+ )
+
+ return envelope_json_response(GroupUserGet.from_model(user))
+
+
+@routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user")
+@login_required
+@permission_required("groups.*")
+@handle_plugin_requests_exceptions
+async def delete_group_user(request: web.Request):
+ req_ctx = GroupsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request)
+ await _groups_api.delete_group_member(
+ request.app, req_ctx.user_id, path_params.gid, path_params.uid
+ )
+
+ return web.json_response(status=status.HTTP_204_NO_CONTENT)
diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_handlers.py
deleted file mode 100644
index fac761aaf25..00000000000
--- a/services/web/server/src/simcore_service_webserver/groups/_handlers.py
+++ /dev/null
@@ -1,405 +0,0 @@
-import functools
-import logging
-from contextlib import suppress
-from typing import Literal
-
-from aiohttp import web
-from models_library.api_schemas_webserver.groups import (
- GroupCreate,
- GroupGet,
- GroupUpdate,
- GroupUserAdd,
- GroupUserGet,
- GroupUserUpdate,
- MyGroupsGet,
-)
-from models_library.users import GroupID, UserID
-from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
-from servicelib.aiohttp import status
-from servicelib.aiohttp.requests_validation import (
- parse_request_body_as,
- parse_request_path_parameters_as,
- parse_request_query_parameters_as,
-)
-from servicelib.aiohttp.typing_extension import Handler
-
-from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..products.api import Product, get_current_product
-from ..scicrunch.db import ResearchResourceRepository
-from ..scicrunch.errors import InvalidRRIDError, ScicrunchError
-from ..scicrunch.models import ResearchResource, ResourceHit
-from ..scicrunch.service_client import SciCrunch
-from ..security.decorators import permission_required
-from ..users.exceptions import UserNotFoundError
-from ..utils_aiohttp import envelope_json_response
-from . import api
-from ._classifiers import GroupClassifierRepository, build_rrids_tree_view
-from .exceptions import (
- GroupNotFoundError,
- UserAlreadyInGroupError,
- UserInGroupNotFoundError,
- UserInsufficientRightsError,
-)
-
-_logger = logging.getLogger(__name__)
-
-
-class _GroupsRequestContext(BaseModel):
- user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required]
- product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required]
-
-
-def _handle_groups_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except UserNotFoundError as exc:
- raise web.HTTPNotFound(
- reason=f"User {exc.uid or exc.email} not found"
- ) from exc
-
- except GroupNotFoundError as exc:
- gid = getattr(exc, "gid", "")
- raise web.HTTPNotFound(reason=f"Group {gid} not found") from exc
-
- except UserInGroupNotFoundError as exc:
- gid = getattr(exc, "gid", "")
- raise web.HTTPNotFound(reason=f"User not found in group {gid}") from exc
-
- except UserAlreadyInGroupError as exc:
- gid = getattr(exc, "gid", "")
- raise web.HTTPConflict(reason=f"User is already in group {gid}") from exc
-
- except UserInsufficientRightsError as exc:
- raise web.HTTPForbidden from exc
-
- return wrapper
-
-
-routes = web.RouteTableDef()
-
-
-@routes.get(f"/{API_VTAG}/groups", name="list_groups")
-@login_required
-@permission_required("groups.read")
-@_handle_groups_exceptions
-async def list_groups(request: web.Request):
- """
- List all groups (organizations, primary, everyone and products) I belong to
- """
- product: Product = get_current_product(request)
- req_ctx = _GroupsRequestContext.model_validate(request)
-
- primary_group, user_groups, all_group = await api.list_user_groups_with_read_access(
- request.app, req_ctx.user_id
- )
-
- my_group = {
- "me": primary_group,
- "organizations": user_groups,
- "all": all_group,
- "product": None,
- }
-
- if product.group_id:
- with suppress(GroupNotFoundError):
- # Product is optional
- my_group["product"] = await api.get_product_group_for_user(
- app=request.app,
- user_id=req_ctx.user_id,
- product_gid=product.group_id,
- )
-
- assert MyGroupsGet.model_validate(my_group) is not None # nosec
- return envelope_json_response(my_group)
-
-
-#
-# Organization groups
-#
-
-
-class _GroupPathParams(BaseModel):
- gid: GroupID
- model_config = ConfigDict(extra="forbid")
-
-
-@routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group")
-@login_required
-@permission_required("groups.read")
-@_handle_groups_exceptions
-async def get_group(request: web.Request):
- """Get one group details"""
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupPathParams, request)
-
- group = await api.get_user_group(request.app, req_ctx.user_id, path_params.gid)
- assert GroupGet.model_validate(group) is not None # nosec
- return envelope_json_response(group)
-
-
-@routes.post(f"/{API_VTAG}/groups", name="create_group")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def create_group(request: web.Request):
- """Creates organization groups"""
- req_ctx = _GroupsRequestContext.model_validate(request)
- create = await parse_request_body_as(GroupCreate, request)
- new_group = create.model_dump(mode="json", exclude_unset=True)
-
- created_group = await api.create_user_group(request.app, req_ctx.user_id, new_group)
- assert GroupGet.model_validate(created_group) is not None # nosec
- return envelope_json_response(created_group, status_cls=web.HTTPCreated)
-
-
-@routes.patch(f"/{API_VTAG}/groups/{{gid}}", name="update_group")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def update_group(request: web.Request):
- """Updates organization groups"""
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupPathParams, request)
- update: GroupUpdate = await parse_request_body_as(GroupUpdate, request)
- new_group_values = update.model_dump(exclude_unset=True)
-
- updated_group = await api.update_user_group(
- request.app, req_ctx.user_id, path_params.gid, new_group_values
- )
- assert GroupGet.model_validate(updated_group) is not None # nosec
- return envelope_json_response(updated_group)
-
-
-@routes.delete(f"/{API_VTAG}/groups/{{gid}}", name="delete_group")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def delete_group(request: web.Request):
- """Deletes organization groups"""
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupPathParams, request)
-
- await api.delete_user_group(request.app, req_ctx.user_id, path_params.gid)
- return web.json_response(status=status.HTTP_204_NO_CONTENT)
-
-
-#
-# Users in organization groups (i.e. members of an organization)
-#
-
-
-@routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def get_group_users(request: web.Request):
- """Gets users in organization groups"""
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupPathParams, request)
-
- group_user = await api.list_users_in_group(
- request.app, req_ctx.user_id, path_params.gid
- )
- assert (
- TypeAdapter(list[GroupUserGet]).validate_python(group_user) is not None
- ) # nosec
- return envelope_json_response(group_user)
-
-
-@routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def add_group_user(request: web.Request):
- """
- Adds a user in an organization group
- """
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupPathParams, request)
- added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request)
-
- await api.add_user_in_group(
- request.app,
- req_ctx.user_id,
- path_params.gid,
- new_user_id=added.uid,
- new_user_email=added.email,
- )
- return web.json_response(status=status.HTTP_204_NO_CONTENT)
-
-
-class _GroupUserPathParams(BaseModel):
- gid: GroupID
- uid: UserID
- model_config = ConfigDict(extra="forbid")
-
-
-@routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def get_group_user(request: web.Request):
- """
- Gets specific user in an organization group
- """
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupUserPathParams, request)
- user = await api.get_user_in_group(
- request.app, req_ctx.user_id, path_params.gid, path_params.uid
- )
- assert GroupUserGet.model_validate(user) is not None # nosec
- return envelope_json_response(user)
-
-
-@routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def update_group_user(request: web.Request):
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupUserPathParams, request)
- update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request)
-
- user = await api.update_user_in_group(
- request.app,
- user_id=req_ctx.user_id,
- gid=path_params.gid,
- the_user_id_in_group=path_params.uid,
- access_rights=update.access_rights.model_dump(),
- )
- assert GroupUserGet.model_validate(user) is not None # nosec
- return envelope_json_response(user)
-
-
-@routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user")
-@login_required
-@permission_required("groups.*")
-@_handle_groups_exceptions
-async def delete_group_user(request: web.Request):
- req_ctx = _GroupsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_GroupUserPathParams, request)
- await api.delete_user_in_group(
- request.app, req_ctx.user_id, path_params.gid, path_params.uid
- )
- return web.json_response(status=status.HTTP_204_NO_CONTENT)
-
-
-#
-# Classifiers
-#
-
-
-class _GroupsParams(BaseModel):
- gid: GroupID
-
-
-class _ClassifiersQuery(BaseModel):
- tree_view: Literal["std"] = "std"
-
-
-@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers")
-@login_required
-@permission_required("groups.*")
-async def get_group_classifiers(request: web.Request):
- try:
- path_params = parse_request_path_parameters_as(_GroupsParams, request)
- query_params: _ClassifiersQuery = parse_request_query_parameters_as(
- _ClassifiersQuery, request
- )
-
- repo = GroupClassifierRepository(request.app)
- if not await repo.group_uses_scicrunch(path_params.gid):
- return await repo.get_classifiers_from_bundle(path_params.gid)
-
- # otherwise, build dynamic tree with RRIDs
- view = await build_rrids_tree_view(
- request.app, tree_view_mode=query_params.tree_view
- )
- except ScicrunchError:
- view = {}
-
- return envelope_json_response(view)
-
-
-def _handle_scicrunch_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except InvalidRRIDError as err:
- raise web.HTTPBadRequest(reason=f"{err}") from err
-
- except ScicrunchError as err:
- user_msg = "Cannot get RRID since scicrunch.org service is not reachable."
- _logger.exception("%s", user_msg)
- raise web.HTTPServiceUnavailable(reason=user_msg) from err
-
- return wrapper
-
-
-@routes.get(
- f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}",
- name="get_scicrunch_resource",
-)
-@login_required
-@permission_required("groups.*")
-@_handle_scicrunch_exceptions
-async def get_scicrunch_resource(request: web.Request):
- rrid = request.match_info["rrid"]
- rrid = SciCrunch.validate_identifier(rrid)
-
- # check if in database first
- repo = ResearchResourceRepository(request.app)
- resource: ResearchResource | None = await repo.get_resource(rrid)
- if not resource:
- # otherwise, request to scicrunch service
- scicrunch = SciCrunch.get_instance(request.app)
- resource = await scicrunch.get_resource_fields(rrid)
-
- return envelope_json_response(resource.model_dump())
-
-
-@routes.post(
- f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}",
- name="add_scicrunch_resource",
-)
-@login_required
-@permission_required("groups.*")
-@_handle_scicrunch_exceptions
-async def add_scicrunch_resource(request: web.Request):
- rrid = request.match_info["rrid"]
-
- # check if exists
- repo = ResearchResourceRepository(request.app)
- resource: ResearchResource | None = await repo.get_resource(rrid)
- if not resource:
- # then request scicrunch service
- scicrunch = SciCrunch.get_instance(request.app)
- resource = await scicrunch.get_resource_fields(rrid)
-
- # insert new or if exists, then update
- await repo.upsert(resource)
-
- return envelope_json_response(resource.model_dump())
-
-
-@routes.get(
- f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search",
- name="search_scicrunch_resources",
-)
-@login_required
-@permission_required("groups.*")
-@_handle_scicrunch_exceptions
-async def search_scicrunch_resources(request: web.Request):
- guess_name = str(request.query["guess_name"]).strip()
-
- scicrunch = SciCrunch.get_instance(request.app)
- hits: list[ResourceHit] = await scicrunch.search_resource(guess_name)
-
- return envelope_json_response([hit.model_dump() for hit in hits])
diff --git a/services/web/server/src/simcore_service_webserver/groups/_users.py b/services/web/server/src/simcore_service_webserver/groups/_users.py
deleted file mode 100644
index 37b8d3453aa..00000000000
--- a/services/web/server/src/simcore_service_webserver/groups/_users.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-NOTE: Coupling with user's plugin api modules should be added here to avoid cyclic dependencies
-"""
-
-from collections.abc import Mapping
-from typing import Any
-
-from ..utils import gravatar_hash
-
-
-def convert_user_in_group_to_schema(user: Mapping[str, Any]) -> dict[str, str]:
-
- group_user = {
- "id": user["id"],
- "first_name": user["first_name"],
- "last_name": user["last_name"],
- "login": user["email"],
- "gravatar_id": gravatar_hash(user["email"]),
- }
- group_user["accessRights"] = user["access_rights"]
- group_user["gid"] = user["primary_gid"]
- return group_user
diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py
deleted file mode 100644
index 4f0f3ad759f..00000000000
--- a/services/web/server/src/simcore_service_webserver/groups/_utils.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from typing import TypedDict
-
-from aiopg.sa.result import RowProxy
-
-from .exceptions import UserInsufficientRightsError
-
-_GROUPS_SCHEMA_TO_DB = {
- "gid": "gid",
- "label": "name",
- "description": "description",
- "thumbnail": "thumbnail",
- "accessRights": "access_rights",
- "inclusionRules": "inclusion_rules",
-}
-
-
-class AccessRightsDict(TypedDict):
- read: bool
- write: bool
- delete: bool
-
-
-def check_group_permissions(
- group: RowProxy, user_id: int, gid: int, permission: str
-) -> None:
- if not group.access_rights[permission]:
- raise UserInsufficientRightsError(
- user_id=user_id, gid=gid, permission=permission
- )
-
-
-def convert_groups_db_to_schema(
- db_row: RowProxy, *, prefix: str | None = "", **kwargs
-) -> dict:
- converted_dict = {
- k: db_row[f"{prefix}{v}"]
- for k, v in _GROUPS_SCHEMA_TO_DB.items()
- if f"{prefix}{v}" in db_row
- }
- converted_dict.update(**kwargs)
- return converted_dict
-
-
-def convert_groups_schema_to_db(schema: dict) -> dict:
- return {
- v: schema[k]
- for k, v in _GROUPS_SCHEMA_TO_DB.items()
- if k in schema and k != "gid"
- }
diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py
index 503eee73839..207e1ffb303 100644
--- a/services/web/server/src/simcore_service_webserver/groups/api.py
+++ b/services/web/server/src/simcore_service_webserver/groups/api.py
@@ -1,203 +1,23 @@
-from typing import Any
-
-from aiohttp import web
-from aiopg.sa.result import RowProxy
-from models_library.emails import LowerCaseEmailStr
-from models_library.groups import Group
-from models_library.users import GroupID, UserID
-
-from ..db.plugin import get_database_engine
-from ..users.api import get_user
-from . import _db
-from ._utils import AccessRightsDict
-from .exceptions import GroupsError
-
-
-async def list_user_groups_with_read_access(
- app: web.Application, user_id: UserID
-) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]:
- """
- Returns the user primary group, standard groups and the all group
- """
- # NOTE: Careful! It seems we are filtering out groups, such as Product Groups,
- # because they do not have read access. I believe this was done because the frontend did not want to display them.
- async with get_database_engine(app).acquire() as conn:
- return await _db.get_all_user_groups_with_read_access(conn, user_id=user_id)
-
-
-async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Group]:
- """
- Return all user groups
- """
- async with get_database_engine(app).acquire() as conn:
- groups_db = await _db.get_all_user_groups(conn, user_id=user_id)
-
- return [Group.model_construct(**group.model_dump()) for group in groups_db]
-
-
-async def get_user_group(
- app: web.Application, user_id: UserID, gid: GroupID
-) -> dict[str, str]:
- """
- Gets group gid if user associated to it and has read access
-
- raises GroupNotFoundError
- raises UserInsufficientRightsError
- """
- async with get_database_engine(app).acquire() as conn:
- return await _db.get_user_group(conn, user_id=user_id, gid=gid)
-
-
-async def get_product_group_for_user(
- app: web.Application, user_id: UserID, product_gid: GroupID
-) -> dict[str, str]:
- """
- Returns product's group if user belongs to it, otherwise it
- raises GroupNotFoundError
- """
- async with get_database_engine(app).acquire() as conn:
- return await _db.get_product_group_for_user(
- conn, user_id=user_id, product_gid=product_gid
- )
-
-
-async def create_user_group(
- app: web.Application, user_id: UserID, new_group: dict
-) -> dict[str, Any]:
- async with get_database_engine(app).acquire() as conn:
- return await _db.create_user_group(conn, user_id=user_id, new_group=new_group)
-
-
-async def update_user_group(
- app: web.Application,
- user_id: UserID,
- gid: GroupID,
- new_group_values: dict[str, str],
-) -> dict[str, str]:
- async with get_database_engine(app).acquire() as conn:
- return await _db.update_user_group(
- conn, user_id=user_id, gid=gid, new_group_values=new_group_values
- )
-
-
-async def delete_user_group(
- app: web.Application, user_id: UserID, gid: GroupID
-) -> None:
- async with get_database_engine(app).acquire() as conn:
- return await _db.delete_user_group(conn, user_id=user_id, gid=gid)
-
-
-async def list_users_in_group(
- app: web.Application, user_id: UserID, gid: GroupID
-) -> list[dict[str, str]]:
- async with get_database_engine(app).acquire() as conn:
- return await _db.list_users_in_group(conn, user_id=user_id, gid=gid)
-
-
-async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None:
- user: dict = await get_user(app, user_id)
-
- async with get_database_engine(app).acquire() as conn:
- return await _db.auto_add_user_to_groups(conn, user=user)
-
-
-async def auto_add_user_to_product_group(
- app: web.Application, user_id: UserID, product_name: str
-) -> GroupID:
- async with get_database_engine(app).acquire() as conn:
- return await _db.auto_add_user_to_product_group(
- conn, user_id=user_id, product_name=product_name
- )
-
-
-async def is_user_by_email_in_group(
- app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID
-) -> bool:
- async with get_database_engine(app).acquire() as conn:
- return await _db.is_user_by_email_in_group(
- conn,
- email=user_email,
- group_id=group_id,
- )
-
-
-async def add_user_in_group(
- app: web.Application,
- user_id: UserID,
- gid: GroupID,
- *,
- new_user_id: UserID | None = None,
- new_user_email: str | None = None,
- access_rights: AccessRightsDict | None = None,
-) -> None:
- """Adds new_user (either by id or email) in group (with gid) owned by user_id
-
- Raises:
- UserInGroupNotFoundError
- GroupsException
- """
-
- if not new_user_id and not new_user_email:
- msg = "Invalid method call, missing user id or user email"
- raise GroupsError(msg=msg)
-
- async with get_database_engine(app).acquire() as conn:
- if new_user_email:
- user: RowProxy = await _db.get_user_from_email(conn, new_user_email)
- new_user_id = user["id"]
-
- if not new_user_id:
- msg = "Missing new user in arguments"
- raise GroupsError(msg=msg)
-
- return await _db.add_new_user_in_group(
- conn,
- user_id=user_id,
- gid=gid,
- new_user_id=new_user_id,
- access_rights=access_rights,
- )
-
-
-async def get_user_in_group(
- app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int
-) -> dict[str, str]:
- async with get_database_engine(app).acquire() as conn:
- return await _db.get_user_in_group(
- conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group
- )
-
-
-async def update_user_in_group(
- app: web.Application,
- user_id: UserID,
- gid: GroupID,
- the_user_id_in_group: int,
- access_rights: dict,
-) -> dict[str, str]:
- async with get_database_engine(app).acquire() as conn:
- return await _db.update_user_in_group(
- conn,
- user_id=user_id,
- gid=gid,
- the_user_id_in_group=the_user_id_in_group,
- access_rights=access_rights,
- )
-
-
-async def delete_user_in_group(
- app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int
-) -> None:
- async with get_database_engine(app).acquire() as conn:
- return await _db.delete_user_in_group(
- conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group
- )
-
-
-async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None:
- async with get_database_engine(app).acquire() as conn:
- group_db = await _db.get_group_from_gid(conn, gid=gid)
-
- if group_db:
- return Group.model_construct(**group_db.model_dump())
- return None
+#
+# Domain-Specific Interfaces
+#
+from ._groups_api import (
+ add_user_in_group,
+ auto_add_user_to_groups,
+ auto_add_user_to_product_group,
+ get_group_from_gid,
+ is_user_by_email_in_group,
+ list_all_user_groups_ids,
+ list_user_groups_ids_with_read_access,
+)
+
+__all__: tuple[str, ...] = (
+ "add_user_in_group",
+ "auto_add_user_to_groups",
+ "auto_add_user_to_product_group",
+ "get_group_from_gid",
+ "is_user_by_email_in_group",
+ "list_all_user_groups_ids",
+ "list_user_groups_ids_with_read_access",
+ # nopycln: file
+)
diff --git a/services/web/server/src/simcore_service_webserver/groups/models.py b/services/web/server/src/simcore_service_webserver/groups/models.py
deleted file mode 100644
index bac7f2987bd..00000000000
--- a/services/web/server/src/simcore_service_webserver/groups/models.py
+++ /dev/null
@@ -1,6 +0,0 @@
-# mypy: disable-error-code=truthy-function
-from ._utils import convert_groups_db_to_schema
-
-assert convert_groups_db_to_schema # nosec
-
-__all__: tuple[str, ...] = ("convert_groups_db_to_schema",)
diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py
index 70b2f4eeb25..7000926383c 100644
--- a/services/web/server/src/simcore_service_webserver/groups/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py
@@ -5,7 +5,7 @@
from .._constants import APP_SETTINGS_KEY
from ..products.plugin import setup_products
-from . import _handlers
+from . import _classifiers_handlers, _groups_handlers
_logger = logging.getLogger(__name__)
@@ -23,4 +23,5 @@ def setup_groups(app: web.Application):
# plugin dependencies
setup_products(app)
- app.router.add_routes(_handlers.routes)
+ app.router.add_routes(_groups_handlers.routes)
+ app.router.add_routes(_classifiers_handlers.routes)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py
index b1088b67873..d5978f794d2 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py
@@ -65,7 +65,7 @@
from ..catalog import client as catalog_client
from ..director_v2 import api as director_v2_api
from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..groups.api import get_group_from_gid, list_all_user_groups
+from ..groups.api import get_group_from_gid, list_all_user_groups_ids
from ..groups.exceptions import GroupNotFoundError
from ..login.decorators import login_required
from ..projects.api import has_user_project_access_rights
@@ -559,7 +559,7 @@ async def get_project_services_access_for_gid(
# Get the group from the provided group ID
_sharing_with_group: Group | None = await get_group_from_gid(
- app=request.app, gid=query_params.for_gid
+ app=request.app, group_id=query_params.for_gid
)
# Check if the group exists
@@ -571,8 +571,10 @@ async def get_project_services_access_for_gid(
_user_id = await get_user_id_from_gid(
app=request.app, primary_gid=query_params.for_gid
)
- _user_groups = await list_all_user_groups(app=request.app, user_id=_user_id)
- groups_to_compare.update({group.gid for group in _user_groups})
+ user_groups_ids = await list_all_user_groups_ids(
+ app=request.app, user_id=_user_id
+ )
+ groups_to_compare.update(set(user_groups_ids))
groups_to_compare.add(query_params.for_gid)
elif _sharing_with_group.group_type == GroupTypeInModel.STANDARD:
groups_to_compare = {query_params.for_gid}
diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
index 356e2cc1ba7..078c22e8cf7 100644
--- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
@@ -17,7 +17,7 @@
from servicelib.logging_utils import get_log_record_extra, log_context
from servicelib.request_keys import RQT_USERID_KEY
-from ..groups.api import list_user_groups_with_read_access
+from ..groups.api import list_user_groups_ids_with_read_access
from ..login.decorators import login_required
from ..products.api import Product, get_current_product
from ..resource_manager.user_sessions import managed_resource
@@ -89,15 +89,13 @@ async def _set_user_in_group_rooms(
app: web.Application, user_id: UserID, socket_id: SocketID
) -> None:
"""Adds user in rooms associated to its groups"""
- primary_group, user_groups, all_group = await list_user_groups_with_read_access(
- app, user_id
- )
- groups = [primary_group] + user_groups + ([all_group] if bool(all_group) else [])
+
+ group_ids = await list_user_groups_ids_with_read_access(app, user_id=user_id)
sio = get_socket_server(app)
- for group in groups:
+ for gid in group_ids:
# NOTE socketio need to be upgraded that's why enter_room is not an awaitable
- sio.enter_room(socket_id, SocketIORoomStr.from_group_id(group["gid"]))
+ sio.enter_room(socket_id, SocketIORoomStr.from_group_id(gid))
sio.enter_room(socket_id, SocketIORoomStr.from_user_id(user_id))
diff --git a/services/web/server/src/simcore_service_webserver/tree.md b/services/web/server/src/simcore_service_webserver/tree.md
new file mode 100644
index 00000000000..0117a6c851e
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/tree.md
@@ -0,0 +1,562 @@
+This is a tree view of my app. It is built in python's aiohttp.
+
+
+├── activity
+│ ├── _api.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ └── settings.py
+├── announcements
+│ ├── _api.py
+│ ├── _handlers.py
+│ ├── _models.py
+│ ├── plugin.py
+│ └── _redis.py
+├── api_keys
+│ ├── api.py
+│ ├── errors.py
+│ ├── _exceptions_handlers.py
+│ ├── _models.py
+│ ├── plugin.py
+│ ├── _repository.py
+│ ├── _rest.py
+│ ├── _rpc.py
+│ └── _service.py
+├── application.py
+├── application_settings.py
+├── application_settings_utils.py
+├── catalog
+│ ├── _api.py
+│ ├── _api_units.py
+│ ├── client.py
+│ ├── _constants.py
+│ ├── exceptions.py
+│ ├── _handlers_errors.py
+│ ├── _handlers.py
+│ ├── licenses
+│ │ ├── api.py
+│ │ ├── errors.py
+│ │ ├── _exceptions_handlers.py
+│ │ ├── _licensed_items_api.py
+│ │ ├── _licensed_items_db.py
+│ │ ├── _licensed_items_handlers.py
+│ │ ├── _models.py
+│ │ └── plugin.py
+│ ├── _models.py
+│ ├── plugin.py
+│ ├── settings.py
+│ └── _tags_handlers.py
+├── cli.py
+├── _constants.py
+├── db
+│ ├── _aiopg.py
+│ ├── _asyncpg.py
+│ ├── base_repository.py
+│ ├── models.py
+│ ├── plugin.py
+│ └── settings.py
+├── db_listener
+│ ├── _db_comp_tasks_listening_task.py
+│ ├── plugin.py
+│ └── _utils.py
+├── diagnostics
+│ ├── _handlers.py
+│ ├── _healthcheck.py
+│ ├── _monitoring.py
+│ ├── plugin.py
+│ └── settings.py
+├── director_v2
+│ ├── _abc.py
+│ ├── api.py
+│ ├── _api_utils.py
+│ ├── _core_base.py
+│ ├── _core_computations.py
+│ ├── _core_dynamic_services.py
+│ ├── _core_utils.py
+│ ├── exceptions.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ └── settings.py
+├── dynamic_scheduler
+│ ├── api.py
+│ ├── plugin.py
+│ └── settings.py
+├── email
+│ ├── _core.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ ├── settings.py
+│ └── utils.py
+├── errors.py
+├── exception_handling
+│ ├── _base.py
+│ └── _factory.py
+├── exporter
+│ ├── exceptions.py
+│ ├── _formatter
+│ │ ├── archive.py
+│ │ ├── _sds.py
+│ │ ├── template_json.py
+│ │ └── xlsx
+│ │ ├── code_description.py
+│ │ ├── core
+│ │ │ ├── styling_components.py
+│ │ │ └── xlsx_base.py
+│ │ ├── dataset_description.py
+│ │ ├── manifest.py
+│ │ ├── utils.py
+│ │ └── writer.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ ├── settings.py
+│ └── utils.py
+├── folders
+│ ├── api.py
+│ ├── errors.py
+│ ├── _exceptions_handlers.py
+│ ├── _folders_api.py
+│ ├── _folders_db.py
+│ ├── _folders_handlers.py
+│ ├── _models.py
+│ ├── plugin.py
+│ ├── _trash_api.py
+│ ├── _trash_handlers.py
+│ ├── _workspaces_api.py
+│ └── _workspaces_handlers.py
+├── garbage_collector
+│ ├── _core_disconnected.py
+│ ├── _core_guests.py
+│ ├── _core_orphans.py
+│ ├── _core.py
+│ ├── _core_utils.py
+│ ├── plugin.py
+│ ├── settings.py
+│ ├── _tasks_api_keys.py
+│ ├── _tasks_core.py
+│ ├── _tasks_trash.py
+│ └── _tasks_users.py
+├── groups
+│ ├── api.py
+│ ├── _classifiers_api.py
+│ ├── _classifiers_handlers.py
+│ ├── _common
+│ │ ├── exceptions_handlers.py
+│ │ └── schemas.py
+│ ├── exceptions.py
+│ ├── _groups_api.py
+│ ├── _groups_db.py
+│ ├── _groups_handlers.py
+│ └── plugin.py
+├── invitations
+│ ├── api.py
+│ ├── _client.py
+│ ├── _core.py
+│ ├── errors.py
+│ ├── plugin.py
+│ └── settings.py
+├── login
+│ ├── _2fa_api.py
+│ ├── _2fa_handlers.py
+│ ├── _auth_api.py
+│ ├── _auth_handlers.py
+│ ├── cli.py
+│ ├── _confirmation.py
+│ ├── _constants.py
+│ ├── decorators.py
+│ ├── errors.py
+│ ├── handlers_change.py
+│ ├── handlers_confirmation.py
+│ ├── handlers_registration.py
+│ ├── _models.py
+│ ├── plugin.py
+│ ├── _registration_api.py
+│ ├── _registration_handlers.py
+│ ├── _registration.py
+│ ├── _security.py
+│ ├── settings.py
+│ ├── _sql.py
+│ ├── storage.py
+│ ├── utils_email.py
+│ └── utils.py
+├── log.py
+├── long_running_tasks.py
+├── __main__.py
+├── meta_modeling
+│ ├── _function_nodes.py
+│ ├── _handlers.py
+│ ├── _iterations.py
+│ ├── plugin.py
+│ ├── _projects.py
+│ ├── _results.py
+│ └── _version_control.py
+├── _meta.py
+├── models.py
+├── notifications
+│ ├── plugin.py
+│ ├── project_logs.py
+│ ├── _rabbitmq_consumers_common.py
+│ ├── _rabbitmq_exclusive_queue_consumers.py
+│ ├── _rabbitmq_nonexclusive_queue_consumers.py
+│ └── wallet_osparc_credits.py
+├── payments
+│ ├── api.py
+│ ├── _autorecharge_api.py
+│ ├── _autorecharge_db.py
+│ ├── errors.py
+│ ├── _events.py
+│ ├── _methods_api.py
+│ ├── _methods_db.py
+│ ├── _onetime_api.py
+│ ├── _onetime_db.py
+│ ├── plugin.py
+│ ├── _rpc_invoice.py
+│ ├── _rpc.py
+│ ├── settings.py
+│ ├── _socketio.py
+│ └── _tasks.py
+├── products
+│ ├── _api.py
+│ ├── api.py
+│ ├── _db.py
+│ ├── errors.py
+│ ├── _events.py
+│ ├── _handlers.py
+│ ├── _invitations_handlers.py
+│ ├── _middlewares.py
+│ ├── _model.py
+│ ├── plugin.py
+│ └── _rpc.py
+├── projects
+│ ├── _access_rights_api.py
+│ ├── _access_rights_db.py
+│ ├── api.py
+│ ├── _comments_api.py
+│ ├── _comments_db.py
+│ ├── _comments_handlers.py
+│ ├── _common_models.py
+│ ├── _crud_api_create.py
+│ ├── _crud_api_delete.py
+│ ├── _crud_api_read.py
+│ ├── _crud_handlers_models.py
+│ ├── _crud_handlers.py
+│ ├── db.py
+│ ├── _db_utils.py
+│ ├── exceptions.py
+│ ├── _folders_api.py
+│ ├── _folders_db.py
+│ ├── _folders_handlers.py
+│ ├── _groups_api.py
+│ ├── _groups_db.py
+│ ├── _groups_handlers.py
+│ ├── lock.py
+│ ├── _metadata_api.py
+│ ├── _metadata_db.py
+│ ├── _metadata_handlers.py
+│ ├── models.py
+│ ├── _nodes_api.py
+│ ├── _nodes_handlers.py
+│ ├── _nodes_utils.py
+│ ├── nodes_utils.py
+│ ├── _observer.py
+│ ├── _permalink_api.py
+│ ├── plugin.py
+│ ├── _ports_api.py
+│ ├── _ports_handlers.py
+│ ├── _projects_access.py
+│ ├── projects_api.py
+│ ├── _projects_db.py
+│ ├── _projects_nodes_pricing_unit_handlers.py
+│ ├── settings.py
+│ ├── _states_handlers.py
+│ ├── _tags_api.py
+│ ├── _tags_handlers.py
+│ ├── _trash_api.py
+│ ├── _trash_handlers.py
+│ ├── utils.py
+│ ├── _wallets_api.py
+│ ├── _wallets_handlers.py
+│ ├── _workspaces_api.py
+│ └── _workspaces_handlers.py
+├── publications
+│ ├── _handlers.py
+│ └── plugin.py
+├── rabbitmq.py
+├── rabbitmq_settings.py
+├── redis.py
+├── resource_manager
+│ ├── _constants.py
+│ ├── plugin.py
+│ ├── registry.py
+│ ├── settings.py
+│ └── user_sessions.py
+├── _resources.py
+├── resource_usage
+│ ├── api.py
+│ ├── _client.py
+│ ├── _constants.py
+│ ├── errors.py
+│ ├── _observer.py
+│ ├── plugin.pyf
+│ ├── _pricing_plans_admin_api.py
+│ ├── _pricing_plans_admin_handlers.py
+│ ├── _pricing_plans_api.py
+│ ├── _pricing_plans_handlers.py
+│ ├── _service_runs_api.py
+│ ├── _service_runs_handlers.py
+│ ├── settings.py
+│ └── _utils.py
+├── rest
+│ ├── _handlers.py
+│ ├── healthcheck.py
+│ ├── plugin.py
+│ ├── settings.py
+│ └── _utils.py
+├── scicrunch
+│ ├── db.py
+│ ├── errors.py
+│ ├── models.py
+│ ├── plugin.py
+│ ├── _resolver.py
+│ ├── _rest.py
+│ ├── service_client.py
+│ └── settings.py
+├── security
+│ ├── api.py
+│ ├── _authz_access_model.py
+│ ├── _authz_access_roles.py
+│ ├── _authz_db.py
+│ ├── _authz_policy.py
+│ ├── _constants.py
+│ ├── decorators.py
+│ ├── _identity_api.py
+│ ├── _identity_policy.py
+│ └── plugin.py
+├── session
+│ ├── access_policies.py
+│ ├── api.py
+│ ├── _cookie_storage.py
+│ ├── errors.py
+│ ├── plugin.py
+│ └── settings.py
+├── socketio
+│ ├── _handlers.py
+│ ├── messages.py
+│ ├── models.py
+│ ├── _observer.py
+│ ├── plugin.py
+│ ├── server.py
+│ └── _utils.py
+├── statics
+│ ├── _constants.py
+│ ├── _events.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ └── settings.py
+├── storage
+│ ├── api.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ ├── schemas.py
+│ └── settings.py
+├── studies_dispatcher
+│ ├── _catalog.py
+│ ├── _constants.py
+│ ├── _core.py
+│ ├── _errors.py
+│ ├── _models.py
+│ ├── plugin.py
+│ ├── _projects_permalinks.py
+│ ├── _projects.py
+│ ├── _redirects_handlers.py
+│ ├── _rest_handlers.py
+│ ├── settings.py
+│ ├── _studies_access.py
+│ └── _users.py
+├── tags
+│ ├── _api.py
+│ ├── _handlers.py
+│ ├── plugin.py
+│ └── schemas.py
+├── tracing.py
+├── users
+│ ├── _api.py
+│ ├── api.py
+│ ├── _constants.py
+│ ├── _db.py
+│ ├── exceptions.py
+│ ├── _handlers.py
+│ ├── _models.py
+│ ├── _notifications_handlers.py
+│ ├── _notifications.py
+│ ├── plugin.py
+│ ├── _preferences_api.py
+│ ├── preferences_api.py
+│ ├── _preferences_db.py
+│ ├── _preferences_handlers.py
+│ ├── _preferences_models.py
+│ ├── _schemas.py
+│ ├── schemas.py
+│ ├── settings.py
+│ ├── _tokens_handlers.py
+│ └── _tokens.py
+├── utils_aiohttp.py
+├── utils.py
+├── utils_rate_limiting.py
+├── version_control
+│ ├── _core.py
+│ ├── db.py
+│ ├── errors.py
+│ ├── _handlers_base.py
+│ ├── _handlers.py
+│ ├── models.py
+│ ├── plugin.py
+│ ├── vc_changes.py
+│ └── vc_tags.py
+├── wallets
+│ ├── _api.py
+│ ├── api.py
+│ ├── _constants.py
+│ ├── _db.py
+│ ├── errors.py
+│ ├── _events.py
+│ ├── _groups_api.py
+│ ├── _groups_db.py
+│ ├── _groups_handlers.py
+│ ├── _handlers.py
+│ ├── _payments_handlers.py
+│ └── plugin.py
+└── workspaces
+ ├── api.py
+ ├── errors.py
+ ├── _exceptions_handlers.py
+ ├── _groups_api.py
+ ├── _groups_db.py
+ ├── _groups_handlers.py
+ ├── _models.py
+ ├── plugin.py
+ ├── _trash_api.py
+ ├── _trash_handlers.py
+ ├── _workspaces_api.py
+ ├── _workspaces_db.py
+ └── _workspaces_handlers.py
+
+
+
+
+
+The top folders represent plugins that could be interprested as different domains with small compling between each other
+
+Here are some conventions
+
+- `plugin` has a setup function to setup the app (e.g. add routes, setup events etc ). Classic `setup_xxx(app)` for aiohttp
+- `settings` includes pydantic settings classes specific to the domain
+- `exceptions` or `errors` include only exceptions classes
+ - `_exceptions_handlers` are utils to handle exceptions
+- `models` correspond to domain models, i.e. not part of any of the controllers interfaces. Those are denoted `scheme`.
+
+Then
+
+- `_handlers` (or _rest) represent the rest handlers (i.e. controller layer)
+- `_rpc` contains handlers (server side) to an RPC interface (i.e. controller layer)
+- `_api` (or `_service`) represent the business logic of this domain (i.e. service layer)
+ - the shared service layer for inter-domain logic is called `api` (i.e. without `_`)
+- `_db` (or `_repository`) represents the repository layer
+
+
+Could you please apply the rules on this structure and come up with a new tree that follows:
+- keeps the domain-drive modular organization
+- every domain implements controller-service-repository (CSR)
+- highligh the shared service layer for inter-domain logic
+
+
+As an output just recreate the tree adding some comments on it (e.g. with #) but no further explanatio is needed
+
+
+
+```plaintext
+├── activity
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for activity domain (service layer)
+│ ├── repositories
+│ │ ├── activity_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for activity domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── announcements
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for announcements domain (service layer)
+│ ├── repositories
+│ │ ├── announcements_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for announcements domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── api_keys
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for api_keys domain (service layer)
+│ ├── repositories
+│ │ ├── api_keys_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for api_keys domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── shared_services
+│ ├── inter_domain_service.py # Shared service layer for inter-domain logic
+├── db
+│ ├── connection.py # Database connection logic
+│ ├── base_repository.py # Base repository logic
+│ ├── plugin.py # Setup function for database
+│ ├── settings.py # Database-specific settings
+├── catalog
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for catalog domain (service layer)
+│ ├── repositories
+│ │ ├── catalog_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for catalog domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── users
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for users domain (service layer)
+│ ├── repositories
+│ │ ├── users_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for users domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── projects
+│ ├── controllers
+│ │ ├── rest_handlers.py # Rest API handlers (controller layer)
+│ │ ├── rpc_handlers.py # RPC handlers (controller layer)
+│ ├── services
+│ │ ├── domain_service.py # Business logic for projects domain (service layer)
+│ ├── repositories
+│ │ ├── projects_repository.py # Data access logic (repository layer)
+│ ├── plugin.py # Setup function for projects domain
+│ ├── settings.py # Domain-specific settings
+│ ├── exceptions.py # Domain-specific exceptions
+├── shared
+│ ├── models
+│ │ ├── user.py # Shared user model
+│ │ ├── project.py # Shared project model
+│ ├── schemas
+│ │ ├── user_schema.py # Shared user schemas
+│ │ ├── project_schema.py # Shared project schemas
+│ ├── utils
+│ │ ├── logger.py # Shared logging logic
+│ │ ├── validators.py # Shared validation logic
+├── application.py # Main application initialization
+└── cli.py # Command-line interface logic
+```
diff --git a/services/web/server/src/simcore_service_webserver/users/_handlers.py b/services/web/server/src/simcore_service_webserver/users/_handlers.py
index d67d772e0ee..25785673a03 100644
--- a/services/web/server/src/simcore_service_webserver/users/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py
@@ -2,7 +2,7 @@
import logging
from aiohttp import web
-from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate
+from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch
from models_library.users import UserID
from pydantic import BaseModel, Field
from servicelib.aiohttp import status
@@ -74,7 +74,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
@_handle_users_exceptions
async def get_my_profile(request: web.Request) -> web.Response:
req_ctx = UsersRequestContext.model_validate(request)
- profile: ProfileGet = await api.get_user_profile(
+ profile: MyProfileGet = await api.get_user_profile(
request.app, req_ctx.user_id, req_ctx.product_name
)
return envelope_json_response(profile)
@@ -89,7 +89,7 @@ async def get_my_profile(request: web.Request) -> web.Response:
@_handle_users_exceptions
async def update_my_profile(request: web.Request) -> web.Response:
req_ctx = UsersRequestContext.model_validate(request)
- profile_update = await parse_request_body_as(ProfileUpdate, request)
+ profile_update = await parse_request_body_as(MyProfilePatch, request)
await api.update_user_profile(
request.app, user_id=req_ctx.user_id, update=profile_update
diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py
index 7fc2c138204..623d4f44396 100644
--- a/services/web/server/src/simcore_service_webserver/users/api.py
+++ b/services/web/server/src/simcore_service_webserver/users/api.py
@@ -15,9 +15,9 @@
from aiopg.sa.engine import Engine
from aiopg.sa.result import RowProxy
from models_library.api_schemas_webserver.users import (
- ProfileGet,
- ProfilePrivacyGet,
- ProfileUpdate,
+ MyProfileGet,
+ MyProfilePatch,
+ MyProfilePrivacyGet,
)
from models_library.basic_types import IDStr
from models_library.products import ProductName
@@ -28,9 +28,9 @@
from simcore_postgres_database.utils_groups_extra_properties import (
GroupExtraPropertiesNotFoundError,
)
+from simcore_postgres_database.utils_users import generate_alternative_username
from ..db.plugin import get_database_engine
-from ..groups.models import convert_groups_db_to_schema
from ..login.storage import AsyncpgStorage, get_plugin_storage
from ..security.api import clean_auth_policy_cache
from . import _db
@@ -46,6 +46,29 @@
_logger = logging.getLogger(__name__)
+_GROUPS_SCHEMA_TO_DB = {
+ "gid": "gid",
+ "label": "name",
+ "description": "description",
+ "thumbnail": "thumbnail",
+ "accessRights": "access_rights",
+}
+
+
+def _convert_groups_db_to_schema(
+ db_row: RowProxy, *, prefix: str | None = "", **kwargs
+) -> dict:
+ # NOTE: Deprecated. has to be replaced with
+ converted_dict = {
+ k: db_row[f"{prefix}{v}"]
+ for k, v in _GROUPS_SCHEMA_TO_DB.items()
+ if f"{prefix}{v}" in db_row
+ }
+ converted_dict.update(**kwargs)
+ converted_dict["inclusionRules"] = {}
+ return converted_dict
+
+
def _parse_as_user(user_id: Any) -> UserID:
try:
return TypeAdapter(UserID).validate_python(user_id)
@@ -55,7 +78,7 @@ def _parse_as_user(user_id: Any) -> UserID:
async def get_user_profile(
app: web.Application, user_id: UserID, product_name: ProductName
-) -> ProfileGet:
+) -> MyProfileGet:
"""
:raises UserNotFoundError:
:raises MissingGroupExtraPropertiesForProductError: when product is not properly configured
@@ -63,7 +86,7 @@ async def get_user_profile(
engine = get_database_engine(app)
user_profile: dict[str, Any] = {}
- user_primary_group = all_group = {}
+ user_primary_group = everyone_group = {}
user_standard_groups = []
user_id = _parse_as_user(user_id)
@@ -98,20 +121,20 @@ async def get_user_profile(
assert user_profile["id"] == user_id # nosec
if row.groups_type == GroupType.EVERYONE:
- all_group = convert_groups_db_to_schema(
+ everyone_group = _convert_groups_db_to_schema(
row,
prefix="groups_",
accessRights=row["user_to_groups_access_rights"],
)
elif row.groups_type == GroupType.PRIMARY:
- user_primary_group = convert_groups_db_to_schema(
+ user_primary_group = _convert_groups_db_to_schema(
row,
prefix="groups_",
accessRights=row["user_to_groups_access_rights"],
)
else:
user_standard_groups.append(
- convert_groups_db_to_schema(
+ _convert_groups_db_to_schema(
row,
prefix="groups_",
accessRights=row["user_to_groups_access_rights"],
@@ -136,7 +159,7 @@ async def get_user_profile(
if user_profile.get("expiration_date"):
optional["expiration_date"] = user_profile["expiration_date"]
- return ProfileGet(
+ return MyProfileGet(
id=user_profile["id"],
user_name=user_profile["user_name"],
first_name=user_profile["first_name"],
@@ -146,9 +169,9 @@ async def get_user_profile(
groups={ # type: ignore[arg-type]
"me": user_primary_group,
"organizations": user_standard_groups,
- "all": all_group,
+ "all": everyone_group,
},
- privacy=ProfilePrivacyGet(
+ privacy=MyProfilePrivacyGet(
hide_fullname=user_profile["privacy_hide_fullname"],
hide_email=user_profile["privacy_hide_email"],
),
@@ -161,7 +184,7 @@ async def update_user_profile(
app: web.Application,
*,
user_id: UserID,
- update: ProfileUpdate,
+ update: MyProfilePatch,
) -> None:
"""
Raises:
@@ -180,8 +203,13 @@ async def update_user_profile(
assert resp.rowcount == 1 # nosec
except db_errors.UniqueViolation as err:
+ user_name = updated_values.get("name")
+
raise UserNameDuplicateError(
- user_name=updated_values.get("name")
+ user_name=user_name,
+ alternative_user_name=generate_alternative_username(user_name),
+ user_id=user_id,
+ updated_values=updated_values,
) from err
diff --git a/services/web/server/src/simcore_service_webserver/users/exceptions.py b/services/web/server/src/simcore_service_webserver/users/exceptions.py
index 653cfeca719..d1f838d2133 100644
--- a/services/web/server/src/simcore_service_webserver/users/exceptions.py
+++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py
@@ -22,7 +22,10 @@ def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: A
class UserNameDuplicateError(UsersBaseError):
- msg_template = "Username {user_name} is already in use. Violates unique constraint"
+ msg_template = (
+ "The username '{user_name}' is already taken. "
+ "Consider using '{alternative_user_name}' instead."
+ )
class TokenNotFoundError(UsersBaseError):
diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py
index f215368ad1d..0e1de456b78 100644
--- a/services/web/server/tests/conftest.py
+++ b/services/web/server/tests/conftest.py
@@ -64,6 +64,7 @@
"pytest_simcore.environment_configs",
"pytest_simcore.faker_users_data",
"pytest_simcore.hypothesis_type_strategies",
+ "pytest_simcore.openapi_specs",
"pytest_simcore.postgres_service",
"pytest_simcore.pydantic_models",
"pytest_simcore.pytest_global_environs",
@@ -74,8 +75,8 @@
"pytest_simcore.services_api_mocks_for_aiohttp_clients",
"pytest_simcore.simcore_service_library_fixtures",
"pytest_simcore.simcore_services",
+ "pytest_simcore.simcore_webserver_groups_fixtures",
"pytest_simcore.socketio_client",
- "pytest_simcore.openapi_specs",
]
diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py
index 9c5c133f378..f373c302df4 100644
--- a/services/web/server/tests/integration/01/test_garbage_collection.py
+++ b/services/web/server/tests/integration/01/test_garbage_collection.py
@@ -21,6 +21,7 @@
from aiohttp import web
from aiohttp.test_utils import TestClient
from aioresponses import aioresponses
+from models_library.groups import EVERYONE_GROUP_ID, StandardGroupCreate
from models_library.projects_state import RunningState
from pytest_mock import MockerFixture
from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in
@@ -35,11 +36,8 @@
from simcore_service_webserver.director_v2.plugin import setup_director_v2
from simcore_service_webserver.garbage_collector import _core as gc_core
from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector
-from simcore_service_webserver.groups.api import (
- add_user_in_group,
- create_user_group,
- list_user_groups_with_read_access,
-)
+from simcore_service_webserver.groups._groups_api import create_standard_group
+from simcore_service_webserver.groups.api import add_user_in_group
from simcore_service_webserver.login.plugin import setup_login
from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks
from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
@@ -261,13 +259,12 @@ async def get_template_project(
):
"""returns a tempalte shared with all"""
assert client.app
- _, _, all_group = await list_user_groups_with_read_access(client.app, user["id"])
# the information comes from a file, randomize it
project_data["name"] = f"Fake template {uuid4()}"
project_data["uuid"] = f"{uuid4()}"
project_data["accessRights"] = {
- str(all_group["gid"]): {"read": True, "write": False, "delete": False}
+ str(EVERYONE_GROUP_ID): {"read": True, "write": False, "delete": False}
}
if access_rights is not None:
project_data["accessRights"].update(access_rights)
@@ -281,22 +278,33 @@ async def get_template_project(
)
-async def get_group(client: TestClient, user):
+async def get_group(client: TestClient, user: dict):
"""Creates a group for a given user"""
- return await create_user_group(
+ assert client.app
+
+ group, _ = await create_standard_group(
app=client.app,
user_id=user["id"],
- new_group={"label": uuid4(), "description": uuid4(), "thumbnail": None},
+ create=StandardGroupCreate.model_validate(
+ {
+ "name": f"name-{uuid4()}",
+ "description": f"desc-{uuid4()}",
+ "thumbnail": None,
+ }
+ ),
)
+ return group.model_dump(mode="json")
async def invite_user_to_group(client: TestClient, owner, invitee, group):
"""Invite a user to a group on which the owner has writes over"""
+ assert client.app
+
await add_user_in_group(
client.app,
owner["id"],
group["gid"],
- new_user_id=invitee["id"],
+ new_by_user_id=invitee["id"],
)
diff --git a/services/web/server/tests/integration/conftest.py b/services/web/server/tests/integration/conftest.py
index 0dee770f2f2..2f8cda8aa5e 100644
--- a/services/web/server/tests/integration/conftest.py
+++ b/services/web/server/tests/integration/conftest.py
@@ -15,7 +15,6 @@
import json
import logging
import sys
-from collections.abc import AsyncIterable
from copy import deepcopy
from pathlib import Path
from string import Template
@@ -27,13 +26,6 @@
from pytest_simcore.helpers import FIXTURE_CONFIG_CORE_SERVICES_SELECTION
from pytest_simcore.helpers.dict_tools import ConfigDict
from pytest_simcore.helpers.docker import get_service_published_port
-from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
-from simcore_service_webserver.groups.api import (
- add_user_in_group,
- create_user_group,
- delete_user_group,
- list_user_groups_with_read_access,
-)
CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent
@@ -177,72 +169,6 @@ def mock_orphaned_services(mocker: MockerFixture) -> mock.Mock:
)
-@pytest.fixture
-async def primary_group(client, logged_user: UserInfoDict) -> dict[str, str]:
- primary_group, _, _ = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
- return primary_group
-
-
-@pytest.fixture
-async def standard_groups(
- client, logged_user: UserInfoDict
-) -> AsyncIterable[list[dict[str, str]]]:
- # create a separate admin account to create some standard groups for the logged user
- sparc_group = {
- "gid": "5", # this will be replaced
- "label": "SPARC",
- "description": "Stimulating Peripheral Activity to Relieve Conditions",
- "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png",
- }
- team_black_group = {
- "gid": "5", # this will be replaced
- "label": "team Black",
- "description": "THE incredible black team",
- "thumbnail": None,
- }
- async with NewUser(
- {"name": f"{logged_user['name']}_admin", "role": "USER"}, client.app
- ) as admin_user:
- sparc_group = await create_user_group(client.app, admin_user["id"], sparc_group)
- team_black_group = await create_user_group(
- client.app, admin_user["id"], team_black_group
- )
- await add_user_in_group(
- client.app,
- admin_user["id"],
- int(sparc_group["gid"]),
- new_user_id=logged_user["id"],
- )
- await add_user_in_group(
- client.app,
- admin_user["id"],
- int(team_black_group["gid"]),
- new_user_email=logged_user["email"],
- )
-
- _, standard_groups, _ = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
-
- yield standard_groups
-
- # clean groups
- await delete_user_group(client.app, admin_user["id"], int(sparc_group["gid"]))
- await delete_user_group(
- client.app, admin_user["id"], int(team_black_group["gid"])
- )
-
-
-@pytest.fixture
-async def all_group(client, logged_user) -> dict[str, str]:
- _, _, all_group = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
- return all_group
-
-
@pytest.fixture(scope="session")
def osparc_product_name() -> str:
return "osparc"
diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py
index d51b467c015..9813ca6009c 100644
--- a/services/web/server/tests/unit/isolated/test_groups_models.py
+++ b/services/web/server/tests/unit/isolated/test_groups_models.py
@@ -1,7 +1,25 @@
import models_library.groups
+import pytest
import simcore_postgres_database.models.groups
-from models_library.api_schemas_webserver.groups import GroupGet
+from faker import Faker
+from models_library.api_schemas_webserver._base import OutputSchema
+from models_library.api_schemas_webserver.groups import (
+ GroupCreate,
+ GroupGet,
+ GroupUpdate,
+ GroupUserAdd,
+ GroupUserGet,
+)
+from models_library.groups import (
+ AccessRightsDict,
+ Group,
+ GroupMember,
+ GroupTypeInModel,
+ StandardGroupCreate,
+ StandardGroupUpdate,
+)
from models_library.utils.enums import enum_to_dict
+from pydantic import ValidationError
def test_models_library_and_postgress_database_enums_are_equivalent():
@@ -39,3 +57,68 @@ def test_sanitize_legacy_data():
assert users_group_2.thumbnail is None
assert users_group_1 == users_group_2
+
+
+def test_output_schemas_from_models(faker: Faker):
+ # output : schema <- model
+ assert issubclass(GroupGet, OutputSchema)
+ domain_model = Group(
+ gid=1,
+ name=faker.word(),
+ description=faker.sentence(),
+ group_type=GroupTypeInModel.STANDARD,
+ thumbnail=None,
+ )
+ output_schema = GroupGet.from_model(
+ domain_model,
+ access_rights=AccessRightsDict(read=True, write=False, delete=False),
+ )
+ assert output_schema.label == domain_model.name
+
+ # output : schema <- model
+ domain_model = GroupMember(
+ id=12,
+ name=faker.user_name(),
+ email=None,
+ first_name=None,
+ last_name=None,
+ primary_gid=13,
+ access_rights=AccessRightsDict(read=True, write=False, delete=False),
+ )
+ output_schema = GroupUserGet.from_model(user=domain_model)
+ assert output_schema.user_name == domain_model.name
+
+
+def test_input_schemas_to_models(faker: Faker):
+ # input : scheam -> model
+ input_schema = GroupCreate(
+ label=faker.word(), description=faker.sentence(), thumbnail=faker.url()
+ )
+ domain_model = input_schema.to_model()
+ assert isinstance(domain_model, StandardGroupCreate)
+ assert domain_model.name == input_schema.label
+
+ # input : scheam -> model
+ input_schema = GroupUpdate(label=faker.word())
+ domain_model = input_schema.to_model()
+ assert isinstance(domain_model, StandardGroupUpdate)
+ assert domain_model.name == input_schema.label
+
+
+def test_group_user_add_options(faker: Faker):
+ def _only_one_true(*args):
+ return sum(bool(arg) for arg in args) == 1
+
+ input_schema = GroupUserAdd(uid=faker.pyint())
+ assert input_schema.uid
+ assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email)
+
+ input_schema = GroupUserAdd(userName=faker.user_name())
+ assert input_schema.user_name
+ assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email)
+
+ input_schema = GroupUserAdd(email=faker.email())
+ assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email)
+
+ with pytest.raises(ValidationError):
+ GroupUserAdd(userName=faker.user_name(), email=faker.email())
diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py
index 8ff676476ee..db129b68550 100644
--- a/services/web/server/tests/unit/isolated/test_users_models.py
+++ b/services/web/server/tests/unit/isolated/test_users_models.py
@@ -11,9 +11,9 @@
import pytest
from faker import Faker
from models_library.api_schemas_webserver.users import (
- ProfileGet,
- ProfilePrivacyGet,
- ProfileUpdate,
+ MyProfileGet,
+ MyProfilePatch,
+ MyProfilePrivacyGet,
)
from models_library.generics import Envelope
from models_library.utils.fastapi_encoders import jsonable_encoder
@@ -26,7 +26,7 @@
@pytest.mark.parametrize(
"model_cls",
- [ProfileGet, ThirdPartyToken],
+ [MyProfileGet, ThirdPartyToken],
)
def test_user_models_examples(
model_cls: type[BaseModel], model_cls_examples: dict[str, Any]
@@ -51,23 +51,23 @@ def test_user_models_examples(
@pytest.fixture
-def fake_profile_get(faker: Faker) -> ProfileGet:
+def fake_profile_get(faker: Faker) -> MyProfileGet:
fake_profile: dict[str, Any] = faker.simple_profile()
first, last = fake_profile["name"].rsplit(maxsplit=1)
- return ProfileGet(
+ return MyProfileGet(
id=faker.pyint(),
first_name=first,
last_name=last,
user_name=fake_profile["username"],
login=fake_profile["mail"],
role="USER",
- privacy=ProfilePrivacyGet(hide_fullname=True, hide_email=True),
+ privacy=MyProfilePrivacyGet(hide_fullname=True, hide_email=True),
preferences={},
)
-def test_profile_get_expiration_date(fake_profile_get: ProfileGet):
+def test_profile_get_expiration_date(fake_profile_get: MyProfileGet):
fake_expiration = datetime.now(UTC)
profile = fake_profile_get.model_copy(
@@ -80,7 +80,7 @@ def test_profile_get_expiration_date(fake_profile_get: ProfileGet):
assert body["expirationDate"] == fake_expiration.date().isoformat()
-def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet):
+def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet):
profile = fake_profile_get.model_copy()
@@ -89,7 +89,7 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet):
assert (
"gravatar_id" not in data
- ), f"{ProfileGet.model_fields['gravatar_id'].deprecated=}"
+ ), f"{MyProfileGet.model_fields['gravatar_id'].deprecated=}"
assert data["id"] == profile.id
assert data["first_name"] == profile.first_name
assert data["last_name"] == profile.last_name
@@ -100,13 +100,13 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet):
@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
def test_profile_get_role(user_role: str):
- for example in ProfileGet.model_json_schema()["examples"]:
+ for example in MyProfileGet.model_json_schema()["examples"]:
data = deepcopy(example)
data["role"] = user_role
- m1 = ProfileGet(**data)
+ m1 = MyProfileGet(**data)
data["role"] = UserRole(user_role)
- m2 = ProfileGet(**data)
+ m2 = MyProfileGet(**data)
assert m1 == m2
@@ -155,13 +155,13 @@ def test_parsing_output_of_get_user_profile():
},
}
- profile = ProfileGet.model_validate(result_from_db_query_and_composition)
+ profile = MyProfileGet.model_validate(result_from_db_query_and_composition)
assert "password" not in profile.model_dump(exclude_unset=True)
def test_mapping_update_models_from_rest_to_db():
- profile_update = ProfileUpdate.model_validate(
+ profile_update = MyProfilePatch.model_validate(
# request payload
{
"first_name": "foo",
diff --git a/services/web/server/tests/unit/with_dbs/01/groups/conftest.py b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py
new file mode 100644
index 00000000000..67e733cfd78
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py
@@ -0,0 +1,43 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+from collections.abc import Callable
+
+import pytest
+import sqlalchemy as sa
+from aiohttp.test_utils import TestClient
+from pytest_simcore.helpers.typing_env import EnvVarsDict
+from servicelib.aiohttp.application import create_safe_application
+from simcore_service_webserver.application_settings import setup_settings
+from simcore_service_webserver.db.plugin import setup_db
+from simcore_service_webserver.groups.plugin import setup_groups
+from simcore_service_webserver.login.plugin import setup_login
+from simcore_service_webserver.rest.plugin import setup_rest
+from simcore_service_webserver.security.plugin import setup_security
+from simcore_service_webserver.session.plugin import setup_session
+from simcore_service_webserver.users.plugin import setup_users
+
+
+@pytest.fixture
+def client(
+ event_loop,
+ aiohttp_client: Callable,
+ app_environment: EnvVarsDict,
+ postgres_db: sa.engine.Engine,
+) -> TestClient:
+ app = create_safe_application()
+
+ setup_settings(app)
+ setup_db(app)
+ setup_session(app)
+ setup_security(app)
+ setup_rest(app)
+ setup_login(app)
+ setup_users(app)
+ setup_groups(app)
+
+ return event_loop.run_until_complete(aiohttp_client(app))
diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py
new file mode 100644
index 00000000000..5adaf33d9af
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py
@@ -0,0 +1,210 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+import operator
+
+import pytest
+from aiohttp.test_utils import TestClient
+from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet
+from pydantic import TypeAdapter
+from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.webserver_login import UserInfoDict
+from pytest_simcore.helpers.webserver_parametrizations import (
+ ExpectedResponse,
+ standard_role_response,
+)
+from servicelib.aiohttp import status
+from simcore_postgres_database.models.users import UserRole
+from simcore_service_webserver._meta import API_VTAG
+
+
+@pytest.mark.parametrize(*standard_role_response(), ids=str)
+async def test_groups_access_rights(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_role: UserRole,
+ expected: ExpectedResponse,
+):
+ assert client.app
+ url = client.app.router["list_groups"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+
+ response = await client.get(f"{url}")
+ await assert_status(
+ response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK
+ )
+
+ url = client.app.router["create_group"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+ resp = await client.post(
+ f"{url}",
+ json={"label": "Black Sabbath", "description": "The founders of Rock'N'Roll"},
+ )
+ await assert_status(resp, expected.created)
+
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_list_user_groups_and_try_modify_organizations(
+ client: TestClient,
+ user_role: UserRole,
+ standard_groups_owner: UserInfoDict,
+ logged_user: UserInfoDict,
+ primary_group: dict[str, str],
+ standard_groups: list[dict[str, str]],
+ all_group: dict[str, str],
+):
+ assert client.app
+ assert logged_user["id"] != standard_groups_owner["id"]
+ assert logged_user["role"] == user_role.value
+
+ # List all groups (organizations, primary, everyone and products) I belong to
+ url = client.app.router["list_groups"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+
+ response = await client.get(f"{url}")
+ data, error = await assert_status(response, status.HTTP_200_OK)
+
+ my_groups = MyGroupsGet.model_validate(data)
+ assert not error
+
+ assert my_groups.me.model_dump(by_alias=True) == primary_group
+ assert my_groups.all.model_dump(by_alias=True) == all_group
+
+ assert my_groups.organizations
+ assert len(my_groups.organizations) == len(standard_groups)
+
+ by_gid = operator.itemgetter("gid")
+ assert sorted(
+ TypeAdapter(list[GroupGet]).dump_python(
+ my_groups.organizations, mode="json", by_alias=True
+ ),
+ key=by_gid,
+ ) == sorted(standard_groups, key=by_gid)
+
+ for group in standard_groups:
+ # try to delete a group
+ url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}")
+ response = await client.delete(f"{url}")
+ await assert_status(response, status.HTTP_403_FORBIDDEN)
+
+ # try to add some user in the group
+ url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}")
+ response = await client.post(f"{url}", json={"uid": logged_user["id"]})
+ await assert_status(response, status.HTTP_403_FORBIDDEN)
+
+ # try to modify the user in the group
+ url = client.app.router["update_group_user"].url_for(
+ gid=f"{group['gid']}", uid=f"{logged_user['id']}"
+ )
+ response = await client.patch(
+ f"{url}",
+ json={"accessRights": {"read": True, "write": True, "delete": True}},
+ )
+ await assert_status(response, status.HTTP_403_FORBIDDEN)
+
+ # try to remove the user from the group
+ url = client.app.router["delete_group_user"].url_for(
+ gid=f"{group['gid']}", uid=f"{logged_user['id']}"
+ )
+ response = await client.delete(f"{url}")
+ await assert_status(response, status.HTTP_403_FORBIDDEN)
+
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_group_creation_workflow(
+ client: TestClient,
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+):
+ assert client.app
+ assert logged_user["id"] != 0
+ assert logged_user["role"] == user_role.value
+
+ url = client.app.router["create_group"].url_for()
+ new_group_data = {
+ "label": "Black Sabbath",
+ "description": "The founders of Rock'N'Roll",
+ "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c",
+ }
+
+ resp = await client.post(f"{url}", json=new_group_data)
+ data, error = await assert_status(resp, status.HTTP_201_CREATED)
+
+ assert not error
+ group = GroupGet.model_validate(data)
+
+ # we get a new gid and the rest keeps the same
+ assert (
+ group.model_dump(include={"label", "description", "thumbnail"}, mode="json")
+ == new_group_data
+ )
+
+ # we get full ownership (i.e all rights) on the group since we are the creator
+ assert group.access_rights.model_dump() == {
+ "read": True,
+ "write": True,
+ "delete": True,
+ }
+
+ # get the groups and check we are part of this new group
+ url = client.app.router["list_groups"].url_for()
+ resp = await client.get(f"{url}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ my_groups = MyGroupsGet.model_validate(data)
+ assert my_groups.organizations
+ assert len(my_groups.organizations) == 1
+ assert (
+ my_groups.organizations[0].model_dump(include=set(new_group_data), mode="json")
+ == new_group_data
+ )
+
+ # check getting one group
+ url = client.app.router["get_group"].url_for(gid=f"{group.gid}")
+ resp = await client.get(f"{url}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ got_group = GroupGet.model_validate(data)
+ assert got_group == group
+
+ # modify the group
+ url = client.app.router["update_group"].url_for(gid=f"{group.gid}")
+ resp = await client.patch(f"{url}", json={"label": "Led Zeppelin"})
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ updated_group = GroupGet.model_validate(data)
+ assert updated_group.model_dump(exclude={"label"}) == got_group.model_dump(
+ exclude={"label"}
+ )
+ assert updated_group.label == "Led Zeppelin"
+
+ # check getting the group returns the newly modified group
+ url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}")
+ resp = await client.get(f"{url}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ got_group = GroupGet.model_validate(data)
+ assert got_group == updated_group
+
+ # delete the group
+ url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}")
+ resp = await client.delete(f"{url}")
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # check deleting the same group again fails
+ url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}")
+ resp = await client.delete(f"{url}")
+ _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+ assert f"{group.gid}" in error["message"]
+
+ # check getting the group fails
+ url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}")
+ resp = await client.get(f"{url}")
+ _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+ assert f"{group.gid}" in error["message"]
diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py
new file mode 100644
index 00000000000..97ebd6e2b51
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py
@@ -0,0 +1,516 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+from collections.abc import AsyncIterator
+from contextlib import AsyncExitStack
+
+import pytest
+from aiohttp.test_utils import TestClient
+from faker import Faker
+from models_library.api_schemas_webserver.groups import GroupGet, GroupUserGet
+from models_library.groups import AccessRightsDict, Group, StandardGroupCreate
+from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict
+from pytest_simcore.helpers.webserver_parametrizations import (
+ ExpectedResponse,
+ standard_role_response,
+)
+from servicelib.aiohttp import status
+from simcore_postgres_database.models.users import UserRole
+from simcore_service_webserver._meta import API_VTAG
+from simcore_service_webserver.groups._groups_api import (
+ create_standard_group,
+ delete_standard_group,
+)
+from simcore_service_webserver.groups._groups_db import (
+ _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS,
+ _DEFAULT_GROUP_READ_ACCESS_RIGHTS,
+)
+from simcore_service_webserver.groups.api import auto_add_user_to_groups
+from simcore_service_webserver.security.api import clean_auth_policy_cache
+
+
+def _assert_group(group: dict[str, str]):
+ return GroupGet.model_validate(group)
+
+
+def _assert__group_user(
+ expected_user: UserInfoDict,
+ expected_access_rights: AccessRightsDict,
+ actual_user: dict,
+ group_owner_id: int,
+):
+ user = GroupUserGet.model_validate(actual_user)
+
+ assert user.id
+ assert user.gid
+
+ # identifiers
+ assert actual_user["userName"] == expected_user["name"]
+ assert "id" in actual_user
+ assert int(user.id) == expected_user["id"]
+
+ assert "gid" in actual_user
+ assert int(user.gid) == expected_user.get("primary_gid")
+
+ # private profile
+ is_private = int(group_owner_id) != int(actual_user["id"])
+ assert "first_name" in actual_user
+ assert actual_user["first_name"] == (
+ None if is_private else expected_user.get("first_name")
+ )
+ assert "last_name" in actual_user
+ assert actual_user["last_name"] == (
+ None if is_private else expected_user.get("last_name")
+ )
+ assert "login" in actual_user
+ assert actual_user["login"] == (None if is_private else expected_user["email"])
+
+ # access-rights
+ assert "accessRights" in actual_user
+ assert actual_user["accessRights"] == expected_access_rights
+
+
+@pytest.mark.parametrize(*standard_role_response())
+async def test_add_remove_users_from_group(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_role: UserRole,
+ expected: ExpectedResponse,
+ faker: Faker,
+):
+ assert client.app
+ new_group = {
+ "gid": "5",
+ "label": "team awesom",
+ "description": "awesomeness is just the summary",
+ "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcSQMopBeN0pq2gg6iIZuLGYniFxUdzi7a2LeT1Xg0Lz84bl36Nlqw%26s&sp=1591110539Tbbb022a272bc117e58cca2f2399e83e6b5d4a2d0a7c283330057d7718ae305bd",
+ }
+
+ # check that our group does not exist
+ url = client.app.router["get_all_group_users"].url_for(gid=new_group["gid"])
+ assert f"{url}" == f"/{API_VTAG}/groups/{new_group['gid']}/users"
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.not_found)
+
+ # Create group
+ url = client.app.router["create_group"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+ resp = await client.post(f"{url}", json=new_group)
+ data, error = await assert_status(resp, expected.created)
+
+ assigned_group = new_group
+ if not error:
+ assert isinstance(data, dict)
+ assigned_group = data
+
+ _assert_group(assigned_group)
+
+ # we get a new gid and the rest keeps the same
+ assert assigned_group["gid"] != new_group["gid"]
+
+ props = ["label", "description", "thumbnail"]
+ assert {assigned_group[p] for p in props} == {new_group[p] for p in props}
+
+ # we get all rights on the group since we are the creator
+ assert assigned_group["accessRights"] == _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS
+
+ group_id = assigned_group["gid"]
+
+ # check that our user is in the group of users
+ url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}")
+ assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users"
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.ok)
+
+ if not error:
+ list_of_users = data
+ assert len(list_of_users) == 1
+ the_owner = list_of_users[0]
+ _assert__group_user(
+ logged_user,
+ _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS,
+ the_owner,
+ group_owner_id=the_owner["id"],
+ )
+
+ # create a random number of users and put them in the group
+ num_new_users = faker.random_int(1, 10)
+ created_users_list = []
+ async with AsyncExitStack() as users_stack:
+ for i in range(num_new_users):
+
+ is_private = i % 2 == 0
+ created_users_list.append(
+ await users_stack.enter_async_context(
+ NewUser(
+ app=client.app, user_data={"privacy_hide_email": is_private}
+ )
+ )
+ )
+ created_users_list[i]["is_private"] = is_private
+ user_id = created_users_list[i]["id"]
+ user_email = created_users_list[i]["email"]
+
+ # ADD
+ url = client.app.router["add_group_user"].url_for(gid=f"{group_id}")
+ assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users"
+ if is_private:
+ # only if privacy allows
+ resp = await client.post(f"{url}", json={"email": user_email})
+ data, error = await assert_status(resp, expected.not_found)
+
+ # always allowed
+ resp = await client.post(f"{url}", json={"uid": user_id})
+ await assert_status(resp, expected.no_content)
+ else:
+ # both work
+ resp = await client.post(f"{url}", json={"email": user_email})
+ await assert_status(resp, expected.no_content)
+
+ # GET
+ url = client.app.router["get_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{user_id}"
+ )
+ assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users/{user_id}"
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.ok)
+ if not error:
+ _assert__group_user(
+ created_users_list[i],
+ _DEFAULT_GROUP_READ_ACCESS_RIGHTS,
+ data,
+ group_owner_id=the_owner["id"] if is_private else user_id,
+ )
+
+ # LIST: check list is correct
+ url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}")
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.ok)
+ if not error:
+ list_of_users = data
+
+ # now we should have all the users in the group + the owner
+ all_created_users = [*created_users_list, logged_user]
+
+ assert len(list_of_users) == len(all_created_users)
+ for user in list_of_users:
+ expected_user: UserInfoDict = next(
+ u for u in all_created_users if int(u["id"]) == int(user["id"])
+ )
+ expected_access_rigths = (
+ _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS
+ if int(user["id"]) == int(logged_user["id"])
+ else _DEFAULT_GROUP_READ_ACCESS_RIGHTS
+ )
+
+ _assert__group_user(
+ expected_user,
+ expected_access_rigths,
+ user,
+ group_owner_id=the_owner["id"]
+ if expected_user.get("is_private", False)
+ else user["id"],
+ )
+
+ # PATCH the user and REMOVE them from the group
+ MANAGER_ACCESS_RIGHTS: AccessRightsDict = {
+ "read": True,
+ "write": True,
+ "delete": False,
+ }
+ for i in range(num_new_users):
+ group_id = assigned_group["gid"]
+ user_id = created_users_list[i]["id"]
+ is_private = created_users_list[i].get("is_private", False)
+
+ # PATCH access-rights
+ url = client.app.router["update_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{user_id}"
+ )
+ resp = await client.patch(
+ f"{url}", json={"accessRights": MANAGER_ACCESS_RIGHTS}
+ )
+ data, error = await assert_status(resp, expected.ok)
+ if not error:
+ _assert__group_user(
+ created_users_list[i],
+ MANAGER_ACCESS_RIGHTS,
+ data,
+ group_owner_id=the_owner["id"] if is_private else user_id,
+ )
+
+ # GET: check it is there
+ url = client.app.router["get_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{user_id}"
+ )
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.ok)
+ if not error:
+ _assert__group_user(
+ created_users_list[i],
+ MANAGER_ACCESS_RIGHTS,
+ data,
+ group_owner_id=the_owner["id"] if is_private else user_id,
+ )
+
+ # REMOVE the user from the group
+ url = client.app.router["delete_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{user_id}"
+ )
+ resp = await client.delete(f"{url}")
+ data, error = await assert_status(resp, expected.no_content)
+
+ # REMOVE: do it again to check it is not found anymore
+ resp = await client.delete(f"{url}")
+ data, error = await assert_status(resp, expected.not_found)
+
+ # GET check it is not there anymore
+ url = client.app.router["get_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{user_id}"
+ )
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected.not_found)
+
+
+@pytest.mark.parametrize(*standard_role_response())
+async def test_group_access_rights(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_role: UserRole,
+ expected: ExpectedResponse,
+):
+ assert client.app
+ # Use-case:
+ # 1. create a group
+ url = client.app.router["create_group"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+
+ new_group = {
+ "gid": "4564",
+ "label": f"this is user {logged_user['id']} group",
+ "description": f"user {logged_user['email']} is the owner of that one",
+ "thumbnail": None,
+ }
+
+ resp = await client.post(f"{url}", json=new_group)
+ data, error = await assert_status(resp, expected.created)
+ if not data:
+ # role cannot create a group so stop here
+ return
+
+ assigned_group = data
+ group_id = assigned_group["gid"]
+
+ async with AsyncExitStack() as users_stack:
+ # 1. have 2 users
+ users = [
+ await users_stack.enter_async_context(NewUser(app=client.app))
+ for _ in range(2)
+ ]
+
+ # 2. ADD the users to the group
+ add_group_user_url = client.app.router["add_group_user"].url_for(
+ gid=f"{group_id}"
+ )
+ assert f"{add_group_user_url}" == f"/{API_VTAG}/groups/{group_id}/users"
+ for user in users:
+ resp = await client.post(f"{add_group_user_url}", json={"uid": user["id"]})
+ await assert_status(resp, expected.no_content)
+
+ # 3. PATCH: user 1 shall be a manager
+ patch_group_user_url = client.app.router["update_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{users[0]['id']}"
+ )
+ assert (
+ f"{patch_group_user_url}"
+ == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}"
+ )
+ params = {"accessRights": {"read": True, "write": True, "delete": False}}
+ resp = await client.patch(f"{patch_group_user_url}", json=params)
+ await assert_status(resp, expected.ok)
+
+ # 4. PATCH user 2 shall be a member
+ patch_group_user_url = client.app.router["update_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{users[1]['id']}"
+ )
+ assert (
+ f"{patch_group_user_url}"
+ == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}"
+ )
+ resp = await client.patch(
+ f"{patch_group_user_url}",
+ json={"accessRights": {"read": True, "write": False, "delete": False}},
+ )
+ await assert_status(resp, expected.ok)
+
+ # let's LOGIN as user 1
+ url = client.app.router["auth_login"].url_for()
+ resp = await client.post(
+ f"{url}",
+ json={
+ "email": users[0]["email"],
+ "password": users[0]["raw_password"],
+ },
+ )
+ await assert_status(resp, expected.ok)
+
+ # check as a manager I can REMOVE user 2
+ delete_group_user_url = client.app.router["delete_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{users[1]['id']}"
+ )
+ assert (
+ f"{delete_group_user_url}"
+ == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}"
+ )
+ resp = await client.delete(f"{delete_group_user_url}")
+ await assert_status(resp, expected.no_content)
+
+ # as a manager I can ADD user 2 again
+ resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]})
+ await assert_status(resp, expected.no_content)
+
+ # as a manager I cannot DELETE the group
+ url = client.app.router["delete_group"].url_for(gid=f"{group_id}")
+ resp = await client.delete(f"{url}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ # now log in as user 2
+ # LOGIN
+ url = client.app.router["auth_login"].url_for()
+ resp = await client.post(
+ f"{url}",
+ json={
+ "email": users[1]["email"],
+ "password": users[1]["raw_password"],
+ },
+ )
+ await assert_status(resp, expected.ok)
+
+ # as a member I cannot REMOVE user 1
+ delete_group_user_url = client.app.router["delete_group_user"].url_for(
+ gid=f"{group_id}", uid=f"{users[0]['id']}"
+ )
+ assert (
+ f"{delete_group_user_url}"
+ == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}"
+ )
+ resp = await client.delete(f"{delete_group_user_url}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ # as a member I cannot ADD user 1
+ resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]})
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ # as a member I cannot DELETE the grouop
+ url = client.app.router["delete_group"].url_for(gid=f"{group_id}")
+ resp = await client.delete(f"{url}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+
+@pytest.mark.parametrize(*standard_role_response())
+async def test_add_user_gets_added_to_group(
+ client: TestClient,
+ standard_groups: list[dict[str, str]],
+ user_role: UserRole,
+ expected: ExpectedResponse,
+):
+
+ assert client.app
+ async with AsyncExitStack() as users_stack:
+ for email in (
+ # SEE StandardGroupCreate.inclusion_rules in
+ # packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py
+ "good@sparc.io",
+ "bad@bad.com",
+ "bad@osparc.com",
+ "good@black.com",
+ "bad@blanco.com",
+ ):
+ user = await users_stack.enter_async_context(
+ LoggedUser(
+ client,
+ user_data={
+ "role": user_role.name,
+ "email": email,
+ "privacy_hide_email": False,
+ },
+ check_if_succeeds=user_role != UserRole.ANONYMOUS,
+ )
+ )
+ await auto_add_user_to_groups(client.app, user["id"])
+
+ url = client.app.router["list_groups"].url_for()
+ assert f"{url}" == f"/{API_VTAG}/groups"
+
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(
+ resp, status.HTTP_200_OK if user_role == UserRole.GUEST else expected.ok
+ )
+ if not error:
+ assert len(data["organizations"]) == (0 if "bad" in email else 1)
+
+ # NOTE: here same email are used for different users! Therefore sessions get mixed!
+ await clean_auth_policy_cache(client.app)
+
+
+@pytest.fixture
+async def group_where_logged_user_is_the_owner(
+ client: TestClient,
+ logged_user: UserInfoDict,
+) -> AsyncIterator[Group]:
+ assert client.app
+ group, _ = await create_standard_group(
+ app=client.app,
+ user_id=logged_user["id"],
+ create=StandardGroupCreate.model_validate(
+ {
+ "name": f"this is user {logged_user['id']} group",
+ "description": f"user {logged_user['email']} is the owner of that one",
+ "thumbnail": None,
+ }
+ ),
+ )
+
+ yield group
+
+ await delete_standard_group(
+ client.app, user_id=logged_user["id"], group_id=group.gid
+ )
+
+
+@pytest.mark.acceptance_test(
+ "Fixes 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812"
+)
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_adding_user_to_group_with_upper_case_email(
+ client: TestClient,
+ user_role: UserRole,
+ group_where_logged_user_is_the_owner: Group,
+ faker: Faker,
+):
+ assert client.app
+ url = client.app.router["add_group_user"].url_for(
+ gid=f"{group_where_logged_user_is_the_owner.gid}"
+ )
+ # adding a user to group with the email in capital letters
+ # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812
+ async with NewUser(
+ app=client.app, user_data={"privacy_hide_email": False}
+ ) as registered_user:
+ assert registered_user["email"] # <--- this email is lower case
+
+ response = await client.post(
+ f"{url}",
+ json={
+ # <--- email in upper case
+ "email": registered_user["email"].upper()
+ },
+ )
+ data, error = await assert_status(response, status.HTTP_204_NO_CONTENT)
+
+ assert not data
+ assert not error
diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py
deleted file mode 100644
index 51f2f746a80..00000000000
--- a/services/web/server/tests/unit/with_dbs/01/test_groups.py
+++ /dev/null
@@ -1,668 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=too-many-arguments
-# pylint: disable=too-many-statements
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-from collections.abc import AsyncIterator, Callable
-from contextlib import AsyncExitStack
-from copy import deepcopy
-from typing import Any
-
-import pytest
-from aiohttp.test_utils import TestClient
-from faker import Faker
-from pytest_simcore.helpers.assert_checks import assert_status
-from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict
-from pytest_simcore.helpers.webserver_parametrizations import (
- ExpectedResponse,
- standard_role_response,
-)
-from servicelib.aiohttp import status
-from servicelib.aiohttp.application import create_safe_application
-from simcore_postgres_database.models.users import UserRole
-from simcore_service_webserver._meta import API_VTAG
-from simcore_service_webserver.application_settings import setup_settings
-from simcore_service_webserver.db.plugin import setup_db
-from simcore_service_webserver.groups._db import (
- _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS,
- _DEFAULT_GROUP_READ_ACCESS_RIGHTS,
-)
-from simcore_service_webserver.groups._utils import AccessRightsDict
-from simcore_service_webserver.groups.api import (
- auto_add_user_to_groups,
- create_user_group,
- delete_user_group,
-)
-from simcore_service_webserver.groups.plugin import setup_groups
-from simcore_service_webserver.login.plugin import setup_login
-from simcore_service_webserver.rest.plugin import setup_rest
-from simcore_service_webserver.security.api import clean_auth_policy_cache
-from simcore_service_webserver.security.plugin import setup_security
-from simcore_service_webserver.session.plugin import setup_session
-from simcore_service_webserver.users.plugin import setup_users
-from simcore_service_webserver.utils import gravatar_hash
-
-
-@pytest.fixture
-def client(
- event_loop,
- aiohttp_client,
- app_cfg,
- postgres_db,
- monkeypatch_setenv_from_app_config: Callable,
-) -> TestClient:
- cfg = deepcopy(app_cfg)
-
- port = cfg["main"]["port"]
-
- assert cfg["rest"]["version"] == API_VTAG
- monkeypatch_setenv_from_app_config(cfg)
-
- # fake config
- app = create_safe_application(cfg)
-
- settings = setup_settings(app)
- print(settings.model_dump_json(indent=1))
-
- setup_db(app)
- setup_session(app)
- setup_security(app)
- setup_rest(app)
- setup_login(app)
- setup_users(app)
- setup_groups(app)
-
- return event_loop.run_until_complete(
- aiohttp_client(app, server_kwargs={"port": port, "host": "localhost"})
- )
-
-
-def _assert_group(group: dict[str, str]):
- properties = ["gid", "label", "description", "thumbnail", "accessRights"]
- assert all(x in group for x in properties)
- access_rights = group["accessRights"]
- access_rights_properties = ["read", "write", "delete"]
- assert all(x in access_rights for x in access_rights_properties)
-
-
-def _assert__group_user(
- expected_user: UserInfoDict,
- expected_access_rights: AccessRightsDict,
- actual_user: dict,
-):
- assert "first_name" in actual_user
- assert actual_user["first_name"] == expected_user["first_name"]
- assert "last_name" in actual_user
- assert actual_user["last_name"] == expected_user["last_name"]
- assert "login" in actual_user
- assert actual_user["login"] == expected_user["email"]
- assert "gravatar_id" in actual_user
- assert actual_user["gravatar_id"] == gravatar_hash(expected_user["email"])
- assert "accessRights" in actual_user
- assert actual_user["accessRights"] == expected_access_rights
- assert "id" in actual_user
- assert actual_user["id"] == expected_user["id"]
- assert "gid" in actual_user
-
-
-@pytest.mark.parametrize(*standard_role_response(), ids=str)
-async def test_list_groups(
- client: TestClient,
- logged_user: UserInfoDict,
- user_role: UserRole,
- expected: ExpectedResponse,
- primary_group: dict[str, str],
- standard_groups: list[dict[str, str]],
- all_group: dict[str, str],
-):
- assert client.app
- url = client.app.router["list_groups"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
-
- response = await client.get(f"{url}")
- data, error = await assert_status(
- response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK
- )
-
- if not error:
- assert isinstance(data, dict)
-
- assert "me" in data
- _assert_group(data["me"])
- assert data["me"] == primary_group
-
- assert "organizations" in data
- assert isinstance(data["organizations"], list)
- for group in data["organizations"]:
- _assert_group(group)
- assert data["organizations"] == standard_groups
-
- assert "all" in data
- _assert_group(data["all"])
- assert data["all"] == all_group
-
- for group in standard_groups:
- # try to delete a group
- url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}")
- response = await client.delete(f"{url}")
- await assert_status(response, status.HTTP_403_FORBIDDEN)
-
- # try to add some user in the group
- url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}")
- response = await client.post(f"{url}", json={"uid": logged_user["id"]})
- await assert_status(response, status.HTTP_403_FORBIDDEN)
-
- # try to modify the user in the group
- url = client.app.router["update_group_user"].url_for(
- gid=f"{group['gid']}", uid=f"{logged_user['id']}"
- )
- response = await client.patch(
- f"{url}",
- json={"accessRights": {"read": True, "write": True, "delete": True}},
- )
- await assert_status(response, status.HTTP_403_FORBIDDEN)
-
- # try to remove the user from the group
- url = client.app.router["delete_group_user"].url_for(
- gid=f"{group['gid']}", uid=f"{logged_user['id']}"
- )
- response = await client.delete(f"{url}")
- await assert_status(response, status.HTTP_403_FORBIDDEN)
-
-
-@pytest.mark.parametrize(*standard_role_response())
-async def test_group_creation_workflow(
- client: TestClient,
- logged_user: UserInfoDict,
- user_role: UserRole,
- expected: ExpectedResponse,
-):
- assert client.app
- url = client.app.router["create_group"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
-
- new_group = {
- "gid": "4564",
- "label": "Black Sabbath",
- "description": "The founders of Rock'N'Roll",
- "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c",
- }
-
- resp = await client.post(f"{url}", json=new_group)
- data, error = await assert_status(resp, expected.created)
-
- assigned_group = new_group
- if not error:
- assert isinstance(data, dict)
- assigned_group = data
- _assert_group(assigned_group)
- # we get a new gid and the rest keeps the same
- assert assigned_group["gid"] != new_group["gid"]
- for prop in ["label", "description", "thumbnail"]:
- assert assigned_group[prop] == new_group[prop]
- # we get all rights on the group since we are the creator
- assert assigned_group["accessRights"] == {
- "read": True,
- "write": True,
- "delete": True,
- }
-
- # get the groups and check we are part of this new group
- url = client.app.router["list_groups"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
-
- resp = await client.get(f"{url}")
- data, error = await assert_status(
- resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK
- )
- if not error and user_role != UserRole.GUEST:
- assert len(data["organizations"]) == 1
- assert data["organizations"][0] == assigned_group
-
- # check getting one group
- url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.get(f"{url}")
- data, error = await assert_status(
- resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND
- )
- if not error:
- assert data == assigned_group
-
- # modify the group
- modified_group = {"label": "Led Zeppelin"}
- url = client.app.router["update_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.patch(f"{url}", json=modified_group)
- data, error = await assert_status(resp, expected.ok)
- if not error:
- assert data != assigned_group
- _assert_group(data)
- assigned_group.update(**modified_group)
- assert data == assigned_group
- # check getting the group returns the newly modified group
- url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.get(f"{url}")
- data, error = await assert_status(
- resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND
- )
- if not error:
- _assert_group(data)
- assert data == assigned_group
-
- # delete the group
- url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.delete(f"{url}")
- data, error = await assert_status(resp, expected.no_content)
- if not error:
- assert not data
-
- # check deleting the same group again fails
- url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.delete(f"{url}")
- data, error = await assert_status(resp, expected.not_found)
-
- # check getting the group fails
- url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}")
- assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}"
- resp = await client.get(f"{url}")
- data, error = await assert_status(
- resp,
- expected.not_found
- if user_role != UserRole.GUEST
- else status.HTTP_404_NOT_FOUND,
- )
-
-
-@pytest.mark.parametrize(*standard_role_response())
-async def test_add_remove_users_from_group(
- client: TestClient,
- logged_user: UserInfoDict,
- user_role: UserRole,
- expected: ExpectedResponse,
- faker: Faker,
-):
- assert client.app
- new_group = {
- "gid": "5",
- "label": "team awesom",
- "description": "awesomeness is just the summary",
- "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcSQMopBeN0pq2gg6iIZuLGYniFxUdzi7a2LeT1Xg0Lz84bl36Nlqw%26s&sp=1591110539Tbbb022a272bc117e58cca2f2399e83e6b5d4a2d0a7c283330057d7718ae305bd",
- }
-
- # check that our group does not exist
- url = client.app.router["get_all_group_users"].url_for(gid=new_group["gid"])
- assert f"{url}" == f"/{API_VTAG}/groups/{new_group['gid']}/users"
- resp = await client.get(f"{url}")
- data, error = await assert_status(resp, expected.not_found)
-
- # Create group
- url = client.app.router["create_group"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
- resp = await client.post(f"{url}", json=new_group)
- data, error = await assert_status(resp, expected.created)
-
- assigned_group = new_group
- if not error:
- assert isinstance(data, dict)
- assigned_group = data
-
- _assert_group(assigned_group)
-
- # we get a new gid and the rest keeps the same
- assert assigned_group["gid"] != new_group["gid"]
-
- props = ["label", "description", "thumbnail"]
- assert {assigned_group[p] for p in props} == {new_group[p] for p in props}
-
- # we get all rights on the group since we are the creator
- assert assigned_group["accessRights"] == _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS
-
- # check that our user is in the group of users
- get_group_users_url = client.app.router["get_all_group_users"].url_for(
- gid=f"{assigned_group['gid']}"
- )
- assert (
- f"{get_group_users_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users"
- )
- resp = await client.get(f"{get_group_users_url}")
- data, error = await assert_status(resp, expected.ok)
-
- if not error:
- list_of_users = data
- assert len(list_of_users) == 1
- the_owner = list_of_users[0]
- _assert__group_user(logged_user, _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, the_owner)
-
- # create a random number of users and put them in the group
- add_group_user_url = client.app.router["add_group_user"].url_for(
- gid=f"{assigned_group['gid']}"
- )
- assert (
- f"{add_group_user_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users"
- )
- num_new_users = faker.random_int(1, 10)
- created_users_list = []
-
- async with AsyncExitStack() as users_stack:
- for i in range(num_new_users):
- created_users_list.append(
- await users_stack.enter_async_context(NewUser(app=client.app))
- )
-
- # add the user once per email once per id to test both
- params = (
- {"uid": created_users_list[i]["id"]}
- if i % 2 == 0
- else {"email": created_users_list[i]["email"]}
- )
- resp = await client.post(f"{add_group_user_url}", json=params)
- data, error = await assert_status(resp, expected.no_content)
-
- get_group_user_url = client.app.router["get_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}"
- )
- assert (
- f"{get_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{created_users_list[i]['id']}"
- )
- resp = await client.get(f"{get_group_user_url}")
- data, error = await assert_status(resp, expected.ok)
- if not error:
- _assert__group_user(
- created_users_list[i], _DEFAULT_GROUP_READ_ACCESS_RIGHTS, data
- )
- # check list is correct
- resp = await client.get(f"{get_group_users_url}")
- data, error = await assert_status(resp, expected.ok)
- if not error:
- list_of_users = data
-
- # now we should have all the users in the group + the owner
- all_created_users = [*created_users_list, logged_user]
- assert len(list_of_users) == len(all_created_users)
- for actual_user in list_of_users:
-
- expected_users_list = list(
- filter(
- lambda x, ac=actual_user: x["email"] == ac["login"],
- all_created_users,
- )
- )
- assert len(expected_users_list) == 1
- expected_user = expected_users_list[0]
-
- expected_access_rigths = _DEFAULT_GROUP_READ_ACCESS_RIGHTS
- if actual_user["login"] == logged_user["email"]:
- expected_access_rigths = _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS
-
- _assert__group_user(
- expected_user,
- expected_access_rigths,
- actual_user,
- )
- all_created_users.remove(expected_users_list[0])
-
- # modify the user and remove them from the group
- MANAGER_ACCESS_RIGHTS: AccessRightsDict = {
- "read": True,
- "write": True,
- "delete": False,
- }
- for i in range(num_new_users):
- update_group_user_url = client.app.router["update_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}"
- )
- resp = await client.patch(
- f"{update_group_user_url}", json={"accessRights": MANAGER_ACCESS_RIGHTS}
- )
- data, error = await assert_status(resp, expected.ok)
- if not error:
- _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data)
- # check it is there
- get_group_user_url = client.app.router["get_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}"
- )
- resp = await client.get(f"{get_group_user_url}")
- data, error = await assert_status(resp, expected.ok)
- if not error:
- _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data)
- # remove the user from the group
- delete_group_user_url = client.app.router["delete_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}"
- )
- resp = await client.delete(f"{delete_group_user_url}")
- data, error = await assert_status(resp, expected.no_content)
- # do it again to check it is not found anymore
- resp = await client.delete(f"{delete_group_user_url}")
- data, error = await assert_status(resp, expected.not_found)
-
- # check it is not there anymore
- get_group_user_url = client.app.router["get_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}"
- )
- resp = await client.get(f"{get_group_user_url}")
- data, error = await assert_status(resp, expected.not_found)
-
-
-@pytest.mark.parametrize(*standard_role_response())
-async def test_group_access_rights(
- client: TestClient,
- logged_user: UserInfoDict,
- user_role: UserRole,
- expected: ExpectedResponse,
-):
- assert client.app
- # Use-case:
- # 1. create a group
- url = client.app.router["create_group"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
-
- new_group = {
- "gid": "4564",
- "label": f"this is user {logged_user['id']} group",
- "description": f"user {logged_user['email']} is the owner of that one",
- "thumbnail": None,
- }
-
- resp = await client.post(f"{url}", json=new_group)
- data, error = await assert_status(resp, expected.created)
- if not data:
- # role cannot create a group so stop here
- return
- assigned_group = data
-
- async with AsyncExitStack() as users_stack:
- # 1. have 2 users
- users = [
- await users_stack.enter_async_context(NewUser(app=client.app))
- for _ in range(2)
- ]
-
- # 2. add the users to the group
- add_group_user_url = client.app.router["add_group_user"].url_for(
- gid=f"{assigned_group['gid']}"
- )
- assert (
- f"{add_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users"
- )
- for i, user in enumerate(users):
- params = {"uid": user["id"]} if i % 2 == 0 else {"email": user["email"]}
- resp = await client.post(f"{add_group_user_url}", json=params)
- data, error = await assert_status(resp, expected.no_content)
- # 3. user 1 shall be a manager
- patch_group_user_url = client.app.router["update_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}"
- )
- assert (
- f"{patch_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}"
- )
- params = {"accessRights": {"read": True, "write": True, "delete": False}}
- resp = await client.patch(f"{patch_group_user_url}", json=params)
- data, error = await assert_status(resp, expected.ok)
- # 4. user 2 shall be a member
- patch_group_user_url = client.app.router["update_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}"
- )
- assert (
- f"{patch_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}"
- )
- params = {"accessRights": {"read": True, "write": False, "delete": False}}
- resp = await client.patch(f"{patch_group_user_url}", json=params)
- data, error = await assert_status(resp, expected.ok)
-
- # let's login as user 1
- # login
- url = client.app.router["auth_login"].url_for()
- resp = await client.post(
- f"{url}",
- json={
- "email": users[0]["email"],
- "password": users[0]["raw_password"],
- },
- )
- await assert_status(resp, expected.ok)
- # check as a manager I can remove user 2
- delete_group_user_url = client.app.router["delete_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}"
- )
- assert (
- f"{delete_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}"
- )
- resp = await client.delete(f"{delete_group_user_url}")
- data, error = await assert_status(resp, expected.no_content)
- # as a manager I can add user 2 again
- resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]})
- data, error = await assert_status(resp, expected.no_content)
- # as a manager I cannot delete the group
- url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}")
- resp = await client.delete(f"{url}")
- data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN)
-
- # now log in as user 2
- # login
- url = client.app.router["auth_login"].url_for()
- resp = await client.post(
- f"{url}",
- json={
- "email": users[1]["email"],
- "password": users[1]["raw_password"],
- },
- )
- await assert_status(resp, expected.ok)
- # as a member I cannot remove user 1
- delete_group_user_url = client.app.router["delete_group_user"].url_for(
- gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}"
- )
- assert (
- f"{delete_group_user_url}"
- == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}"
- )
- resp = await client.delete(f"{delete_group_user_url}")
- data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN)
- # as a member I cannot add user 1
- resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]})
- data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN)
- # as a member I cannot delete the grouop
- url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}")
- resp = await client.delete(f"{url}")
- data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN)
-
-
-@pytest.mark.parametrize(*standard_role_response())
-async def test_add_user_gets_added_to_group(
- client: TestClient,
- standard_groups: list[dict[str, str]],
- user_role: UserRole,
- expected: ExpectedResponse,
-):
- assert client.app
- async with AsyncExitStack() as users_stack:
- for email in (
- "good@sparc.io",
- "bad@bad.com",
- "bad@osparc.com",
- "good@black.com",
- "bad@blanco.com",
- ):
- user = await users_stack.enter_async_context(
- LoggedUser(
- client,
- user_data={"role": user_role.name, "email": email},
- check_if_succeeds=user_role != UserRole.ANONYMOUS,
- )
- )
- await auto_add_user_to_groups(client.app, user["id"])
-
- url = client.app.router["list_groups"].url_for()
- assert f"{url}" == f"/{API_VTAG}/groups"
-
- resp = await client.get(f"{url}")
- data, error = await assert_status(
- resp, status.HTTP_200_OK if user_role == UserRole.GUEST else expected.ok
- )
- if not error:
- assert len(data["organizations"]) == (0 if "bad" in email else 1)
-
- # NOTE: here same email are used for different users! Therefore sessions get mixed!
- await clean_auth_policy_cache(client.app)
-
-
-@pytest.fixture
-async def group_where_logged_user_is_the_owner(
- client: TestClient, logged_user: UserInfoDict
-) -> AsyncIterator[dict[str, Any]]:
- assert client.app
- group = await create_user_group(
- app=client.app,
- user_id=logged_user["id"],
- new_group={
- "gid": "6543",
- "label": f"this is user {logged_user['id']} group",
- "description": f"user {logged_user['email']} is the owner of that one",
- "thumbnail": None,
- },
- )
- yield group
- await delete_user_group(client.app, logged_user["id"], group["gid"])
-
-
-@pytest.mark.acceptance_test(
- "Fixes 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812"
-)
-@pytest.mark.parametrize("user_role", [UserRole.USER])
-async def test_adding_user_to_group_with_upper_case_email(
- client: TestClient,
- user_role: UserRole,
- group_where_logged_user_is_the_owner: dict[str, str],
- faker: Faker,
-):
- assert client.app
- url = client.app.router["add_group_user"].url_for(
- gid=f"{group_where_logged_user_is_the_owner['gid']}"
- )
- # adding a user to group with the email in capital letters
- # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812
- async with NewUser(
- app=client.app,
- ) as registered_user:
- assert registered_user["email"] # <--- this email is lower case
-
- response = await client.post(
- f"{url}",
- json={
- "email": registered_user["email"].upper()
- }, # <--- email in upper case
- )
- data, error = await assert_status(response, status.HTTP_204_NO_CONTENT)
-
- assert not data
- assert not error
diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
index b2fc82f44e6..354a30ef1d9 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
@@ -8,7 +8,7 @@
import sqlalchemy as sa
from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine
from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY
-from simcore_service_webserver.groups._classifiers import GroupClassifierRepository
+from simcore_service_webserver.groups._classifiers_api import GroupClassifierRepository
from sqlalchemy.sql import text
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
index 26d6f0cfb0e..95a2671739b 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
@@ -33,10 +33,8 @@
from simcore_postgres_database.models.projects_to_products import projects_to_products
from simcore_service_webserver._meta import api_version_prefix
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.groups.api import (
- auto_add_user_to_product_group,
- get_product_group_for_user,
-)
+from simcore_service_webserver.groups._groups_api import get_product_group_for_user
+from simcore_service_webserver.groups.api import auto_add_user_to_product_group
from simcore_service_webserver.groups.exceptions import GroupNotFoundError
from simcore_service_webserver.products.api import get_product
from simcore_service_webserver.projects._permalink_api import ProjectPermalink
@@ -294,9 +292,14 @@ async def logged_user_registed_in_two_products(
# registered to osparc
osparc_product = get_product(client.app, "osparc")
assert osparc_product.group_id
- assert await get_product_group_for_user(
- client.app, user_id=logged_user["id"], product_gid=osparc_product.group_id
+
+ group, _ = await get_product_group_for_user(
+ # should not raise
+ client.app,
+ user_id=logged_user["id"],
+ product_gid=osparc_product.group_id,
)
+ assert group.gid == osparc_product.group_id
# not registered to s4l
s4l_product = get_product(client.app, s4l_products_db_name)
@@ -312,9 +315,13 @@ async def logged_user_registed_in_two_products(
client.app, user_id=logged_user["id"], product_name=s4l_products_db_name
)
- assert await get_product_group_for_user(
- client.app, user_id=logged_user["id"], product_gid=s4l_product.group_id
+ group, _ = await get_product_group_for_user(
+ # should not raise
+ client.app,
+ user_id=logged_user["id"],
+ product_gid=s4l_product.group_id,
)
+ assert group.gid == s4l_product.group_id
@pytest.mark.parametrize(
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
index 762642dfb5c..0ece8630d0f 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
@@ -9,7 +9,7 @@
import pytest
from aiohttp.test_utils import TestClient
from faker import Faker
-from models_library.api_schemas_webserver.users import ProfileGet
+from models_library.api_schemas_webserver.users import MyProfileGet
from models_library.products import ProductName
from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_error, assert_status
@@ -494,7 +494,7 @@ async def test_registraton_with_invitation_for_trial_account(
url = client.app.router["get_my_profile"].url_for()
response = await client.get(url.path)
data, _ = await assert_status(response, status.HTTP_200_OK)
- profile = ProfileGet.model_validate(data)
+ profile = MyProfileGet.model_validate(data)
expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS)
assert profile.expiration_date
diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py
index 4e2829c6fce..a872b98858c 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_users.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_users.py
@@ -18,7 +18,7 @@
from aiopg.sa.connection import SAConnection
from faker import Faker
from models_library.api_schemas_webserver.auth import AccountRequestInfo
-from models_library.api_schemas_webserver.users import ProfileGet
+from models_library.api_schemas_webserver.users import MyProfileGet
from models_library.generics import Envelope
from psycopg2 import OperationalError
from pytest_simcore.helpers.assert_checks import assert_status
@@ -117,7 +117,7 @@ async def test_get_profile(
resp = await client.get(f"{url}")
data, error = await assert_status(resp, status.HTTP_200_OK)
- resp_model = Envelope[ProfileGet].model_validate(await resp.json())
+ resp_model = Envelope[MyProfileGet].model_validate(await resp.json())
assert resp_model.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data
assert resp_model.error is None
@@ -202,7 +202,7 @@ async def test_profile_workflow(
url = client.app.router["get_my_profile"].url_for()
resp = await client.get(f"{url}")
data, _ = await assert_status(resp, status.HTTP_200_OK)
- my_profile = ProfileGet.model_validate(data)
+ my_profile = MyProfileGet.model_validate(data)
url = client.app.router["update_my_profile"].url_for()
resp = await client.patch(
@@ -218,7 +218,7 @@ async def test_profile_workflow(
url = client.app.router["get_my_profile"].url_for()
resp = await client.get(f"{url}")
data, _ = await assert_status(resp, status.HTTP_200_OK)
- updated_profile = ProfileGet.model_validate(data)
+ updated_profile = MyProfileGet.model_validate(data)
assert updated_profile.first_name != my_profile.first_name
assert updated_profile.last_name == my_profile.last_name
diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py
index 37217d58519..991d7fd8d56 100644
--- a/services/web/server/tests/unit/with_dbs/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/conftest.py
@@ -19,7 +19,6 @@
from copy import deepcopy
from decimal import Decimal
from pathlib import Path
-from typing import Any
from unittest import mock
from unittest.mock import AsyncMock, MagicMock
@@ -47,7 +46,7 @@
from pytest_simcore.helpers.faker_factories import random_product
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
-from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
+from pytest_simcore.helpers.webserver_login import UserInfoDict
from pytest_simcore.helpers.webserver_parametrizations import MockedStorageSubsystem
from pytest_simcore.helpers.webserver_projects import NewProject
from redis import Redis
@@ -69,12 +68,6 @@
from simcore_service_webserver._constants import INDEX_RESOURCE_NAME
from simcore_service_webserver.application import create_application
from simcore_service_webserver.db.plugin import get_database_engine
-from simcore_service_webserver.groups.api import (
- add_user_in_group,
- create_user_group,
- delete_user_group,
- list_user_groups_with_read_access,
-)
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.statics._constants import (
FRONTEND_APP_DEFAULT,
@@ -592,97 +585,6 @@ async def redis_locks_client(
# Moved to packages/pytest-simcore/src/pytest_simcore/websocket_client.py
-# USER GROUP FIXTURES -------------------------------------------------------
-
-
-@pytest.fixture
-async def primary_group(
- client: TestClient,
- logged_user: UserInfoDict,
-) -> dict[str, Any]:
- assert client.app
- primary_group, _, _ = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
- return primary_group
-
-
-@pytest.fixture
-async def standard_groups(
- client: TestClient,
- logged_user: UserInfoDict,
-) -> AsyncIterator[list[dict[str, Any]]]:
- assert client.app
- sparc_group = {
- "gid": "5", # this will be replaced
- "label": "SPARC",
- "description": "Stimulating Peripheral Activity to Relieve Conditions",
- "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png",
- "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"},
- }
- team_black_group = {
- "gid": "5", # this will be replaced
- "label": "team Black",
- "description": "THE incredible black team",
- "thumbnail": None,
- "inclusionRules": {"email": r"@(black)+\.(io|com)$"},
- }
-
- # create a separate account to own standard groups
- async with NewUser(
- {"name": f"{logged_user['name']}_groups_owner", "role": "USER"}, client.app
- ) as owner_user:
- # creates two groups
- sparc_group = await create_user_group(
- app=client.app,
- user_id=owner_user["id"],
- new_group=sparc_group,
- )
- team_black_group = await create_user_group(
- app=client.app,
- user_id=owner_user["id"],
- new_group=team_black_group,
- )
-
- # adds logged_user to sparc group
- await add_user_in_group(
- app=client.app,
- user_id=owner_user["id"],
- gid=sparc_group["gid"],
- new_user_id=logged_user["id"],
- )
-
- # adds logged_user to team-black group
- await add_user_in_group(
- app=client.app,
- user_id=owner_user["id"],
- gid=team_black_group["gid"],
- new_user_email=logged_user["email"],
- )
-
- _, std_groups, _ = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
-
- yield std_groups
-
- # clean groups
- await delete_user_group(client.app, owner_user["id"], sparc_group["gid"])
- await delete_user_group(client.app, owner_user["id"], team_black_group["gid"])
-
-
-@pytest.fixture
-async def all_group(
- client: TestClient,
- logged_user: UserInfoDict,
-) -> dict[str, str]:
- assert client.app
- _, _, all_group = await list_user_groups_with_read_access(
- client.app, logged_user["id"]
- )
- return all_group
-
-
@pytest.fixture
def mock_dynamic_scheduler_rabbitmq(mocker: MockerFixture) -> None:
mocker.patch(