From 355e696bd20a0eaf29e35427522a68f4916d447b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:00:21 +0200 Subject: [PATCH 01/55] doc --- SERVICES.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 SERVICES.md diff --git a/SERVICES.md b/SERVICES.md new file mode 100644 index 00000000000..4cd69a157c8 --- /dev/null +++ b/SERVICES.md @@ -0,0 +1,61 @@ +# services +> +> Auto generated on `2025-04-22 14:55:44` using +```cmd +cd osparc-simcore +python ./scripts/echo_services_markdown.py +``` +| Name|Files| | +| ----------|----------|---------- | +| **AGENT**|| | +| |[services/agent/Dockerfile](./services/agent/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/agent)](https://hub.docker.com/r/itisfoundation/agent/tags) | +| **API-SERVER**|| | +| |[services/api-server/openapi.json](./services/api-server/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/api-server/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/api-server/openapi.json) | +| |[services/api-server/Dockerfile](./services/api-server/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/api-server)](https://hub.docker.com/r/itisfoundation/api-server/tags) | +| **AUTOSCALING**|| | +| |[services/autoscaling/Dockerfile](./services/autoscaling/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/autoscaling)](https://hub.docker.com/r/itisfoundation/autoscaling/tags) | +| **CATALOG**|| | +| |[services/catalog/openapi.json](./services/catalog/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/catalog/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/catalog/openapi.json) | +| |[services/catalog/Dockerfile](./services/catalog/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/catalog)](https://hub.docker.com/r/itisfoundation/catalog/tags) | +| **CLUSTERS-KEEPER**|| | +| |[services/clusters-keeper/Dockerfile](./services/clusters-keeper/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/clusters-keeper)](https://hub.docker.com/r/itisfoundation/clusters-keeper/tags) | +| **DASK-SIDECAR**|| | +| |[services/dask-sidecar/Dockerfile](./services/dask-sidecar/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dask-sidecar)](https://hub.docker.com/r/itisfoundation/dask-sidecar/tags) | +| **DATCORE-ADAPTER**|| | +| |[services/datcore-adapter/Dockerfile](./services/datcore-adapter/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/datcore-adapter)](https://hub.docker.com/r/itisfoundation/datcore-adapter/tags) | +| **DIRECTOR**|| | +| |[services/director/src/simcore_service_director/api/v0/openapi.yaml](./services/director/src/simcore_service_director/api/v0/openapi.yaml)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director/src/simcore_service_director/api/v0/openapi.yaml) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director/src/simcore_service_director/api/v0/openapi.yaml) | +| |[services/director/Dockerfile](./services/director/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/director)](https://hub.docker.com/r/itisfoundation/director/tags) | +| **DIRECTOR-V2**|| | +| |[services/director-v2/openapi.json](./services/director-v2/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director-v2/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director-v2/openapi.json) | +| |[services/director-v2/Dockerfile](./services/director-v2/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/director-v2)](https://hub.docker.com/r/itisfoundation/director-v2/tags) | +| **DOCKER-API-PROXY**|| | +| |[services/docker-api-proxy/Dockerfile](./services/docker-api-proxy/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/docker-api-proxy)](https://hub.docker.com/r/itisfoundation/docker-api-proxy/tags) | +| **DYNAMIC-SCHEDULER**|| | +| |[services/dynamic-scheduler/openapi.json](./services/dynamic-scheduler/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-scheduler/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-scheduler/openapi.json) | +| |[services/dynamic-scheduler/Dockerfile](./services/dynamic-scheduler/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dynamic-scheduler)](https://hub.docker.com/r/itisfoundation/dynamic-scheduler/tags) | +| **DYNAMIC-SIDECAR**|| | +| |[services/dynamic-sidecar/openapi.json](./services/dynamic-sidecar/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-sidecar/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-sidecar/openapi.json) | +| |[services/dynamic-sidecar/Dockerfile](./services/dynamic-sidecar/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dynamic-sidecar)](https://hub.docker.com/r/itisfoundation/dynamic-sidecar/tags) | +| **EFS-GUARDIAN**|| | +| |[services/efs-guardian/Dockerfile](./services/efs-guardian/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/efs-guardian)](https://hub.docker.com/r/itisfoundation/efs-guardian/tags) | +| **INVITATIONS**|| | +| |[services/invitations/openapi.json](./services/invitations/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/invitations/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/invitations/openapi.json) | +| |[services/invitations/Dockerfile](./services/invitations/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/invitations)](https://hub.docker.com/r/itisfoundation/invitations/tags) | +| **MIGRATION**|| | +| |[services/migration/Dockerfile](./services/migration/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/migration)](https://hub.docker.com/r/itisfoundation/migration/tags) | +| **PAYMENTS**|| | +| |[services/payments/openapi.json](./services/payments/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/payments/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/payments/openapi.json) | +| |[services/payments/Dockerfile](./services/payments/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/payments)](https://hub.docker.com/r/itisfoundation/payments/tags) | +| **RESOURCE-USAGE-TRACKER**|| | +| |[services/resource-usage-tracker/openapi.json](./services/resource-usage-tracker/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/resource-usage-tracker/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/resource-usage-tracker/openapi.json) | +| |[services/resource-usage-tracker/Dockerfile](./services/resource-usage-tracker/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/resource-usage-tracker)](https://hub.docker.com/r/itisfoundation/resource-usage-tracker/tags) | +| **STATIC-WEBSERVER**|| | +| |[services/static-webserver/client/tools/qooxdoo-kit/builder/Dockerfile](./services/static-webserver/client/tools/qooxdoo-kit/builder/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/static-webserver)](https://hub.docker.com/r/itisfoundation/static-webserver/tags) | +| **STORAGE**|| | +| |[services/storage/openapi.json](./services/storage/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/storage/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/storage/openapi.json) | +| |[services/storage/Dockerfile](./services/storage/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/storage)](https://hub.docker.com/r/itisfoundation/storage/tags) | +| **WEB**|| | +| |[services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml](./services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) | +| |[services/web/Dockerfile](./services/web/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/webserver)](https://hub.docker.com/r/itisfoundation/webserver/tags) | +| || | From f9efd1e93513f8be6683873ecf3f923e4f12d0d5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 19:46:34 +0200 Subject: [PATCH 02/55] =?UTF-8?q?=F0=9F=8E=A8=20[Admin]=20Add=20endpoint?= =?UTF-8?q?=20to=20list=20users=20for=20admin=20with=20approval=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/specs/web-server/_users.py | 13 ++++ .../src/common_library/users_enums.py | 6 ++ .../api_schemas_webserver/users.py | 14 ++++ .../api/v0/openapi.yaml | 68 +++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index d0d733a01e3..9dbcbc5fb5c 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -7,6 +7,7 @@ from enum import Enum from typing import Annotated +from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.users import ( MyPermissionGet, @@ -16,11 +17,13 @@ MyTokenGet, UserForAdminGet, UserGet, + UsersForAdminListQueryParams, UsersForAdminSearchQueryParams, UsersSearch, ) from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope +from models_library.rest_pagination import Page from models_library.user_preferences import PreferenceIdentifier from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.users._common.schemas import PreRegisteredUserGet @@ -143,6 +146,16 @@ async def search_users(_body: UsersSearch): ... _extra_tags: list[str | Enum] = ["admin"] +@router.get( + "/admin/users", + response_model=Envelope[Page[UserForAdminGet]], + tags=_extra_tags, +) +async def list_users_for_admin( + _query: Annotated[as_query(UsersForAdminListQueryParams), Depends()], +): ... + + @router.get( "/admin/users:search", response_model=Envelope[list[UserForAdminGet]], diff --git a/packages/common-library/src/common_library/users_enums.py b/packages/common-library/src/common_library/users_enums.py index 7ebe4a617e9..2dc22c7e082 100644 --- a/packages/common-library/src/common_library/users_enums.py +++ b/packages/common-library/src/common_library/users_enums.py @@ -57,3 +57,9 @@ class UserStatus(str, Enum): BANNED = "BANNED" # This user is inactive because it was marked for deletion DELETED = "DELETED" + + +class AccountApprovalStatus(str, Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" 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 1facf8bb1e9..bfd2ab23d56 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 @@ -8,6 +8,8 @@ from common_library.dict_tools import remap_keys from common_library.users_enums import UserStatus from models_library.groups import AccessRightsDict +from models_library.rest_filters import Filters +from models_library.rest_pagination import PageQueryParameters from pydantic import ( ConfigDict, EmailStr, @@ -238,6 +240,18 @@ def from_domain_model(cls, data): return cls.model_validate(data, from_attributes=True) +class UsersForAdminListFilter(Filters): + approved: Annotated[ + bool | None, + Field( + description="Filter users by approval status: True for approved, False for pending/rejected, None for all" + ), + ] = None + + +class UsersForAdminListQueryParams(PageQueryParameters, UsersForAdminListFilter): ... + + class UsersForAdminSearchQueryParams(RequestParameters): email: Annotated[ str, 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 4b461f1e9e8..963258af2a6 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 @@ -1365,6 +1365,43 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_UserGet__' + /v0/admin/users: + get: + tags: + - users + - admin + summary: List Users For Admin + operationId: list_users_for_admin + parameters: + - name: approved + in: query + required: false + schema: + anyOf: + - type: boolean + - type: 'null' + title: Approved + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Page_UserForAdminGet__' /v0/admin/users:search: get: tags: @@ -10178,6 +10215,19 @@ components: title: Error type: object title: Envelope[NodeRetrieved] + Envelope_Page_UserForAdminGet__: + properties: + data: + anyOf: + - $ref: '#/components/schemas/Page_UserForAdminGet_' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[Page[UserForAdminGet]] Envelope_PaymentMethodGet_: properties: data: @@ -13760,6 +13810,24 @@ components: - _links - data title: Page[ServiceRunGet] + Page_UserForAdminGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/UserForAdminGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[UserForAdminGet] PatchRequestBody: properties: value: From 8c903e1773bf16b36a945a27e6577a11ce02c232 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 19:58:13 +0200 Subject: [PATCH 03/55] drafts tests --- .../tests/unit/with_dbs/03/test_users.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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 79d2b82b054..eab88b52997 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 @@ -750,6 +750,105 @@ async def test_search_and_pre_registration( } +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_list_users_for_admin( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, +): + assert client.app + + # Create some pre-registered users + pre_registered_users = [] + for _ in range(3): + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post("/v0/admin/users:pre-register", json=form_data) + assert resp.status == status.HTTP_200_OK + pre_registered_data = await resp.json() + pre_registered_users.append(pre_registered_data) + + # Register one of the pre-registered users + new_user = await simcore_service_webserver.login._auth_service.create_user( + client.app, + email=pre_registered_users[0]["data"]["email"], + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + # Test pagination (page 1, limit 2) + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get(f"{url}", params={"page": 1, "per_page": 2}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify pagination structure + assert "items" in data + assert "pagination" in data + assert data["pagination"]["page"] == 1 + assert data["pagination"]["per_page"] == 2 + assert data["pagination"]["total"] >= 1 # At least the logged user + + # Test pagination (page 2, limit 2) + resp = await client.get(f"{url}", params={"page": 2, "per_page": 2}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["pagination"]["page"] == 2 + + # Test filtering by approval status (only approved users) + resp = await client.get(f"{url}", params={"approved": True}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # All items should be registered users with status + for item in data["items"]: + user = UserForAdminGet(**item) + assert user.registered is True + assert user.status is not None + + # Test filtering by approval status (only non-approved users) + resp = await client.get(f"{url}", params={"approved": False}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # All items should be non-registered or non-approved users + assert len(data["items"]) >= 2 # We created at least 2 non-registered users + for item in data["items"]: + user = UserForAdminGet(**item) + assert user.registered is False or user.status != UserStatus.ACTIVE + + # Combine pagination and filtering + resp = await client.get( + f"{url}", params={"approved": True, "page": 1, "per_page": 1} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data["items"]) == 1 + assert data["pagination"]["page"] == 1 + assert data["pagination"]["per_page"] == 1 + + # Verify content of a specific user + resp = await client.get(f"{url}", params={"approved": True}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Find the newly registered user in the list + registered_user = next( + (item for item in data["items"] if item["email"] == new_user["email"]), + None, + ) + assert registered_user is not None + + user = UserForAdminGet(**registered_user) + assert user.registered is True + assert user.status == UserStatus.ACTIVE + assert user.email == new_user["email"] + + @pytest.mark.parametrize( "institution_key", [ From 7952c8bf9c719cc961236e582ca14f5421fc78bf Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 20:08:21 +0200 Subject: [PATCH 04/55] =?UTF-8?q?=F0=9F=8E=A8=20[Admin]=20Implement=20endp?= =?UTF-8?q?oint=20to=20list=20users=20for=20admin=20with=20pagination=20su?= =?UTF-8?q?pport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/_users_rest.py | 38 ++++++++++++++++++- .../utils_aiohttp.py | 8 ++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 43cbb4f8422..5559b8e681d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -5,10 +5,14 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, + UserForAdminGet, UserGet, + UsersForAdminListQueryParams, UsersForAdminSearchQueryParams, UsersSearch, ) +from models_library.rest_pagination import Page +from models_library.rest_pagination_utils import paginate_data from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -29,7 +33,7 @@ from ..products import products_web from ..products.models import Product from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response +from ..utils_aiohttp import create_json_response_from_page, envelope_json_response from . import _users_service from ._common.schemas import PreRegisteredUserGet, UsersRequestContext from .exceptions import ( @@ -160,6 +164,38 @@ async def search_users(request: web.Request) -> web.Response: _RESPONSE_MODEL_MINIMAL_POLICY["exclude_none"] = True +@routes.get(f"/{API_VTAG}/admin/users", name="list_users_for_admin") +@login_required +@permission_required("admin.users.read") +@_handle_users_exceptions +async def list_users_for_admin(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + query_params = parse_request_query_parameters_as( + UsersForAdminListQueryParams, request + ) + + users, total_count = await _users_service.list_users_as_admin( + request.app, + filter_approved=query_params.approved, + limit=query_params.limit, + offset=query_params.offset, + ) + + page = Page[UserForAdminGet].model_validate( + paginate_data( + chunk=users, + request_url=request.url, + total=total_count, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + + return create_json_response_from_page(page) + + @routes.get(f"/{API_VTAG}/admin/users:search", name="search_users_for_admin") @login_required @permission_required("admin.users.read") diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index 10f28669c8a..163b5d4382b 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -9,6 +9,7 @@ from common_library.json_serialization import json_dumps from common_library.network import is_ip_address from models_library.generics import Envelope +from models_library.rest_pagination import ItemT, Page from pydantic import BaseModel, Field from servicelib.common_headers import X_FORWARDED_PROTO from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON @@ -77,6 +78,13 @@ def envelope_json_response( ) +def create_json_response_from_page(page: Page[ItemT]): + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + # # Special models and responses for the front-end # From b43e82958b7166e7d8ff09ecbe67c179b9752bec Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 20:54:27 +0200 Subject: [PATCH 05/55] =?UTF-8?q?=F0=9F=8E=A8=20[Database]=20Add=20account?= =?UTF-8?q?=5Frequest=5Fstatus=20column=20to=20users=5Fpre=5Fregistration?= =?UTF-8?q?=5Fdetails=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/common_library/users_enums.py | 4 +- ...c961d_new_account_request_status_column.py | 38 +++++++++++++++++++ .../models/users_details.py | 9 +++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py diff --git a/packages/common-library/src/common_library/users_enums.py b/packages/common-library/src/common_library/users_enums.py index 2dc22c7e082..4e0bcfdd92f 100644 --- a/packages/common-library/src/common_library/users_enums.py +++ b/packages/common-library/src/common_library/users_enums.py @@ -59,7 +59,9 @@ class UserStatus(str, Enum): DELETED = "DELETED" -class AccountApprovalStatus(str, Enum): +class AccountRequestStatus(str, Enum): + """Status of an account request""" + PENDING = "PENDING" APPROVED = "APPROVED" REJECTED = "REJECTED" diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py new file mode 100644 index 00000000000..0d02c138fbb --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py @@ -0,0 +1,38 @@ +"""new account_request_status column + +Revision ID: 5a51c6cc961d +Revises: cf8f743fd0b7 +Create Date: 2025-04-22 18:28:21.850932+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5a51c6cc961d" +down_revision = "cf8f743fd0b7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_status", + sa.Enum("PENDING", "APPROVED", "REJECTED", name="accountrequeststatus"), + server_default=sa.text("'PENDING'::account_request_status"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users_pre_registration_details", "account_request_status") + + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index 555e623dbdc..f38fda3da24 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -1,4 +1,5 @@ import sqlalchemy as sa +from common_library.users_enums import AccountRequestStatus from sqlalchemy.dialects import postgresql from ._common import ( @@ -53,6 +54,14 @@ doc="Phone provided on pre-registration" "NOTE: this is not copied upon registration since it needs to be confirmed", ), + # Account request status + sa.Column( + "account_request_status", + sa.Enum(AccountRequestStatus), + nullable=False, + server_default=sa.text("'PENDING'::account_request_status"), + doc="Status of the account request: PENDING, APPROVED, REJECTED", + ), # Billable address columns: sa.Column("institution", sa.String(), doc="the name of a company or university"), sa.Column("address", sa.String()), From b12f6fbd70a70120602c2fbed7178cab2c356a93 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 20:59:49 +0200 Subject: [PATCH 06/55] drafts repository --- .../users/_users_repository.py | 114 +++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index a5be330f25b..e4793b44b0b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -3,7 +3,7 @@ import sqlalchemy as sa from aiohttp import web -from common_library.users_enums import UserRole +from common_library.users_enums import AccountRequestStatus, UserRole from models_library.groups import GroupID from models_library.products import ProductName from models_library.users import ( @@ -487,6 +487,118 @@ async def is_user_in_product_name( return value is not None +async def list_users_for_admin( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + filter_approved: bool | None = None, + limit: int = 50, + offset: int = 0, + include_deleted: bool = False, +) -> tuple[list[dict[str, Any]], int]: + """ + Gets users data for admin with pagination support using SQLAlchemy expressions + + Args: + engine: The database engine + connection: Optional existing connection to reuse + filter_approved: If set, filters users by their approval status + limit: Maximum number of users to return + offset: Number of users to skip for pagination + include_deleted: Whether to include users marked as deleted + + Returns: + Tuple of (list of user data, total count) + """ + + # Define the join between users and users_pre_registration_details + joined_tables = users.outerjoin( + users_pre_registration_details, + users.c.id == users_pre_registration_details.c.user_id, + ) + + # Basic where clause - exclude deleted by default + where_conditions = [] + if not include_deleted: + where_conditions.append(users.c.status != UserStatus.DELETED) + + # Add filtering by approval status if requested + if filter_approved is not None: + if filter_approved: + where_conditions.append( + users_pre_registration_details.c.account_request_status + == AccountRequestStatus.APPROVED + ) + else: + where_conditions.append( + users_pre_registration_details.c.account_request_status + != AccountRequestStatus.APPROVED + ) + + # Combine all conditions with AND + where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() + + # Count query to get total number of users + count_query = ( + sa.select(sa.func.count().label("total")) + .select_from(joined_tables) + .where(where_clause) + ) + + # Main query to get user data + main_query = ( + sa.select( + users.c.id.label("user_id"), + users.c.name, + sa.case( + (users.c.email.is_(None), users_pre_registration_details.c.pre_email), + else_=users.c.email, + ).label("email"), + sa.case( + ( + users.c.first_name.is_(None), + users_pre_registration_details.c.pre_first_name, + ), + else_=users.c.first_name, + ).label("first_name"), + sa.case( + ( + users.c.last_name.is_(None), + users_pre_registration_details.c.pre_last_name, + ), + else_=users.c.last_name, + ).label("last_name"), + users.c.status, + users.c.created, + users_pre_registration_details.c.institution, + users_pre_registration_details.c.pre_phone.label("phone"), + users_pre_registration_details.c.address, + users_pre_registration_details.c.city, + users_pre_registration_details.c.state, + users_pre_registration_details.c.postal_code, + users_pre_registration_details.c.country, + users_pre_registration_details.c.extras, + users_pre_registration_details.c.account_request_status, + ) + .select_from(joined_tables) + .where(where_clause) + .order_by(users.c.created.desc()) # newest first + .limit(limit) + .offset(offset) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + # Get total count + count_result = await conn.execute(count_query) + total_count = count_result.scalar() + + # Get user records + result = await conn.execute(main_query) + records = result.mappings().all() + + return list(records), total_count + + # # USER PROFILE # From ca24939e7343f1045c3f9882ea723fb22915086c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 22 Apr 2025 21:00:50 +0200 Subject: [PATCH 07/55] drafts service layer --- .../users/_users_service.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 29361eb8f09..2869cb7173d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -199,6 +199,51 @@ async def is_user_in_product( ) +async def list_users_as_admin( + app: web.Application, + *, + filter_approved: bool | None = None, + limit: int = 50, + offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """ + Get a paginated list of users for admin view with filtering options. + + Args: + app: The web application instance + filter_approved: If set, filters users by their approval status + limit: Maximum number of users to return + offset: Number of users to skip for pagination + + Returns: + A tuple containing (list of user dictionaries, total count of users) + """ + engine = get_asyncpg_engine(app) + + # Get user data with pagination + users_data, total_count = await _users_repository.list_users_for_admin( + engine=engine, filter_approved=filter_approved, limit=limit, offset=offset + ) + + # For each user, append additional information if needed + result = [] + for user in users_data: + # Add any additional processing needed for admin view + user_dict = dict(user) + + # Add products information if needed + user_id = user.get("user_id") + if user_id: + products = await _users_repository.get_user_products( + engine, user_id=user_id + ) + user_dict["products"] = [p.product_name for p in products] + + result.append(user_dict) + + return result, total_count + + # # GET USER PROPERTIES # From 3307c309546301f94a4ff0dedcfc19e6d89b36da Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Apr 2025 08:58:12 +0200 Subject: [PATCH 08/55] cleanup --- api/specs/web-server/_auth.py | 4 ++-- .../models/users_details.py | 2 +- .../simcore_service_webserver/api/v0/openapi.yaml | 2 +- .../login/_controller/rest/preregistration.py | 12 +++--------- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 40f08406084..c922c92073d 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -260,8 +260,8 @@ async def email_confirmation(code: str): @router.get( "/auth/captcha", - operation_id="request_captcha", + operation_id="generate_captcha", status_code=status.HTTP_200_OK, responses={status.HTTP_200_OK: {"content": {"image/png": {}}}}, ) -async def request_captcha(): ... +async def generate_captcha(): ... diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index f38fda3da24..817dc53f583 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -54,7 +54,7 @@ doc="Phone provided on pre-registration" "NOTE: this is not copied upon registration since it needs to be confirmed", ), - # Account request status + # Approval status of the account request sa.Column( "account_request_status", sa.Enum(AccountRequestStatus), 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 963258af2a6..56278ab904b 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 @@ -372,7 +372,7 @@ paths: tags: - auth summary: Request Captcha - operationId: request_captcha + operationId: generate_captcha responses: '200': description: Successful Response diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index 3f8d72309c4..4306173b548 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -57,10 +57,7 @@ def _get_ipinfo(request: web.Request) -> dict[str, Any]: } -@routes.post( - f"/{API_VTAG}/auth/request-account", - name="request_product_account", -) +@routes.post(f"/{API_VTAG}/auth/request-account", name="request_product_account") @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) async def request_product_account(request: web.Request): product = products_web.get_current_product(request) @@ -148,12 +145,9 @@ async def unregister_account(request: web.Request): return response -@routes.get( - f"/{API_VTAG}/auth/captcha", - name="request_captcha", -) +@routes.get(f"/{API_VTAG}/auth/captcha", name="generate_captcha") @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def request_captcha(request: web.Request): +async def generate_captcha(request: web.Request): session = await get_session(request) captcha_text, image_data = await _preregistration_service.generate_captcha() From 377b43ffae0cdcd27de66d1a9f5551158e3ded1f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:21:28 +0200 Subject: [PATCH 09/55] =?UTF-8?q?=E2=9C=A8=20[Users]=20Enhance=20admin=20u?= =?UTF-8?q?ser=20listing=20with=20invited=5Fby=20field=20and=20registratio?= =?UTF-8?q?n=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...c961d_new_account_request_status_column.py | 38 ------------------- .../users/_users_repository.py | 17 ++++++++- .../users/_users_service.py | 8 +++- .../tests/unit/with_dbs/03/test_users.py | 3 +- 4 files changed, 24 insertions(+), 42 deletions(-) delete mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py deleted file mode 100644 index 0d02c138fbb..00000000000 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5a51c6cc961d_new_account_request_status_column.py +++ /dev/null @@ -1,38 +0,0 @@ -"""new account_request_status column - -Revision ID: 5a51c6cc961d -Revises: cf8f743fd0b7 -Create Date: 2025-04-22 18:28:21.850932+00:00 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "5a51c6cc961d" -down_revision = "cf8f743fd0b7" -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - - op.add_column( - "users_pre_registration_details", - sa.Column( - "account_request_status", - sa.Enum("PENDING", "APPROVED", "REJECTED", name="accountrequeststatus"), - server_default=sa.text("'PENDING'::account_request_status"), - nullable=False, - ), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("users_pre_registration_details", "account_request_status") - - # ### end Alembic commands ### diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index e4793b44b0b..3bb6759f73f 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -546,6 +546,14 @@ async def list_users_for_admin( ) # Main query to get user data + invited_by = ( + sa.select( + users.c.name, + ) + .where(users_pre_registration_details.c.created_by == users.c.id) + .label("invited_by") + ) + main_query = ( sa.select( users.c.id.label("user_id"), @@ -571,7 +579,13 @@ async def list_users_for_admin( users.c.status, users.c.created, users_pre_registration_details.c.institution, - users_pre_registration_details.c.pre_phone.label("phone"), + sa.case( + ( + users.c.phone.is_(None), + users_pre_registration_details.c.pre_phone, + ), + else_=users.c.phone, + ).label("phone"), users_pre_registration_details.c.address, users_pre_registration_details.c.city, users_pre_registration_details.c.state, @@ -579,6 +593,7 @@ async def list_users_for_admin( users_pre_registration_details.c.country, users_pre_registration_details.c.extras, users_pre_registration_details.c.account_request_status, + invited_by, ) .select_from(joined_tables) .where(where_clause) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 2869cb7173d..f55c9a3b80d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -222,7 +222,7 @@ async def list_users_as_admin( # Get user data with pagination users_data, total_count = await _users_repository.list_users_for_admin( - engine=engine, filter_approved=filter_approved, limit=limit, offset=offset + engine, filter_approved=filter_approved, limit=limit, offset=offset ) # For each user, append additional information if needed @@ -239,6 +239,12 @@ async def list_users_as_admin( ) user_dict["products"] = [p.product_name for p in products] + user_dict["registered"] = ( + user_id is not None + if user.get("pre_email") + else user.get("status") is not None + ) + result.append(user_dict) return result, total_count 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 eab88b52997..9f6a086cbe8 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 @@ -773,8 +773,7 @@ async def test_list_users_for_admin( form_data["email"] = faker.email() resp = await client.post("/v0/admin/users:pre-register", json=form_data) - assert resp.status == status.HTTP_200_OK - pre_registered_data = await resp.json() + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) pre_registered_users.append(pre_registered_data) # Register one of the pre-registered users From 0204522e82ec905b787d6d951239c78a00b184d1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:29:10 +0200 Subject: [PATCH 10/55] migration --- ...e2e3b_new_account_request_status_column.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py new file mode 100644 index 00000000000..740c0b32a8d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py @@ -0,0 +1,37 @@ +"""new account_request_status column + +Revision ID: de44e42e2e3b +Revises: 742123f0933a +Create Date: 2025-04-24 07:27:20.753638+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "de44e42e2e3b" +down_revision = "742123f0933a" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_status", + sa.Enum("PENDING", "APPROVED", "REJECTED", name="accountrequeststatus"), + server_default=sa.text("'PENDING'::account_request_status"), + nullable=False, + ), + ) + + +def downgrade(): + op.create_index( + "idx_projects_last_change_date_desc", + "projects", + [sa.text("last_change_date DESC")], + unique=False, + ) From 9b245c442c7144d105cd2c8db2bfc8a138cf9cc8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:41:11 +0200 Subject: [PATCH 11/55] fixes migration --- ...f115e_new_account_request_status_column.py | 42 +++++++++++++++++++ ...e2e3b_new_account_request_status_column.py | 37 ---------------- .../users/_users_service.py | 6 +++ .../tests/unit/with_dbs/03/test_users.py | 2 +- 4 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py delete mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py new file mode 100644 index 00000000000..507dce9ae57 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py @@ -0,0 +1,42 @@ +"""new account_request_status column + +Revision ID: c2e3e38f115e +Revises: 742123f0933a +Create Date: 2025-04-24 07:29:42.530145+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c2e3e38f115e" +down_revision = "742123f0933a" +branch_labels = None +depends_on = None + + +def upgrade(): + # Create the enum type first + account_request_status = sa.Enum( + "PENDING", "APPROVED", "REJECTED", name="accountrequeststatus" + ) + account_request_status.create(op.get_bind()) + + # Reuse the enum in the column definition + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_status", + account_request_status, + server_default=sa.text("'PENDING'::accountrequeststatus"), + nullable=False, + ), + ) + + +def downgrade(): + op.drop_column("users_pre_registration_details", "account_request_status") + + # Drop the enum type after dropping the column + sa.Enum(name="accountrequeststatus").drop(op.get_bind()) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py deleted file mode 100644 index 740c0b32a8d..00000000000 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/de44e42e2e3b_new_account_request_status_column.py +++ /dev/null @@ -1,37 +0,0 @@ -"""new account_request_status column - -Revision ID: de44e42e2e3b -Revises: 742123f0933a -Create Date: 2025-04-24 07:27:20.753638+00:00 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "de44e42e2e3b" -down_revision = "742123f0933a" -branch_labels = None -depends_on = None - - -def upgrade(): - op.add_column( - "users_pre_registration_details", - sa.Column( - "account_request_status", - sa.Enum("PENDING", "APPROVED", "REJECTED", name="accountrequeststatus"), - server_default=sa.text("'PENDING'::account_request_status"), - nullable=False, - ), - ) - - -def downgrade(): - op.create_index( - "idx_projects_last_change_date_desc", - "projects", - [sa.text("last_change_date DESC")], - unique=False, - ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index f55c9a3b80d..ce4c41a910d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -43,6 +43,7 @@ async def pre_register_user( app: web.Application, profile: PreRegisteredUserGet, creator_user_id: UserID, + product_name: ProductName, ) -> UserForAdminGet: found = await search_users_as_admin( @@ -71,6 +72,11 @@ async def pre_register_user( if key in details: details[f"pre_{key}"] = details.pop(key) + # adds the product name to the extras field + extras = details.get("extras", {}) + extras["product_name"] = product_name + details["extras"] = extras + await _users_repository.create_user_details( get_asyncpg_engine(app), email=profile.email, 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 9f6a086cbe8..709c2e9ced2 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 @@ -779,7 +779,7 @@ async def test_list_users_for_admin( # Register one of the pre-registered users new_user = await simcore_service_webserver.login._auth_service.create_user( client.app, - email=pre_registered_users[0]["data"]["email"], + email=pre_registered_users[0]["email"], password=DEFAULT_TEST_PASSWORD, status_upon_creation=UserStatus.ACTIVE, expires_at=None, From ee3e70f8f0c463554681e6221af86104800323dd Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 24 Apr 2025 09:42:23 +0200 Subject: [PATCH 12/55] drafting tests --- .../users/_users_repository.py | 254 +++++++++--------- .../users/_users_rest.py | 5 +- .../unit/with_dbs/03/test_users_repository.py | 6 + 3 files changed, 137 insertions(+), 128 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/03/test_users_repository.py diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 3bb6759f73f..6243c400859 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -20,6 +20,7 @@ from simcore_postgres_database.models.users_details import ( users_pre_registration_details, ) +from simcore_postgres_database.utils import as_postgres_sql_query_str from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, GroupExtraPropertiesRepo, @@ -376,6 +377,132 @@ async def search_users_and_get_profile( return [row async for row in result] +async def list_users_for_admin( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + filter_approved: bool | None = None, + limit: int = 50, + offset: int = 0, + include_deleted: bool = False, +) -> tuple[list[dict[str, Any]], int]: + """ + Gets users data for admin with pagination support using SQLAlchemy expressions + + Args: + engine: The database engine + connection: Optional existing connection to reuse + filter_approved: If set, filters users by their approval status + limit: Maximum number of users to return + offset: Number of users to skip for pagination + include_deleted: Whether to include users marked as deleted + + Returns: + Tuple of (list of user data, total count) + """ + joined_user_tables = users.outerjoin( + users_pre_registration_details, + users.c.id == users_pre_registration_details.c.user_id, + ) + + where_conditions = [] + if not include_deleted: + where_conditions.append(users.c.status != UserStatus.DELETED) + + if filter_approved is not None: + if filter_approved: + where_conditions.append( + users_pre_registration_details.c.account_request_status + == AccountRequestStatus.APPROVED + ) + else: + where_conditions.append( + users_pre_registration_details.c.account_request_status + != AccountRequestStatus.APPROVED + ) + + where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() + + # Count query + count_query = ( + sa.select(sa.func.count().label("total")) + .select_from(joined_user_tables) + .where(where_clause) + ) + + # Create an alias for the users table to use in the subquery + users_alias = sa.alias(users, name="creators") + invited_by = ( + sa.select( + users_alias.c.name, + ) + .where( + users_pre_registration_details.c.created_by.isnot(None) + & (users_pre_registration_details.c.created_by == users_alias.c.id) + ) + .correlate(None) + .scalar_subquery() + .label("invited_by") + ) + + # Main query to get user data + main_query = ( + sa.select( + users_pre_registration_details.c.pre_email, # unique + users_pre_registration_details.c.pre_first_name, + users_pre_registration_details.c.pre_last_name, + users_pre_registration_details.c.institution, + users_pre_registration_details.c.pre_phone, + users_pre_registration_details.c.address, + users_pre_registration_details.c.city, + users_pre_registration_details.c.state, + users_pre_registration_details.c.postal_code, + users_pre_registration_details.c.country, + users_pre_registration_details.c.user_id, + users_pre_registration_details.c.extras, + users_pre_registration_details.c.created, + users_pre_registration_details.c.account_request_status, + users.c.id.label("user_id"), + users.c.name.label("user_name"), + users.c.first_name, + users.c.last_name, + users.c.email, + users.c.phone, + users.c.created_at, + users.c.status, + invited_by, + ) + .select_from(joined_user_tables) + .where(where_clause) + .order_by( + users_pre_registration_details.c.created.desc(), # newest pre-registered first + users_pre_registration_details.c.pre_email, + ) + .limit(limit) + .offset(offset) + ) + + print( + "-" * 100, + "\n", + as_postgres_sql_query_str(main_query), + "-" * 100, + "\n", + as_postgres_sql_query_str(count_query), + ) # DEBUG + + async with pass_or_acquire_connection(engine, connection) as conn: + # Get total count + count_result = await conn.execute(count_query) + total_count = count_result.scalar() + + # Get user records + result = await conn.execute(main_query) + records = result.mappings().all() + + return list(records), total_count + + async def get_user_products( engine: AsyncEngine, connection: AsyncConnection | None = None, @@ -487,133 +614,6 @@ async def is_user_in_product_name( return value is not None -async def list_users_for_admin( - engine: AsyncEngine, - connection: AsyncConnection | None = None, - *, - filter_approved: bool | None = None, - limit: int = 50, - offset: int = 0, - include_deleted: bool = False, -) -> tuple[list[dict[str, Any]], int]: - """ - Gets users data for admin with pagination support using SQLAlchemy expressions - - Args: - engine: The database engine - connection: Optional existing connection to reuse - filter_approved: If set, filters users by their approval status - limit: Maximum number of users to return - offset: Number of users to skip for pagination - include_deleted: Whether to include users marked as deleted - - Returns: - Tuple of (list of user data, total count) - """ - - # Define the join between users and users_pre_registration_details - joined_tables = users.outerjoin( - users_pre_registration_details, - users.c.id == users_pre_registration_details.c.user_id, - ) - - # Basic where clause - exclude deleted by default - where_conditions = [] - if not include_deleted: - where_conditions.append(users.c.status != UserStatus.DELETED) - - # Add filtering by approval status if requested - if filter_approved is not None: - if filter_approved: - where_conditions.append( - users_pre_registration_details.c.account_request_status - == AccountRequestStatus.APPROVED - ) - else: - where_conditions.append( - users_pre_registration_details.c.account_request_status - != AccountRequestStatus.APPROVED - ) - - # Combine all conditions with AND - where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() - - # Count query to get total number of users - count_query = ( - sa.select(sa.func.count().label("total")) - .select_from(joined_tables) - .where(where_clause) - ) - - # Main query to get user data - invited_by = ( - sa.select( - users.c.name, - ) - .where(users_pre_registration_details.c.created_by == users.c.id) - .label("invited_by") - ) - - main_query = ( - sa.select( - users.c.id.label("user_id"), - users.c.name, - sa.case( - (users.c.email.is_(None), users_pre_registration_details.c.pre_email), - else_=users.c.email, - ).label("email"), - sa.case( - ( - users.c.first_name.is_(None), - users_pre_registration_details.c.pre_first_name, - ), - else_=users.c.first_name, - ).label("first_name"), - sa.case( - ( - users.c.last_name.is_(None), - users_pre_registration_details.c.pre_last_name, - ), - else_=users.c.last_name, - ).label("last_name"), - users.c.status, - users.c.created, - users_pre_registration_details.c.institution, - sa.case( - ( - users.c.phone.is_(None), - users_pre_registration_details.c.pre_phone, - ), - else_=users.c.phone, - ).label("phone"), - users_pre_registration_details.c.address, - users_pre_registration_details.c.city, - users_pre_registration_details.c.state, - users_pre_registration_details.c.postal_code, - users_pre_registration_details.c.country, - users_pre_registration_details.c.extras, - users_pre_registration_details.c.account_request_status, - invited_by, - ) - .select_from(joined_tables) - .where(where_clause) - .order_by(users.c.created.desc()) # newest first - .limit(limit) - .offset(offset) - ) - - async with pass_or_acquire_connection(engine, connection) as conn: - # Get total count - count_result = await conn.execute(count_query) - total_count = count_result.scalar() - - # Get user records - result = await conn.execute(main_query) - records = result.mappings().all() - - return list(records), total_count - - # # USER PROFILE # diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 5559b8e681d..b5db8a5cc5d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -231,7 +231,10 @@ async def pre_register_user_for_admin(request: web.Request) -> web.Response: pre_user_profile = await parse_request_body_as(PreRegisteredUserGet, request) user_profile = await _users_service.pre_register_user( - request.app, profile=pre_user_profile, creator_user_id=req_ctx.user_id + request.app, + profile=pre_user_profile, + creator_user_id=req_ctx.user_id, + product_name=req_ctx.product_name, ) return envelope_json_response( user_profile.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/test_users_repository.py new file mode 100644 index 00000000000..970f1e09d29 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/test_users_repository.py @@ -0,0 +1,6 @@ +# create a PO user (w/o pre-registration) +# +# An user requests an account -> gets pre +# +# +# From 6c8331010d47bf5d292848b49fe262dad5d52e03 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 15 May 2025 20:29:30 +0200 Subject: [PATCH 13/55] rename --- api/specs/web-server/_auth.py | 4 ++-- .../src/simcore_service_webserver/api/v0/openapi.yaml | 4 ++-- .../login/_controller/rest/preregistration.py | 6 +++--- .../login/_preregistration_service.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index c922c92073d..7860ef98f03 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -260,8 +260,8 @@ async def email_confirmation(code: str): @router.get( "/auth/captcha", - operation_id="generate_captcha", + operation_id="create_captcha", status_code=status.HTTP_200_OK, responses={status.HTTP_200_OK: {"content": {"image/png": {}}}}, ) -async def generate_captcha(): ... +async def create_captcha(): ... 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 56278ab904b..d8f56192f8a 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 @@ -371,8 +371,8 @@ paths: get: tags: - auth - summary: Request Captcha - operationId: generate_captcha + summary: Create Captcha + operationId: create_captcha responses: '200': description: Successful Response diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index 4306173b548..3c88769966a 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -145,12 +145,12 @@ async def unregister_account(request: web.Request): return response -@routes.get(f"/{API_VTAG}/auth/captcha", name="generate_captcha") +@routes.get(f"/{API_VTAG}/auth/captcha", name="create_captcha") @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def generate_captcha(request: web.Request): +async def create_captcha(request: web.Request): session = await get_session(request) - captcha_text, image_data = await _preregistration_service.generate_captcha() + captcha_text, image_data = await _preregistration_service.create_captcha() # Store captcha text in session session[CAPTCHA_SESSION_KEY] = captcha_text diff --git a/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py b/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py index 8a5d4194330..e5e8a9f29ea 100644 --- a/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py @@ -108,7 +108,7 @@ async def send_account_request_email_to_support( ) -async def generate_captcha() -> tuple[str, bytes]: +async def create_captcha() -> tuple[str, bytes]: captcha_text = generate_passcode(number_of_digits=6) image = ImageCaptcha(width=140, height=45) From b267059cfc32bbb0249a3b5be9b27bc2fec5d0be Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 15 May 2025 20:46:52 +0200 Subject: [PATCH 14/55] rename --- .../src/common_library/users_enums.py | 8 ++-- .../api_schemas_webserver/users.py | 13 +++--- ...f115e_new_account_request_status_column.py | 42 ------------------- .../models/users_details.py | 1 - 4 files changed, 11 insertions(+), 53 deletions(-) delete mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py diff --git a/packages/common-library/src/common_library/users_enums.py b/packages/common-library/src/common_library/users_enums.py index 4e0bcfdd92f..4a0606bf46e 100644 --- a/packages/common-library/src/common_library/users_enums.py +++ b/packages/common-library/src/common_library/users_enums.py @@ -60,8 +60,8 @@ class UserStatus(str, Enum): class AccountRequestStatus(str, Enum): - """Status of an account request""" + """Status of the request for an account""" - PENDING = "PENDING" - APPROVED = "APPROVED" - REJECTED = "REJECTED" + PENDING = "PENDING" # Pending PO review to approve/reject the request + APPROVED = "APPROVED" # PO approved the request + REJECTED = "REJECTED" # PO rejected the request 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 bfd2ab23d56..394508bb457 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 @@ -241,12 +241,13 @@ def from_domain_model(cls, data): class UsersForAdminListFilter(Filters): - approved: Annotated[ - bool | None, - Field( - description="Filter users by approval status: True for approved, False for pending/rejected, None for all" - ), - ] = None + # TODO: create a filter for two views that OM needs + # + # 1. States of an Account Resquest: PENDING, REJECTED, APPROVED + # 2. If APPROVED AND user uses the invitation link, then it can be in any of these states: + # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED + # + approved: bool | None = None class UsersForAdminListQueryParams(PageQueryParameters, UsersForAdminListFilter): ... diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py deleted file mode 100644 index 507dce9ae57..00000000000 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c2e3e38f115e_new_account_request_status_column.py +++ /dev/null @@ -1,42 +0,0 @@ -"""new account_request_status column - -Revision ID: c2e3e38f115e -Revises: 742123f0933a -Create Date: 2025-04-24 07:29:42.530145+00:00 - -""" - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "c2e3e38f115e" -down_revision = "742123f0933a" -branch_labels = None -depends_on = None - - -def upgrade(): - # Create the enum type first - account_request_status = sa.Enum( - "PENDING", "APPROVED", "REJECTED", name="accountrequeststatus" - ) - account_request_status.create(op.get_bind()) - - # Reuse the enum in the column definition - op.add_column( - "users_pre_registration_details", - sa.Column( - "account_request_status", - account_request_status, - server_default=sa.text("'PENDING'::accountrequeststatus"), - nullable=False, - ), - ) - - -def downgrade(): - op.drop_column("users_pre_registration_details", "account_request_status") - - # Drop the enum type after dropping the column - sa.Enum(name="accountrequeststatus").drop(op.get_bind()) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index 817dc53f583..c2c9d3c2720 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -60,7 +60,6 @@ sa.Enum(AccountRequestStatus), nullable=False, server_default=sa.text("'PENDING'::account_request_status"), - doc="Status of the account request: PENDING, APPROVED, REJECTED", ), # Billable address columns: sa.Column("institution", sa.String(), doc="the name of a company or university"), From 2d335cf06772c57cf198c05e31e68b767e410cd0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 19 May 2025 15:55:42 +0200 Subject: [PATCH 15/55] @odeimaiz review: adapting web-api --- api/specs/web-server/_users.py | 26 ++++++ .../api_schemas_webserver/users.py | 10 ++- .../api/v0/openapi.yaml | 79 ++++++++++++++++++- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 9dbcbc5fb5c..a0861b12de5 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -15,8 +15,10 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + UserApprove, UserForAdminGet, UserGet, + UserReject, UsersForAdminListQueryParams, UsersForAdminSearchQueryParams, UsersSearch, @@ -156,6 +158,30 @@ async def list_users_for_admin( ): ... +@router.post( + "/admin/users:approve", + response_model=Envelope[Page[UserForAdminGet]], + tags=_extra_tags, +) +async def approve_user_account(_body: UserApprove): ... + + +@router.post( + "/admin/users:reject", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def reject_user_account(_body: UserReject): ... + + +@router.post( + "/admin/users:resendConfirmationEmail", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def resend_user_confirmation_email(_body: UserApprove): ... + + @router.get( "/admin/users:search", response_model=Envelope[list[UserForAdminGet]], 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 394508bb457..bda56be2847 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 @@ -247,12 +247,20 @@ class UsersForAdminListFilter(Filters): # 2. If APPROVED AND user uses the invitation link, then it can be in any of these states: # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED # - approved: bool | None = None + status: Literal["PENDING"] | None = None class UsersForAdminListQueryParams(PageQueryParameters, UsersForAdminListFilter): ... +class UserApprove(InputSchema): + email: EmailStr + + +class UserReject(InputSchema): + email: EmailStr + + class UsersForAdminSearchQueryParams(RequestParameters): email: Annotated[ str, 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 d8f56192f8a..0f2d6d42e43 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 @@ -1373,14 +1373,15 @@ paths: summary: List Users For Admin operationId: list_users_for_admin parameters: - - name: approved + - name: status in: query required: false schema: anyOf: - - type: boolean + - const: PENDING + type: string - type: 'null' - title: Approved + title: Status - name: limit in: query required: false @@ -1402,6 +1403,58 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_Page_UserForAdminGet__' + /v0/admin/users:approve: + post: + tags: + - users + - admin + summary: Approve User Account + operationId: approve_user_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserApprove' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Page_UserForAdminGet__' + /v0/admin/users:reject: + post: + tags: + - users + - admin + summary: Reject User Account + operationId: reject_user_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserReject' + required: true + responses: + '204': + description: Successful Response + /v0/admin/users:resendConfirmationEmail: + post: + tags: + - users + - admin + summary: Resend User Confirmation Email + operationId: resend_user_confirmation_email + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserApprove' + required: true + responses: + '204': + description: Successful Response /v0/admin/users:search: get: tags: @@ -16952,6 +17005,16 @@ components: - number - e_tag title: UploadedPart + UserApprove: + properties: + email: + type: string + format: email + title: Email + type: object + required: + - email + title: UserApprove UserForAdminGet: properties: firstName: @@ -17205,6 +17268,16 @@ components: required: - read title: UserNotificationPatch + UserReject: + properties: + email: + type: string + format: email + title: Email + type: object + required: + - email + title: UserReject UserStatus: type: string enum: From a873bbd0fe7eae6327afa068cd8db1e71bd69ddf Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 19 May 2025 16:11:28 +0200 Subject: [PATCH 16/55] creates tests --- .../tests/unit/with_dbs/03/test_users.py | 124 +++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) 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 709c2e9ced2..c709d89701b 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 @@ -27,6 +27,7 @@ UserForAdminGet, UserGet, ) +from models_library.products import ProductName from psycopg2 import OperationalError from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status @@ -41,7 +42,7 @@ switch_client_session_to, ) from servicelib.aiohttp import status -from servicelib.rest_constants import RESPONSE_MODEL_POLICY +from servicelib.rest_constants import RESPONSE_MODEL_POLICY, X_PRODUCT_NAME_HEADER from simcore_service_webserver.users._common.schemas import ( MAX_BYTES_SIZE_EXTRAS, PreRegisteredUserGet, @@ -919,3 +920,124 @@ def test_preuserprofile_pre_given_names( print(pre_user_profile.model_dump_json(indent=1)) assert pre_user_profile.first_name in ["Pedro-Luis", "Pedro Luis"] assert pre_user_profile.first_name == pre_user_profile.last_name + + +@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) +async def test_pending_users_management( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, +): + """Test the management of pending users: + - list pending users + - approve user account + - reject user account + - resend confirmation email + """ + assert client.app + + # Create some pre-registered users + pre_registered_users = [] + for _ in range(3): + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post( + "/v0/admin/users:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_users.append(pre_registered_data) + + # 1. List pending users (not yet approved) + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify response structure and content + assert "items" in data + assert "pagination" in data + assert len(data["items"]) >= 3 # At least our 3 pre-registered users + + # Verify each pre-registered user is in the list + for pre_user in pre_registered_users: + found = next( + (item for item in data["items"] if item["email"] == pre_user["email"]), + None, + ) + assert found is not None + assert found["registered"] is False + + # 2. Approve one of the pre-registered users + approval_data = {"email": pre_registered_users[0]["email"]} + resp = await client.post( + "/v0/admin/users:approve", + json=approval_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + approved_data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify response structure + assert "invitationLink" in approved_data + assert approved_data.get("email") == pre_registered_users[0]["email"] + + # Verify the user is no longer in the pending list + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # The approved user should no longer be in the pending list + assert all( + item["email"] != pre_registered_users[0]["email"] for item in data["items"] + ) + + # 3. Reject another pre-registered user + rejection_data = {"email": pre_registered_users[1]["email"]} + resp = await client.post( + "/v0/admin/users:reject", + json=rejection_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Verify the rejected user is no longer in the pending list + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert all( + item["email"] != pre_registered_users[1]["email"] for item in data["items"] + ) + + # 4. Resend confirmation email to the approved user + resend_data = {"email": pre_registered_users[0]["email"]} + resp = await client.post( + "/v0/admin/users:resendConfirmationEmail", + json=resend_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Search for the approved user to confirm their status + resp = await client.get( + "/v0/admin/users:search", + params={"email": pre_registered_users[0]["email"]}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found_users, _ = await assert_status(resp, status.HTTP_200_OK) + + # Should find exactly one user + assert len(found_users) == 1 + found_user = UserForAdminGet(**found_users[0]) + + # User should be registered but in CONFIRMATION_PENDING status + assert found_user.registered is True + assert found_user.status == UserStatus.CONFIRMATION_PENDING From b3f018169949c18c1e0c8f4b55e3573952ebbfb8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 19 May 2025 17:17:00 +0200 Subject: [PATCH 17/55] refactor: update pk_value handling in insert functions to support None --- .../pytest_simcore/helpers/postgres_tools.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py index b3a01381b7a..47832357658 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py @@ -89,13 +89,17 @@ async def _async_insert_and_get_row( table: sa.Table, values: dict[str, Any], pk_col: sa.Column, - pk_value: Any, + pk_value: Any | None = None, ): result = await conn.execute(table.insert().values(**values).returning(pk_col)) row = result.one() - # NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9) - assert getattr(row, pk_col.name) == pk_value + # Get the pk_value from the row if not provided + if pk_value is None: + pk_value = getattr(row, pk_col.name) + else: + # NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9) + assert getattr(row, pk_col.name) == pk_value result = await conn.execute(sa.select(table).where(pk_col == pk_value)) return result.one() @@ -106,13 +110,17 @@ def _sync_insert_and_get_row( table: sa.Table, values: dict[str, Any], pk_col: sa.Column, - pk_value: Any, -): + pk_value: Any | None = None, +) -> sa.engine.Row: result = conn.execute(table.insert().values(**values).returning(pk_col)) row = result.one() - # NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9) - assert getattr(row, pk_col.name) == pk_value + # Get the pk_value from the row if not provided + if pk_value is None: + pk_value = getattr(row, pk_col.name) + else: + # NOTE: DO NO USE row[pk_col] since you will get a deprecation error (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9) + assert getattr(row, pk_col.name) == pk_value result = conn.execute(sa.select(table).where(pk_col == pk_value)) return result.one() @@ -125,13 +133,16 @@ async def insert_and_get_row_lifespan( table: sa.Table, values: dict[str, Any], pk_col: sa.Column, - pk_value: Any, + pk_value: Any | None = None, ) -> AsyncIterator[dict[str, Any]]: # insert & get async with sqlalchemy_async_engine.begin() as conn: row = await _async_insert_and_get_row( conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value ) + # If pk_value was None, get it from the row for deletion later + if pk_value is None: + pk_value = getattr(row, pk_col.name) assert row @@ -151,7 +162,7 @@ def sync_insert_and_get_row_lifespan( table: sa.Table, values: dict[str, Any], pk_col: sa.Column, - pk_value: Any, + pk_value: Any | None = None, ) -> Iterator[dict[str, Any]]: """sync version of insert_and_get_row_lifespan. @@ -164,6 +175,9 @@ def sync_insert_and_get_row_lifespan( row = _sync_insert_and_get_row( conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value ) + # If pk_value was None, get it from the row for deletion later + if pk_value is None: + pk_value = getattr(row, pk_col.name) assert row From 65133146dd2e1dfc346502ac60d5a29b8722d315 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 19 May 2025 17:17:37 +0200 Subject: [PATCH 18/55] feat: enhance pre-registration details with product name and foreign key support --- .../models/users_details.py | 36 ++++++++++++------- .../pytest_simcore/helpers/faker_factories.py | 3 ++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index c2c9d3c2720..879d1576c94 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -4,11 +4,13 @@ from ._common import ( RefActions, + column_created_by_user, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, ) from .base import metadata +from .products import products # Import the products table from .users import users users_pre_registration_details = sa.Table( @@ -19,6 +21,13 @@ # a row can be added in this table during pre-registration i.e. even before the `users` row exists. # metadata, + sa.Column( + "id", + sa.BigInteger, + sa.Identity(start=1, cycle=False), + primary_key=True, + doc="Primary key for the pre-registration entry", + ), sa.Column( "user_id", sa.Integer, @@ -35,7 +44,7 @@ "pre_email", sa.String(), nullable=False, - unique=True, + unique=False, # Same email could be used for different products doc="Email of the user on pre-registration (copied to users.email upon registration)", ), sa.Column( @@ -61,6 +70,19 @@ nullable=False, server_default=sa.text("'PENDING'::account_request_status"), ), + # Product the user is requesting access to + sa.Column( + "product_name", + sa.String, + sa.ForeignKey( + products.c.name, + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, + name="fk_users_pre_registration_details_product_name", + ), + nullable=True, + doc="Product that the user is requesting an account for", + ), # Billable address columns: sa.Column("institution", sa.String(), doc="the name of a company or university"), sa.Column("address", sa.String()), @@ -75,17 +97,7 @@ doc="Extra information provided in the form but still not defined as a column.", ), # Other related users - sa.Column( - "created_by", - sa.Integer, - sa.ForeignKey( - users.c.id, - onupdate=RefActions.CASCADE, - ondelete=RefActions.SET_NULL, - ), - nullable=True, - doc="PO user that issued this pre-registration", - ), + column_created_by_user(users_table=users, required=False), column_created_datetime(timezone=False), column_modified_datetime(timezone=False), ) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py index 6ff20f1b072..74fc777f7af 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -85,8 +85,10 @@ def random_user( def random_pre_registration_details( fake: Faker = DEFAULT_FAKER, *, + # foreign keys user_id: int | None = None, created_by: int | None = None, + product_name: str | None = None, **overrides, ): from simcore_postgres_database.models.users_details import ( @@ -117,6 +119,7 @@ def random_pre_registration_details( "eula": True, "ipinfo": {"x-real-ip": "127.0.0.1"}, }, + "product_name": product_name, "created_by": created_by, # user id } From 30d63980e2fac6a824a72af335b1087a640c82d1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 19 May 2025 18:04:46 +0200 Subject: [PATCH 19/55] feat: add new pre-registration columns and update related user handling --- ...9c4816a31b_new_pre_registration_columns.py | 107 ++++++++++++++ .../models/users_details.py | 11 +- .../simcore_postgres_database/utils_users.py | 23 ++- .../tests/test_users_details.py | 137 +++++++++++------- .../login/_auth_service.py | 9 +- 5 files changed, 217 insertions(+), 70 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py new file mode 100644 index 00000000000..a2c5246cee3 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py @@ -0,0 +1,107 @@ +"""new pre-registration columns + +Revision ID: ba9c4816a31b +Revises: b39f2dc87ccd +Create Date: 2025-05-19 15:21:40.182354+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ba9c4816a31b" +down_revision = "b39f2dc87ccd" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Create the enum type first before using it + account_request_status = sa.Enum( + "PENDING", "APPROVED", "REJECTED", name="accountrequeststatus" + ) + account_request_status.create(op.get_bind(), checkfirst=True) + + op.add_column( + "users_pre_registration_details", + sa.Column( + "id", + sa.BigInteger(), + sa.Identity(always=False, start=1, cycle=False), + nullable=False, + ), + ) + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_status", + account_request_status, # Use the created enum type + server_default="PENDING", # Simply use the string value as default + nullable=False, + ), + ) + op.add_column( + "users_pre_registration_details", + sa.Column("product_name", sa.String(), nullable=True), + ) + op.drop_constraint( + "users_pre_registration_details_pre_email_key", + "users_pre_registration_details", + type_="unique", + ) + op.create_foreign_key( + "fk_users_pre_registration_details_product_name", + "users_pre_registration_details", + "products", + ["product_name"], + ["name"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + # Set primary key on id column + op.create_primary_key( + "users_pre_registration_details_pk", + "users_pre_registration_details", + ["id"], + ) + # Add composite unique constraint on pre_email and product_name + op.create_unique_constraint( + "users_pre_registration_details_pre_email_product_name_key", + "users_pre_registration_details", + ["pre_email", "product_name"], + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Drop the composite unique constraint + op.drop_constraint( + "users_pre_registration_details_pre_email_product_name_key", + "users_pre_registration_details", + type_="unique", + ) + op.drop_constraint( + "users_pre_registration_details_pk", + "users_pre_registration_details", + type_="primary", + ) + op.drop_constraint( + "fk_users_pre_registration_details_product_name", + "users_pre_registration_details", + type_="foreignkey", + ) + op.create_unique_constraint( + "users_pre_registration_details_pre_email_key", + "users_pre_registration_details", + ["pre_email"], + ) + op.drop_column("users_pre_registration_details", "product_name") + op.drop_column("users_pre_registration_details", "account_request_status") + op.drop_column("users_pre_registration_details", "id") + + # Drop the enum type in downgrade + sa.Enum(name="accountrequeststatus").drop(op.get_bind(), checkfirst=True) + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index 879d1576c94..ab3d2614d2b 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -44,7 +44,6 @@ "pre_email", sa.String(), nullable=False, - unique=False, # Same email could be used for different products doc="Email of the user on pre-registration (copied to users.email upon registration)", ), sa.Column( @@ -68,7 +67,8 @@ "account_request_status", sa.Enum(AccountRequestStatus), nullable=False, - server_default=sa.text("'PENDING'::account_request_status"), + server_default=AccountRequestStatus.PENDING.value, + doc="Status of approval of the account request", ), # Product the user is requesting access to sa.Column( @@ -100,6 +100,13 @@ column_created_by_user(users_table=users, required=False), column_created_datetime(timezone=False), column_modified_datetime(timezone=False), + # CONSTRAINTS: + # Composite unique constraint to ensure a user can only have one pre-registration per product + sa.UniqueConstraint( + "pre_email", + "product_name", + name="users_pre_registration_details_pre_email_product_name_key", + ), ) register_modified_datetime_auto_update_trigger(users_pre_registration_details) 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 29544e35b9c..7070eb25107 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -88,15 +88,15 @@ async def new_user( users.c.status, ).where(users.c.id == user_id) ) - row = await maybe_await(result.first()) - from aiopg.sa.result import RowProxy - - assert isinstance(row, RowProxy) # nosec - return row + return await maybe_await(result.first()) @staticmethod - async def join_and_update_from_pre_registration_details( - conn: DBConnection, new_user_id: int, new_user_email: str + async def link_and_update_user_from_pre_registration( + conn: DBConnection, + *, + new_user_id: int, + new_user_email: str, + update_user: bool = True, ) -> None: """After a user is created, it can be associated with information provided during invitation @@ -113,11 +113,8 @@ async def join_and_update_from_pre_registration_details( .values(user_id=new_user_id) ) - from aiopg.sa.result import ResultProxy - - assert isinstance(result, ResultProxy) # nosec - - if result.rowcount: + if update_user: + # COPIES some pre-registration details to the users table pre_columns = ( users_pre_registration_details.c.pre_first_name, users_pre_registration_details.c.pre_last_name, @@ -141,7 +138,7 @@ async def join_and_update_from_pre_registration_details( users_pre_registration_details.c.pre_email == new_user_email ) ) - if details := await maybe_await(result.fetchone()): + if details := result.first(): await conn.execute( users.update() .where(users.c.id == new_user_id) diff --git a/packages/postgres-database/tests/test_users_details.py b/packages/postgres-database/tests/test_users_details.py index f99ab6396f0..062bd6fb22e 100644 --- a/packages/postgres-database/tests/test_users_details.py +++ b/packages/postgres-database/tests/test_users_details.py @@ -3,75 +3,104 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +from collections.abc import AsyncIterable from dataclasses import dataclass +from typing import Any import pytest import sqlalchemy as sa -from aiopg.sa.connection import SAConnection from aiopg.sa.result import RowProxy from faker import Faker from pytest_simcore.helpers.faker_factories import ( random_pre_registration_details, + random_product, random_user, ) +from pytest_simcore.helpers.postgres_tools import ( + insert_and_get_row_lifespan, +) +from simcore_postgres_database.models.products import products from simcore_postgres_database.models.users import UserRole, UserStatus, users from simcore_postgres_database.models.users_details import ( users_pre_registration_details, ) +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) from simcore_postgres_database.utils_users import UsersRepo +from sqlalchemy.ext.asyncio import AsyncEngine @pytest.fixture -async def po_user( +async def product_name( faker: Faker, - connection: SAConnection, -): - user_id = await connection.scalar( - users.insert() - .values(**random_user(faker, role=UserRole.PRODUCT_OWNER)) - .returning(users.c.id) - ) - assert user_id + asyncpg_engine: AsyncEngine, +) -> AsyncIterable[str]: + async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup + asyncpg_engine, + table=products, + values=random_product(fake=faker, name="s4l"), + pk_col=products.c.name, + ) as row: + yield row["name"] - result = await connection.execute(sa.select(users).where(users.c.id == user_id)) - yield await result.first() - users.delete().where(users.c.id == user_id) +@pytest.fixture +async def po_user( + faker: Faker, + asyncpg_engine: AsyncEngine, +) -> AsyncIterable[dict[str, Any]]: + async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup + asyncpg_engine, + table=users, + values=random_user(faker, role=UserRole.PRODUCT_OWNER), + pk_col=users.c.id, + ) as row: + yield row @pytest.mark.acceptance_test( "pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138" ) async def test_user_creation_workflow( - connection: SAConnection, faker: Faker, po_user: RowProxy + asyncpg_engine: AsyncEngine, + faker: Faker, + po_user: dict[str, Any], + product_name: str, ): # a PO creates an invitation fake_pre_registration_data = random_pre_registration_details( - faker, created_by=po_user.id + faker, created_by=po_user["id"], product_name=product_name ) - pre_email = await connection.scalar( - sa.insert(users_pre_registration_details) - .values(**fake_pre_registration_data) - .returning(users_pre_registration_details.c.pre_email) - ) + async with transaction_context(asyncpg_engine) as connection: + pre_email = await connection.scalar( + sa.insert(users_pre_registration_details) + .values(**fake_pre_registration_data) + .returning(users_pre_registration_details.c.pre_email) + ) assert pre_email is not None assert pre_email == fake_pre_registration_data["pre_email"] - # user gets created - new_user = await UsersRepo.new_user( - connection, - email=pre_email, - password_hash="123456", # noqa: S106 - status=UserStatus.ACTIVE, - expires_at=None, - ) - await UsersRepo.join_and_update_from_pre_registration_details( - connection, new_user.id, new_user.email - ) + async with transaction_context(asyncpg_engine) as connection: + # user gets created + new_user = await UsersRepo.new_user( + connection, + email=pre_email, + password_hash="123456", # noqa: S106 + status=UserStatus.ACTIVE, + expires_at=None, + ) + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) - invoice_data = await UsersRepo.get_billing_details(connection, user_id=new_user.id) - assert invoice_data is not None + async with pass_or_acquire_connection(asyncpg_engine) as connection: + invoice_data = await UsersRepo.get_billing_details( + connection, user_id=new_user.id + ) + assert invoice_data is not None # drafts converting data models from https://github.com/ITISFoundation/osparc-simcore/pull/5402 @dataclass @@ -84,7 +113,11 @@ class UserAddress: @classmethod def create_from_db(cls, row: RowProxy): - parts = (row[c] for c in ("institution", "address") if row[c]) + parts = ( + getattr(row, col_name) + for col_name in ("institution", "address") + if getattr(row, col_name) + ) return cls( line1=". ".join(parts), state=row.state, @@ -110,28 +143,30 @@ def create_from_db(cls, row: RowProxy): assert user_address.country == fake_pre_registration_data["country"] # now let's update the user - result = await connection.execute( - users.update() - .values(first_name="My New Name") - .where(users.c.id == new_user.id) - .returning("*") - ) - updated_user = await result.fetchone() + async with transaction_context(asyncpg_engine) as connection: + result = await connection.execute( + users.update() + .values(first_name="My New Name") + .where(users.c.id == new_user.id) + .returning("*") + ) + updated_user = result.one() assert updated_user assert updated_user.first_name == "My New Name" assert updated_user.id == new_user.id for _ in range(2): - await UsersRepo.join_and_update_from_pre_registration_details( - connection, new_user.id, new_user.email - ) + async with transaction_context(asyncpg_engine) as connection: + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) - result = await connection.execute( - users.select().where(users.c.id == new_user.id) - ) - current_user = await result.fetchone() - assert current_user + result = await connection.execute( + users.select().where(users.c.id == new_user.id) + ) + current_user = result.one() + assert current_user - # overriden! - assert current_user.first_name != updated_user.first_name + # overriden! + assert current_user.first_name != updated_user.first_name diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_service.py b/services/web/server/src/simcore_service_webserver/login/_auth_service.py index a936f7f62f2..84f1958de9e 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_service.py @@ -4,9 +4,10 @@ from aiohttp import web from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserStatus +from simcore_postgres_database.utils_repos import transaction_context from simcore_postgres_database.utils_users import UsersRepo +from simcore_service_api_server.api.dependencies.database import get_db_asyncpg_engine -from ..db.plugin import get_database_engine from ..groups.api import is_user_by_email_in_group from ..products.models import Product from ..security.api import check_password, encrypt_password @@ -30,7 +31,7 @@ async def create_user( expires_at: datetime | None, ) -> dict[str, Any]: - async with get_database_engine(app).acquire() as conn: + async with transaction_context(get_db_asyncpg_engine(app)) as conn: user = await UsersRepo.new_user( conn, email=email, @@ -38,8 +39,8 @@ async def create_user( status=status_upon_creation, expires_at=expires_at, ) - await UsersRepo.join_and_update_from_pre_registration_details( - conn, user.id, user.email + await UsersRepo.link_and_update_user_from_pre_registration( + conn, new_user_id=user.id, new_user_email=user.email ) return dict(user.items()) From f7678e1e098dfd44831814c210e15092ccdd6498 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 20 May 2025 16:29:39 +0200 Subject: [PATCH 20/55] feat: enhance user listing with product name and account request status filtering --- .../users/_users_repository.py | 52 ++++++++----------- .../users/_users_rest.py | 12 ++--- .../users/_users_service.py | 14 +++-- 3 files changed, 37 insertions(+), 41 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 6243c400859..1c5ec8280f7 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -1,4 +1,5 @@ import contextlib +import logging from typing import Any import sqlalchemy as sa @@ -48,6 +49,8 @@ UserNotFoundError, ) +_logger = logging.getLogger(__name__) + def _parse_as_user(user_id: Any) -> UserID: try: @@ -381,45 +384,33 @@ async def list_users_for_admin( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - filter_approved: bool | None = None, - limit: int = 50, - offset: int = 0, - include_deleted: bool = False, + product_name: ProductName, + filter_account_request_status: AccountRequestStatus | None = None, + filter_include_deleted: bool = False, + pagination_limit: int = 50, + pagination_offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: """ Gets users data for admin with pagination support using SQLAlchemy expressions - Args: - engine: The database engine - connection: Optional existing connection to reuse - filter_approved: If set, filters users by their approval status - limit: Maximum number of users to return - offset: Number of users to skip for pagination - include_deleted: Whether to include users marked as deleted - Returns: Tuple of (list of user data, total count) """ joined_user_tables = users.outerjoin( users_pre_registration_details, - users.c.id == users_pre_registration_details.c.user_id, + (users.c.id == users_pre_registration_details.c.user_id) + & (users_pre_registration_details.c.product_name == product_name), ) where_conditions = [] - if not include_deleted: + if not filter_include_deleted: where_conditions.append(users.c.status != UserStatus.DELETED) - if filter_approved is not None: - if filter_approved: - where_conditions.append( - users_pre_registration_details.c.account_request_status - == AccountRequestStatus.APPROVED - ) - else: - where_conditions.append( - users_pre_registration_details.c.account_request_status - != AccountRequestStatus.APPROVED - ) + if filter_account_request_status is not None: + where_conditions.append( + users_pre_registration_details.c.account_request_status + == filter_account_request_status + ) where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() @@ -478,18 +469,17 @@ async def list_users_for_admin( users_pre_registration_details.c.created.desc(), # newest pre-registered first users_pre_registration_details.c.pre_email, ) - .limit(limit) - .offset(offset) + .limit(pagination_limit) + .offset(pagination_offset) ) - print( + _logger.debug( + "%s\n%s\n%s\n%s", "-" * 100, - "\n", as_postgres_sql_query_str(main_query), "-" * 100, - "\n", as_postgres_sql_query_str(count_query), - ) # DEBUG + ) async with pass_or_acquire_connection(engine, connection) as conn: # Get total count diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index b5db8a5cc5d..5b7c1245408 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -1,6 +1,3 @@ -import logging -from contextlib import suppress - from aiohttp import web from models_library.api_schemas_webserver.users import ( MyProfileGet, @@ -178,9 +175,11 @@ async def list_users_for_admin(request: web.Request) -> web.Response: users, total_count = await _users_service.list_users_as_admin( request.app, - filter_approved=query_params.approved, - limit=query_params.limit, - offset=query_params.offset, + filter_account_request_status={"PENDING": AccountRequestStatus.PENDING}.get( + query_params.status or "" + ), + pagination_limit=query_params.limit, + pagination_offset=query_params.offset, ) page = Page[UserForAdminGet].model_validate( @@ -236,6 +235,7 @@ async def pre_register_user_for_admin(request: web.Request) -> web.Response: creator_user_id=req_ctx.user_id, product_name=req_ctx.product_name, ) + return envelope_json_response( user_profile.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY) ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index ce4c41a910d..1705db5f1c7 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -3,6 +3,7 @@ import pycountry from aiohttp import web +from common_library.users_enums import AccountRequestStatus from models_library.api_schemas_webserver.users import MyProfilePatch, UserForAdminGet from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr @@ -208,9 +209,10 @@ async def is_user_in_product( async def list_users_as_admin( app: web.Application, *, - filter_approved: bool | None = None, - limit: int = 50, - offset: int = 0, + product_name: ProductName, + filter_account_request_status: AccountRequestStatus | None = None, + pagination_limit: int = 50, + pagination_offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: """ Get a paginated list of users for admin view with filtering options. @@ -228,7 +230,11 @@ async def list_users_as_admin( # Get user data with pagination users_data, total_count = await _users_repository.list_users_for_admin( - engine, filter_approved=filter_approved, limit=limit, offset=offset + engine, + product_name=product_name, + filter_account_request_status=filter_account_request_status, + pagination_limit=pagination_limit, + pagination_offset=pagination_offset, ) # For each user, append additional information if needed From 299e946af62c390be779d1ea8d0f7daf030db9f8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 20 May 2025 16:51:27 +0200 Subject: [PATCH 21/55] refactor: improve code readability by restructuring class inheritance and renaming variables --- .../api_schemas_webserver/users.py | 4 +-- .../simcore_postgres_database/utils_users.py | 7 +++-- .../tests/test_users_details.py | 29 ++++++++++++++----- .../login/_controller/rest/preregistration.py | 5 +++- 4 files changed, 30 insertions(+), 15 deletions(-) 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 bda56be2847..23543638bc5 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 @@ -241,8 +241,6 @@ def from_domain_model(cls, data): class UsersForAdminListFilter(Filters): - # TODO: create a filter for two views that OM needs - # # 1. States of an Account Resquest: PENDING, REJECTED, APPROVED # 2. If APPROVED AND user uses the invitation link, then it can be in any of these states: # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED @@ -250,7 +248,7 @@ class UsersForAdminListFilter(Filters): status: Literal["PENDING"] | None = None -class UsersForAdminListQueryParams(PageQueryParameters, UsersForAdminListFilter): ... +class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... class UserApprove(InputSchema): 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 7070eb25107..587f90ee504 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -138,13 +138,14 @@ async def link_and_update_user_from_pre_registration( users_pre_registration_details.c.pre_email == new_user_email ) ) - if details := result.first(): + if pre_registration_details_data := result.first(): + # NOTE: could have many products! which to use? await conn.execute( users.update() .where(users.c.id == new_user_id) .values( - first_name=details.pre_first_name, # type: ignore[union-attr] - last_name=details.pre_last_name, # type: ignore[union-attr] + first_name=pre_registration_details_data.pre_first_name, # type: ignore[union-attr] + last_name=pre_registration_details_data.pre_last_name, # type: ignore[union-attr] ) ) diff --git a/packages/postgres-database/tests/test_users_details.py b/packages/postgres-database/tests/test_users_details.py index 062bd6fb22e..97bca48f6db 100644 --- a/packages/postgres-database/tests/test_users_details.py +++ b/packages/postgres-database/tests/test_users_details.py @@ -10,8 +10,10 @@ import pytest import sqlalchemy as sa from aiopg.sa.result import RowProxy +from common_library.groups_enums import GroupType from faker import Faker from pytest_simcore.helpers.faker_factories import ( + random_group, random_pre_registration_details, random_product, random_user, @@ -19,6 +21,7 @@ from pytest_simcore.helpers.postgres_tools import ( insert_and_get_row_lifespan, ) +from simcore_postgres_database.models.groups import groups from simcore_postgres_database.models.products import products from simcore_postgres_database.models.users import UserRole, UserStatus, users from simcore_postgres_database.models.users_details import ( @@ -33,17 +36,25 @@ @pytest.fixture -async def product_name( +async def product( faker: Faker, asyncpg_engine: AsyncEngine, -) -> AsyncIterable[str]: - async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup +) -> AsyncIterable[dict[str, Any]]: + async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup # noqa: SIM117 asyncpg_engine, - table=products, - values=random_product(fake=faker, name="s4l"), + table=groups, + values=random_group(faker=faker, type=GroupType.STANDARD.name), pk_col=products.c.name, - ) as row: - yield row["name"] + ) as product_group: + async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup + asyncpg_engine, + table=products, + values=random_product( + fake=faker, name="s4l", group_id=product_group["gid"] + ), + pk_col=products.c.name, + ) as row: + yield row @pytest.fixture @@ -67,8 +78,10 @@ async def test_user_creation_workflow( asyncpg_engine: AsyncEngine, faker: Faker, po_user: dict[str, Any], - product_name: str, + product: dict[str, Any], ): + product_name = product["name"] + # a PO creates an invitation fake_pre_registration_data = random_pre_registration_details( faker, created_by=po_user["id"], product_name=product_name diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index 3c88769966a..b656d7cc4d3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -57,7 +57,10 @@ def _get_ipinfo(request: web.Request) -> dict[str, Any]: } -@routes.post(f"/{API_VTAG}/auth/request-account", name="request_product_account") +@routes.post( + f"/{API_VTAG}/auth/request-account", + name="request_product_account", +) @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) async def request_product_account(request: web.Request): product = products_web.get_current_product(request) From f510d06f471366b3a80ea42c31467c9d9a0fe04d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 20 May 2025 17:03:33 +0200 Subject: [PATCH 22/55] fix: correct primary key column references and ensure proper type casting in test fixtures --- .../postgres-database/tests/test_users_details.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/postgres-database/tests/test_users_details.py b/packages/postgres-database/tests/test_users_details.py index 97bca48f6db..6ec817792b1 100644 --- a/packages/postgres-database/tests/test_users_details.py +++ b/packages/postgres-database/tests/test_users_details.py @@ -43,14 +43,14 @@ async def product( async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup # noqa: SIM117 asyncpg_engine, table=groups, - values=random_group(faker=faker, type=GroupType.STANDARD.name), - pk_col=products.c.name, + values=random_group(fake=faker, type=GroupType.STANDARD.name), + pk_col=groups.c.gid, ) as product_group: async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup asyncpg_engine, table=products, values=random_product( - fake=faker, name="s4l", group_id=product_group["gid"] + fake=faker, name="s4l", group_id=int(product_group["gid"]) ), pk_col=products.c.name, ) as row: @@ -65,7 +65,12 @@ async def po_user( async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup asyncpg_engine, table=users, - values=random_user(faker, role=UserRole.PRODUCT_OWNER), + values=random_user( + faker, + email="po-user@email.com", + name="po-user-fixture", + role=UserRole.PRODUCT_OWNER, + ), pk_col=users.c.id, ) as row: yield row From 19f5d7f903a0c90e15c73aff06b6eb8e0450aa77 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 20 May 2025 17:20:03 +0200 Subject: [PATCH 23/55] feat: add account request review fields and update approval workflow in pre-registration details --- ...9c4816a31b_new_pre_registration_columns.py | 33 ++ .../models/users_details.py | 22 +- .../tests/test_users_details.py | 469 +++++++++++++++--- .../pytest_simcore/helpers/postgres_tools.py | 10 +- 4 files changed, 457 insertions(+), 77 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py index a2c5246cee3..6bf5169538c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/ba9c4816a31b_new_pre_registration_columns.py @@ -42,6 +42,22 @@ def upgrade(): nullable=False, ), ) + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_reviewed_by", + sa.Integer(), + nullable=True, + ), + ) + op.add_column( + "users_pre_registration_details", + sa.Column( + "account_request_reviewed_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) op.add_column( "users_pre_registration_details", sa.Column("product_name", sa.String(), nullable=True), @@ -60,6 +76,16 @@ def upgrade(): onupdate="CASCADE", ondelete="SET NULL", ) + # Add foreign key for account_request_reviewed_by + op.create_foreign_key( + "fk_users_pre_registration_reviewed_by_user_id", + "users_pre_registration_details", + "users", + ["account_request_reviewed_by"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) # Set primary key on id column op.create_primary_key( "users_pre_registration_details_pk", @@ -88,6 +114,11 @@ def downgrade(): "users_pre_registration_details", type_="primary", ) + op.drop_constraint( + "fk_users_pre_registration_reviewed_by_user_id", + "users_pre_registration_details", + type_="foreignkey", + ) op.drop_constraint( "fk_users_pre_registration_details_product_name", "users_pre_registration_details", @@ -99,6 +130,8 @@ def downgrade(): ["pre_email"], ) op.drop_column("users_pre_registration_details", "product_name") + op.drop_column("users_pre_registration_details", "account_request_reviewed_at") + op.drop_column("users_pre_registration_details", "account_request_reviewed_by") op.drop_column("users_pre_registration_details", "account_request_status") op.drop_column("users_pre_registration_details", "id") diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index ab3d2614d2b..cf58af8187e 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -62,13 +62,31 @@ doc="Phone provided on pre-registration" "NOTE: this is not copied upon registration since it needs to be confirmed", ), - # Approval status of the account request + # Account Request sa.Column( "account_request_status", sa.Enum(AccountRequestStatus), nullable=False, server_default=AccountRequestStatus.PENDING.value, - doc="Status of approval of the account request", + doc="Status of review for the account request", + ), + sa.Column( + "account_request_reviewed_by", + sa.Integer, + sa.ForeignKey( + users.c.id, + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, + name="fk_users_pre_registration_reviewed_by_user_id", + ), + nullable=True, + doc="Tracks who approved or rejected the account request", + ), + sa.Column( + "account_request_reviewed_at", + sa.DateTime(timezone=True), + nullable=True, + doc="Timestamp when the account request was reviewed", ), # Product the user is requesting access to sa.Column( diff --git a/packages/postgres-database/tests/test_users_details.py b/packages/postgres-database/tests/test_users_details.py index 6ec817792b1..e4b6bfeb70f 100644 --- a/packages/postgres-database/tests/test_users_details.py +++ b/packages/postgres-database/tests/test_users_details.py @@ -3,14 +3,15 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, AsyncIterator +from contextlib import AsyncExitStack from dataclasses import dataclass -from typing import Any +from typing import Any, Protocol, Self import pytest import sqlalchemy as sa -from aiopg.sa.result import RowProxy from common_library.groups_enums import GroupType +from common_library.users_enums import AccountRequestStatus from faker import Faker from pytest_simcore.helpers.faker_factories import ( random_group, @@ -35,30 +36,60 @@ from sqlalchemy.ext.asyncio import AsyncEngine +class CreateProductCallable(Protocol): + """Callable that creates a product and returns its row.""" + + async def __call__(self, name: str) -> dict[str, Any]: ... + + @pytest.fixture -async def product( +async def product_factory( faker: Faker, asyncpg_engine: AsyncEngine, -) -> AsyncIterable[dict[str, Any]]: - async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup # noqa: SIM117 - asyncpg_engine, - table=groups, - values=random_group(fake=faker, type=GroupType.STANDARD.name), - pk_col=groups.c.gid, - ) as product_group: - async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup - asyncpg_engine, - table=products, - values=random_product( - fake=faker, name="s4l", group_id=int(product_group["gid"]) - ), - pk_col=products.c.name, - ) as row: - yield row +) -> AsyncIterator[CreateProductCallable]: + """Fixture that yields a factory function to create products. + + All products created with this factory will be automatically cleaned up when the test ends. + """ + async with AsyncExitStack() as exit_stack: + + async def _create_product(name: str) -> dict[str, Any]: + # 1. create a product group + product_group_row = await exit_stack.enter_async_context( + insert_and_get_row_lifespan( + asyncpg_engine, + table=groups, + values=random_group(fake=faker, type=GroupType.STANDARD.name), + pk_col=groups.c.gid, + ) + ) + + # 2. create the product using that group + product_name = name or faker.pystr(min_chars=3, max_chars=10) + return await exit_stack.enter_async_context( + insert_and_get_row_lifespan( + asyncpg_engine, + table=products, + values=random_product( + fake=faker, + name=product_name, + group_id=int(product_group_row["gid"]), + ), + pk_col=products.c.name, + ) + ) + + yield _create_product @pytest.fixture -async def po_user( +async def product(product_factory: CreateProductCallable) -> dict[str, Any]: + """Returns a single product for backward compatibility.""" + return await product_factory("s4l") + + +@pytest.fixture +async def product_owner_user( faker: Faker, asyncpg_engine: AsyncEngine, ) -> AsyncIterable[dict[str, Any]]: @@ -76,20 +107,130 @@ async def po_user( yield row +@dataclass +class UserAddress: + """Model for user address information from database records.""" + + line1: str | None + state: str | None + postal_code: str | None + city: str | None + country: str + + @classmethod + def create_from_db(cls, row) -> Self: + parts = ( + getattr(row, col_name) + for col_name in ("institution", "address") + if getattr(row, col_name) + ) + return cls( + line1=". ".join(parts), + state=row.state, + postal_code=row.postal_code, + city=row.city, + country=row.country, + ) + + +@pytest.fixture +async def pre_registered_user( + asyncpg_engine: AsyncEngine, + faker: Faker, + product_owner_user: dict[str, Any], + product: dict[str, Any], +) -> tuple[str, dict[str, Any]]: + """Creates a pre-registered user and returns the email and registration data.""" + product_name = product["name"] + fake_pre_registration_data = random_pre_registration_details( + faker, + pre_email="pre-registered@user.com", + created_by=product_owner_user["id"], + product_name=product_name, + ) + + async with transaction_context(asyncpg_engine) as connection: + pre_email = await connection.scalar( + sa.insert(users_pre_registration_details) + .values(**fake_pre_registration_data) + .returning(users_pre_registration_details.c.pre_email) + ) + + assert pre_email == fake_pre_registration_data["pre_email"] + return pre_email, fake_pre_registration_data + + +async def test_user_requests_account_and_is_approved( + asyncpg_engine: AsyncEngine, + faker: Faker, + product_owner_user: dict[str, Any], + product: dict[str, Any], +): + product_name = product["name"] + + # 1. User request an account + interested_user_email = "interested@user.com" + + async with transaction_context(asyncpg_engine) as connection: + pre_email = await connection.scalar( + sa.insert(users_pre_registration_details) + .values( + **random_pre_registration_details( + faker, + pre_email=interested_user_email, + product_name=product_name, + ) + ) + .returning(users_pre_registration_details.c.pre_email) + ) + assert pre_email is not None + assert pre_email == interested_user_email + + # 2. PO approves the account request + async with transaction_context(asyncpg_engine) as connection: + await connection.execute( + users_pre_registration_details.update() + .where(users_pre_registration_details.c.pre_email == pre_email) + .values( + account_request_status=AccountRequestStatus.APPROVED, + account_request_reviewed_by=product_owner_user["id"], + account_request_reviewed_at=sa.func.now(), + ) + ) + + # 3. Verify approval was recorded + async with pass_or_acquire_connection(asyncpg_engine) as connection: + result = await connection.execute( + sa.select( + users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.account_request_reviewed_by, + users_pre_registration_details.c.account_request_reviewed_at, + ).where(users_pre_registration_details.c.pre_email == pre_email) + ) + approval_record = result.one() + assert approval_record.account_request_status == AccountRequestStatus.APPROVED + assert approval_record.account_request_reviewed_by == product_owner_user["id"] + assert approval_record.account_request_reviewed_at is not None + + @pytest.mark.acceptance_test( - "pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138" + "pre-registration link creation in https://github.com/ITISFoundation/osparc-simcore/issues/5138" ) -async def test_user_creation_workflow( +async def test_create_pre_registration_link( asyncpg_engine: AsyncEngine, faker: Faker, - po_user: dict[str, Any], + product_owner_user: dict[str, Any], product: dict[str, Any], ): + """Test that a PO can create a pre-registration link for a user.""" product_name = product["name"] - # a PO creates an invitation + # PO creates a pre-registration and sends an email with the invitation link fake_pre_registration_data = random_pre_registration_details( - faker, created_by=po_user["id"], product_name=product_name + faker, + pre_email="interested@user.com", + created_by=product_owner_user["id"], + product_name=product_name, ) async with transaction_context(asyncpg_engine) as connection: @@ -98,9 +239,22 @@ async def test_user_creation_workflow( .values(**fake_pre_registration_data) .returning(users_pre_registration_details.c.pre_email) ) + assert pre_email is not None assert pre_email == fake_pre_registration_data["pre_email"] + +@pytest.mark.acceptance_test( + "pre-registration user creation in https://github.com/ITISFoundation/osparc-simcore/issues/5138" +) +async def test_create_and_link_user_from_pre_registration( + asyncpg_engine: AsyncEngine, + pre_registered_user: tuple[str, dict[str, Any]], +): + """Test that a user can be created from a pre-registration link and is linked properly.""" + pre_email, _ = pre_registered_user + + # Invitation link is clicked and the user is created and linked to the pre-registration async with transaction_context(asyncpg_engine) as connection: # user gets created new_user = await UsersRepo.new_user( @@ -114,53 +268,81 @@ async def test_user_creation_workflow( connection, new_user_id=new_user.id, new_user_email=new_user.email ) + # Verify the user was created and linked + async with pass_or_acquire_connection(asyncpg_engine) as connection: + result = await connection.execute( + sa.select(users_pre_registration_details.c.user_id).where( + users_pre_registration_details.c.pre_email == pre_email + ) + ) + user_id = result.scalar() + assert user_id == new_user.id + + +@pytest.mark.acceptance_test( + "pre-registration billing info in https://github.com/ITISFoundation/osparc-simcore/issues/5138" +) +async def test_get_billing_details_from_pre_registration( + asyncpg_engine: AsyncEngine, + pre_registered_user: tuple[str, dict[str, Any]], +): + """Test that billing details can be retrieved from pre-registration data.""" + pre_email, fake_pre_registration_data = pre_registered_user + + # Create the user + async with transaction_context(asyncpg_engine) as connection: + new_user = await UsersRepo.new_user( + connection, + email=pre_email, + password_hash="123456", # noqa: S106 + status=UserStatus.ACTIVE, + expires_at=None, + ) + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) + + # Get billing details async with pass_or_acquire_connection(asyncpg_engine) as connection: invoice_data = await UsersRepo.get_billing_details( connection, user_id=new_user.id ) assert invoice_data is not None - # drafts converting data models from https://github.com/ITISFoundation/osparc-simcore/pull/5402 - @dataclass - class UserAddress: - line1: str | None - state: str | None - postal_code: str | None - city: str | None - country: str - - @classmethod - def create_from_db(cls, row: RowProxy): - parts = ( - getattr(row, col_name) - for col_name in ("institution", "address") - if getattr(row, col_name) - ) - return cls( - line1=". ".join(parts), - state=row.state, - postal_code=row.postal_code, - city=row.city, - country=row.country, - ) - + # Test UserAddress model conversion user_address = UserAddress.create_from_db(invoice_data) - # Expects something like - # { - # "line1": "Jones, Jefferson and Rivera. 5938 Ramos Pike Suite 080, Lake Marytown, RI 65195", - # "state": "Virginia", - # "postal_code": "08756", - # "city": "Johnmouth", - # "country": "Trinidad and Tobago" - # } - + # Verify address fields match the pre-registration data assert user_address.line1 assert user_address.state == fake_pre_registration_data["state"] assert user_address.postal_code == fake_pre_registration_data["postal_code"] assert user_address.country == fake_pre_registration_data["country"] - # now let's update the user + +@pytest.mark.acceptance_test( + "pre-registration user update in https://github.com/ITISFoundation/osparc-simcore/issues/5138" +) +async def test_update_user_from_pre_registration( + asyncpg_engine: AsyncEngine, + pre_registered_user: tuple[str, dict[str, Any]], +): + """Test that pre-registration details override manual updates when re-linking.""" + pre_email, _ = pre_registered_user + + # Create the user and link to pre-registration + async with transaction_context(asyncpg_engine) as connection: + new_user = await UsersRepo.new_user( + connection, + email=pre_email, + password_hash="123456", # noqa: S106 + status=UserStatus.ACTIVE, + expires_at=None, + ) + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) + + # Update the user manually async with transaction_context(asyncpg_engine) as connection: result = await connection.execute( users.update() @@ -174,17 +356,164 @@ def create_from_db(cls, row: RowProxy): assert updated_user.first_name == "My New Name" assert updated_user.id == new_user.id - for _ in range(2): - async with transaction_context(asyncpg_engine) as connection: - await UsersRepo.link_and_update_user_from_pre_registration( - connection, new_user_id=new_user.id, new_user_email=new_user.email + # Re-link the user to pre-registration, which should override manual updates + async with transaction_context(asyncpg_engine) as connection: + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) + + result = await connection.execute( + users.select().where(users.c.id == new_user.id) + ) + current_user = result.one() + assert current_user + + # Verify that the manual updates were overridden + assert current_user.first_name != updated_user.first_name + + +async def test_user_preregisters_for_multiple_products_with_different_outcomes( + asyncpg_engine: AsyncEngine, + faker: Faker, + product_owner_user: dict[str, Any], + product_factory: CreateProductCallable, +): + """Test scenario where a user pre-registers for multiple products and gets different approval outcomes.""" + # Create two products + product1 = await product_factory("s4l") + product2 = await product_factory("tip") + + # User email for pre-registration + user_email = "multi-product-user@example.com" + + # User pre-registers for both products + async with transaction_context(asyncpg_engine) as connection: + # Pre-register for product1 + await connection.execute( + sa.insert(users_pre_registration_details).values( + **random_pre_registration_details( + faker, + pre_email=user_email, + product_name=product1["name"], + ) + ) + ) + + # Pre-register for product2 + await connection.execute( + sa.insert(users_pre_registration_details).values( + **random_pre_registration_details( + faker, + pre_email=user_email, + product_name=product2["name"], + ) + ) + ) + + # Verify both pre-registrations were created + async with pass_or_acquire_connection(asyncpg_engine) as connection: + result = await connection.execute( + sa.select( + users_pre_registration_details.c.pre_email, + users_pre_registration_details.c.product_name, + users_pre_registration_details.c.account_request_status, + ) + .where(users_pre_registration_details.c.pre_email == user_email) + .order_by(users_pre_registration_details.c.product_name) + ) + + registrations = result.fetchall() + assert len(registrations) == 2 + assert all( + reg.account_request_status == AccountRequestStatus.PENDING + for reg in registrations + ) + + # 2. PO approves and rejects the requests + async with transaction_context(asyncpg_engine) as connection: + # PO approves the request for product1 + await connection.execute( + users_pre_registration_details.update() + .where( + (users_pre_registration_details.c.pre_email == user_email) + & (users_pre_registration_details.c.product_name == product1["name"]) ) + .values( + account_request_status=AccountRequestStatus.APPROVED, + account_request_reviewed_by=product_owner_user["id"], + account_request_reviewed_at=sa.func.now(), + ) + ) - result = await connection.execute( - users.select().where(users.c.id == new_user.id) + # PO rejects the request for product2 + await connection.execute( + users_pre_registration_details.update() + .where( + (users_pre_registration_details.c.pre_email == user_email) + & (users_pre_registration_details.c.product_name == product2["name"]) + ) + .values( + account_request_status=AccountRequestStatus.REJECTED, + account_request_reviewed_by=product_owner_user["id"], + account_request_reviewed_at=sa.func.now(), + ) + ) + + # Verify the status updates + async with pass_or_acquire_connection(asyncpg_engine) as connection: + result = await connection.execute( + sa.select( + users_pre_registration_details.c.product_name, + users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.account_request_reviewed_by, + users_pre_registration_details.c.account_request_reviewed_at, ) - current_user = result.one() - assert current_user + .where(users_pre_registration_details.c.pre_email == user_email) + .order_by(users_pre_registration_details.c.created) + ) + + registrations = result.fetchall() + assert len(registrations) == 2 + + # Check product1 was approved + assert registrations[0].product_name == product1["name"] + assert registrations[0].account_request_status == AccountRequestStatus.APPROVED + assert registrations[0].account_request_reviewed_by == product_owner_user["id"] + assert registrations[0].account_request_reviewed_at is not None + + # Check product2 was rejected + assert registrations[1].product_name == product2["name"] + assert registrations[1].account_request_status == AccountRequestStatus.REJECTED + assert registrations[1].account_request_reviewed_by == product_owner_user["id"] + assert registrations[1].account_request_reviewed_at is not None + + # 3.Now create a user account with the approved pre-registration + async with transaction_context(asyncpg_engine) as connection: + new_user = await UsersRepo.new_user( + connection, + email=user_email, + password_hash="123456", # noqa: S106 + status=UserStatus.ACTIVE, + expires_at=None, + ) + await UsersRepo.link_and_update_user_from_pre_registration( + connection, new_user_id=new_user.id, new_user_email=new_user.email + ) + + # Verify both pre-registrations are linked to the new user + async with pass_or_acquire_connection(asyncpg_engine) as connection: + result = await connection.execute( + sa.select( + users_pre_registration_details.c.product_name, + users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.user_id, + ) + .where(users_pre_registration_details.c.pre_email == user_email) + .order_by(users_pre_registration_details.c.product_name) + ) + + registrations = result.fetchall() + assert len(registrations) == 2 - # overriden! - assert current_user.first_name != updated_user.first_name + # Both registrations should be linked to the same user, regardless of approval status + assert all(reg.user_id == new_user.id for reg in registrations) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py index 47832357658..1e854e8b687 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py @@ -90,7 +90,7 @@ async def _async_insert_and_get_row( values: dict[str, Any], pk_col: sa.Column, pk_value: Any | None = None, -): +) -> sa.engine.Row: result = await conn.execute(table.insert().values(**values).returning(pk_col)) row = result.one() @@ -135,7 +135,7 @@ async def insert_and_get_row_lifespan( pk_col: sa.Column, pk_value: Any | None = None, ) -> AsyncIterator[dict[str, Any]]: - # insert & get + # SETUP: insert & get async with sqlalchemy_async_engine.begin() as conn: row = await _async_insert_and_get_row( conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value @@ -150,7 +150,7 @@ async def insert_and_get_row_lifespan( # pylint: disable=protected-access yield row._asdict() - # delete row + # TEAD-DOWN: delete row async with sqlalchemy_async_engine.begin() as conn: await conn.execute(table.delete().where(pk_col == pk_value)) @@ -170,7 +170,7 @@ def sync_insert_and_get_row_lifespan( database tables before the app starts since it does not require an `event_loop` fixture (which is funcition-scoped ) """ - # insert & get + # SETUP: insert & get with sqlalchemy_sync_engine.begin() as conn: row = _sync_insert_and_get_row( conn, table=table, values=values, pk_col=pk_col, pk_value=pk_value @@ -185,6 +185,6 @@ def sync_insert_and_get_row_lifespan( # pylint: disable=protected-access yield row._asdict() - # delete row + # TEARDOWN: delete row with sqlalchemy_sync_engine.begin() as conn: conn.execute(table.delete().where(pk_col == pk_value)) From e18305709c7252d2c123a2b9b9f6cc88990484f6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 14:58:47 +0200 Subject: [PATCH 24/55] rm --- .../server/tests/unit/with_dbs/03/test_users_repository.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 services/web/server/tests/unit/with_dbs/03/test_users_repository.py diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/test_users_repository.py deleted file mode 100644 index 970f1e09d29..00000000000 --- a/services/web/server/tests/unit/with_dbs/03/test_users_repository.py +++ /dev/null @@ -1,6 +0,0 @@ -# create a PO user (w/o pre-registration) -# -# An user requests an account -> gets pre -# -# -# From d5a45ea0e15565733e8323de57f12e86a3a56476 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 15:04:20 +0200 Subject: [PATCH 25/55] cleanup --- .../src/simcore_service_webserver/users/_users_rest.py | 2 ++ .../src/simcore_service_webserver/users/_users_service.py | 4 ++-- .../tests/unit/with_dbs/03/users/test_users_repository.py | 6 ++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 5b7c1245408..98fe69a670b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -1,3 +1,5 @@ +from contextlib import suppress + from aiohttp import web from models_library.api_schemas_webserver.users import ( MyProfileGet, diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 5bfd151f676..5296b8543c0 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -223,8 +223,8 @@ async def list_users_as_admin( Args: app: The web application instance filter_approved: If set, filters users by their approval status - limit: Maximum number of users to return - offset: Number of users to skip for pagination + pagination_limit: Maximum number of users to return + pagination_offset: Number of users to skip for pagination Returns: A tuple containing (list of user dictionaries, total count of users) diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index 94dfec56a33..abf168b0c3b 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -12,9 +12,7 @@ users_pre_registration_details, ) from simcore_service_webserver.db.plugin import get_asyncpg_engine -from simcore_service_webserver.users._users_repository import ( - create_user_pre_registration, -) +from simcore_service_webserver.users import _users_repository async def test_create_user_pre_registration( @@ -35,7 +33,7 @@ async def test_create_user_pre_registration( } # Act - pre_registration_id = await create_user_pre_registration( + pre_registration_id = await _users_repository.create_user_pre_registration( asyncpg_engine, email=test_email, created_by=created_by_user_id, From 6f8d5a5ab56e2eafa71815eece6a340c80945bf8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 15:27:35 +0200 Subject: [PATCH 26/55] list_user_pre_registartion --- .../users/_users_repository.py | 198 ++++++++++++++-- .../03/users/test_users_repository.py | 219 ++++++++++++++++++ 2 files changed, 393 insertions(+), 24 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 3c28a071d02..e74d2311b35 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -4,6 +4,7 @@ import sqlalchemy as sa from aiohttp import web +from common_library.exclude import UnSet, is_unset from common_library.users_enums import AccountRequestStatus, UserRole from models_library.groups import GroupID from models_library.products import ProductName @@ -534,30 +535,6 @@ async def get_user_products( return [row async for row in result] -async def create_user_pre_registration( - engine: AsyncEngine, - connection: AsyncConnection | None = None, - *, - email: str, - created_by: UserID, - product_name: ProductName, - **other_values, -) -> int: - async with transaction_context(engine, connection) as conn: - result = await conn.execute( - sa.insert(users_pre_registration_details) - .values( - created_by=created_by, - pre_email=email, - product_name=product_name, - **other_values, - ) - .returning(users_pre_registration_details.c.id) - ) - pre_registration_id: int = result.scalar_one() - return pre_registration_id - - async def get_user_billing_details( engine: AsyncEngine, connection: AsyncConnection | None = None, *, user_id: UserID ) -> UserBillingDetails: @@ -694,3 +671,176 @@ async def update_user_profile( ) from err raise # not due to name duplication + + +async def create_user_pre_registration( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + email: str, + created_by: UserID, + product_name: ProductName, + **other_values, +) -> int: + async with transaction_context(engine, connection) as conn: + result = await conn.execute( + sa.insert(users_pre_registration_details) + .values( + created_by=created_by, + pre_email=email, + product_name=product_name, + **other_values, + ) + .returning(users_pre_registration_details.c.id) + ) + pre_registration_id: int = result.scalar_one() + return pre_registration_id + + +async def list_user_pre_registrations( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + filter_by_pre_email: str | None = None, + filter_by_product_name: ProductName | UnSet = UnSet.VALUE, + filter_by_account_request_status: AccountRequestStatus | None = None, + pagination_limit: int = 50, + pagination_offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """Lists user pre-registrations with optional filters. + + Args: + engine: Database engine + connection: Optional existing connection + filter_by_pre_email: Filter by email pattern (SQL LIKE pattern) + filter_by_product_name: Filter by product name + filter_by_account_request_status: Filter by account request status + pagination_limit: Maximum number of results to return + pagination_offset: Number of results to skip (for pagination) + + Returns: + Tuple of (list of pre-registration records, total count) + """ + # Base query conditions + where_conditions = [] + + # Apply filters if provided + if filter_by_pre_email is not None: + where_conditions.append( + users_pre_registration_details.c.pre_email.ilike(f"%{filter_by_pre_email}%") + ) + + if not is_unset(filter_by_product_name): + where_conditions.append( + users_pre_registration_details.c.product_name == filter_by_product_name + ) + + if filter_by_account_request_status is not None: + where_conditions.append( + users_pre_registration_details.c.account_request_status + == filter_by_account_request_status + ) + + # Combine conditions + where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() + + # Create an alias for the users table for the created_by join + creator_users_alias = sa.alias(users, name="creator") + reviewer_users_alias = sa.alias(users, name="reviewer") + + # Count query for pagination + count_query = ( + sa.select(sa.func.count().label("total")) + .select_from(users_pre_registration_details) + .where(where_clause) + ) + + # Main query to get pre-registration data + main_query = ( + sa.select( + users_pre_registration_details.c.id, + users_pre_registration_details.c.user_id, + users_pre_registration_details.c.pre_email, + users_pre_registration_details.c.pre_first_name, + users_pre_registration_details.c.pre_last_name, + users_pre_registration_details.c.pre_phone, + users_pre_registration_details.c.institution, + users_pre_registration_details.c.address, + users_pre_registration_details.c.city, + users_pre_registration_details.c.state, + users_pre_registration_details.c.postal_code, + users_pre_registration_details.c.country, + users_pre_registration_details.c.product_name, + users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.extras, + users_pre_registration_details.c.created, + users_pre_registration_details.c.modified, + users_pre_registration_details.c.created_by, + creator_users_alias.c.name.label("created_by_name"), + users_pre_registration_details.c.account_request_reviewed_by, + reviewer_users_alias.c.name.label("reviewed_by_name"), + users_pre_registration_details.c.account_request_reviewed_at, + ) + .select_from( + users_pre_registration_details.outerjoin( + creator_users_alias, + users_pre_registration_details.c.created_by == creator_users_alias.c.id, + ).outerjoin( + reviewer_users_alias, + users_pre_registration_details.c.account_request_reviewed_by + == reviewer_users_alias.c.id, + ) + ) + .where(where_clause) + .order_by( + users_pre_registration_details.c.created.desc(), + users_pre_registration_details.c.pre_email, + ) + .limit(pagination_limit) + .offset(pagination_offset) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + # Get total count + count_result = await conn.execute(count_query) + total_count = count_result.scalar() + + # Get pre-registration records + result = await conn.execute(main_query) + records = result.mappings().all() + + return list(records), total_count + + +async def review_user_pre_registration( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + pre_registration_id: int, + reviewed_by: UserID, + new_status: AccountRequestStatus, +) -> None: + """Updates the account request status of a pre-registered user. + + Args: + engine: The database engine + connection: Optional existing connection + pre_registration_id: ID of the pre-registration record + reviewed_by: ID of the user who reviewed the request + new_status: New status (APPROVED or REJECTED) + """ + if new_status not in (AccountRequestStatus.APPROVED, AccountRequestStatus.REJECTED): + raise ValueError( + f"Invalid status for review: {new_status}. Must be APPROVED or REJECTED." + ) + + async with transaction_context(engine, connection) as conn: + await conn.execute( + users_pre_registration_details.update() + .values( + account_request_status=new_status, + account_request_reviewed_by=reviewed_by, + account_request_reviewed_at=sa.func.now(), + ) + .where(users_pre_registration_details.c.id == pre_registration_id) + ) diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index abf168b0c3b..64d8d068aeb 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -7,6 +7,7 @@ import sqlalchemy as sa from aiohttp import web +from common_library.users_enums import AccountRequestStatus from models_library.products import ProductName from simcore_postgres_database.models.users_details import ( users_pre_registration_details, @@ -66,3 +67,221 @@ async def test_create_user_pre_registration( assert record.created_by == created_by_user_id assert record.product_name == product_name assert record.institution == institution + + +async def test_review_user_pre_registration( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], +): + # Arrange + asyncpg_engine = get_asyncpg_engine(app) + + test_email = "review.test@example.com" + created_by_user_id = product_owner_user["id"] + reviewer_id = product_owner_user["id"] # Same user as creator for this test + institution = "Test Institution" + pre_registration_details: dict[str, Any] = { + "institution": institution, + "pre_first_name": "Review", + "pre_last_name": "Test", + } + + # Create a pre-registration to review + pre_registration_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=test_email, + created_by=created_by_user_id, + product_name=product_name, + **pre_registration_details, + ) + + # Act - review and approve the registration + new_status = AccountRequestStatus.APPROVED + await _users_repository.review_user_pre_registration( + asyncpg_engine, + pre_registration_id=pre_registration_id, + reviewed_by=reviewer_id, + new_status=new_status, + ) + + # Assert - Use list_user_pre_registrations to verify + registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_pre_email=test_email, + filter_by_product_name=product_name, + ) + + # Check count and that we found our registration + assert count == 1 + assert len(registrations) == 1 + + # Get the registration + reg = registrations[0] + + # Verify details + assert reg["id"] == pre_registration_id + assert reg["pre_email"] == test_email + assert reg["pre_first_name"] == "Review" + assert reg["pre_last_name"] == "Test" + assert reg["institution"] == institution + assert reg["product_name"] == product_name + assert reg["account_request_status"] == new_status + assert reg["created_by"] == created_by_user_id + assert reg["account_request_reviewed_by"] == reviewer_id + assert reg["account_request_reviewed_at"] is not None + assert reg["created_by_name"] == product_owner_user["name"] + assert reg["reviewed_by_name"] == product_owner_user["name"] + + # Clean up + async with asyncpg_engine.connect() as conn: + await conn.execute( + sa.delete(users_pre_registration_details).where( + users_pre_registration_details.c.id == pre_registration_id + ) + ) + await conn.commit() + + +async def test_list_user_pre_registrations( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], +): + # Arrange + asyncpg_engine = get_asyncpg_engine(app) + created_by_user_id = product_owner_user["id"] + + # Create multiple pre-registrations with different statuses + emails = [ + "test1@example.com", + "test2@example.com", + "test3@example.com", + "different@example.com", + ] + pre_reg_ids = [] + + # Create pending registrations + for i, email in enumerate(emails[:3]): + pre_reg_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=email, + created_by=created_by_user_id, + product_name=product_name, + pre_first_name=f"User{i+1}", + pre_last_name="Test", + institution="Test Institution", + ) + pre_reg_ids.append(pre_reg_id) + + # Create and approve one registration + await _users_repository.review_user_pre_registration( + asyncpg_engine, + pre_registration_id=pre_reg_ids[0], + reviewed_by=created_by_user_id, + new_status=AccountRequestStatus.APPROVED, + ) + + # Create and reject one registration + await _users_repository.review_user_pre_registration( + asyncpg_engine, + pre_registration_id=pre_reg_ids[1], + reviewed_by=created_by_user_id, + new_status=AccountRequestStatus.REJECTED, + ) + + # The third registration remains in PENDING status + + # Act & Assert - Test different filter combinations + + # 1. Get all registrations (should be 3) + all_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_product_name=product_name, + ) + assert count == 3 + assert len(all_registrations) == 3 + + # 2. Filter by email pattern (should match first 3 emails with "test") + test_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_pre_email="test", + filter_by_product_name=product_name, + ) + assert count == 3 + assert len(test_registrations) == 3 + assert all("test" in reg["pre_email"] for reg in test_registrations) + + # 3. Filter by status - APPROVED + approved_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_account_request_status=AccountRequestStatus.APPROVED, + filter_by_product_name=product_name, + ) + assert count == 1 + assert len(approved_registrations) == 1 + assert approved_registrations[0]["pre_email"] == emails[0] + assert ( + approved_registrations[0]["account_request_status"] + == AccountRequestStatus.APPROVED + ) + + # 4. Filter by status - REJECTED + rejected_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_account_request_status=AccountRequestStatus.REJECTED, + filter_by_product_name=product_name, + ) + assert count == 1 + assert len(rejected_registrations) == 1 + assert rejected_registrations[0]["pre_email"] == emails[1] + assert ( + rejected_registrations[0]["account_request_status"] + == AccountRequestStatus.REJECTED + ) + + # 5. Filter by status - PENDING + pending_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_account_request_status=AccountRequestStatus.PENDING, + filter_by_product_name=product_name, + ) + assert count == 1 + assert len(pending_registrations) == 1 + assert pending_registrations[0]["pre_email"] == emails[2] + assert ( + pending_registrations[0]["account_request_status"] + == AccountRequestStatus.PENDING + ) + + # 6. Test pagination + paginated_registrations, count = ( + await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_product_name=product_name, + pagination_limit=2, + pagination_offset=0, + ) + ) + assert count == 3 # Still shows total count of 3 + assert len(paginated_registrations) == 2 # But only returns 2 records + + # Get next page + page2_registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_product_name=product_name, + pagination_limit=2, + pagination_offset=2, + ) + assert count == 3 + assert len(page2_registrations) == 1 # Only 1 record on the second page + + # Clean up + async with asyncpg_engine.connect() as conn: + for pre_reg_id in pre_reg_ids: + await conn.execute( + sa.delete(users_pre_registration_details).where( + users_pre_registration_details.c.id == pre_reg_id + ) + ) + await conn.commit() From 6a60b047a8bdcd1abeddaa1f07847cb5dd8ffb21 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 15:39:42 +0200 Subject: [PATCH 27/55] =?UTF-8?q?=E2=9C=A8=20users:=20Enhance=20user=20pre?= =?UTF-8?q?-registration=20with=20optional=20linking=20to=20existing=20use?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/_users_repository.py | 49 ++++++++++++-- .../users/_users_rest.py | 1 + .../03/users/test_users_repository.py | 66 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index e74d2311b35..ecbf7e49b78 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -678,19 +678,54 @@ async def create_user_pre_registration( connection: AsyncConnection | None = None, *, email: str, - created_by: UserID, + created_by: UserID | None = None, product_name: ProductName, + link_to_existing_user: bool = True, **other_values, ) -> int: + """Creates a user pre-registration entry. + + Args: + engine: Database engine + connection: Optional existing connection + email: Email address for the pre-registration + created_by: ID of the user creating the pre-registration (None for anonymous) + product_name: Product name the user is requesting access to + link_to_existing_user: Whether to link the pre-registration to an existing user with the same email + **other_values: Additional values to insert in the pre-registration entry + + Returns: + ID of the created pre-registration + """ async with transaction_context(engine, connection) as conn: + # If link_to_existing_user is True, try to find a matching user + user_id = None + if link_to_existing_user: + result = await conn.execute( + sa.select(users.c.id).where(users.c.email == email) + ) + user = result.one_or_none() + if user: + user_id = user.id + + # Insert the pre-registration record + values = { + "pre_email": email, + "product_name": product_name, + **other_values, + } + + # Only add created_by if not None + if created_by is not None: + values["created_by"] = created_by + + # Add user_id if found + if user_id is not None: + values["user_id"] = user_id + result = await conn.execute( sa.insert(users_pre_registration_details) - .values( - created_by=created_by, - pre_email=email, - product_name=product_name, - **other_values, - ) + .values(**values) .returning(users_pre_registration_details.c.id) ) pre_registration_id: int = result.scalar_one() diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 98fe69a670b..ab05d97a43b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -1,3 +1,4 @@ +import logging from contextlib import suppress from aiohttp import web diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index 64d8d068aeb..4d81fb28e0b 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -5,6 +5,7 @@ from typing import Any +import pytest import sqlalchemy as sa from aiohttp import web from common_library.users_enums import AccountRequestStatus @@ -285,3 +286,68 @@ async def test_list_user_pre_registrations( ) ) await conn.commit() + + +@pytest.mark.parametrize( + "link_to_existing_user,expected_linked", [(True, True), (False, False)] +) +async def test_create_pre_registration_with_existing_user_linking( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], + link_to_existing_user: bool, + expected_linked: bool, +): + """Test that creating a pre-registration for an existing user correctly handles auto-linking.""" + # Arrange + asyncpg_engine = get_asyncpg_engine(app) + existing_user_id = product_owner_user["id"] + existing_user_email = product_owner_user["email"] + + # Act - Create pre-registration with the same email as product_owner_user + pre_registration_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=existing_user_email, # Same email as existing user + created_by=existing_user_id, + product_name=product_name, + link_to_existing_user=link_to_existing_user, # Parameter to test + pre_first_name="Link-Test", + pre_last_name="User", + institution=f"{'Auto-linked' if link_to_existing_user else 'No-link'} Institution", + ) + + # Assert - Verify through list_user_pre_registrations + registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_pre_email=existing_user_email, + filter_by_product_name=product_name, + ) + + # Verify count and that we found our registration + assert count == 1 + assert len(registrations) == 1 + + # Get the registration + reg = registrations[0] + + # Verify linking behavior based on parameter + assert reg["id"] == pre_registration_id + assert reg["pre_email"] == existing_user_email + + # When True, user_id should be set to the existing user ID + # When False, user_id should be None + if expected_linked: + assert ( + reg["user_id"] == existing_user_id + ), "Should be linked to the existing user" + else: + assert reg["user_id"] is None, "Should NOT be linked to any user" + + # Clean up + async with asyncpg_engine.connect() as conn: + await conn.execute( + sa.delete(users_pre_registration_details).where( + users_pre_registration_details.c.id == pre_registration_id + ) + ) + await conn.commit() From d4f19cfc615ebb9476f7a882bcfd50eded4f59ce Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 16:00:30 +0200 Subject: [PATCH 28/55] split tests --- .../users/_users_rest.py | 6 +- .../with_dbs/03/test_users_rest_models.py | 120 +++++ ...t_users.py => test_users_rest_profiles.py} | 455 +----------------- .../03/test_users_rest_registration.py | 408 ++++++++++++++++ 4 files changed, 534 insertions(+), 455 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py rename services/web/server/tests/unit/with_dbs/03/{test_users.py => test_users_rest_profiles.py} (55%) create mode 100644 services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index ab05d97a43b..e968be7e042 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -2,6 +2,7 @@ from contextlib import suppress from aiohttp import web +from common_library.users_enums import AccountRequestStatus from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, @@ -178,8 +179,9 @@ async def list_users_for_admin(request: web.Request) -> web.Response: users, total_count = await _users_service.list_users_as_admin( request.app, - filter_account_request_status={"PENDING": AccountRequestStatus.PENDING}.get( - query_params.status or "" + product_name=req_ctx.product_name, + filter_account_request_status=( + AccountRequestStatus(query_params.status) if query_params.status else None ), pagination_limit=query_params.limit, pagination_offset=query_params.offset, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py new file mode 100644 index 00000000000..ef68295a7f0 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py @@ -0,0 +1,120 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import sys +from copy import deepcopy +from typing import Any + +import pytest +from faker import Faker +from models_library.api_schemas_webserver.auth import AccountRequestInfo +from pytest_simcore.helpers.faker_factories import random_pre_registration_details +from simcore_service_webserver.users._common.schemas import ( + MAX_BYTES_SIZE_EXTRAS, + PreRegisteredUserGet, +) + + +@pytest.fixture +def account_request_form(faker: Faker) -> dict[str, Any]: + # This is AccountRequestInfo.form + form = { + "firstName": faker.first_name(), + "lastName": faker.last_name(), + "email": faker.email(), + "phone": faker.phone_number(), + "company": faker.company(), + # billing info + "address": faker.address().replace("\n", ", "), + "city": faker.city(), + "postalCode": faker.postcode(), + "country": faker.country(), + # extras + "application": faker.word(), + "description": faker.sentence(), + "hear": faker.word(), + "privacyPolicy": True, + "eula": True, + } + + # keeps in sync fields from example and this fixture + assert set(form) == set(AccountRequestInfo.model_json_schema()["example"]["form"]) + return form + + +@pytest.mark.parametrize( + "institution_key", + [ + "institution", + "companyName", + "company", + "university", + "universityName", + ], +) +def test_preuserprofile_parse_model_from_request_form_data( + account_request_form: dict[str, Any], + institution_key: str, +): + data = deepcopy(account_request_form) + data[institution_key] = data.pop("company") + data["comment"] = "extra comment" + + # pre-processors + pre_user_profile = PreRegisteredUserGet(**data) + + print(pre_user_profile.model_dump_json(indent=1)) + + # institution aliases + assert pre_user_profile.institution == account_request_form["company"] + + # extras + assert { + "application", + "description", + "hear", + "privacyPolicy", + "eula", + "comment", + } == set(pre_user_profile.extras) + assert pre_user_profile.extras["comment"] == "extra comment" + + +def test_preuserprofile_parse_model_without_extras( + account_request_form: dict[str, Any], +): + required = { + f.alias or f_name + for f_name, f in PreRegisteredUserGet.model_fields.items() + if f.is_required() + } + data = {k: account_request_form[k] for k in required} + assert not PreRegisteredUserGet(**data).extras + + +def test_preuserprofile_max_bytes_size_extras_limits(faker: Faker): + data = random_pre_registration_details(faker) + data_size = sys.getsizeof(data["extras"]) + + assert data_size < MAX_BYTES_SIZE_EXTRAS + + +@pytest.mark.parametrize( + "given_name", ["PEDrO-luis", "pedro luis", " pedro LUiS ", "pedro lUiS "] +) +def test_preuserprofile_pre_given_names( + given_name: str, + account_request_form: dict[str, Any], +): + account_request_form["firstName"] = given_name + account_request_form["lastName"] = given_name + + pre_user_profile = PreRegisteredUserGet(**account_request_form) + print(pre_user_profile.model_dump_json(indent=1)) + assert pre_user_profile.first_name in ["Pedro-Luis", "Pedro Luis"] + assert pre_user_profile.first_name == pre_user_profile.last_name 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_rest_profiles.py similarity index 55% rename from services/web/server/tests/unit/with_dbs/03/test_users.py rename to services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index c709d89701b..c88958f38b9 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -7,7 +7,6 @@ import functools -import sys from collections.abc import AsyncIterable from copy import deepcopy from http import HTTPStatus @@ -15,26 +14,17 @@ from unittest.mock import MagicMock, Mock import pytest -import simcore_service_webserver.login._auth_service from aiohttp.test_utils import TestClient from aiopg.sa.connection import SAConnection -from common_library.users_enums import UserRole, UserStatus -from faker import Faker -from models_library.api_schemas_webserver.auth import AccountRequestInfo +from common_library.users_enums import UserRole from models_library.api_schemas_webserver.groups import GroupUserGet from models_library.api_schemas_webserver.users import ( MyProfileGet, - UserForAdminGet, UserGet, ) -from models_library.products import ProductName from psycopg2 import OperationalError from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.faker_factories import ( - DEFAULT_TEST_PASSWORD, - random_pre_registration_details, -) from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from pytest_simcore.helpers.webserver_login import ( NewUser, @@ -42,11 +32,7 @@ switch_client_session_to, ) from servicelib.aiohttp import status -from servicelib.rest_constants import RESPONSE_MODEL_POLICY, X_PRODUCT_NAME_HEADER -from simcore_service_webserver.users._common.schemas import ( - MAX_BYTES_SIZE_EXTRAS, - PreRegisteredUserGet, -) +from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_service_webserver.users._preferences_service import ( get_frontend_user_preferences_aggregation, ) @@ -223,7 +209,6 @@ async def test_search_users_by_partial_email( @pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_search_users_by_partial_username( - user_role: UserRole, logged_user: UserInfoDict, client: TestClient, partial_username: str, @@ -605,439 +590,3 @@ async def test_get_profile_with_failing_db_connection( data, error = await assert_status(resp, expected) assert not data assert error["message"] == "Authentication service is temporary unavailable" - - -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - *( - (role, status.HTTP_403_FORBIDDEN) - for role in UserRole - if role not in {UserRole.PRODUCT_OWNER, UserRole.ANONYMOUS} - ), - (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), - ], -) -async def test_access_rights_on_search_users_only_product_owners_can_access( - client: TestClient, - logged_user: UserInfoDict, - expected: HTTPStatus, -): - assert client.app - - url = client.app.router["search_users_for_admin"].url_for() - assert url.path == "/v0/admin/users:search" - - resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) - await assert_status(resp, expected) - - -@pytest.fixture -def account_request_form(faker: Faker) -> dict[str, Any]: - # This is AccountRequestInfo.form - form = { - "firstName": faker.first_name(), - "lastName": faker.last_name(), - "email": faker.email(), - "phone": faker.phone_number(), - "company": faker.company(), - # billing info - "address": faker.address().replace("\n", ", "), - "city": faker.city(), - "postalCode": faker.postcode(), - "country": faker.country(), - # extras - "application": faker.word(), - "description": faker.sentence(), - "hear": faker.word(), - "privacyPolicy": True, - "eula": True, - } - - # keeps in sync fields from example and this fixture - assert set(form) == set(AccountRequestInfo.model_json_schema()["example"]["form"]) - return form - - -@pytest.mark.acceptance_test( - "pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138" -) -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_search_and_pre_registration( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], -): - assert client.app - - # ONLY in `users` and NOT `users_pre_registration_details` - resp = await client.get( - "/v0/admin/users:search", params={"email": logged_user["email"]} - ) - assert resp.status == status.HTTP_200_OK - - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - got = UserForAdminGet( - **found[0], - institution=None, - address=None, - city=None, - state=None, - postal_code=None, - country=None, - ) - expected = { - "first_name": logged_user.get("first_name"), - "last_name": logged_user.get("last_name"), - "email": logged_user["email"], - "institution": None, - "phone": logged_user.get("phone"), - "address": None, - "city": None, - "state": None, - "postal_code": None, - "country": None, - "extras": {}, - "registered": True, - "status": UserStatus.ACTIVE, - } - assert got.model_dump(include=set(expected)) == expected - - # NOT in `users` and ONLY `users_pre_registration_details` - - # create pre-registration - resp = await client.post("/v0/admin/users:pre-register", json=account_request_form) - assert resp.status == status.HTTP_200_OK - - resp = await client.get( - "/v0/admin/users:search", params={"email": account_request_form["email"]} - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - got = UserForAdminGet(**found[0], state=None, status=None) - - assert got.model_dump(include={"registered", "status"}) == { - "registered": False, - "status": None, - } - - # Emulating registration of pre-register user - new_user = ( - await simcore_service_webserver.login._auth_service.create_user( # noqa: SLF001 - client.app, - email=account_request_form["email"], - password=DEFAULT_TEST_PASSWORD, - status_upon_creation=UserStatus.ACTIVE, - expires_at=None, - ) - ) - - resp = await client.get( - "/v0/admin/users:search", params={"email": account_request_form["email"]} - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - got = UserForAdminGet(**found[0], state=None) - assert got.model_dump(include={"registered", "status"}) == { - "registered": True, - "status": new_user["status"].name, - } - - -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_list_users_for_admin( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, -): - assert client.app - - # Create some pre-registered users - pre_registered_users = [] - for _ in range(3): - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = faker.email() - - resp = await client.post("/v0/admin/users:pre-register", json=form_data) - pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registered_users.append(pre_registered_data) - - # Register one of the pre-registered users - new_user = await simcore_service_webserver.login._auth_service.create_user( - client.app, - email=pre_registered_users[0]["email"], - password=DEFAULT_TEST_PASSWORD, - status_upon_creation=UserStatus.ACTIVE, - expires_at=None, - ) - - # Test pagination (page 1, limit 2) - url = client.app.router["list_users_for_admin"].url_for() - resp = await client.get(f"{url}", params={"page": 1, "per_page": 2}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Verify pagination structure - assert "items" in data - assert "pagination" in data - assert data["pagination"]["page"] == 1 - assert data["pagination"]["per_page"] == 2 - assert data["pagination"]["total"] >= 1 # At least the logged user - - # Test pagination (page 2, limit 2) - resp = await client.get(f"{url}", params={"page": 2, "per_page": 2}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert data["pagination"]["page"] == 2 - - # Test filtering by approval status (only approved users) - resp = await client.get(f"{url}", params={"approved": True}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # All items should be registered users with status - for item in data["items"]: - user = UserForAdminGet(**item) - assert user.registered is True - assert user.status is not None - - # Test filtering by approval status (only non-approved users) - resp = await client.get(f"{url}", params={"approved": False}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # All items should be non-registered or non-approved users - assert len(data["items"]) >= 2 # We created at least 2 non-registered users - for item in data["items"]: - user = UserForAdminGet(**item) - assert user.registered is False or user.status != UserStatus.ACTIVE - - # Combine pagination and filtering - resp = await client.get( - f"{url}", params={"approved": True, "page": 1, "per_page": 1} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(data["items"]) == 1 - assert data["pagination"]["page"] == 1 - assert data["pagination"]["per_page"] == 1 - - # Verify content of a specific user - resp = await client.get(f"{url}", params={"approved": True}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Find the newly registered user in the list - registered_user = next( - (item for item in data["items"] if item["email"] == new_user["email"]), - None, - ) - assert registered_user is not None - - user = UserForAdminGet(**registered_user) - assert user.registered is True - assert user.status == UserStatus.ACTIVE - assert user.email == new_user["email"] - - -@pytest.mark.parametrize( - "institution_key", - [ - "institution", - "companyName", - "company", - "university", - "universityName", - ], -) -def test_preuserprofile_parse_model_from_request_form_data( - account_request_form: dict[str, Any], - institution_key: str, -): - data = deepcopy(account_request_form) - data[institution_key] = data.pop("company") - data["comment"] = "extra comment" - - # pre-processors - pre_user_profile = PreRegisteredUserGet(**data) - - print(pre_user_profile.model_dump_json(indent=1)) - - # institution aliases - assert pre_user_profile.institution == account_request_form["company"] - - # extras - assert { - "application", - "description", - "hear", - "privacyPolicy", - "eula", - "comment", - } == set(pre_user_profile.extras) - assert pre_user_profile.extras["comment"] == "extra comment" - - -def test_preuserprofile_parse_model_without_extras( - account_request_form: dict[str, Any], -): - required = { - f.alias or f_name - for f_name, f in PreRegisteredUserGet.model_fields.items() - if f.is_required() - } - data = {k: account_request_form[k] for k in required} - assert not PreRegisteredUserGet(**data).extras - - -def test_preuserprofile_max_bytes_size_extras_limits(faker: Faker): - data = random_pre_registration_details(faker) - data_size = sys.getsizeof(data["extras"]) - - assert data_size < MAX_BYTES_SIZE_EXTRAS - - -@pytest.mark.parametrize( - "given_name", ["PEDrO-luis", "pedro luis", " pedro LUiS ", "pedro lUiS "] -) -def test_preuserprofile_pre_given_names( - given_name: str, - account_request_form: dict[str, Any], -): - account_request_form["firstName"] = given_name - account_request_form["lastName"] = given_name - - pre_user_profile = PreRegisteredUserGet(**account_request_form) - print(pre_user_profile.model_dump_json(indent=1)) - assert pre_user_profile.first_name in ["Pedro-Luis", "Pedro Luis"] - assert pre_user_profile.first_name == pre_user_profile.last_name - - -@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) -async def test_pending_users_management( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, -): - """Test the management of pending users: - - list pending users - - approve user account - - reject user account - - resend confirmation email - """ - assert client.app - - # Create some pre-registered users - pre_registered_users = [] - for _ in range(3): - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = faker.email() - - resp = await client.post( - "/v0/admin/users:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registered_users.append(pre_registered_data) - - # 1. List pending users (not yet approved) - url = client.app.router["list_users_for_admin"].url_for() - resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Verify response structure and content - assert "items" in data - assert "pagination" in data - assert len(data["items"]) >= 3 # At least our 3 pre-registered users - - # Verify each pre-registered user is in the list - for pre_user in pre_registered_users: - found = next( - (item for item in data["items"] if item["email"] == pre_user["email"]), - None, - ) - assert found is not None - assert found["registered"] is False - - # 2. Approve one of the pre-registered users - approval_data = {"email": pre_registered_users[0]["email"]} - resp = await client.post( - "/v0/admin/users:approve", - json=approval_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - approved_data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Verify response structure - assert "invitationLink" in approved_data - assert approved_data.get("email") == pre_registered_users[0]["email"] - - # Verify the user is no longer in the pending list - resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # The approved user should no longer be in the pending list - assert all( - item["email"] != pre_registered_users[0]["email"] for item in data["items"] - ) - - # 3. Reject another pre-registered user - rejection_data = {"email": pre_registered_users[1]["email"]} - resp = await client.post( - "/v0/admin/users:reject", - json=rejection_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # Verify the rejected user is no longer in the pending list - resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert all( - item["email"] != pre_registered_users[1]["email"] for item in data["items"] - ) - - # 4. Resend confirmation email to the approved user - resend_data = {"email": pre_registered_users[0]["email"]} - resp = await client.post( - "/v0/admin/users:resendConfirmationEmail", - json=resend_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # Search for the approved user to confirm their status - resp = await client.get( - "/v0/admin/users:search", - params={"email": pre_registered_users[0]["email"]}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found_users, _ = await assert_status(resp, status.HTTP_200_OK) - - # Should find exactly one user - assert len(found_users) == 1 - found_user = UserForAdminGet(**found_users[0]) - - # User should be registered but in CONFIRMATION_PENDING status - assert found_user.registered is True - assert found_user.status == UserStatus.CONFIRMATION_PENDING diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py new file mode 100644 index 00000000000..78f02915cfc --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -0,0 +1,408 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from http import HTTPStatus +from typing import Any + +import pytest +import simcore_service_webserver.login._auth_service +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole, UserStatus +from faker import Faker +from models_library.api_schemas_webserver.auth import AccountRequestInfo +from models_library.api_schemas_webserver.users import ( + UserForAdminGet, +) +from models_library.products import ProductName +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import ( + DEFAULT_TEST_PASSWORD, +) +from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict +from pytest_simcore.helpers.webserver_login import ( + UserInfoDict, +) +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER + + +@pytest.fixture +def app_environment( + app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch +) -> EnvVarsDict: + # disables GC and DB-listener + return app_environment | setenvs_from_dict( + monkeypatch, + { + "WEBSERVER_GARBAGE_COLLECTOR": "null", + "WEBSERVER_DB_LISTENER": "0", + }, + ) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + *( + (role, status.HTTP_403_FORBIDDEN) + for role in UserRole + if role not in {UserRole.PRODUCT_OWNER, UserRole.ANONYMOUS} + ), + (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), + ], +) +async def test_access_rights_on_search_users_only_product_owners_can_access( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, +): + assert client.app + + url = client.app.router["search_users_for_admin"].url_for() + assert url.path == "/v0/admin/users:search" + + resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) + await assert_status(resp, expected) + + +@pytest.fixture +def account_request_form(faker: Faker) -> dict[str, Any]: + # This is AccountRequestInfo.form + form = { + "firstName": faker.first_name(), + "lastName": faker.last_name(), + "email": faker.email(), + "phone": faker.phone_number(), + "company": faker.company(), + # billing info + "address": faker.address().replace("\n", ", "), + "city": faker.city(), + "postalCode": faker.postcode(), + "country": faker.country(), + # extras + "application": faker.word(), + "description": faker.sentence(), + "hear": faker.word(), + "privacyPolicy": True, + "eula": True, + } + + # keeps in sync fields from example and this fixture + assert set(form) == set(AccountRequestInfo.model_json_schema()["example"]["form"]) + return form + + +@pytest.mark.acceptance_test( + "pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138" +) +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_search_and_pre_registration( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], +): + assert client.app + + # ONLY in `users` and NOT `users_pre_registration_details` + resp = await client.get( + "/v0/admin/users:search", params={"email": logged_user["email"]} + ) + assert resp.status == status.HTTP_200_OK + + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + got = UserForAdminGet( + **found[0], + institution=None, + address=None, + city=None, + state=None, + postal_code=None, + country=None, + ) + expected = { + "first_name": logged_user.get("first_name"), + "last_name": logged_user.get("last_name"), + "email": logged_user["email"], + "institution": None, + "phone": logged_user.get("phone"), + "address": None, + "city": None, + "state": None, + "postal_code": None, + "country": None, + "extras": {}, + "registered": True, + "status": UserStatus.ACTIVE, + } + assert got.model_dump(include=set(expected)) == expected + + # NOT in `users` and ONLY `users_pre_registration_details` + + # create pre-registration + resp = await client.post("/v0/admin/users:pre-register", json=account_request_form) + assert resp.status == status.HTTP_200_OK + + resp = await client.get( + "/v0/admin/users:search", params={"email": account_request_form["email"]} + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + got = UserForAdminGet(**found[0], state=None, status=None) + + assert got.model_dump(include={"registered", "status"}) == { + "registered": False, + "status": None, + } + + # Emulating registration of pre-register user + new_user = ( + await simcore_service_webserver.login._auth_service.create_user( # noqa: SLF001 + client.app, + email=account_request_form["email"], + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + ) + + resp = await client.get( + "/v0/admin/users:search", params={"email": account_request_form["email"]} + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + got = UserForAdminGet(**found[0], state=None) + assert got.model_dump(include={"registered", "status"}) == { + "registered": True, + "status": new_user["status"].name, + } + + +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_list_users_for_admin( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, +): + assert client.app + + # Create some pre-registered users + pre_registered_users = [] + for _ in range(3): + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post("/v0/admin/users:pre-register", json=form_data) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_users.append(pre_registered_data) + + # Register one of the pre-registered users + new_user = await simcore_service_webserver.login._auth_service.create_user( + client.app, + email=pre_registered_users[0]["email"], + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + # Test pagination (page 1, limit 2) + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get(f"{url}", params={"page": 1, "per_page": 2}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify pagination structure + assert "items" in data + assert "pagination" in data + assert data["pagination"]["page"] == 1 + assert data["pagination"]["per_page"] == 2 + assert data["pagination"]["total"] >= 1 # At least the logged user + + # Test pagination (page 2, limit 2) + resp = await client.get(f"{url}", params={"page": 2, "per_page": 2}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["pagination"]["page"] == 2 + + # Test filtering by approval status (only approved users) + resp = await client.get(f"{url}", params={"approved": True}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # All items should be registered users with status + for item in data["items"]: + user = UserForAdminGet(**item) + assert user.registered is True + assert user.status is not None + + # Test filtering by approval status (only non-approved users) + resp = await client.get(f"{url}", params={"approved": False}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # All items should be non-registered or non-approved users + assert len(data["items"]) >= 2 # We created at least 2 non-registered users + for item in data["items"]: + user = UserForAdminGet(**item) + assert user.registered is False or user.status != UserStatus.ACTIVE + + # Combine pagination and filtering + resp = await client.get( + f"{url}", params={"approved": True, "page": 1, "per_page": 1} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data["items"]) == 1 + assert data["pagination"]["page"] == 1 + assert data["pagination"]["per_page"] == 1 + + # Verify content of a specific user + resp = await client.get(f"{url}", params={"approved": True}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Find the newly registered user in the list + registered_user = next( + (item for item in data["items"] if item["email"] == new_user["email"]), + None, + ) + assert registered_user is not None + + user = UserForAdminGet(**registered_user) + assert user.registered is True + assert user.status == UserStatus.ACTIVE + assert user.email == new_user["email"] + + +@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) +async def test_pending_users_management( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, +): + """Test the management of pending users: + - list pending users + - approve user account + - reject user account + - resend confirmation email + """ + assert client.app + + # Create some pre-registered users + pre_registered_users = [] + for _ in range(3): + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post( + "/v0/admin/users:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_users.append(pre_registered_data) + + # 1. List pending users (not yet approved) + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify response structure and content + assert "items" in data + assert "pagination" in data + assert len(data["items"]) >= 3 # At least our 3 pre-registered users + + # Verify each pre-registered user is in the list + for pre_user in pre_registered_users: + found = next( + (item for item in data["items"] if item["email"] == pre_user["email"]), + None, + ) + assert found is not None + assert found["registered"] is False + + # 2. Approve one of the pre-registered users + approval_data = {"email": pre_registered_users[0]["email"]} + resp = await client.post( + "/v0/admin/users:approve", + json=approval_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + approved_data, _ = await assert_status(resp, status.HTTP_200_OK) + + # Verify response structure + assert "invitationLink" in approved_data + assert approved_data.get("email") == pre_registered_users[0]["email"] + + # Verify the user is no longer in the pending list + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # The approved user should no longer be in the pending list + assert all( + item["email"] != pre_registered_users[0]["email"] for item in data["items"] + ) + + # 3. Reject another pre-registered user + rejection_data = {"email": pre_registered_users[1]["email"]} + resp = await client.post( + "/v0/admin/users:reject", + json=rejection_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Verify the rejected user is no longer in the pending list + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert all( + item["email"] != pre_registered_users[1]["email"] for item in data["items"] + ) + + # 4. Resend confirmation email to the approved user + resend_data = {"email": pre_registered_users[0]["email"]} + resp = await client.post( + "/v0/admin/users:resendConfirmationEmail", + json=resend_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Search for the approved user to confirm their status + resp = await client.get( + "/v0/admin/users:search", + params={"email": pre_registered_users[0]["email"]}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found_users, _ = await assert_status(resp, status.HTTP_200_OK) + + # Should find exactly one user + assert len(found_users) == 1 + found_user = UserForAdminGet(**found_users[0]) + + # User should be registered but in CONFIRMATION_PENDING status + assert found_user.registered is True + assert found_user.status == UserStatus.CONFIRMATION_PENDING From cc8838881ac55b37703e5557c0f504d7a59c853e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 16:47:10 +0200 Subject: [PATCH 29/55] =?UTF-8?q?=E2=9C=A8=20users:=20Refactor=20user=20se?= =?UTF-8?q?arch=20and=20listing=20functions=20to=20merge=20pre-registered?= =?UTF-8?q?=20and=20registered=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/_users_repository.py | 424 ++++++++++-------- .../users/_users_service.py | 16 +- .../03/users/test_users_repository.py | 130 ++++++ 3 files changed, 383 insertions(+), 187 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index ecbf7e49b78..452dac323cf 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -319,186 +319,6 @@ async def update_user_status( ) -async def search_users_and_get_profile( - engine: AsyncEngine, - connection: AsyncConnection | None = None, - *, - email_like: str, - product_name: ProductName | None = None, -) -> list[Row]: - users_alias = sa.alias(users, name="users_alias") - - invited_by = ( - sa.select( - users_alias.c.name, - ) - .where(users_pre_registration_details.c.created_by == users_alias.c.id) - .label("invited_by") - ) - - async with pass_or_acquire_connection(engine, connection) as conn: - columns = ( - users.c.first_name, - users.c.last_name, - users.c.email, - users.c.phone, - users_pre_registration_details.c.pre_email, - users_pre_registration_details.c.pre_first_name, - users_pre_registration_details.c.pre_last_name, - users_pre_registration_details.c.institution, - users_pre_registration_details.c.pre_phone, - users_pre_registration_details.c.address, - users_pre_registration_details.c.city, - users_pre_registration_details.c.state, - users_pre_registration_details.c.postal_code, - users_pre_registration_details.c.country, - users_pre_registration_details.c.user_id, - users_pre_registration_details.c.extras, - users.c.status, - invited_by, - ) - - join_condition = users.c.id == users_pre_registration_details.c.user_id - if product_name: - join_condition = join_condition & ( - users_pre_registration_details.c.product_name == product_name - ) - - left_outer_join = ( - sa.select(*columns) - .select_from( - users_pre_registration_details.outerjoin(users, join_condition) - ) - .where(users_pre_registration_details.c.pre_email.like(email_like)) - ) - right_outer_join = ( - sa.select(*columns) - .select_from( - users.outerjoin( - users_pre_registration_details, - join_condition, - ) - ) - .where(users.c.email.like(email_like)) - ) - - result = await conn.stream(sa.union(left_outer_join, right_outer_join)) - return [row async for row in result] - - -async def list_users_for_admin( - engine: AsyncEngine, - connection: AsyncConnection | None = None, - *, - product_name: ProductName, - filter_account_request_status: AccountRequestStatus | None = None, - filter_include_deleted: bool = False, - pagination_limit: int = 50, - pagination_offset: int = 0, -) -> tuple[list[dict[str, Any]], int]: - """ - Gets users data for admin with pagination support using SQLAlchemy expressions - - Returns: - Tuple of (list of user data, total count) - """ - joined_user_tables = users.outerjoin( - users_pre_registration_details, - (users.c.id == users_pre_registration_details.c.user_id) - & (users_pre_registration_details.c.product_name == product_name), - ) - - where_conditions = [] - if not filter_include_deleted: - where_conditions.append(users.c.status != UserStatus.DELETED) - - if filter_account_request_status is not None: - where_conditions.append( - users_pre_registration_details.c.account_request_status - == filter_account_request_status - ) - - where_clause = sa.and_(*where_conditions) if where_conditions else sa.true() - - # Count query - count_query = ( - sa.select(sa.func.count().label("total")) - .select_from(joined_user_tables) - .where(where_clause) - ) - - # Create an alias for the users table to use in the subquery - users_alias = sa.alias(users, name="creators") - invited_by = ( - sa.select( - users_alias.c.name, - ) - .where( - users_pre_registration_details.c.created_by.isnot(None) - & (users_pre_registration_details.c.created_by == users_alias.c.id) - ) - .correlate(None) - .scalar_subquery() - .label("invited_by") - ) - - # Main query to get user data - main_query = ( - sa.select( - users_pre_registration_details.c.pre_email, # unique - users_pre_registration_details.c.pre_first_name, - users_pre_registration_details.c.pre_last_name, - users_pre_registration_details.c.institution, - users_pre_registration_details.c.pre_phone, - users_pre_registration_details.c.address, - users_pre_registration_details.c.city, - users_pre_registration_details.c.state, - users_pre_registration_details.c.postal_code, - users_pre_registration_details.c.country, - users_pre_registration_details.c.user_id, - users_pre_registration_details.c.extras, - users_pre_registration_details.c.created, - users_pre_registration_details.c.account_request_status, - users.c.id.label("user_id"), - users.c.name.label("user_name"), - users.c.first_name, - users.c.last_name, - users.c.email, - users.c.phone, - users.c.created_at, - users.c.status, - invited_by, - ) - .select_from(joined_user_tables) - .where(where_clause) - .order_by( - users_pre_registration_details.c.created.desc(), # newest pre-registered first - users_pre_registration_details.c.pre_email, - ) - .limit(pagination_limit) - .offset(pagination_offset) - ) - - _logger.debug( - "%s\n%s\n%s\n%s", - "-" * 100, - as_postgres_sql_query_str(main_query), - "-" * 100, - as_postgres_sql_query_str(count_query), - ) - - async with pass_or_acquire_connection(engine, connection) as conn: - # Get total count - count_result = await conn.execute(count_query) - total_count = count_result.scalar() - - # Get user records - result = await conn.execute(main_query) - records = result.mappings().all() - - return list(records), total_count - - async def get_user_products( engine: AsyncEngine, connection: AsyncConnection | None = None, @@ -673,6 +493,11 @@ async def update_user_profile( raise # not due to name duplication +# +# PRE-REGISTRATION +# + + async def create_user_pre_registration( engine: AsyncEngine, connection: AsyncConnection | None = None, @@ -879,3 +704,242 @@ async def review_user_pre_registration( ) .where(users_pre_registration_details.c.id == pre_registration_id) ) + + +# +# PRE AND REGISTERED USERS +# + + +async def search_merged_pre_and_registered_users( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + email_like: str, + product_name: ProductName | None = None, +) -> list[Row]: + users_alias = sa.alias(users, name="users_alias") + + invited_by = ( + sa.select( + users_alias.c.name, + ) + .where(users_pre_registration_details.c.created_by == users_alias.c.id) + .label("invited_by") + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + columns = ( + users.c.first_name, + users.c.last_name, + users.c.email, + users.c.phone, + users_pre_registration_details.c.pre_email, + users_pre_registration_details.c.pre_first_name, + users_pre_registration_details.c.pre_last_name, + users_pre_registration_details.c.institution, + users_pre_registration_details.c.pre_phone, + users_pre_registration_details.c.address, + users_pre_registration_details.c.city, + users_pre_registration_details.c.state, + users_pre_registration_details.c.postal_code, + users_pre_registration_details.c.country, + users_pre_registration_details.c.user_id, + users_pre_registration_details.c.extras, + users.c.status, + invited_by, + ) + + join_condition = users.c.id == users_pre_registration_details.c.user_id + if product_name: + join_condition = join_condition & ( + users_pre_registration_details.c.product_name == product_name + ) + + left_outer_join = ( + sa.select(*columns) + .select_from( + users_pre_registration_details.outerjoin(users, join_condition) + ) + .where(users_pre_registration_details.c.pre_email.like(email_like)) + ) + right_outer_join = ( + sa.select(*columns) + .select_from( + users.outerjoin( + users_pre_registration_details, + join_condition, + ) + ) + .where(users.c.email.like(email_like)) + ) + + result = await conn.stream(sa.union(left_outer_join, right_outer_join)) + return [row async for row in result] + + +async def list_merged_pre_and_registered_users( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + filter_account_request_status: AccountRequestStatus | None = None, + filter_include_deleted: bool = False, + pagination_limit: int = 50, + pagination_offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """Retrieves and merges users from both users and pre-registration tables. + + This returns: + 1. Users who are registered with the platform (in users table) + 2. Users who are pre-registered (in users_pre_registration_details table) + 3. Users who are both registered and pre-registered + + Args: + engine: Database engine + connection: Optional existing connection + product_name: Product name to filter by + filter_account_request_status: Optional filter by account request status + filter_include_deleted: Whether to include deleted users + pagination_limit: Maximum number of results to return + pagination_offset: Number of results to skip (for pagination) + + Returns: + Tuple of (list of merged user data, total count) + """ + # Create alias for users table for creator and reviewer lookups + creators_alias = sa.alias(users, name="creators") + reviewers_alias = sa.alias(users, name="reviewers") + + # Query for pre-registered users + invited_by = ( + sa.select( + creators_alias.c.name, + ) + .where(users_pre_registration_details.c.created_by == creators_alias.c.id) + .correlate(None) + .scalar_subquery() + .label("invited_by") + ) + + # Base where conditions for both queries + pre_reg_where = [users_pre_registration_details.c.product_name == product_name] + users_where = [] + + # Add account request status filter if specified + if filter_account_request_status is not None: + pre_reg_where.append( + users_pre_registration_details.c.account_request_status + == filter_account_request_status + ) + + # Add filter for deleted users + if not filter_include_deleted: + users_where.append(users.c.status != UserStatus.DELETED) + + # Query for pre-registered users that are not yet in the users table + # We need to left join with users to identify if the pre-registered user is already in the system + pre_reg_query = ( + sa.select( + users_pre_registration_details.c.pre_email.label("email"), + users_pre_registration_details.c.pre_first_name.label("first_name"), + users_pre_registration_details.c.pre_last_name.label("last_name"), + users_pre_registration_details.c.pre_phone.label("phone"), + users_pre_registration_details.c.institution, + users_pre_registration_details.c.address, + users_pre_registration_details.c.city, + users_pre_registration_details.c.state, + users_pre_registration_details.c.postal_code, + users_pre_registration_details.c.country, + users_pre_registration_details.c.user_id, + users_pre_registration_details.c.extras, + users_pre_registration_details.c.created, + users_pre_registration_details.c.account_request_status, + users.c.id.label("user_id"), + users.c.name.label("user_name"), + users.c.status, + users.c.created_at, + invited_by, + sa.literal(True).label("is_pre_registered"), + ) + .select_from( + users_pre_registration_details.outerjoin( + users, users_pre_registration_details.c.user_id == users.c.id + ) + ) + .where(sa.and_(*pre_reg_where)) + ) + + # Query for users that are associated with the product through groups + users_query = ( + sa.select( + users.c.email, + users.c.first_name, + users.c.last_name, + users.c.phone, + sa.literal(None).label("institution"), + sa.literal(None).label("address"), + sa.literal(None).label("city"), + sa.literal(None).label("state"), + sa.literal(None).label("postal_code"), + sa.literal(None).label("country"), + users.c.id.label("user_id"), + sa.literal(None).label("extras"), + users.c.created_at.label("created"), + sa.literal(None).label("account_request_status"), + users.c.id.label("user_id"), + users.c.name.label("user_name"), + users.c.status, + users.c.created_at, + sa.literal(None).label("invited_by"), + sa.literal(False).label("is_pre_registered"), + ) + .select_from( + users.join(user_to_groups, user_to_groups.c.uid == users.c.id) + .join(groups, groups.c.gid == user_to_groups.c.gid) + .join(products, products.c.group_id == groups.c.gid) + ) + .where(sa.and_(products.c.name == product_name, *users_where)) + ) + + # Combine with a UNION ALL query + merged_query = pre_reg_query.union_all(users_query) + + # Add distinct on user_id to eliminate duplicates + distinct_query = ( + sa.select(merged_query.c) + .select_from(merged_query) + .distinct(merged_query.c.email) + .order_by( + merged_query.c.email, + # Prioritize pre-registration records if duplicate emails exist + merged_query.c.is_pre_registered.desc(), + merged_query.c.created.desc(), + ) + .limit(pagination_limit) + .offset(pagination_offset) + ) + + # Count query (for pagination) + count_query = sa.select(sa.func.count().label("total")).select_from( + sa.select(merged_query.c.email).select_from(merged_query).distinct().subquery() + ) + + _logger.debug( + "%s\n%s\n%s\n%s", + "-" * 100, + as_postgres_sql_query_str(distinct_query), + "-" * 100, + as_postgres_sql_query_str(count_query), + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + # Get total count + count_result = await conn.execute(count_query) + total_count = count_result.scalar() + + # Get user records + result = await conn.execute(distinct_query) + records = result.mappings().all() + + return list(records), total_count diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 5296b8543c0..9c35f3c8d85 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -155,7 +155,7 @@ def _glob_to_sql_like(glob_pattern: str) -> str: # Convert glob wildcards to SQL LIKE wildcards return sql_like_pattern.replace("*", "%").replace("?", "_") - rows = await _users_repository.search_users_and_get_profile( + rows = await _users_repository.search_merged_pre_and_registered_users( get_asyncpg_engine(app), email_like=_glob_to_sql_like(email_glob), product_name=product_name, @@ -232,12 +232,14 @@ async def list_users_as_admin( engine = get_asyncpg_engine(app) # Get user data with pagination - users_data, total_count = await _users_repository.list_users_for_admin( - engine, - product_name=product_name, - filter_account_request_status=filter_account_request_status, - pagination_limit=pagination_limit, - pagination_offset=pagination_offset, + users_data, total_count = ( + await _users_repository.list_merged_pre_and_registered_users( + engine, + product_name=product_name, + filter_account_request_status=filter_account_request_status, + pagination_limit=pagination_limit, + pagination_offset=pagination_offset, + ) ) # For each user, append additional information if needed diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index 4d81fb28e0b..3a773b03fa0 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -351,3 +351,133 @@ async def test_create_pre_registration_with_existing_user_linking( ) ) await conn.commit() + + +async def test_list_merged_pre_and_registered_users( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], +): + """Tests that list_merged_pre_and_registered_users correctly merges users from both tables.""" + # Arrange + asyncpg_engine = get_asyncpg_engine(app) + created_by_user_id = product_owner_user["id"] + + # The product_owner_user is already a registered user associated with the product + + # 1. Create a pre-registered user that is not in the users table + pre_reg_email = "pre.registered.only@example.com" + pre_reg_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=pre_reg_email, + created_by=created_by_user_id, + product_name=product_name, + pre_first_name="Pre-Registered", + pre_last_name="Only", + institution="Pre-Reg Institution", + address="123 Pre Street", + city="Pre City", + state="Pre State", + postal_code="12345", + country="US", + ) + + # 2. Create a pre-registration for the product_owner_user (both registered and pre-registered) + owner_pre_reg_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=product_owner_user["email"], + created_by=created_by_user_id, + product_name=product_name, + pre_first_name="Owner", + pre_last_name="PreReg", + institution="Owner Institution", + link_to_existing_user=True, # This will link to the existing user + ) + + try: + # Act + users_list, total_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + pagination_limit=10, + pagination_offset=0, + ) + ) + + # Assert + + # 1. Check that we got the correct total count + assert ( + total_count >= 2 + ), "Should have at least 2 users (pre-registered only and product owner)" + + # 2. Find the pre-registered only user in the results + pre_reg_only_user = next( + (user for user in users_list if user["email"] == pre_reg_email), None + ) + assert pre_reg_only_user is not None, "Pre-registered user should be in results" + assert pre_reg_only_user["is_pre_registered"] is True + assert ( + pre_reg_only_user["user_id"] is None + ), "Pre-registered only user shouldn't have a user_id" + assert pre_reg_only_user["institution"] == "Pre-Reg Institution" + assert pre_reg_only_user["first_name"] == "Pre-Registered" + assert pre_reg_only_user["last_name"] == "Only" + assert ( + pre_reg_only_user["invited_by"] is not None + ), "Should have invited_by field" + + # 3. Check the product owner (both registered and pre-registered) + product_owner = next( + ( + user + for user in users_list + if user["email"] == product_owner_user["email"] + ), + None, + ) + assert product_owner is not None, "Product owner should be in results" + assert ( + product_owner["is_pre_registered"] is True + ), "Should prefer pre-registration record" + assert ( + product_owner["user_id"] == product_owner_user["id"] + ), "Should be linked to existing user" + assert product_owner["institution"] == "Owner Institution" + assert ( + product_owner["first_name"] == "Owner" + ), "Should use pre-registration first name" + assert ( + product_owner["user_name"] is not None + ), "Should include user_name from users table" + assert ( + product_owner["status"] is not None + ), "Should include status from users table" + + # 4. Test filtering by account request status + pending_users, pending_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_account_request_status=AccountRequestStatus.PENDING, + filter_include_deleted=False, + ) + ) + + # Both pre-registrations should be in pending status by default + assert pending_count >= 2 + assert len(pending_users) >= 2 + + finally: + # Clean up + async with asyncpg_engine.connect() as conn: + await conn.execute( + sa.delete(users_pre_registration_details).where( + users_pre_registration_details.c.id.in_( + [pre_reg_id, owner_pre_reg_id] + ) + ) + ) + await conn.commit() From 9e1c2ec6947d036b281000bb24332cd71e5ccad0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 17:06:46 +0200 Subject: [PATCH 30/55] =?UTF-8?q?=E2=9C=A8=20users:=20Update=20user=20sche?= =?UTF-8?q?ma=20and=20repository=20queries=20to=20use=20'created=5Fby'=20i?= =?UTF-8?q?nstead=20of=20'invited=5Fby'=20and=20adjust=20related=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_schemas_webserver/users.py | 2 +- .../users/_users_repository.py | 45 ++++++++----------- .../users/_users_rest.py | 2 +- .../users/_users_service.py | 2 +- .../03/users/test_users_repository.py | 15 ++++++- 5 files changed, 34 insertions(+), 32 deletions(-) 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 23543638bc5..8763978683f 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 @@ -291,7 +291,7 @@ class UserForAdminGet(OutputSchema): ] = DEFAULT_FACTORY # authorization - invited_by: str | None = None + invited_by: Annotated[str | None, Field(alias="created_by")] = None # user status registered: bool diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 452dac323cf..25c24111e3e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -807,21 +807,6 @@ async def list_merged_pre_and_registered_users( Returns: Tuple of (list of merged user data, total count) """ - # Create alias for users table for creator and reviewer lookups - creators_alias = sa.alias(users, name="creators") - reviewers_alias = sa.alias(users, name="reviewers") - - # Query for pre-registered users - invited_by = ( - sa.select( - creators_alias.c.name, - ) - .where(users_pre_registration_details.c.created_by == creators_alias.c.id) - .correlate(None) - .scalar_subquery() - .label("invited_by") - ) - # Base where conditions for both queries pre_reg_where = [users_pre_registration_details.c.product_name == product_name] users_where = [] @@ -851,7 +836,7 @@ async def list_merged_pre_and_registered_users( users_pre_registration_details.c.state, users_pre_registration_details.c.postal_code, users_pre_registration_details.c.country, - users_pre_registration_details.c.user_id, + users_pre_registration_details.c.user_id.label("pre_reg_user_id"), users_pre_registration_details.c.extras, users_pre_registration_details.c.created, users_pre_registration_details.c.account_request_status, @@ -859,7 +844,8 @@ async def list_merged_pre_and_registered_users( users.c.name.label("user_name"), users.c.status, users.c.created_at, - invited_by, + # Use created_by directly instead of a subquery + users_pre_registration_details.c.created_by.label("created_by"), sa.literal(True).label("is_pre_registered"), ) .select_from( @@ -883,7 +869,7 @@ async def list_merged_pre_and_registered_users( sa.literal(None).label("state"), sa.literal(None).label("postal_code"), sa.literal(None).label("country"), - users.c.id.label("user_id"), + sa.literal(None).label("pre_reg_user_id"), sa.literal(None).label("extras"), users.c.created_at.label("created"), sa.literal(None).label("account_request_status"), @@ -891,7 +877,8 @@ async def list_merged_pre_and_registered_users( users.c.name.label("user_name"), users.c.status, users.c.created_at, - sa.literal(None).label("invited_by"), + # Match the created_by field from the pre_reg query + sa.literal(None).label("created_by"), sa.literal(False).label("is_pre_registered"), ) .select_from( @@ -905,16 +892,17 @@ async def list_merged_pre_and_registered_users( # Combine with a UNION ALL query merged_query = pre_reg_query.union_all(users_query) - # Add distinct on user_id to eliminate duplicates + # Add distinct on email to eliminate duplicates + merged_query_subq = merged_query.subquery() distinct_query = ( - sa.select(merged_query.c) - .select_from(merged_query) - .distinct(merged_query.c.email) + sa.select(merged_query_subq) + .select_from(merged_query_subq) + .distinct(merged_query_subq.c.email) .order_by( - merged_query.c.email, + merged_query_subq.c.email, # Prioritize pre-registration records if duplicate emails exist - merged_query.c.is_pre_registered.desc(), - merged_query.c.created.desc(), + merged_query_subq.c.is_pre_registered.desc(), + merged_query_subq.c.created.desc(), ) .limit(pagination_limit) .offset(pagination_offset) @@ -922,7 +910,10 @@ async def list_merged_pre_and_registered_users( # Count query (for pagination) count_query = sa.select(sa.func.count().label("total")).select_from( - sa.select(merged_query.c.email).select_from(merged_query).distinct().subquery() + sa.select(merged_query_subq.c.email) + .select_from(merged_query_subq) + .distinct() + .subquery() ) _logger.debug( diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index e968be7e042..4803e2934c6 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -177,7 +177,7 @@ async def list_users_for_admin(request: web.Request) -> web.Response: UsersForAdminListQueryParams, request ) - users, total_count = await _users_service.list_users_as_admin( + users, total_count = await _users_service.list_all_users_as_admin( request.app, product_name=req_ctx.product_name, filter_account_request_status=( diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 9c35f3c8d85..ef706141aa6 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -209,7 +209,7 @@ async def is_user_in_product( ) -async def list_users_as_admin( +async def list_all_users_as_admin( app: web.Application, *, product_name: ProductName, diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index 3a773b03fa0..a8f8f36fb5b 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -419,15 +419,19 @@ async def test_list_merged_pre_and_registered_users( ) assert pre_reg_only_user is not None, "Pre-registered user should be in results" assert pre_reg_only_user["is_pre_registered"] is True + # Check the pre_registration user_id is None but using the new column name + assert pre_reg_only_user["pre_reg_user_id"] is None + # For non-linked users, user_id is still None assert ( pre_reg_only_user["user_id"] is None ), "Pre-registered only user shouldn't have a user_id" assert pre_reg_only_user["institution"] == "Pre-Reg Institution" assert pre_reg_only_user["first_name"] == "Pre-Registered" assert pre_reg_only_user["last_name"] == "Only" + # Check created_by field instead of invited_by assert ( - pre_reg_only_user["invited_by"] is not None - ), "Should have invited_by field" + pre_reg_only_user["created_by"] == created_by_user_id + ), "Should have created_by field with the creator's ID" # 3. Check the product owner (both registered and pre-registered) product_owner = next( @@ -442,6 +446,10 @@ async def test_list_merged_pre_and_registered_users( assert ( product_owner["is_pre_registered"] is True ), "Should prefer pre-registration record" + # Check both the pre_reg_user_id (from pre-registration) and user_id (from users table) + assert ( + product_owner["pre_reg_user_id"] == product_owner_user["id"] + ), "pre_reg_user_id should match the product owner id" assert ( product_owner["user_id"] == product_owner_user["id"] ), "Should be linked to existing user" @@ -455,6 +463,9 @@ async def test_list_merged_pre_and_registered_users( assert ( product_owner["status"] is not None ), "Should include status from users table" + assert ( + product_owner["created_by"] == created_by_user_id + ), "Should have created_by field with the creator's ID" # 4. Test filtering by account request status pending_users, pending_count = ( From dc4e942b8429e623197ca7ae8a73d7d0508a31b4 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 17:48:35 +0200 Subject: [PATCH 31/55] testing listing users as admin --- .../api_schemas_webserver/users.py | 17 +- .../users/_users_repository.py | 2 - .../users/_users_rest.py | 6 +- .../03/test_users_rest_registration.py | 231 ++++++------------ 4 files changed, 90 insertions(+), 166 deletions(-) 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 8763978683f..b3809bd3230 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 @@ -245,7 +245,20 @@ class UsersForAdminListFilter(Filters): # 2. If APPROVED AND user uses the invitation link, then it can be in any of these states: # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED # - status: Literal["PENDING"] | None = None + status: ( + Literal[ + "PENDING", + # NOTE: future states + # "REJECTED", + # "APPROVED", + # "CONFIRMATION_PENDING", + # "ACTIVE", + # "EXPIRED", + # "BANNED", + # "DELETED", + ] + | None + ) = None class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... @@ -291,7 +304,7 @@ class UserForAdminGet(OutputSchema): ] = DEFAULT_FACTORY # authorization - invited_by: Annotated[str | None, Field(alias="created_by")] = None + invited_by: str | None = None # user status registered: bool diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 25c24111e3e..b69c5252216 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -843,7 +843,6 @@ async def list_merged_pre_and_registered_users( users.c.id.label("user_id"), users.c.name.label("user_name"), users.c.status, - users.c.created_at, # Use created_by directly instead of a subquery users_pre_registration_details.c.created_by.label("created_by"), sa.literal(True).label("is_pre_registered"), @@ -876,7 +875,6 @@ async def list_merged_pre_and_registered_users( users.c.id.label("user_id"), users.c.name.label("user_name"), users.c.status, - users.c.created_at, # Match the created_by field from the pre_reg query sa.literal(None).label("created_by"), sa.literal(False).label("is_pre_registered"), diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 4803e2934c6..850ec7ab0b5 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -1,5 +1,6 @@ import logging from contextlib import suppress +from typing import Any from aiohttp import web from common_library.users_enums import AccountRequestStatus @@ -187,9 +188,12 @@ async def list_users_for_admin(request: web.Request) -> web.Response: pagination_offset=query_params.offset, ) + def _to_domain_model(user: dict[str, Any]) -> UserForAdminGet: + return UserForAdminGet(extras=user.pop("extras") or {}, **user) + page = Page[UserForAdminGet].model_validate( paginate_data( - chunk=users, + chunk=[_to_domain_model(user) for user in users], request_url=request.url, total=total_count, limit=query_params.limit, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 78f02915cfc..03c430d83d4 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -11,6 +11,9 @@ import pytest import simcore_service_webserver.login._auth_service +import simcore_service_webserver.users +import simcore_service_webserver.users._users_repository +import simcore_service_webserver.users._users_service from aiohttp.test_utils import TestClient from common_library.users_enums import UserRole, UserStatus from faker import Faker @@ -200,112 +203,13 @@ async def test_list_users_for_admin( logged_user: UserInfoDict, account_request_form: dict[str, Any], faker: Faker, -): - assert client.app - - # Create some pre-registered users - pre_registered_users = [] - for _ in range(3): - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = faker.email() - - resp = await client.post("/v0/admin/users:pre-register", json=form_data) - pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registered_users.append(pre_registered_data) - - # Register one of the pre-registered users - new_user = await simcore_service_webserver.login._auth_service.create_user( - client.app, - email=pre_registered_users[0]["email"], - password=DEFAULT_TEST_PASSWORD, - status_upon_creation=UserStatus.ACTIVE, - expires_at=None, - ) - - # Test pagination (page 1, limit 2) - url = client.app.router["list_users_for_admin"].url_for() - resp = await client.get(f"{url}", params={"page": 1, "per_page": 2}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Verify pagination structure - assert "items" in data - assert "pagination" in data - assert data["pagination"]["page"] == 1 - assert data["pagination"]["per_page"] == 2 - assert data["pagination"]["total"] >= 1 # At least the logged user - - # Test pagination (page 2, limit 2) - resp = await client.get(f"{url}", params={"page": 2, "per_page": 2}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert data["pagination"]["page"] == 2 - - # Test filtering by approval status (only approved users) - resp = await client.get(f"{url}", params={"approved": True}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # All items should be registered users with status - for item in data["items"]: - user = UserForAdminGet(**item) - assert user.registered is True - assert user.status is not None - - # Test filtering by approval status (only non-approved users) - resp = await client.get(f"{url}", params={"approved": False}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # All items should be non-registered or non-approved users - assert len(data["items"]) >= 2 # We created at least 2 non-registered users - for item in data["items"]: - user = UserForAdminGet(**item) - assert user.registered is False or user.status != UserStatus.ACTIVE - - # Combine pagination and filtering - resp = await client.get( - f"{url}", params={"approved": True, "page": 1, "per_page": 1} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(data["items"]) == 1 - assert data["pagination"]["page"] == 1 - assert data["pagination"]["per_page"] == 1 - - # Verify content of a specific user - resp = await client.get(f"{url}", params={"approved": True}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - # Find the newly registered user in the list - registered_user = next( - (item for item in data["items"] if item["email"] == new_user["email"]), - None, - ) - assert registered_user is not None - - user = UserForAdminGet(**registered_user) - assert user.registered is True - assert user.status == UserStatus.ACTIVE - assert user.email == new_user["email"] - - -@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) -async def test_pending_users_management( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, product_name: ProductName, ): - """Test the management of pending users: - - list pending users - - approve user account - - reject user account - - resend confirmation email - """ assert client.app - # Create some pre-registered users + # 1. Create several pre-registered users pre_registered_users = [] - for _ in range(3): + for _ in range(5): # Create 5 pre-registered users form_data = account_request_form.copy() form_data["firstName"] = faker.first_name() form_data["lastName"] = faker.last_name() @@ -319,90 +223,95 @@ async def test_pending_users_management( pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) pre_registered_users.append(pre_registered_data) - # 1. List pending users (not yet approved) + # Verify all pre-registered users are in PENDING status url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) data, _ = await assert_status(resp, status.HTTP_200_OK) - # Verify response structure and content - assert "items" in data - assert "pagination" in data - assert len(data["items"]) >= 3 # At least our 3 pre-registered users - - # Verify each pre-registered user is in the list + pending_emails = [user["email"] for user in data if user["status"] == "PENDING"] for pre_user in pre_registered_users: - found = next( - (item for item in data["items"] if item["email"] == pre_user["email"]), - None, - ) - assert found is not None - assert found["registered"] is False - - # 2. Approve one of the pre-registered users - approval_data = {"email": pre_registered_users[0]["email"]} - resp = await client.post( - "/v0/admin/users:approve", - json=approval_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - approved_data, _ = await assert_status(resp, status.HTTP_200_OK) + assert pre_user["email"] in pending_emails - # Verify response structure - assert "invitationLink" in approved_data - assert approved_data.get("email") == pre_registered_users[0]["email"] + # 2. Register one of the pre-registered users + registered_email = pre_registered_users[0]["email"] + new_user = await simcore_service_webserver.login._auth_service.create_user( + client.app, + email=registered_email, + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) - # Verify the user is no longer in the pending list + # 3. Test filtering by status + # a. Check PENDING filter (should exclude the registered user) resp = await client.get( f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) - data, _ = await assert_status(resp, status.HTTP_200_OK) + pending_data, _ = await assert_status(resp, status.HTTP_200_OK) + + # The registered user should no longer be in pending status + pending_emails = [user["email"] for user in pending_data] + assert registered_email not in pending_emails + assert len(pending_emails) >= len(pre_registered_users) - 1 - # The approved user should no longer be in the pending list - assert all( - item["email"] != pre_registered_users[0]["email"] for item in data["items"] + # b. Check ACTIVE filter (should include only the registered user) + resp = await client.get( + f"{url}?status=ACTIVE", headers={X_PRODUCT_NAME_HEADER: product_name} ) + active_data, _ = await assert_status(resp, status.HTTP_200_OK) - # 3. Reject another pre-registered user - rejection_data = {"email": pre_registered_users[1]["email"]} - resp = await client.post( - "/v0/admin/users:reject", - json=rejection_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, + # Find the registered user in the active users + active_user = next( + (item for item in active_data if item["email"] == registered_email), + None, ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) + assert active_user is not None + assert UserForAdminGet(**active_user).status == UserStatus.ACTIVE - # Verify the rejected user is no longer in the pending list + # 4. Test pagination + # a. First page (limit 2) resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert all( - item["email"] != pre_registered_users[1]["email"] for item in data["items"] + f"{url}", + params={"limit": 2, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, ) + assert resp.status == status.HTTP_200_OK + page1_payload = await resp.json() + + assert len(page1_payload["items"]) == 2 + assert page1_payload["meta"]["limit"] == 2 + assert page1_payload["meta"]["offset"] == 0 + assert page1_payload["meta"]["total"] >= len(pre_registered_users) - # 4. Resend confirmation email to the approved user - resend_data = {"email": pre_registered_users[0]["email"]} - resp = await client.post( - "/v0/admin/users:resendConfirmationEmail", - json=resend_data, + # b. Second page (limit 2) + resp = await client.get( + f"{url}", + params={"limit": 2, "offset": 2}, headers={X_PRODUCT_NAME_HEADER: product_name}, ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) + assert resp.status == status.HTTP_200_OK + page2_payload = await resp.json() - # Search for the approved user to confirm their status + assert len(page2_payload["items"]) == 2 + assert page2_payload["meta"]["limit"] == 2 + assert page2_payload["meta"]["offset"] == 2 + + # Ensure page 1 and page 2 contain different items + page1_emails = [item["email"] for item in page1_payload["data"]] + page2_emails = [item["email"] for item in page2_payload["data"]] + assert not set(page1_emails).intersection(page2_emails) + + # 5. Combine status filter with pagination resp = await client.get( - "/v0/admin/users:search", - params={"email": pre_registered_users[0]["email"]}, + f"{url}", + params={"status": "PENDING", "limit": 2, "offset": 0}, headers={X_PRODUCT_NAME_HEADER: product_name}, ) - found_users, _ = await assert_status(resp, status.HTTP_200_OK) + filtered_page_data, _ = await assert_status(resp, status.HTTP_200_OK) - # Should find exactly one user - assert len(found_users) == 1 - found_user = UserForAdminGet(**found_users[0]) - - # User should be registered but in CONFIRMATION_PENDING status - assert found_user.registered is True - assert found_user.status == UserStatus.CONFIRMATION_PENDING + assert len(filtered_page_data) <= 2 + for item in filtered_page_data: + user = UserForAdminGet(**item) + assert user.registered is False # Pending users are not registered From 99e97b5c7fa8f6e2c7a59c9913bccd15620e7c84 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 18:00:18 +0200 Subject: [PATCH 32/55] =?UTF-8?q?=E2=9C=A8=20users:=20Implement=20user=20a?= =?UTF-8?q?ccount=20approval=20functionality=20with=20appropriate=20status?= =?UTF-8?q?=20code=20and=20service=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/specs/web-server/_users.py | 2 +- .../security/_authz_access_roles.py | 8 +-- .../users/_users_rest.py | 23 +++++++++ .../users/_users_service.py | 50 +++++++++++++++++++ .../03/test_users_rest_registration.py | 15 +++++- 5 files changed, 91 insertions(+), 7 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index a0861b12de5..308be6b1a99 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -160,7 +160,7 @@ async def list_users_for_admin( @router.post( "/admin/users:approve", - response_model=Envelope[Page[UserForAdminGet]], + status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, ) async def approve_user_account(_body: UserApprove): ... diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index e0cc216e22b..6ab95b425e6 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -1,10 +1,9 @@ -""" Defines different user roles and its associated permission +"""Defines different user roles and its associated permission - This definition is consumed by the security._access_model to build an access model for the framework - The access model is created upon setting up of the security subsystem +This definition is consumed by the security._access_model to build an access model for the framework +The access model is created upon setting up of the security subsystem """ - from simcore_postgres_database.models.users import UserRole from typing_extensions import ( # https://docs.pydantic.dev/latest/api/standard_library_types/#typeddict TypedDict, @@ -106,6 +105,7 @@ class PermissionDict(TypedDict, total=False): "product.details.*", "product.invitations.create", "admin.users.read", + "admin.users.write", ], inherits=[UserRole.TESTER], ), diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 850ec7ab0b5..766125fd4bd 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -7,6 +7,7 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, + UserApprove, UserForAdminGet, UserGet, UsersForAdminListQueryParams, @@ -248,3 +249,25 @@ async def pre_register_user_for_admin(request: web.Request) -> web.Response: return envelope_json_response( user_profile.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY) ) + + +@routes.post(f"/{API_VTAG}/admin/users:approve", name="approve_user_account") +@login_required +@permission_required("admin.users.write") +@_handle_users_exceptions +async def approve_user_account(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + approval_data = await parse_request_body_as(UserApprove, request) + + # Approve the user account, passing the current user's ID as the reviewer + pre_registration_id = await _users_service.approve_user_account( + request.app, + pre_registration_email=approval_data.email, + product_name=req_ctx.product_name, + reviewer_id=req_ctx.user_id, + ) + assert pre_registration_id # nosec + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index ef706141aa6..bf44584f451 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -449,3 +449,53 @@ async def update_my_profile( user_id=user_id, update=ToUserUpdateDB.from_api(update), ) + + +async def approve_user_account( + app: web.Application, + *, + pre_registration_email: LowerCaseEmailStr, + product_name: ProductName, + reviewer_id: UserID, +) -> int: + """Approve a user account based on their pre-registration email. + + Args: + app: The web application instance + pre_registration_email: Email of the pre-registered user to approve + product_name: Product name for which the user is being approved + reviewer_id: ID of the user approving the account + + Returns: + int: The ID of the approved pre-registration record + + Raises: + ValueError: If no pre-registration is found for the email/product + """ + engine = get_asyncpg_engine(app) + + # First, find the pre-registration entry matching the email and product + pre_registrations, _ = await _users_repository.list_user_pre_registrations( + engine, + filter_by_pre_email=pre_registration_email, + filter_by_product_name=product_name, + filter_by_account_request_status=AccountRequestStatus.PENDING, + ) + + if not pre_registrations: + msg = f"No pending pre-registration found for email {pre_registration_email} in product {product_name}" + raise ValueError(msg) + + # There should be only one registration matching these criteria + pre_registration = pre_registrations[0] + pre_registration_id = pre_registration["id"] + + # Update the pre-registration status to APPROVED using the reviewer's ID + await _users_repository.review_user_pre_registration( + engine, + pre_registration_id=pre_registration_id, + reviewed_by=reviewer_id, + new_status=AccountRequestStatus.APPROVED, + ) + + return pre_registration_id diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 03c430d83d4..42c6f9665d1 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -230,12 +230,22 @@ async def test_list_users_for_admin( ) data, _ = await assert_status(resp, status.HTTP_200_OK) - pending_emails = [user["email"] for user in data if user["status"] == "PENDING"] + pending_emails = [user["email"] for user in data if user["status"] is None] for pre_user in pre_registered_users: assert pre_user["email"] in pending_emails - # 2. Register one of the pre-registered users + # 2. Register one of the pre-registered users: approve + create account registered_email = pre_registered_users[0]["email"] + + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": registered_email}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Emulates user accepting invitation link new_user = await simcore_service_webserver.login._auth_service.create_user( client.app, email=registered_email, @@ -246,6 +256,7 @@ async def test_list_users_for_admin( # 3. Test filtering by status # a. Check PENDING filter (should exclude the registered user) + url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) From 5b0a63e6cab8b8f6e5589b138a9beec65e1712b6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 18:37:41 +0200 Subject: [PATCH 33/55] =?UTF-8?q?=E2=9C=A8=20users:=20Add=20account=20requ?= =?UTF-8?q?est=20status=20fields=20and=20update=20user=20approval/rejectio?= =?UTF-8?q?n=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/specs/web-server/_users.py | 2 + .../api_schemas_webserver/users.py | 9 +- .../api/v0/openapi.yaml | 90 ++++++++----------- .../users/_users_repository.py | 4 + 4 files changed, 47 insertions(+), 58 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 308be6b1a99..2a9203d33f7 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -170,6 +170,7 @@ async def approve_user_account(_body: UserApprove): ... "/admin/users:reject", status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, + include_in_schema=False, # UNDER DEVELOPMENT ) async def reject_user_account(_body: UserReject): ... @@ -178,6 +179,7 @@ async def reject_user_account(_body: UserReject): ... "/admin/users:resendConfirmationEmail", status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, + include_in_schema=False, # UNDER DEVELOPMENT ) async def resend_user_confirmation_email(_body: UserApprove): ... 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 b3809bd3230..beebef11803 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 @@ -1,12 +1,12 @@ import re -from datetime import date +from datetime import date, datetime from enum import Enum from typing import Annotated, Any, Literal, Self import annotated_types from common_library.basic_types import DEFAULT_FACTORY from common_library.dict_tools import remap_keys -from common_library.users_enums import UserStatus +from common_library.users_enums import AccountRequestStatus, UserStatus from models_library.groups import AccessRightsDict from models_library.rest_filters import Filters from models_library.rest_pagination import PageQueryParameters @@ -303,8 +303,11 @@ class UserForAdminGet(OutputSchema): ), ] = DEFAULT_FACTORY - # authorization + # pre-registration invited_by: str | None = None + account_request_status: AccountRequestStatus | None + account_request_reviewed_by: UserID | None = None + account_request_reviewed_at: datetime | None = None # user status registered: bool 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 0f2d6d42e43..a51b42328e2 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 @@ -1373,15 +1373,6 @@ paths: summary: List Users For Admin operationId: list_users_for_admin parameters: - - name: status - in: query - required: false - schema: - anyOf: - - const: PENDING - type: string - - type: 'null' - title: Status - name: limit in: query required: false @@ -1396,6 +1387,15 @@ paths: type: integer default: 0 title: Offset + - name: status + in: query + required: false + schema: + anyOf: + - const: PENDING + type: string + - type: 'null' + title: Status responses: '200': description: Successful Response @@ -1410,42 +1410,6 @@ paths: - admin summary: Approve User Account operationId: approve_user_account - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UserApprove' - required: true - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_Page_UserForAdminGet__' - /v0/admin/users:reject: - post: - tags: - - users - - admin - summary: Reject User Account - operationId: reject_user_account - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/UserReject' - required: true - responses: - '204': - description: Successful Response - /v0/admin/users:resendConfirmationEmail: - post: - tags: - - users - - admin - summary: Resend User Confirmation Email - operationId: resend_user_confirmation_email requestBody: content: application/json: @@ -8405,6 +8369,14 @@ components: phone: +1 123456789 postalCode: '98001' privacyPolicy: true + AccountRequestStatus: + type: string + enum: + - PENDING + - APPROVED + - REJECTED + title: AccountRequestStatus + description: Status of the request for an account Activity: properties: stats: @@ -17076,6 +17048,23 @@ components: - type: string - type: 'null' title: Invitedby + accountRequestStatus: + anyOf: + - $ref: '#/components/schemas/AccountRequestStatus' + - type: 'null' + accountRequestReviewedBy: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Accountrequestreviewedby + accountRequestReviewedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Accountrequestreviewedat registered: type: boolean title: Registered @@ -17104,6 +17093,7 @@ components: - state - postalCode - country + - accountRequestStatus - registered - status title: UserForAdminGet @@ -17268,16 +17258,6 @@ components: required: - read title: UserNotificationPatch - UserReject: - properties: - email: - type: string - format: email - title: Email - type: object - required: - - email - title: UserReject UserStatus: type: string enum: diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index b69c5252216..da6a23d21d3 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -840,6 +840,8 @@ async def list_merged_pre_and_registered_users( users_pre_registration_details.c.extras, users_pre_registration_details.c.created, users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.account_request_reviewed_by, + users_pre_registration_details.c.account_request_reviewed_at, users.c.id.label("user_id"), users.c.name.label("user_name"), users.c.status, @@ -872,6 +874,8 @@ async def list_merged_pre_and_registered_users( sa.literal(None).label("extras"), users.c.created_at.label("created"), sa.literal(None).label("account_request_status"), + sa.literal(None).label("account_request_reviewed_by"), + sa.literal(None).label("account_request_reviewed_at"), users.c.id.label("user_id"), users.c.name.label("user_name"), users.c.status, From 22cf0e2a188f271e69134a1d6b3f18ab99bf7640 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 18:44:10 +0200 Subject: [PATCH 34/55] =?UTF-8?q?=E2=9C=A8=20users:=20Update=20account=20r?= =?UTF-8?q?equest=20status=20handling=20in=20API=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_schemas_webserver/users.py | 22 +++++-------------- .../api/v0/openapi.yaml | 9 +++++--- .../users/_users_rest.py | 4 +++- .../03/test_users_rest_registration.py | 8 +++---- 4 files changed, 18 insertions(+), 25 deletions(-) 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 beebef11803..3f043edd8c5 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 @@ -241,24 +241,12 @@ def from_domain_model(cls, data): class UsersForAdminListFilter(Filters): - # 1. States of an Account Resquest: PENDING, REJECTED, APPROVED - # 2. If APPROVED AND user uses the invitation link, then it can be in any of these states: - # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED + # 1. account_request_status: PENDING, REJECTED, APPROVED + # 2. If APPROVED AND user uses the invitation link, then when user is registered, + # it can be in any of these statuses: + # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED # - status: ( - Literal[ - "PENDING", - # NOTE: future states - # "REJECTED", - # "APPROVED", - # "CONFIRMATION_PENDING", - # "ACTIVE", - # "EXPIRED", - # "BANNED", - # "DELETED", - ] - | None - ) = None + account_request_status: Literal["PENDING", "REJECETED", "APPROVED"] | None = None class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... 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 a51b42328e2..3e855410e4b 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 @@ -1387,15 +1387,18 @@ paths: type: integer default: 0 title: Offset - - name: status + - name: account_request_status in: query required: false schema: anyOf: - - const: PENDING + - enum: + - PENDING + - REJECETED + - APPROVED type: string - type: 'null' - title: Status + title: Account Request Status responses: '200': description: Successful Response diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 766125fd4bd..ad7b53e7d53 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -183,7 +183,9 @@ async def list_users_for_admin(request: web.Request) -> web.Response: request.app, product_name=req_ctx.product_name, filter_account_request_status=( - AccountRequestStatus(query_params.status) if query_params.status else None + AccountRequestStatus(query_params.account_request_status) + if query_params.account_request_status + else None ), pagination_limit=query_params.limit, pagination_offset=query_params.offset, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 42c6f9665d1..0c48e2fce00 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -267,15 +267,15 @@ async def test_list_users_for_admin( assert registered_email not in pending_emails assert len(pending_emails) >= len(pre_registered_users) - 1 - # b. Check ACTIVE filter (should include only the registered user) + # b. Check all users resp = await client.get( - f"{url}?status=ACTIVE", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?status=APPROVED", headers={X_PRODUCT_NAME_HEADER: product_name} ) - active_data, _ = await assert_status(resp, status.HTTP_200_OK) + approved_data, _ = await assert_status(resp, status.HTTP_200_OK) # Find the registered user in the active users active_user = next( - (item for item in active_data if item["email"] == registered_email), + (item for item in approved_data if item["email"] == registered_email), None, ) assert active_user is not None From 785cabb5f58dce9c2a7a6bc7ef18cd973b32233f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 21 May 2025 18:50:40 +0200 Subject: [PATCH 35/55] implements reject_user_account --- api/specs/web-server/_users.py | 1 - .../api/v0/openapi.yaml | 26 ++++++ .../users/_users_rest.py | 22 +++++ .../users/_users_service.py | 50 +++++++++++ .../03/test_users_rest_registration.py | 84 +++++++++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 2a9203d33f7..52d5c88603c 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -170,7 +170,6 @@ async def approve_user_account(_body: UserApprove): ... "/admin/users:reject", status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, - include_in_schema=False, # UNDER DEVELOPMENT ) async def reject_user_account(_body: UserReject): ... 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 3e855410e4b..7b99b983463 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 @@ -1422,6 +1422,22 @@ paths: responses: '204': description: Successful Response + /v0/admin/users:reject: + post: + tags: + - users + - admin + summary: Reject User Account + operationId: reject_user_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserReject' + required: true + responses: + '204': + description: Successful Response /v0/admin/users:search: get: tags: @@ -17261,6 +17277,16 @@ components: required: - read title: UserNotificationPatch + UserReject: + properties: + email: + type: string + format: email + title: Email + type: object + required: + - email + title: UserReject UserStatus: type: string enum: diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index ad7b53e7d53..2dbcfba24ce 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -273,3 +273,25 @@ async def approve_user_account(request: web.Request) -> web.Response: assert pre_registration_id # nosec return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.post(f"/{API_VTAG}/admin/users:reject", name="reject_user_account") +@login_required +@permission_required("admin.users.write") +@_handle_users_exceptions +async def reject_user_account(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + rejection_data = await parse_request_body_as(UserReject, request) + + # Reject the user account, passing the current user's ID as the reviewer + pre_registration_id = await _users_service.reject_user_account( + request.app, + pre_registration_email=rejection_data.email, + product_name=req_ctx.product_name, + reviewer_id=req_ctx.user_id, + ) + assert pre_registration_id # nosec + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index bf44584f451..08dbae4ced6 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -499,3 +499,53 @@ async def approve_user_account( ) return pre_registration_id + + +async def reject_user_account( + app: web.Application, + *, + pre_registration_email: LowerCaseEmailStr, + product_name: ProductName, + reviewer_id: UserID, +) -> int: + """Reject a user account based on their pre-registration email. + + Args: + app: The web application instance + pre_registration_email: Email of the pre-registered user to reject + product_name: Product name for which the user is being rejected + reviewer_id: ID of the user rejecting the account + + Returns: + int: The ID of the rejected pre-registration record + + Raises: + ValueError: If no pre-registration is found for the email/product + """ + engine = get_asyncpg_engine(app) + + # First, find the pre-registration entry matching the email and product + pre_registrations, _ = await _users_repository.list_user_pre_registrations( + engine, + filter_by_pre_email=pre_registration_email, + filter_by_product_name=product_name, + filter_by_account_request_status=AccountRequestStatus.PENDING, + ) + + if not pre_registrations: + msg = f"No pending pre-registration found for email {pre_registration_email} in product {product_name}" + raise ValueError(msg) + + # There should be only one registration matching these criteria + pre_registration = pre_registrations[0] + pre_registration_id = pre_registration["id"] + + # Update the pre-registration status to REJECTED using the reviewer's ID + await _users_repository.review_user_pre_registration( + engine, + pre_registration_id=pre_registration_id, + reviewed_by=reviewer_id, + new_status=AccountRequestStatus.REJECTED, + ) + + return pre_registration_id diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 0c48e2fce00..f428f1cb416 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -326,3 +326,87 @@ async def test_list_users_for_admin( for item in filtered_page_data: user = UserForAdminGet(**item) assert user.registered is False # Pending users are not registered + + +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_reject_user_account( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, +): + assert client.app + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post( + "/v0/admin/users:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_email = pre_registered_data["email"] + + # 2. Verify the user is in PENDING status + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + pending_emails = [user["email"] for user in data if user["status"] is None] + assert pre_registered_email in pending_emails + + # 3. Reject the pre-registered user + url = client.app.router["reject_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": pre_registered_email}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 4. Verify the user is no longer in PENDING status + url = client.app.router["list_users_for_admin"].url_for() + resp = await client.get( + f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + pending_data, _ = await assert_status(resp, status.HTTP_200_OK) + pending_emails = [user["email"] for user in pending_data] + assert pre_registered_email not in pending_emails + + # 5. Verify the user is now in REJECTED status + # First get user details to check status + resp = await client.get( + "/v0/admin/users:search", + params={"email": pre_registered_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + # Check that account_request_status is REJECTED + user_data = found[0] + assert user_data["account_request_status"] == "REJECTED" + assert user_data["account_request_reviewed_by"] == logged_user["id"] + assert user_data["account_request_reviewed_at"] is not None + + # 6. Verify that a rejected user cannot be approved + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": pre_registered_email}, + ) + # Should fail as the account is already reviewed + assert resp.status == status.HTTP_400_BAD_REQUEST From fb77a5f65db4a53683e3bffdaaeb49fc52bff104 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 10:55:37 +0200 Subject: [PATCH 36/55] cleanup --- SERVICES.md | 61 ------------------- .../src/common_library/users_enums.py | 5 ++ .../api_schemas_webserver/users.py | 2 +- 3 files changed, 6 insertions(+), 62 deletions(-) delete mode 100644 SERVICES.md diff --git a/SERVICES.md b/SERVICES.md deleted file mode 100644 index 4cd69a157c8..00000000000 --- a/SERVICES.md +++ /dev/null @@ -1,61 +0,0 @@ -# services -> -> Auto generated on `2025-04-22 14:55:44` using -```cmd -cd osparc-simcore -python ./scripts/echo_services_markdown.py -``` -| Name|Files| | -| ----------|----------|---------- | -| **AGENT**|| | -| |[services/agent/Dockerfile](./services/agent/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/agent)](https://hub.docker.com/r/itisfoundation/agent/tags) | -| **API-SERVER**|| | -| |[services/api-server/openapi.json](./services/api-server/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/api-server/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/api-server/openapi.json) | -| |[services/api-server/Dockerfile](./services/api-server/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/api-server)](https://hub.docker.com/r/itisfoundation/api-server/tags) | -| **AUTOSCALING**|| | -| |[services/autoscaling/Dockerfile](./services/autoscaling/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/autoscaling)](https://hub.docker.com/r/itisfoundation/autoscaling/tags) | -| **CATALOG**|| | -| |[services/catalog/openapi.json](./services/catalog/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/catalog/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/catalog/openapi.json) | -| |[services/catalog/Dockerfile](./services/catalog/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/catalog)](https://hub.docker.com/r/itisfoundation/catalog/tags) | -| **CLUSTERS-KEEPER**|| | -| |[services/clusters-keeper/Dockerfile](./services/clusters-keeper/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/clusters-keeper)](https://hub.docker.com/r/itisfoundation/clusters-keeper/tags) | -| **DASK-SIDECAR**|| | -| |[services/dask-sidecar/Dockerfile](./services/dask-sidecar/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dask-sidecar)](https://hub.docker.com/r/itisfoundation/dask-sidecar/tags) | -| **DATCORE-ADAPTER**|| | -| |[services/datcore-adapter/Dockerfile](./services/datcore-adapter/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/datcore-adapter)](https://hub.docker.com/r/itisfoundation/datcore-adapter/tags) | -| **DIRECTOR**|| | -| |[services/director/src/simcore_service_director/api/v0/openapi.yaml](./services/director/src/simcore_service_director/api/v0/openapi.yaml)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director/src/simcore_service_director/api/v0/openapi.yaml) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director/src/simcore_service_director/api/v0/openapi.yaml) | -| |[services/director/Dockerfile](./services/director/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/director)](https://hub.docker.com/r/itisfoundation/director/tags) | -| **DIRECTOR-V2**|| | -| |[services/director-v2/openapi.json](./services/director-v2/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director-v2/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/director-v2/openapi.json) | -| |[services/director-v2/Dockerfile](./services/director-v2/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/director-v2)](https://hub.docker.com/r/itisfoundation/director-v2/tags) | -| **DOCKER-API-PROXY**|| | -| |[services/docker-api-proxy/Dockerfile](./services/docker-api-proxy/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/docker-api-proxy)](https://hub.docker.com/r/itisfoundation/docker-api-proxy/tags) | -| **DYNAMIC-SCHEDULER**|| | -| |[services/dynamic-scheduler/openapi.json](./services/dynamic-scheduler/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-scheduler/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-scheduler/openapi.json) | -| |[services/dynamic-scheduler/Dockerfile](./services/dynamic-scheduler/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dynamic-scheduler)](https://hub.docker.com/r/itisfoundation/dynamic-scheduler/tags) | -| **DYNAMIC-SIDECAR**|| | -| |[services/dynamic-sidecar/openapi.json](./services/dynamic-sidecar/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-sidecar/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/dynamic-sidecar/openapi.json) | -| |[services/dynamic-sidecar/Dockerfile](./services/dynamic-sidecar/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/dynamic-sidecar)](https://hub.docker.com/r/itisfoundation/dynamic-sidecar/tags) | -| **EFS-GUARDIAN**|| | -| |[services/efs-guardian/Dockerfile](./services/efs-guardian/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/efs-guardian)](https://hub.docker.com/r/itisfoundation/efs-guardian/tags) | -| **INVITATIONS**|| | -| |[services/invitations/openapi.json](./services/invitations/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/invitations/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/invitations/openapi.json) | -| |[services/invitations/Dockerfile](./services/invitations/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/invitations)](https://hub.docker.com/r/itisfoundation/invitations/tags) | -| **MIGRATION**|| | -| |[services/migration/Dockerfile](./services/migration/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/migration)](https://hub.docker.com/r/itisfoundation/migration/tags) | -| **PAYMENTS**|| | -| |[services/payments/openapi.json](./services/payments/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/payments/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/payments/openapi.json) | -| |[services/payments/Dockerfile](./services/payments/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/payments)](https://hub.docker.com/r/itisfoundation/payments/tags) | -| **RESOURCE-USAGE-TRACKER**|| | -| |[services/resource-usage-tracker/openapi.json](./services/resource-usage-tracker/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/resource-usage-tracker/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/resource-usage-tracker/openapi.json) | -| |[services/resource-usage-tracker/Dockerfile](./services/resource-usage-tracker/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/resource-usage-tracker)](https://hub.docker.com/r/itisfoundation/resource-usage-tracker/tags) | -| **STATIC-WEBSERVER**|| | -| |[services/static-webserver/client/tools/qooxdoo-kit/builder/Dockerfile](./services/static-webserver/client/tools/qooxdoo-kit/builder/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/static-webserver)](https://hub.docker.com/r/itisfoundation/static-webserver/tags) | -| **STORAGE**|| | -| |[services/storage/openapi.json](./services/storage/openapi.json)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/storage/openapi.json) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/storage/openapi.json) | -| |[services/storage/Dockerfile](./services/storage/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/storage)](https://hub.docker.com/r/itisfoundation/storage/tags) | -| **WEB**|| | -| |[services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml](./services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml)|[![ReDoc](https://img.shields.io/badge/OpenAPI-ReDoc-85ea2d?logo=openapiinitiative)](https://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) [![Swagger UI](https://img.shields.io/badge/OpenAPI-Swagger_UI-85ea2d?logo=swagger)](https://petstore.swagger.io/?url=https://raw.githubusercontent.com/ITISFoundation/osparc-simcore/refs/heads/master/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) | -| |[services/web/Dockerfile](./services/web/Dockerfile)|[![Docker Image Size](https://img.shields.io/docker/image-size/itisfoundation/webserver)](https://hub.docker.com/r/itisfoundation/webserver/tags) | -| || | diff --git a/packages/common-library/src/common_library/users_enums.py b/packages/common-library/src/common_library/users_enums.py index 4a0606bf46e..3505a355a44 100644 --- a/packages/common-library/src/common_library/users_enums.py +++ b/packages/common-library/src/common_library/users_enums.py @@ -65,3 +65,8 @@ class AccountRequestStatus(str, Enum): PENDING = "PENDING" # Pending PO review to approve/reject the request APPROVED = "APPROVED" # PO approved the request REJECTED = "REJECTED" # PO rejected the request + + @classmethod + def reviewed(cls) -> list[str]: + """Returns the list of statuses that are considered reviewed""" + return [cls.APPROVED, cls.REJECTED] 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 3f043edd8c5..1c064ef7dd1 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 @@ -246,7 +246,7 @@ class UsersForAdminListFilter(Filters): # it can be in any of these statuses: # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED # - account_request_status: Literal["PENDING", "REJECETED", "APPROVED"] | None = None + review_status: Literal["PENDING", "REVIEWED"] | None = None class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... From 7e9d985df216662a3ccddce8b2a6d42878be0e55 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 11:13:50 +0200 Subject: [PATCH 37/55] changes filter --- .../src/common_library/users_enums.py | 5 -- .../users/_users_repository.py | 28 +++--- .../users/_users_rest.py | 18 ++-- .../users/_users_service.py | 7 +- .../03/test_users_rest_registration.py | 32 ++++--- .../03/users/test_users_repository.py | 86 ++++++++++++++++--- 6 files changed, 131 insertions(+), 45 deletions(-) diff --git a/packages/common-library/src/common_library/users_enums.py b/packages/common-library/src/common_library/users_enums.py index 3505a355a44..4a0606bf46e 100644 --- a/packages/common-library/src/common_library/users_enums.py +++ b/packages/common-library/src/common_library/users_enums.py @@ -65,8 +65,3 @@ class AccountRequestStatus(str, Enum): PENDING = "PENDING" # Pending PO review to approve/reject the request APPROVED = "APPROVED" # PO approved the request REJECTED = "REJECTED" # PO rejected the request - - @classmethod - def reviewed(cls) -> list[str]: - """Returns the list of statuses that are considered reviewed""" - return [cls.APPROVED, cls.REJECTED] diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index da6a23d21d3..b8f04bba684 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -690,9 +690,8 @@ async def review_user_pre_registration( new_status: New status (APPROVED or REJECTED) """ if new_status not in (AccountRequestStatus.APPROVED, AccountRequestStatus.REJECTED): - raise ValueError( - f"Invalid status for review: {new_status}. Must be APPROVED or REJECTED." - ) + msg = f"Invalid status for review: {new_status}. Must be APPROVED or REJECTED." + raise ValueError(msg) async with transaction_context(engine, connection) as conn: await conn.execute( @@ -783,7 +782,7 @@ async def list_merged_pre_and_registered_users( connection: AsyncConnection | None = None, *, product_name: ProductName, - filter_account_request_status: AccountRequestStatus | None = None, + filter_any_account_request_status: list[AccountRequestStatus] | None = None, filter_include_deleted: bool = False, pagination_limit: int = 50, pagination_offset: int = 0, @@ -799,7 +798,8 @@ async def list_merged_pre_and_registered_users( engine: Database engine connection: Optional existing connection product_name: Product name to filter by - filter_account_request_status: Optional filter by account request status + filter_any_account_request_status: If provided, only returns users with account request status in this list + (only pre-registered users with any of these statuses will be included) filter_include_deleted: Whether to include deleted users pagination_limit: Maximum number of results to return pagination_offset: Number of results to skip (for pagination) @@ -812,10 +812,11 @@ async def list_merged_pre_and_registered_users( users_where = [] # Add account request status filter if specified - if filter_account_request_status is not None: + if filter_any_account_request_status: pre_reg_where.append( - users_pre_registration_details.c.account_request_status - == filter_account_request_status + users_pre_registration_details.c.account_request_status.in_( + filter_any_account_request_status + ) ) # Add filter for deleted users @@ -891,8 +892,15 @@ async def list_merged_pre_and_registered_users( .where(sa.and_(products.c.name == product_name, *users_where)) ) - # Combine with a UNION ALL query - merged_query = pre_reg_query.union_all(users_query) + # If filtering by account request status, we only want pre-registered users with any of those statuses + # No need to union with regular users as they don't have account_request_status + if ( + filter_any_account_request_status is not None + and filter_any_account_request_status + ): + merged_query = pre_reg_query + else: + merged_query = pre_reg_query.union_all(users_query) # Add distinct on email to eliminate duplicates merged_query_subq = merged_query.subquery() diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 2dbcfba24ce..61705615210 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -10,6 +10,7 @@ UserApprove, UserForAdminGet, UserGet, + UserReject, UsersForAdminListQueryParams, UsersForAdminSearchQueryParams, UsersSearch, @@ -179,14 +180,21 @@ async def list_users_for_admin(request: web.Request) -> web.Response: UsersForAdminListQueryParams, request ) + if query_params.review_status == "PENDING": + filter_any_account_request_status = [AccountRequestStatus.PENDING] + elif query_params.review_status == "REVIEWED": + filter_any_account_request_status = [ + AccountRequestStatus.APPROVED, + AccountRequestStatus.REJECTED, + ] + else: + # ALL + filter_any_account_request_status = None + users, total_count = await _users_service.list_all_users_as_admin( request.app, product_name=req_ctx.product_name, - filter_account_request_status=( - AccountRequestStatus(query_params.account_request_status) - if query_params.account_request_status - else None - ), + filter_any_account_request_status=filter_any_account_request_status, pagination_limit=query_params.limit, pagination_offset=query_params.offset, ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 08dbae4ced6..89376d5e832 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -183,6 +183,9 @@ async def _list_products_or_none(user_id): country=r.country, extras=r.extras or {}, invited_by=r.invited_by, + account_request_status=r.account_request_status, + account_request_reviewed_by=r.account_request_reviewed_by, + account_request_reviewed_at=r.account_request_reviewed_at, products=await _list_products_or_none(r.user_id), # NOTE: old users will not have extra details registered=r.user_id is not None if r.pre_email else r.status is not None, @@ -213,7 +216,7 @@ async def list_all_users_as_admin( app: web.Application, *, product_name: ProductName, - filter_account_request_status: AccountRequestStatus | None = None, + filter_any_account_request_status: list[AccountRequestStatus] | None = None, pagination_limit: int = 50, pagination_offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: @@ -236,7 +239,7 @@ async def list_all_users_as_admin( await _users_repository.list_merged_pre_and_registered_users( engine, product_name=product_name, - filter_account_request_status=filter_account_request_status, + filter_any_account_request_status=filter_any_account_request_status, pagination_limit=pagination_limit, pagination_offset=pagination_offset, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index f428f1cb416..723293a30b9 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -226,11 +226,16 @@ async def test_list_users_for_admin( # Verify all pre-registered users are in PENDING status url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) data, _ = await assert_status(resp, status.HTTP_200_OK) - pending_emails = [user["email"] for user in data if user["status"] is None] + # Access the items field from the paginated response + pending_emails = [ + user["email"] + for user in data["items"] + if user.get("account_request_status") == "PENDING" + ] for pre_user in pre_registered_users: assert pre_user["email"] in pending_emails @@ -258,7 +263,7 @@ async def test_list_users_for_admin( # a. Check PENDING filter (should exclude the registered user) url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) pending_data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -267,19 +272,24 @@ async def test_list_users_for_admin( assert registered_email not in pending_emails assert len(pending_emails) >= len(pre_registered_users) - 1 - # b. Check all users + # b. Check REVIEWED users (should include the registered user) resp = await client.get( - f"{url}?status=APPROVED", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?review_status=REVIEWED", headers={X_PRODUCT_NAME_HEADER: product_name} ) approved_data, _ = await assert_status(resp, status.HTTP_200_OK) - # Find the registered user in the active users + # Find the registered user in the reviewed users active_user = next( - (item for item in approved_data if item["email"] == registered_email), + ( + UserForAdminGet(**item) + for item in approved_data + if item["email"] == registered_email + ), None, ) assert active_user is not None - assert UserForAdminGet(**active_user).status == UserStatus.ACTIVE + assert active_user.account_request_status == "APPROVED" + assert active_user.status == UserStatus.ACTIVE # 4. Test pagination # a. First page (limit 2) @@ -317,15 +327,17 @@ async def test_list_users_for_admin( # 5. Combine status filter with pagination resp = await client.get( f"{url}", - params={"status": "PENDING", "limit": 2, "offset": 0}, + params={"review_status": "PENDING", "limit": 2, "offset": 0}, headers={X_PRODUCT_NAME_HEADER: product_name}, ) - filtered_page_data, _ = await assert_status(resp, status.HTTP_200_OK) + filtered_page_payload, _ = await assert_status(resp, status.HTTP_200_OK) + filtered_page_data = filtered_page_payload["data"] assert len(filtered_page_data) <= 2 for item in filtered_page_data: user = UserForAdminGet(**item) assert user.registered is False # Pending users are not registered + assert user.account_request_status == "PENDING" @pytest.mark.parametrize( diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index a8f8f36fb5b..bd425debd95 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -365,7 +365,7 @@ async def test_list_merged_pre_and_registered_users( # The product_owner_user is already a registered user associated with the product - # 1. Create a pre-registered user that is not in the users table + # 1. Create a pre-registered user that is not in the users table - PENDING status pre_reg_email = "pre.registered.only@example.com" pre_reg_id = await _users_repository.create_user_pre_registration( asyncpg_engine, @@ -394,8 +394,27 @@ async def test_list_merged_pre_and_registered_users( link_to_existing_user=True, # This will link to the existing user ) + # 3. Create another pre-registered user with APPROVED status + approved_email = "approved.user@example.com" + approved_reg_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=approved_email, + created_by=created_by_user_id, + product_name=product_name, + pre_first_name="Approved", + pre_last_name="User", + institution="Approved Institution", + ) + # Set to APPROVED status + await _users_repository.review_user_pre_registration( + asyncpg_engine, + pre_registration_id=approved_reg_id, + reviewed_by=created_by_user_id, + new_status=AccountRequestStatus.APPROVED, + ) + try: - # Act + # Act - Get all users without filtering users_list, total_count = ( await _users_repository.list_merged_pre_and_registered_users( asyncpg_engine, @@ -407,11 +426,8 @@ async def test_list_merged_pre_and_registered_users( ) # Assert - - # 1. Check that we got the correct total count - assert ( - total_count >= 2 - ), "Should have at least 2 users (pre-registered only and product owner)" + # 1. Check that we got the correct total count - should include all users + assert total_count >= 3, "Should have at least 3 users" # 2. Find the pre-registered only user in the results pre_reg_only_user = next( @@ -467,19 +483,63 @@ async def test_list_merged_pre_and_registered_users( product_owner["created_by"] == created_by_user_id ), "Should have created_by field with the creator's ID" - # 4. Test filtering by account request status + # 4. Test filtering by any account request status - PENDING only pending_users, pending_count = ( await _users_repository.list_merged_pre_and_registered_users( asyncpg_engine, product_name=product_name, - filter_account_request_status=AccountRequestStatus.PENDING, + filter_any_account_request_status=[AccountRequestStatus.PENDING], + filter_include_deleted=False, + ) + ) + + # Only pending pre-registrations should be included (default status is PENDING) + assert pending_count == 2 + assert len(pending_users) == 2 + pending_emails = [user["email"] for user in pending_users] + assert pre_reg_email in pending_emails + assert product_owner_user["email"] in pending_emails + # The approved user should not be in this result + assert approved_email not in pending_emails + + # 5. Test filtering by any account request status - APPROVED only + approved_users, approved_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=[AccountRequestStatus.APPROVED], + filter_include_deleted=False, + ) + ) + + # Only approved pre-registrations should be included + assert approved_count == 1 + assert len(approved_users) == 1 + assert approved_users[0]["email"] == approved_email + assert ( + approved_users[0]["account_request_status"] == AccountRequestStatus.APPROVED + ) + + # 6. Test filtering by multiple account request statuses - PENDING and APPROVED + mixed_status_users, mixed_status_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=[ + AccountRequestStatus.PENDING, + AccountRequestStatus.APPROVED, + ], filter_include_deleted=False, ) ) - # Both pre-registrations should be in pending status by default - assert pending_count >= 2 - assert len(pending_users) >= 2 + # Both pending and approved users should be included + assert mixed_status_count == 3 + assert len(mixed_status_users) == 3 + mixed_status_emails = [user["email"] for user in mixed_status_users] + assert pre_reg_email in mixed_status_emails + assert product_owner_user["email"] in mixed_status_emails + assert approved_email in mixed_status_emails finally: # Clean up @@ -487,7 +547,7 @@ async def test_list_merged_pre_and_registered_users( await conn.execute( sa.delete(users_pre_registration_details).where( users_pre_registration_details.c.id.in_( - [pre_reg_id, owner_pre_reg_id] + [pre_reg_id, owner_pre_reg_id, approved_reg_id] ) ) ) From 9b7f1ccb55bb73feef7f296418695813b886e940 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 11:29:00 +0200 Subject: [PATCH 38/55] simplify tests --- .../tests/unit/with_dbs/03/users/conftest.py | 26 +- .../03/users/test_users_repository.py | 479 ++++++++++++------ .../with_dbs/03/users/test_users_service.py | 17 +- 3 files changed, 351 insertions(+), 171 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/users/conftest.py b/services/web/server/tests/unit/with_dbs/03/users/conftest.py index 7f5545977c5..2b99f9e01f6 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/users/conftest.py @@ -4,7 +4,7 @@ # pylint: disable=too-many-arguments import asyncio -from collections.abc import AsyncIterable, Callable +from collections.abc import AsyncGenerator, AsyncIterable, Callable from typing import Any import pytest @@ -14,6 +14,9 @@ from faker import Faker from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp.application import create_safe_application +from simcore_postgres_database.models.users_details import ( + users_pre_registration_details, +) from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.db.plugin import get_asyncpg_engine, setup_db from sqlalchemy.ext.asyncio import AsyncEngine @@ -55,6 +58,19 @@ def asyncpg_engine( return get_asyncpg_engine(app) +@pytest.fixture +async def pre_registration_details_db_cleanup( + app: web.Application, +) -> AsyncGenerator[None, None]: + """Fixture to clean up all pre-registration details after test""" + yield + + # Tear down - clean up the pre-registration details table + async with get_asyncpg_engine(app).connect() as conn: + await conn.execute(sa.delete(users_pre_registration_details)) + await conn.commit() + + @pytest.fixture async def product_owner_user( faker: Faker, @@ -62,12 +78,8 @@ async def product_owner_user( ) -> AsyncIterable[dict[str, Any]]: """A PO user in the database""" - from pytest_simcore.helpers.faker_factories import ( - random_user, - ) - from pytest_simcore.helpers.postgres_tools import ( - insert_and_get_row_lifespan, - ) + from pytest_simcore.helpers.faker_factories import random_user + from pytest_simcore.helpers.postgres_tools import insert_and_get_row_lifespan from simcore_postgres_database.models.users import UserRole, users async with insert_and_get_row_lifespan( # pylint:disable=contextmanager-generator-missing-cleanup diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index bd425debd95..48d1991a1b9 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -3,6 +3,8 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +from collections.abc import AsyncIterator +from dataclasses import dataclass from typing import Any import pytest @@ -17,10 +19,35 @@ from simcore_service_webserver.users import _users_repository +@pytest.fixture +async def pre_registration_details_db_cleanup( + app: web.Application, +) -> AsyncIterator[list[int]]: + """Fixture to clean up pre-registration details after tests. + + Returns a list that tests can append pre-registration IDs to. + All records with these IDs will be deleted when the fixture is torn down. + """ + pre_registration_ids = [] + yield pre_registration_ids + + if pre_registration_ids: + # Clean up at the end of the test + asyncpg_engine = get_asyncpg_engine(app) + async with asyncpg_engine.connect() as conn: + await conn.execute( + sa.delete(users_pre_registration_details).where( + users_pre_registration_details.c.id.in_(pre_registration_ids) + ) + ) + await conn.commit() + + async def test_create_user_pre_registration( app: web.Application, product_name: ProductName, product_owner_user: dict[str, Any], + pre_registration_details_db_cleanup: list[int], ): # Arrange asyncpg_engine = get_asyncpg_engine(app) @@ -43,6 +70,9 @@ async def test_create_user_pre_registration( **pre_registration_details, ) + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_registration_id) + # Assert async with asyncpg_engine.connect() as conn: # Query to check if the record was inserted @@ -54,14 +84,6 @@ async def test_create_user_pre_registration( ) record = result.first() - # Clean up - delete the test record - await conn.execute( - sa.delete(users_pre_registration_details).where( - users_pre_registration_details.c.id == pre_registration_id - ) - ) - await conn.commit() - # Verify the record was created with correct values assert record is not None assert record.pre_email == test_email @@ -74,6 +96,7 @@ async def test_review_user_pre_registration( app: web.Application, product_name: ProductName, product_owner_user: dict[str, Any], + pre_registration_details_db_cleanup: list[int], ): # Arrange asyncpg_engine = get_asyncpg_engine(app) @@ -97,6 +120,9 @@ async def test_review_user_pre_registration( **pre_registration_details, ) + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_registration_id) + # Act - review and approve the registration new_status = AccountRequestStatus.APPROVED await _users_repository.review_user_pre_registration( @@ -148,6 +174,7 @@ async def test_list_user_pre_registrations( app: web.Application, product_name: ProductName, product_owner_user: dict[str, Any], + pre_registration_details_db_cleanup: list[int], ): # Arrange asyncpg_engine = get_asyncpg_engine(app) @@ -174,6 +201,8 @@ async def test_list_user_pre_registrations( institution="Test Institution", ) pre_reg_ids.append(pre_reg_id) + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_reg_id) # Create and approve one registration await _users_repository.review_user_pre_registration( @@ -297,6 +326,7 @@ async def test_create_pre_registration_with_existing_user_linking( product_owner_user: dict[str, Any], link_to_existing_user: bool, expected_linked: bool, + pre_registration_details_db_cleanup: list[int], ): """Test that creating a pre-registration for an existing user correctly handles auto-linking.""" # Arrange @@ -316,6 +346,9 @@ async def test_create_pre_registration_with_existing_user_linking( institution=f"{'Auto-linked' if link_to_existing_user else 'No-link'} Institution", ) + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_registration_id) + # Assert - Verify through list_user_pre_registrations registrations, count = await _users_repository.list_user_pre_registrations( asyncpg_engine, @@ -343,28 +376,40 @@ async def test_create_pre_registration_with_existing_user_linking( else: assert reg["user_id"] is None, "Should NOT be linked to any user" - # Clean up - async with asyncpg_engine.connect() as conn: - await conn.execute( - sa.delete(users_pre_registration_details).where( - users_pre_registration_details.c.id == pre_registration_id - ) - ) - await conn.commit() + +@dataclass +class MixedUserTestData: + """Test data for user pre-registration tests with mixed states.""" + + created_by_user_id: str + product_owner_email: str + product_owner_id: str + pre_reg_email: str + pre_reg_id: int + owner_pre_reg_id: int + approved_email: str + approved_reg_id: int -async def test_list_merged_pre_and_registered_users( +@pytest.fixture +async def mixed_user_data( app: web.Application, product_name: ProductName, product_owner_user: dict[str, Any], -): - """Tests that list_merged_pre_and_registered_users correctly merges users from both tables.""" - # Arrange + pre_registration_details_db_cleanup: list[int], +) -> MixedUserTestData: + """Create a mix of pre-registered users in different states to test listing functionality. + + Creates: + 1. A pre-registered only user (PENDING) + 2. A pre-registration for the existing product owner (linked) + 3. A pre-registered user in APPROVED state + + Returns a dataclass with created IDs and emails for verification and cleanup. + """ asyncpg_engine = get_asyncpg_engine(app) created_by_user_id = product_owner_user["id"] - # The product_owner_user is already a registered user associated with the product - # 1. Create a pre-registered user that is not in the users table - PENDING status pre_reg_email = "pre.registered.only@example.com" pre_reg_id = await _users_repository.create_user_pre_registration( @@ -382,6 +427,9 @@ async def test_list_merged_pre_and_registered_users( country="US", ) + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_reg_id) + # 2. Create a pre-registration for the product_owner_user (both registered and pre-registered) owner_pre_reg_id = await _users_repository.create_user_pre_registration( asyncpg_engine, @@ -394,6 +442,9 @@ async def test_list_merged_pre_and_registered_users( link_to_existing_user=True, # This will link to the existing user ) + # Add to cleanup list + pre_registration_details_db_cleanup.append(owner_pre_reg_id) + # 3. Create another pre-registered user with APPROVED status approved_email = "approved.user@example.com" approved_reg_id = await _users_repository.create_user_pre_registration( @@ -405,6 +456,10 @@ async def test_list_merged_pre_and_registered_users( pre_last_name="User", institution="Approved Institution", ) + + # Add to cleanup list + pre_registration_details_db_cleanup.append(approved_reg_id) + # Set to APPROVED status await _users_repository.review_user_pre_registration( asyncpg_engine, @@ -413,142 +468,268 @@ async def test_list_merged_pre_and_registered_users( new_status=AccountRequestStatus.APPROVED, ) - try: - # Act - Get all users without filtering - users_list, total_count = ( - await _users_repository.list_merged_pre_and_registered_users( - asyncpg_engine, - product_name=product_name, - filter_include_deleted=False, - pagination_limit=10, - pagination_offset=0, - ) - ) + return MixedUserTestData( + created_by_user_id=created_by_user_id, + product_owner_email=product_owner_user["email"], + product_owner_id=product_owner_user["id"], + pre_reg_email=pre_reg_email, + pre_reg_id=pre_reg_id, + owner_pre_reg_id=owner_pre_reg_id, + approved_email=approved_email, + approved_reg_id=approved_reg_id, + ) - # Assert - # 1. Check that we got the correct total count - should include all users - assert total_count >= 3, "Should have at least 3 users" + # No explicit cleanup needed here, as pre_registration_details_db_cleanup will handle it - # 2. Find the pre-registered only user in the results - pre_reg_only_user = next( - (user for user in users_list if user["email"] == pre_reg_email), None - ) - assert pre_reg_only_user is not None, "Pre-registered user should be in results" - assert pre_reg_only_user["is_pre_registered"] is True - # Check the pre_registration user_id is None but using the new column name - assert pre_reg_only_user["pre_reg_user_id"] is None - # For non-linked users, user_id is still None - assert ( - pre_reg_only_user["user_id"] is None - ), "Pre-registered only user shouldn't have a user_id" - assert pre_reg_only_user["institution"] == "Pre-Reg Institution" - assert pre_reg_only_user["first_name"] == "Pre-Registered" - assert pre_reg_only_user["last_name"] == "Only" - # Check created_by field instead of invited_by - assert ( - pre_reg_only_user["created_by"] == created_by_user_id - ), "Should have created_by field with the creator's ID" - - # 3. Check the product owner (both registered and pre-registered) - product_owner = next( - ( - user - for user in users_list - if user["email"] == product_owner_user["email"] - ), - None, + +async def test_list_merged_users_all_users( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test that list_merged_pre_and_registered_users correctly returns all users.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get all users without filtering + users_list, total_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + pagination_limit=10, + pagination_offset=0, ) - assert product_owner is not None, "Product owner should be in results" - assert ( - product_owner["is_pre_registered"] is True - ), "Should prefer pre-registration record" - # Check both the pre_reg_user_id (from pre-registration) and user_id (from users table) - assert ( - product_owner["pre_reg_user_id"] == product_owner_user["id"] - ), "pre_reg_user_id should match the product owner id" - assert ( - product_owner["user_id"] == product_owner_user["id"] - ), "Should be linked to existing user" - assert product_owner["institution"] == "Owner Institution" - assert ( - product_owner["first_name"] == "Owner" - ), "Should use pre-registration first name" - assert ( - product_owner["user_name"] is not None - ), "Should include user_name from users table" - assert ( - product_owner["status"] is not None - ), "Should include status from users table" - assert ( - product_owner["created_by"] == created_by_user_id - ), "Should have created_by field with the creator's ID" - - # 4. Test filtering by any account request status - PENDING only - pending_users, pending_count = ( - await _users_repository.list_merged_pre_and_registered_users( - asyncpg_engine, - product_name=product_name, - filter_any_account_request_status=[AccountRequestStatus.PENDING], - filter_include_deleted=False, - ) + ) + + # Assert + # Check that we got the correct total count - should include all users from test data + assert total_count >= 3, "Should have at least 3 users" + + # Create a set of expected emails for easier checking + expected_emails = { + mixed_user_data.pre_reg_email, + mixed_user_data.product_owner_email, + mixed_user_data.approved_email, + } + + # Check that all our test users are in the results + found_emails = {user["email"] for user in users_list} + assert expected_emails.issubset( + found_emails + ), "All expected users should be in results" + + +async def test_list_merged_users_pre_registered_only( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test pre-registered only user details are correctly returned.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get all users + users_list, _ = await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + ) + + # Find the pre-registered only user in the results + pre_reg_only_user = next( + (user for user in users_list if user["email"] == mixed_user_data.pre_reg_email), + None, + ) + + # Assert + assert pre_reg_only_user is not None, "Pre-registered user should be in results" + assert pre_reg_only_user["is_pre_registered"] is True + # Check the pre_registration user_id is None but using the new column name + assert pre_reg_only_user["pre_reg_user_id"] is None + # For non-linked users, user_id is still None + assert ( + pre_reg_only_user["user_id"] is None + ), "Pre-registered only user shouldn't have a user_id" + assert pre_reg_only_user["institution"] == "Pre-Reg Institution" + assert pre_reg_only_user["first_name"] == "Pre-Registered" + assert pre_reg_only_user["last_name"] == "Only" + assert pre_reg_only_user["created_by"] == mixed_user_data.created_by_user_id + + +async def test_list_merged_users_linked_user( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test that a linked user (both registered and pre-registered) has correct data.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get all users + users_list, _ = await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + ) + + # Find the product owner (both registered and pre-registered) in the results + product_owner = next( + ( + user + for user in users_list + if user["email"] == mixed_user_data.product_owner_email + ), + None, + ) + + # Assert + assert product_owner is not None, "Product owner should be in results" + assert ( + product_owner["is_pre_registered"] is True + ), "Should prefer pre-registration record" + + # Check both the pre_reg_user_id (from pre-registration) and user_id (from users table) + assert ( + product_owner["pre_reg_user_id"] == mixed_user_data.product_owner_id + ), "pre_reg_user_id should match the product owner id" + assert ( + product_owner["user_id"] == mixed_user_data.product_owner_id + ), "Should be linked to existing user" + + assert product_owner["institution"] == "Owner Institution" + assert ( + product_owner["first_name"] == "Owner" + ), "Should use pre-registration first name" + assert ( + product_owner["user_name"] is not None + ), "Should include user_name from users table" + assert product_owner["status"] is not None, "Should include status from users table" + assert product_owner["created_by"] == mixed_user_data.created_by_user_id + + +async def test_list_merged_users_filter_pending( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test filtering by PENDING account request status.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get users with PENDING status + pending_users, pending_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=[AccountRequestStatus.PENDING], + filter_include_deleted=False, ) + ) - # Only pending pre-registrations should be included (default status is PENDING) - assert pending_count == 2 - assert len(pending_users) == 2 - pending_emails = [user["email"] for user in pending_users] - assert pre_reg_email in pending_emails - assert product_owner_user["email"] in pending_emails - # The approved user should not be in this result - assert approved_email not in pending_emails - - # 5. Test filtering by any account request status - APPROVED only - approved_users, approved_count = ( - await _users_repository.list_merged_pre_and_registered_users( - asyncpg_engine, - product_name=product_name, - filter_any_account_request_status=[AccountRequestStatus.APPROVED], - filter_include_deleted=False, - ) + # Assert + # Only pending pre-registrations should be included (default status is PENDING) + assert pending_count == 2 + assert len(pending_users) == 2 + pending_emails = [user["email"] for user in pending_users] + assert mixed_user_data.pre_reg_email in pending_emails + assert mixed_user_data.product_owner_email in pending_emails + # The approved user should not be in this result + assert mixed_user_data.approved_email not in pending_emails + + +async def test_list_merged_users_filter_approved( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test filtering by APPROVED account request status.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get users with APPROVED status + approved_users, approved_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=[AccountRequestStatus.APPROVED], + filter_include_deleted=False, ) + ) - # Only approved pre-registrations should be included - assert approved_count == 1 - assert len(approved_users) == 1 - assert approved_users[0]["email"] == approved_email - assert ( - approved_users[0]["account_request_status"] == AccountRequestStatus.APPROVED + # Assert + # Only approved pre-registrations should be included + assert approved_count == 1 + assert len(approved_users) == 1 + assert approved_users[0]["email"] == mixed_user_data.approved_email + assert approved_users[0]["account_request_status"] == AccountRequestStatus.APPROVED + + +async def test_list_merged_users_multiple_statuses( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test filtering by multiple account request statuses.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get users with either PENDING or APPROVED status + mixed_status_users, mixed_status_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=[ + AccountRequestStatus.PENDING, + AccountRequestStatus.APPROVED, + ], + filter_include_deleted=False, ) + ) - # 6. Test filtering by multiple account request statuses - PENDING and APPROVED - mixed_status_users, mixed_status_count = ( - await _users_repository.list_merged_pre_and_registered_users( - asyncpg_engine, - product_name=product_name, - filter_any_account_request_status=[ - AccountRequestStatus.PENDING, - AccountRequestStatus.APPROVED, - ], - filter_include_deleted=False, - ) + # Assert + # Both pending and approved users should be included + assert mixed_status_count == 3 + assert len(mixed_status_users) == 3 + mixed_status_emails = [user["email"] for user in mixed_status_users] + assert mixed_user_data.pre_reg_email in mixed_status_emails + assert mixed_user_data.product_owner_email in mixed_status_emails + assert mixed_user_data.approved_email in mixed_status_emails + + +async def test_list_merged_users_pagination( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, +): + """Test pagination of merged user results.""" + asyncpg_engine = get_asyncpg_engine(app) + + # Act - Get first page with limit 2 + page1_users, total_count = ( + await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + pagination_limit=2, + pagination_offset=0, ) + ) - # Both pending and approved users should be included - assert mixed_status_count == 3 - assert len(mixed_status_users) == 3 - mixed_status_emails = [user["email"] for user in mixed_status_users] - assert pre_reg_email in mixed_status_emails - assert product_owner_user["email"] in mixed_status_emails - assert approved_email in mixed_status_emails + # Get second page with limit 2 + page2_users, _ = await _users_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_include_deleted=False, + pagination_limit=2, + pagination_offset=2, + ) - finally: - # Clean up - async with asyncpg_engine.connect() as conn: - await conn.execute( - sa.delete(users_pre_registration_details).where( - users_pre_registration_details.c.id.in_( - [pre_reg_id, owner_pre_reg_id, approved_reg_id] - ) - ) - ) - await conn.commit() + # Assert + # Check pagination works correctly + assert len(page1_users) == 2, "First page should have 2 users" + assert total_count >= 3, "Total count should report all users" + + if total_count > 2: + assert len(page2_users) > 0, "Second page should have at least 1 user" + + # Ensure the emails are different between pages + page1_emails = [user["email"] for user in page1_users] + page2_emails = [user["email"] for user in page2_users] + assert not set(page1_emails).intersection( + set(page2_emails) + ), "Pages should have different users" diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py index 775e9c7a544..bd5d9e26a99 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py @@ -3,7 +3,7 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments -from collections.abc import AsyncGenerator, AsyncIterable +from collections.abc import AsyncIterable from typing import Any import pytest @@ -21,19 +21,6 @@ ) -@pytest.fixture -async def pre_registration_details_cleanup( - app: web.Application, -) -> AsyncGenerator[None, None]: - """Fixture to clean up all pre-registration details after test""" - yield - - # Tear down - clean up the pre-registration details table - async with get_asyncpg_engine(app).connect() as conn: - await conn.execute(sa.delete(users_pre_registration_details)) - await conn.commit() - - @pytest.fixture async def pre_registered_user_created( app: web.Application, @@ -155,7 +142,7 @@ async def test_search_users_as_admin_wildcard( app: web.Application, product_name: ProductName, product_owner_user: dict[str, Any], - pre_registration_details_cleanup: None, + pre_registration_details_db_cleanup: None, ): """Test searching for users with wildcards""" # Arrange From 4f967a4b252d168b281656cf61724fca0367ccf1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 14:40:51 +0200 Subject: [PATCH 39/55] =?UTF-8?q?services/webserver=20api=20version:=200.6?= =?UTF-8?q?5.0=20=E2=86=92=200.66.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/web/server/VERSION | 2 +- services/web/server/setup.cfg | 6 +++--- .../src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/web/server/VERSION b/services/web/server/VERSION index afed694eede..e40e4fc339c 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.65.0 +0.66.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 74695ace879..dad07775c2b 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.65.0 +current_version = 0.66.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False @@ -13,13 +13,13 @@ commit_args = --no-verify addopts = --strict-markers asyncio_mode = auto asyncio_default_fixture_loop_scope = function -markers = +markers = slow: marks tests as slow (deselect with '-m "not slow"') acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows." testit: "marks test to run during development" heavy_load: "mark tests that require large amount of data" [mypy] -plugins = +plugins = pydantic.mypy sqlalchemy.ext.mypy.plugin 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 7b99b983463..e975e39d187 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.65.0 + version: 0.66.0 servers: - url: '' description: webserver From d6d80c43c76ae268dde70822be04355a4d1a677b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 14:48:01 +0200 Subject: [PATCH 40/55] fixes mypy --- .../users/_users_repository.py | 16 +++++++--------- .../users/_users_service.py | 4 ++-- .../simcore_service_webserver/utils_aiohttp.py | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index b8f04bba684..b084308c163 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -1,6 +1,6 @@ import contextlib import logging -from typing import Any +from typing import Any, cast import sqlalchemy as sa from aiohttp import web @@ -663,13 +663,13 @@ async def list_user_pre_registrations( async with pass_or_acquire_connection(engine, connection) as conn: # Get total count count_result = await conn.execute(count_query) - total_count = count_result.scalar() + total_count = count_result.scalar_one() # Get pre-registration records result = await conn.execute(main_query) records = result.mappings().all() - return list(records), total_count + return cast(list[dict[str, Any]], list(records)), total_count async def review_user_pre_registration( @@ -894,10 +894,8 @@ async def list_merged_pre_and_registered_users( # If filtering by account request status, we only want pre-registered users with any of those statuses # No need to union with regular users as they don't have account_request_status - if ( - filter_any_account_request_status is not None - and filter_any_account_request_status - ): + merged_query: sa.sql.Select | sa.sql.CompoundSelect + if filter_any_account_request_status: merged_query = pre_reg_query else: merged_query = pre_reg_query.union_all(users_query) @@ -937,10 +935,10 @@ async def list_merged_pre_and_registered_users( async with pass_or_acquire_connection(engine, connection) as conn: # Get total count count_result = await conn.execute(count_query) - total_count = count_result.scalar() + total_count = count_result.scalar_one() # Get user records result = await conn.execute(distinct_query) records = result.mappings().all() - return list(records), total_count + return cast(list[dict[str, Any]], records), total_count diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 89376d5e832..afa2367af23 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -491,7 +491,7 @@ async def approve_user_account( # There should be only one registration matching these criteria pre_registration = pre_registrations[0] - pre_registration_id = pre_registration["id"] + pre_registration_id: int = pre_registration["id"] # Update the pre-registration status to APPROVED using the reviewer's ID await _users_repository.review_user_pre_registration( @@ -541,7 +541,7 @@ async def reject_user_account( # There should be only one registration matching these criteria pre_registration = pre_registrations[0] - pre_registration_id = pre_registration["id"] + pre_registration_id: int = pre_registration["id"] # Update the pre-registration status to REJECTED using the reviewer's ID await _users_repository.review_user_pre_registration( diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index 163b5d4382b..b70a6c6897a 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -78,7 +78,7 @@ def envelope_json_response( ) -def create_json_response_from_page(page: Page[ItemT]): +def create_json_response_from_page(page: Page[ItemT]) -> web.Response: return web.Response( text=page.model_dump_json(**RESPONSE_MODEL_POLICY), content_type=MIMETYPE_APPLICATION_JSON, From eeca4836d8090de0f698765a54c4de1d328f6345 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 14:56:49 +0200 Subject: [PATCH 41/55] fixes tests --- .../src/simcore_service_webserver/users/_users_repository.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index b084308c163..76f055caa42 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -745,6 +745,9 @@ async def search_merged_pre_and_registered_users( users_pre_registration_details.c.country, users_pre_registration_details.c.user_id, users_pre_registration_details.c.extras, + users_pre_registration_details.c.account_request_status, + users_pre_registration_details.c.account_request_reviewed_by, + users_pre_registration_details.c.account_request_reviewed_at, users.c.status, invited_by, ) From 71c247cbd0cce2f4ebde18b017b192e2877435ff Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 15:08:00 +0200 Subject: [PATCH 42/55] adds invitation options --- .../src/models_library/api_schemas_webserver/users.py | 2 ++ .../src/simcore_service_webserver/api/v0/openapi.yaml | 11 +++++++---- .../simcore_service_webserver/users/_users_rest.py | 7 +++++++ 3 files changed, 16 insertions(+), 4 deletions(-) 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 1c064ef7dd1..0bf446ca5c1 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 @@ -41,6 +41,7 @@ OutputSchemaWithoutCamelCase, ) from .groups import MyGroupsGet +from .products import InvitationGenerate from .users_preferences import AggregatedPreferences # @@ -254,6 +255,7 @@ class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters) class UserApprove(InputSchema): email: EmailStr + invitation: InvitationGenerate | None = None class UserReject(InputSchema): 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 e975e39d187..a5da6567768 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 @@ -1387,18 +1387,17 @@ paths: type: integer default: 0 title: Offset - - name: account_request_status + - name: review_status in: query required: false schema: anyOf: - enum: - PENDING - - REJECETED - - APPROVED + - REVIEWED type: string - type: 'null' - title: Account Request Status + title: Review Status responses: '200': description: Successful Response @@ -17002,6 +17001,10 @@ components: type: string format: email title: Email + invitation: + anyOf: + - $ref: '#/components/schemas/InvitationGenerate' + - type: 'null' type: object required: - email diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 61705615210..603785ac29b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -271,6 +271,13 @@ async def approve_user_account(request: web.Request) -> web.Response: approval_data = await parse_request_body_as(UserApprove, request) + if approval_data.invitation: + _logger.debug( + "TODO: User %s is being approved with invitation %s. Generating invitation ... ", + approval_data.email, + approval_data.invitation, + ) + # Approve the user account, passing the current user's ID as the reviewer pre_registration_id = await _users_service.approve_user_account( request.app, From c43cbdf9a747746792028fb893cfe5da00f10d38 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 15:23:49 +0200 Subject: [PATCH 43/55] returns pre-registration --- api/specs/web-server/_users.py | 3 ++- .../models_library/api_schemas_webserver/users.py | 1 + .../users/_users_repository.py | 3 +++ .../simcore_service_webserver/users/_users_rest.py | 14 ++++++++++---- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 52d5c88603c..1016c25f7f3 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -15,6 +15,7 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + ResendConfirmationCode, UserApprove, UserForAdminGet, UserGet, @@ -180,7 +181,7 @@ async def reject_user_account(_body: UserReject): ... tags=_extra_tags, include_in_schema=False, # UNDER DEVELOPMENT ) -async def resend_user_confirmation_email(_body: UserApprove): ... +async def resend_user_confirmation_email(_body: ResendConfirmationCode): ... @router.get( 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 0bf446ca5c1..358dd4d82a5 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 @@ -294,6 +294,7 @@ class UserForAdminGet(OutputSchema): ] = DEFAULT_FACTORY # pre-registration + pre_registration_id: int | None invited_by: str | None = None account_request_status: AccountRequestStatus | None account_request_reviewed_by: UserID | None = None diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 76f055caa42..d3c04a1794b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -729,6 +729,7 @@ async def search_merged_pre_and_registered_users( async with pass_or_acquire_connection(engine, connection) as conn: columns = ( + users_pre_registration_details.c.id, users.c.first_name, users.c.last_name, users.c.email, @@ -830,6 +831,7 @@ async def list_merged_pre_and_registered_users( # We need to left join with users to identify if the pre-registered user is already in the system pre_reg_query = ( sa.select( + users_pre_registration_details.c.id, users_pre_registration_details.c.pre_email.label("email"), users_pre_registration_details.c.pre_first_name.label("first_name"), users_pre_registration_details.c.pre_last_name.label("last_name"), @@ -864,6 +866,7 @@ async def list_merged_pre_and_registered_users( # Query for users that are associated with the product through groups users_query = ( sa.select( + sa.literal(None).label("id"), users.c.email, users.c.first_name, users.c.last_name, diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 603785ac29b..9fb514932e8 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -200,7 +200,9 @@ async def list_users_for_admin(request: web.Request) -> web.Response: ) def _to_domain_model(user: dict[str, Any]) -> UserForAdminGet: - return UserForAdminGet(extras=user.pop("extras") or {}, **user) + return UserForAdminGet( + extras=user.pop("extras") or {}, pre_registration_id=user.pop("id"), **user + ) page = Page[UserForAdminGet].model_validate( paginate_data( @@ -243,7 +245,7 @@ async def search_users_for_admin(request: web.Request) -> web.Response: f"/{API_VTAG}/admin/users:pre-register", name="pre_register_user_for_admin" ) @login_required -@permission_required("admin.users.read") +@permission_required("admin.users.write") @_handle_users_exceptions async def pre_register_user_for_admin(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) @@ -273,9 +275,13 @@ async def approve_user_account(request: web.Request) -> web.Response: if approval_data.invitation: _logger.debug( - "TODO: User %s is being approved with invitation %s. Generating invitation ... ", + "TODO: User is being approved with invitation %s: \n" + "1. Approve user account\n" + "2. Generate invitation\n" + "3. Store invitation in extras\n" + "4. Send invitation to user %s\n", + approval_data.invitation.model_dump_json(indent=1), approval_data.email, - approval_data.invitation, ) # Approve the user account, passing the current user's ID as the reviewer From fb8bc848b89aff9049ed0d4daf639f07f9a5914a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 15:27:37 +0200 Subject: [PATCH 44/55] update API endpoint for fetching pending users to use review_status query parameter --- .../client/source/class/osparc/data/Resources.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index c0198d8e59a..3c85f1adcd0 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -1063,7 +1063,7 @@ qx.Class.define("osparc.data.Resources", { }, getPendingUsers: { method: "GET", - url: statics.API + "/admin/users?status=PENDING" + url: statics.API + "/admin/users?review_status=PENDING" }, approveUser: { method: "POST", From 726b768d6cab6cc6e173c493cf547f4250475c37 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 16:30:37 +0200 Subject: [PATCH 45/55] fixes --- .../service-library/src/servicelib/aiohttp/rest_responses.py | 2 +- .../src/simcore_service_webserver/users/_users_service.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/service-library/src/servicelib/aiohttp/rest_responses.py b/packages/service-library/src/servicelib/aiohttp/rest_responses.py index 8ddf5090b5a..f7415d9c0d3 100644 --- a/packages/service-library/src/servicelib/aiohttp/rest_responses.py +++ b/packages/service-library/src/servicelib/aiohttp/rest_responses.py @@ -92,7 +92,7 @@ def create_http_error( ) return http_error_cls( - reason=reason, + reason=reason.replace("\n", " ") if reason else None, text=json_dumps( payload, ), diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index afa2367af23..f9e8ff4ea8c 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -183,6 +183,7 @@ async def _list_products_or_none(user_id): country=r.country, extras=r.extras or {}, invited_by=r.invited_by, + pre_registration_id=r.id, account_request_status=r.account_request_status, account_request_reviewed_by=r.account_request_reviewed_by, account_request_reviewed_at=r.account_request_reviewed_at, From 5926529c7ce96066d33a7ee7d9ef8dd76979cc39 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 18:22:01 +0200 Subject: [PATCH 46/55] fix tests --- .../tests/unit/with_dbs/03/test_users_rest_registration.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 723293a30b9..374cee8fdaa 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -126,13 +126,15 @@ async def test_search_and_pre_registration( found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 got = UserForAdminGet( - **found[0], institution=None, address=None, city=None, state=None, postal_code=None, country=None, + pre_registration_id=None, + invited_by=None, + **found[0], ) expected = { "first_name": logged_user.get("first_name"), @@ -233,7 +235,7 @@ async def test_list_users_for_admin( # Access the items field from the paginated response pending_emails = [ user["email"] - for user in data["items"] + for user in data if user.get("account_request_status") == "PENDING" ] for pre_user in pre_registered_users: From 542d78741ef579082ff26304b6d37d84f1682546 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 18:44:46 +0200 Subject: [PATCH 47/55] refactor: remove unused ResendConfirmationCode and update response model references --- api/specs/web-server/_users.py | 12 +---------- .../api/v0/openapi.yaml | 21 +++++++------------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 1016c25f7f3..8b25d230188 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -15,7 +15,6 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, - ResendConfirmationCode, UserApprove, UserForAdminGet, UserGet, @@ -151,7 +150,7 @@ async def search_users(_body: UsersSearch): ... @router.get( "/admin/users", - response_model=Envelope[Page[UserForAdminGet]], + response_model=Page[UserForAdminGet], tags=_extra_tags, ) async def list_users_for_admin( @@ -175,15 +174,6 @@ async def approve_user_account(_body: UserApprove): ... async def reject_user_account(_body: UserReject): ... -@router.post( - "/admin/users:resendConfirmationEmail", - status_code=status.HTTP_204_NO_CONTENT, - tags=_extra_tags, - include_in_schema=False, # UNDER DEVELOPMENT -) -async def resend_user_confirmation_email(_body: ResendConfirmationCode): ... - - @router.get( "/admin/users:search", response_model=Envelope[list[UserForAdminGet]], 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 a5da6567768..f6941f59e4a 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 @@ -1404,7 +1404,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_Page_UserForAdminGet__' + $ref: '#/components/schemas/Page_UserForAdminGet_' /v0/admin/users:approve: post: tags: @@ -10258,19 +10258,6 @@ components: title: Error type: object title: Envelope[NodeRetrieved] - Envelope_Page_UserForAdminGet__: - properties: - data: - anyOf: - - $ref: '#/components/schemas/Page_UserForAdminGet_' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[Page[UserForAdminGet]] Envelope_PaymentMethodGet_: properties: data: @@ -17065,6 +17052,11 @@ components: type: object title: Extras description: Keeps extra information provided in the request form + preRegistrationId: + anyOf: + - type: integer + - type: 'null' + title: Preregistrationid invitedBy: anyOf: - type: string @@ -17115,6 +17107,7 @@ components: - state - postalCode - country + - preRegistrationId - accountRequestStatus - registered - status From 14beb76387fb39292de58acc56986889ec0d9562 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:00:16 +0200 Subject: [PATCH 48/55] fixes all tests --- .../api_schemas_webserver/users.py | 2 + .../users/_users_rest.py | 5 + .../users/_users_service.py | 15 ++- .../users/exceptions.py | 11 ++ .../03/test_users_rest_registration.py | 126 +++++++++++------- 5 files changed, 108 insertions(+), 51 deletions(-) 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 358dd4d82a5..d6b2131e260 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 @@ -249,6 +249,8 @@ class UsersForAdminListFilter(Filters): # review_status: Literal["PENDING", "REVIEWED"] | None = None + model_config = ConfigDict(extra="forbid") + class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 9fb514932e8..81f4c2765d6 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -43,6 +43,7 @@ from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + PendingPreRegistrationNotFoundError, UserNameDuplicateError, UserNotFoundError, ) @@ -51,6 +52,10 @@ _TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + PendingPreRegistrationNotFoundError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + PendingPreRegistrationNotFoundError.msg_template, + ), UserNotFoundError: HttpErrorInfo( status.HTTP_404_NOT_FOUND, "This user cannot be found. Either it is not registered or has enabled privacy settings.", diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index f9e8ff4ea8c..1e6fb4f220d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -31,6 +31,7 @@ from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + PendingPreRegistrationNotFoundError, ) _logger = logging.getLogger(__name__) @@ -474,7 +475,7 @@ async def approve_user_account( int: The ID of the approved pre-registration record Raises: - ValueError: If no pre-registration is found for the email/product + PendingPreRegistrationNotFoundError: If no pre-registration is found for the email/product """ engine = get_asyncpg_engine(app) @@ -487,8 +488,9 @@ async def approve_user_account( ) if not pre_registrations: - msg = f"No pending pre-registration found for email {pre_registration_email} in product {product_name}" - raise ValueError(msg) + raise PendingPreRegistrationNotFoundError( + email=pre_registration_email, product_name=product_name + ) # There should be only one registration matching these criteria pre_registration = pre_registrations[0] @@ -524,7 +526,7 @@ async def reject_user_account( int: The ID of the rejected pre-registration record Raises: - ValueError: If no pre-registration is found for the email/product + PendingPreRegistrationNotFoundError: If no pre-registration is found for the email/product """ engine = get_asyncpg_engine(app) @@ -537,8 +539,9 @@ async def reject_user_account( ) if not pre_registrations: - msg = f"No pending pre-registration found for email {pre_registration_email} in product {product_name}" - raise ValueError(msg) + raise PendingPreRegistrationNotFoundError( + email=pre_registration_email, product_name=product_name + ) # There should be only one registration matching these criteria pre_registration = pre_registrations[0] 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 eb4b7503deb..b1533222195 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -64,3 +64,14 @@ class BillingDetailsNotFoundError(UsersBaseError): class MissingGroupExtraPropertiesForProductError(UsersBaseError): msg_template = "Missing group_extra_property for product_name={product_name}" tip = "Add a new row in group_extra_property table and assign it to this product" + + +class PendingPreRegistrationNotFoundError(UsersBaseError): + msg_template = ( + "No pending pre-registration found for email {email} in product {product_name}" + ) + + def __init__(self, *, email: str, product_name: str, **ctx: Any): + super().__init__(**ctx) + self.email = email + self.product_name = product_name diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 374cee8fdaa..ee8ce5f0c0e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -6,6 +6,7 @@ # pylint: disable=unused-variable +from collections.abc import AsyncGenerator from http import HTTPStatus from typing import Any @@ -14,7 +15,9 @@ import simcore_service_webserver.users import simcore_service_webserver.users._users_repository import simcore_service_webserver.users._users_service +import sqlalchemy as sa from aiohttp.test_utils import TestClient +from common_library.pydantic_fields_extension import is_nullable from common_library.users_enums import UserRole, UserStatus from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo @@ -22,16 +25,27 @@ UserForAdminGet, ) from models_library.products import ProductName +from models_library.rest_pagination import Page from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.faker_factories import ( DEFAULT_TEST_PASSWORD, ) from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import ( UserInfoDict, ) from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from simcore_postgres_database.models.users_details import ( + users_pre_registration_details, +) +from simcore_service_webserver.db.plugin import get_asyncpg_engine + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments @pytest.fixture @@ -101,6 +115,22 @@ def account_request_form(faker: Faker) -> dict[str, Any]: return form +@pytest.fixture +async def pre_registration_details_db_cleanup( + client: TestClient, +) -> AsyncGenerator[None, None]: + """Fixture to clean up all pre-registration details after test""" + + assert client.app + + yield + + # Tear down - clean up the pre-registration details table + async with get_asyncpg_engine(client.app).connect() as conn: + await conn.execute(sa.delete(users_pre_registration_details)) + await conn.commit() + + @pytest.mark.acceptance_test( "pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138" ) @@ -114,6 +144,7 @@ async def test_search_and_pre_registration( client: TestClient, logged_user: UserInfoDict, account_request_form: dict[str, Any], + pre_registration_details_db_cleanup: None, ): assert client.app @@ -125,17 +156,14 @@ async def test_search_and_pre_registration( found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserForAdminGet( - institution=None, - address=None, - city=None, - state=None, - postal_code=None, - country=None, - pre_registration_id=None, - invited_by=None, - **found[0], - ) + + nullable_fields = { + name: None + for name, field in UserForAdminGet.model_fields.items() + if is_nullable(field) + } + + got = UserForAdminGet.model_validate({**nullable_fields, **found[0]}) expected = { "first_name": logged_user.get("first_name"), "last_name": logged_user.get("last_name"), @@ -206,6 +234,7 @@ async def test_list_users_for_admin( account_request_form: dict[str, Any], faker: Faker, product_name: ProductName, + pre_registration_details_db_cleanup: None, ): assert client.app @@ -230,14 +259,18 @@ async def test_list_users_for_admin( resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) - data, _ = await assert_status(resp, status.HTTP_200_OK) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + + # Parse response into Page[UserForAdminGet] model + page_model = Page[UserForAdminGet].model_validate(response_json) # Access the items field from the paginated response - pending_emails = [ - user["email"] - for user in data - if user.get("account_request_status") == "PENDING" + pending_users = [ + user for user in page_model.data if user.account_request_status == "PENDING" ] + pending_emails = [user.email for user in pending_users] + for pre_user in pre_registered_users: assert pre_user["email"] in pending_emails @@ -267,10 +300,12 @@ async def test_list_users_for_admin( resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) - pending_data, _ = await assert_status(resp, status.HTTP_200_OK) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + pending_page = Page[UserForAdminGet].model_validate(response_json) # The registered user should no longer be in pending status - pending_emails = [user["email"] for user in pending_data] + pending_emails = [user.email for user in pending_page.data] assert registered_email not in pending_emails assert len(pending_emails) >= len(pre_registered_users) - 1 @@ -278,15 +313,13 @@ async def test_list_users_for_admin( resp = await client.get( f"{url}?review_status=REVIEWED", headers={X_PRODUCT_NAME_HEADER: product_name} ) - approved_data, _ = await assert_status(resp, status.HTTP_200_OK) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + reviewed_page = Page[UserForAdminGet].model_validate(response_json) # Find the registered user in the reviewed users active_user = next( - ( - UserForAdminGet(**item) - for item in approved_data - if item["email"] == registered_email - ), + (user for user in reviewed_page.data if user.email == registered_email), None, ) assert active_user is not None @@ -301,12 +334,13 @@ async def test_list_users_for_admin( headers={X_PRODUCT_NAME_HEADER: product_name}, ) assert resp.status == status.HTTP_200_OK - page1_payload = await resp.json() + response_json = await resp.json() + page1 = Page[UserForAdminGet].model_validate(response_json) - assert len(page1_payload["items"]) == 2 - assert page1_payload["meta"]["limit"] == 2 - assert page1_payload["meta"]["offset"] == 0 - assert page1_payload["meta"]["total"] >= len(pre_registered_users) + assert len(page1.data) == 2 + assert page1.meta.limit == 2 + assert page1.meta.offset == 0 + assert page1.meta.total >= len(pre_registered_users) # b. Second page (limit 2) resp = await client.get( @@ -315,15 +349,16 @@ async def test_list_users_for_admin( headers={X_PRODUCT_NAME_HEADER: product_name}, ) assert resp.status == status.HTTP_200_OK - page2_payload = await resp.json() + response_json = await resp.json() + page2 = Page[UserForAdminGet].model_validate(response_json) - assert len(page2_payload["items"]) == 2 - assert page2_payload["meta"]["limit"] == 2 - assert page2_payload["meta"]["offset"] == 2 + assert len(page2.data) == 2 + assert page2.meta.limit == 2 + assert page2.meta.offset == 2 # Ensure page 1 and page 2 contain different items - page1_emails = [item["email"] for item in page1_payload["data"]] - page2_emails = [item["email"] for item in page2_payload["data"]] + page1_emails = [user.email for user in page1.data] + page2_emails = [user.email for user in page2.data] assert not set(page1_emails).intersection(page2_emails) # 5. Combine status filter with pagination @@ -332,12 +367,12 @@ async def test_list_users_for_admin( params={"review_status": "PENDING", "limit": 2, "offset": 0}, headers={X_PRODUCT_NAME_HEADER: product_name}, ) - filtered_page_payload, _ = await assert_status(resp, status.HTTP_200_OK) - filtered_page_data = filtered_page_payload["data"] + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + filtered_page = Page[UserForAdminGet].model_validate(response_json) - assert len(filtered_page_data) <= 2 - for item in filtered_page_data: - user = UserForAdminGet(**item) + assert len(filtered_page.data) <= 2 + for user in filtered_page.data: assert user.registered is False # Pending users are not registered assert user.account_request_status == "PENDING" @@ -354,6 +389,7 @@ async def test_reject_user_account( account_request_form: dict[str, Any], faker: Faker, product_name: ProductName, + pre_registration_details_db_cleanup: None, ): assert client.app @@ -374,7 +410,7 @@ async def test_reject_user_account( # 2. Verify the user is in PENDING status url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -393,7 +429,7 @@ async def test_reject_user_account( # 4. Verify the user is no longer in PENDING status url = client.app.router["list_users_for_admin"].url_for() resp = await client.get( - f"{url}?status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} + f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) pending_data, _ = await assert_status(resp, status.HTTP_200_OK) pending_emails = [user["email"] for user in pending_data] @@ -411,9 +447,9 @@ async def test_reject_user_account( # Check that account_request_status is REJECTED user_data = found[0] - assert user_data["account_request_status"] == "REJECTED" - assert user_data["account_request_reviewed_by"] == logged_user["id"] - assert user_data["account_request_reviewed_at"] is not None + assert user_data["accountRequestStatus"] == "REJECTED" + assert user_data["accountRequestReviewedBy"] == logged_user["id"] + assert user_data["accountRequestReviewedAt"] is not None # 6. Verify that a rejected user cannot be approved url = client.app.router["approve_user_account"].url_for() From 2b5daeeebca31bccce23f8d108f0bf320dba4070 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:18:28 +0200 Subject: [PATCH 49/55] rename as user-account --- api/specs/web-server/_users.py | 32 ++++---- .../api_schemas_webserver/users.py | 6 +- scripts/maintenance/pre_registration.py | 2 +- .../source/class/osparc/data/Resources.js | 12 +-- .../api/v0/openapi.yaml | 78 +++++++++---------- .../users/_common/schemas.py | 7 +- .../users/_users_rest.py | 34 ++++---- .../users/_users_service.py | 8 +- .../test_products_rest_invitations.py | 16 ++-- .../with_dbs/03/test_users_rest_profiles.py | 2 +- .../03/test_users_rest_registration.py | 54 +++++++------ .../with_dbs/03/users/test_users_service.py | 4 +- 12 files changed, 132 insertions(+), 123 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 8b25d230188..1e2fa1d770c 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -15,12 +15,12 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + UserAccountGet, + UserAccountSearchQueryParams, UserApprove, - UserForAdminGet, UserGet, UserReject, - UsersForAdminListQueryParams, - UsersForAdminSearchQueryParams, + UsersAccountListQueryParams, UsersSearch, ) from models_library.api_schemas_webserver.users_preferences import PatchRequestBody @@ -149,17 +149,17 @@ async def search_users(_body: UsersSearch): ... @router.get( - "/admin/users", - response_model=Page[UserForAdminGet], + "/admin/user-accounts", + response_model=Page[UserAccountGet], tags=_extra_tags, ) -async def list_users_for_admin( - _query: Annotated[as_query(UsersForAdminListQueryParams), Depends()], +async def list_users_accounts( + _query: Annotated[as_query(UsersAccountListQueryParams), Depends()], ): ... @router.post( - "/admin/users:approve", + "/admin/user-accounts:approve", status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, ) @@ -167,7 +167,7 @@ async def approve_user_account(_body: UserApprove): ... @router.post( - "/admin/users:reject", + "/admin/user-accounts:reject", status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, ) @@ -175,20 +175,20 @@ async def reject_user_account(_body: UserReject): ... @router.get( - "/admin/users:search", - response_model=Envelope[list[UserForAdminGet]], + "/admin/user-accounts:search", + response_model=Envelope[list[UserAccountGet]], tags=_extra_tags, ) -async def search_users_for_admin( - _query: Annotated[UsersForAdminSearchQueryParams, Depends()], +async def search_user_account( + _query: Annotated[UserAccountSearchQueryParams, Depends()], ): # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods ... @router.post( - "/admin/users:pre-register", - response_model=Envelope[UserForAdminGet], + "/admin/user-accounts:pre-register", + response_model=Envelope[UserAccountGet], tags=_extra_tags, ) -async def pre_register_user_for_admin(_body: PreRegisteredUserGet): ... +async def pre_register_user_account(_body: PreRegisteredUserGet): ... 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 d6b2131e260..de845daaefd 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 @@ -252,7 +252,7 @@ class UsersForAdminListFilter(Filters): model_config = ConfigDict(extra="forbid") -class UsersForAdminListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... +class UsersAccountListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... class UserApprove(InputSchema): @@ -264,7 +264,7 @@ class UserReject(InputSchema): email: EmailStr -class UsersForAdminSearchQueryParams(RequestParameters): +class UserAccountSearchQueryParams(RequestParameters): email: Annotated[ str, Field( @@ -275,7 +275,7 @@ class UsersForAdminSearchQueryParams(RequestParameters): ] -class UserForAdminGet(OutputSchema): +class UserAccountGet(OutputSchema): # ONLY for admins first_name: str | None last_name: str | None diff --git a/scripts/maintenance/pre_registration.py b/scripts/maintenance/pre_registration.py index 6fb79318dfc..56ad86fa09b 100644 --- a/scripts/maintenance/pre_registration.py +++ b/scripts/maintenance/pre_registration.py @@ -123,7 +123,7 @@ async def _pre_register_user( extras: dict[str, Any] = {}, ) -> dict[str, Any]: """Pre-register a user in the system""" - path = "/v0/admin/users:pre-register" + path = "/v0/admin/user-accounts:pre-register" user_data = PreRegisterUserRequest( firstName=first_name, diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index 4a2cb179caf..55eadb7f61b 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -1059,27 +1059,27 @@ qx.Class.define("osparc.data.Resources", { endpoints: { search: { method: "GET", - url: statics.API + "/admin/users:search?email={email}" + url: statics.API + "/admin/user-accounts:search?email={email}" }, getPendingUsers: { method: "GET", - url: statics.API + "/admin/users?review_status=PENDING" + url: statics.API + "/admin/user-accounts?review_status=PENDING" }, approveUser: { method: "POST", - url: statics.API + "/admin/users:approve" + url: statics.API + "/admin/user-accounts:approve" }, rejectUser: { method: "POST", - url: statics.API + "/admin/users:reject" + url: statics.API + "/admin/user-accounts:reject" }, resendConfirmationEmail: { method: "POST", - url: statics.API + "/admin/users:resendConfirmationEmail" + url: statics.API + "/admin/user-accounts:resendConfirmationEmail" }, preRegister: { method: "POST", - url: statics.API + "/admin/users:pre-register" + url: statics.API + "/admin/user-accounts:pre-register" } } }, 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 98970d6bf1f..a5d823dda9f 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 @@ -1365,13 +1365,13 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_UserGet__' - /v0/admin/users: + /v0/admin/user-accounts: get: tags: - users - admin - summary: List Users For Admin - operationId: list_users_for_admin + summary: List Users Accounts + operationId: list_users_accounts parameters: - name: limit in: query @@ -1404,8 +1404,8 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Page_UserForAdminGet_' - /v0/admin/users:approve: + $ref: '#/components/schemas/Page_UserAccountGet_' + /v0/admin/user-accounts:approve: post: tags: - users @@ -1421,7 +1421,7 @@ paths: responses: '204': description: Successful Response - /v0/admin/users:reject: + /v0/admin/user-accounts:reject: post: tags: - users @@ -1437,13 +1437,13 @@ paths: responses: '204': description: Successful Response - /v0/admin/users:search: + /v0/admin/user-accounts:search: get: tags: - users - admin - summary: Search Users For Admin - operationId: search_users_for_admin + summary: Search User Account + operationId: search_user_account parameters: - name: email in: query @@ -1459,14 +1459,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_UserForAdminGet__' - /v0/admin/users:pre-register: + $ref: '#/components/schemas/Envelope_list_UserAccountGet__' + /v0/admin/user-accounts:pre-register: post: tags: - users - admin - summary: Pre Register User For Admin - operationId: pre_register_user_for_admin + summary: Pre Register User Account + operationId: pre_register_user_account requestBody: content: application/json: @@ -1479,7 +1479,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_UserForAdminGet_' + $ref: '#/components/schemas/Envelope_UserAccountGet_' /v0/wallets: get: tags: @@ -10656,11 +10656,11 @@ components: title: Error type: object title: Envelope[Union[WalletGet, NoneType]] - Envelope_UserForAdminGet_: + Envelope_UserAccountGet_: properties: data: anyOf: - - $ref: '#/components/schemas/UserForAdminGet' + - $ref: '#/components/schemas/UserAccountGet' - type: 'null' error: anyOf: @@ -10668,7 +10668,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[UserForAdminGet] + title: Envelope[UserAccountGet] Envelope_WalletGetWithAvailableCredits_: properties: data: @@ -11212,12 +11212,12 @@ components: title: Error type: object title: Envelope[list[TaskGet]] - Envelope_list_UserForAdminGet__: + Envelope_list_UserAccountGet__: properties: data: anyOf: - items: - $ref: '#/components/schemas/UserForAdminGet' + $ref: '#/components/schemas/UserAccountGet' type: array - type: 'null' title: Data @@ -11227,7 +11227,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[list[UserForAdminGet]] + title: Envelope[list[UserAccountGet]] Envelope_list_UserGet__: properties: data: @@ -13854,7 +13854,7 @@ components: - _links - data title: Page[ServiceRunGet] - Page_UserForAdminGet_: + Page_UserAccountGet_: properties: _meta: $ref: '#/components/schemas/PageMetaInfoLimitOffset' @@ -13862,7 +13862,7 @@ components: $ref: '#/components/schemas/PageLinks' data: items: - $ref: '#/components/schemas/UserForAdminGet' + $ref: '#/components/schemas/UserAccountGet' type: array title: Data additionalProperties: false @@ -13871,7 +13871,7 @@ components: - _meta - _links - data - title: Page[UserForAdminGet] + title: Page[UserAccountGet] PatchRequestBody: properties: value: @@ -16996,21 +16996,7 @@ components: - number - e_tag title: UploadedPart - UserApprove: - properties: - email: - type: string - format: email - title: Email - invitation: - anyOf: - - $ref: '#/components/schemas/InvitationGenerate' - - type: 'null' - type: object - required: - - email - title: UserApprove - UserForAdminGet: + UserAccountGet: properties: firstName: anyOf: @@ -17125,7 +17111,21 @@ components: - accountRequestStatus - registered - status - title: UserForAdminGet + title: UserAccountGet + UserApprove: + properties: + email: + type: string + format: email + title: Email + invitation: + anyOf: + - $ref: '#/components/schemas/InvitationGenerate' + - type: 'null' + type: object + required: + - email + title: UserApprove UserGet: properties: userId: diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py index a76326182ae..cf30b9360b1 100644 --- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py @@ -1,10 +1,9 @@ -""" input/output datasets used in the rest-API +"""input/output datasets used in the rest-API NOTE: Most of the model schemas are in `models_library.api_schemas_webserver.users`, the rest (hidden or needs a dependency) is here """ - import re import sys from contextlib import suppress @@ -12,7 +11,7 @@ import pycountry from models_library.api_schemas_webserver._base import InputSchema -from models_library.api_schemas_webserver.users import UserForAdminGet +from models_library.api_schemas_webserver.users import UserAccountGet from models_library.emails import LowerCaseEmailStr from models_library.users import UserID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -110,5 +109,5 @@ def _pre_check_and_normalize_country(cls, v): # asserts field names are in sync assert set(PreRegisteredUserGet.model_fields).issubset( - UserForAdminGet.model_fields + UserAccountGet.model_fields ) # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 81f4c2765d6..a659a1bff26 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -7,12 +7,12 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, + UserAccountGet, + UserAccountSearchQueryParams, UserApprove, - UserForAdminGet, UserGet, UserReject, - UsersForAdminListQueryParams, - UsersForAdminSearchQueryParams, + UsersAccountListQueryParams, UsersSearch, ) from models_library.rest_pagination import Page @@ -173,16 +173,16 @@ async def search_users(request: web.Request) -> web.Response: _RESPONSE_MODEL_MINIMAL_POLICY["exclude_none"] = True -@routes.get(f"/{API_VTAG}/admin/users", name="list_users_for_admin") +@routes.get(f"/{API_VTAG}/admin/user-accounts", name="list_users_accounts") @login_required @permission_required("admin.users.read") @_handle_users_exceptions -async def list_users_for_admin(request: web.Request) -> web.Response: +async def list_users_accounts(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec query_params = parse_request_query_parameters_as( - UsersForAdminListQueryParams, request + UsersAccountListQueryParams, request ) if query_params.review_status == "PENDING": @@ -204,12 +204,12 @@ async def list_users_for_admin(request: web.Request) -> web.Response: pagination_offset=query_params.offset, ) - def _to_domain_model(user: dict[str, Any]) -> UserForAdminGet: - return UserForAdminGet( + def _to_domain_model(user: dict[str, Any]) -> UserAccountGet: + return UserAccountGet( extras=user.pop("extras") or {}, pre_registration_id=user.pop("id"), **user ) - page = Page[UserForAdminGet].model_validate( + page = Page[UserAccountGet].model_validate( paginate_data( chunk=[_to_domain_model(user) for user in users], request_url=request.url, @@ -222,16 +222,16 @@ def _to_domain_model(user: dict[str, Any]) -> UserForAdminGet: return create_json_response_from_page(page) -@routes.get(f"/{API_VTAG}/admin/users:search", name="search_users_for_admin") +@routes.get(f"/{API_VTAG}/admin/user-accounts:search", name="search_user_account") @login_required @permission_required("admin.users.read") @_handle_users_exceptions -async def search_users_for_admin(request: web.Request) -> web.Response: +async def search_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec - query_params: UsersForAdminSearchQueryParams = parse_request_query_parameters_as( - UsersForAdminSearchQueryParams, request + query_params: UserAccountSearchQueryParams = parse_request_query_parameters_as( + UserAccountSearchQueryParams, request ) found = await _users_service.search_users_as_admin( @@ -247,12 +247,12 @@ async def search_users_for_admin(request: web.Request) -> web.Response: @routes.post( - f"/{API_VTAG}/admin/users:pre-register", name="pre_register_user_for_admin" + f"/{API_VTAG}/admin/user-accounts:pre-register", name="pre_register_user_account" ) @login_required @permission_required("admin.users.write") @_handle_users_exceptions -async def pre_register_user_for_admin(request: web.Request) -> web.Response: +async def pre_register_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) pre_user_profile = await parse_request_body_as(PreRegisteredUserGet, request) @@ -268,7 +268,7 @@ async def pre_register_user_for_admin(request: web.Request) -> web.Response: ) -@routes.post(f"/{API_VTAG}/admin/users:approve", name="approve_user_account") +@routes.post(f"/{API_VTAG}/admin/user-accounts:approve", name="approve_user_account") @login_required @permission_required("admin.users.write") @_handle_users_exceptions @@ -301,7 +301,7 @@ async def approve_user_account(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_204_NO_CONTENT) -@routes.post(f"/{API_VTAG}/admin/users:reject", name="reject_user_account") +@routes.post(f"/{API_VTAG}/admin/user-accounts:reject", name="reject_user_account") @login_required @permission_required("admin.users.write") @_handle_users_exceptions diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 1e6fb4f220d..15e9d01f90e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -4,7 +4,7 @@ import pycountry from aiohttp import web from common_library.users_enums import AccountRequestStatus -from models_library.api_schemas_webserver.users import MyProfilePatch, UserForAdminGet +from models_library.api_schemas_webserver.users import MyProfilePatch, UserAccountGet from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.groups import GroupID @@ -47,7 +47,7 @@ async def pre_register_user( profile: PreRegisteredUserGet, creator_user_id: UserID, product_name: ProductName, -) -> UserForAdminGet: +) -> UserAccountGet: found = await search_users_as_admin( app, email_glob=profile.email, product_name=product_name, include_products=False @@ -140,7 +140,7 @@ async def search_users_as_admin( email_glob: str, product_name: ProductName | None = None, include_products: bool = False, -) -> list[UserForAdminGet]: +) -> list[UserAccountGet]: """ WARNING: this information is reserved for admin users. Note that the returned model include UserForAdminGet @@ -171,7 +171,7 @@ async def _list_products_or_none(user_id): return None return [ - UserForAdminGet( + UserAccountGet( first_name=r.first_name or r.pre_first_name, last_name=r.last_name or r.pre_last_name, email=r.email or r.pre_email, diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py index f384bbe46fb..6b267396786 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py @@ -153,7 +153,9 @@ async def test_pre_registration_and_invitation_workflow( ).model_dump() # Search user -> nothing - response = await client.get("/v0/admin/users:search", params={"email": guest_email}) + response = await client.get( + "/v0/admin/user-accounts:search", params={"email": guest_email} + ) data, _ = await assert_status(response, expected_status) # i.e. no info of requester is found, i.e. needs pre-registration assert data == [] @@ -164,20 +166,22 @@ async def test_pre_registration_and_invitation_workflow( # assert response.status == status.HTTP_409_CONFLICT # Accept user for registration and create invitation for her - response = await client.post("/v0/admin/users:pre-register", json=requester_info) + response = await client.post( + "/v0/admin/user-accounts:pre-register", json=requester_info + ) data, _ = await assert_status(response, expected_status) # Can only pre-register once for _ in range(MANY_TIMES): response = await client.post( - "/v0/admin/users:pre-register", json=requester_info + "/v0/admin/user-accounts:pre-register", json=requester_info ) await assert_status(response, status.HTTP_409_CONFLICT) # Search user again for _ in range(MANY_TIMES): response = await client.get( - "/v0/admin/users:search", params={"email": guest_email} + "/v0/admin/user-accounts:search", params={"email": guest_email} ) data, _ = await assert_status(response, expected_status) assert len(data) == 1 @@ -207,7 +211,9 @@ async def test_pre_registration_and_invitation_workflow( await assert_status(response, status.HTTP_200_OK) # find registered user - response = await client.get("/v0/admin/users:search", params={"email": guest_email}) + response = await client.get( + "/v0/admin/user-accounts:search", params={"email": guest_email} + ) data, _ = await assert_status(response, expected_status) assert len(data) == 1 user_found = data[0] diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index c88958f38b9..8a3882444f2 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -199,7 +199,7 @@ async def test_search_users_by_partial_email( # SEARCH user for admin (from a USER) url = ( - client.app.router["search_users_for_admin"] + client.app.router["search_user_account"] .url_for() .with_query(email=partial_email) ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index ee8ce5f0c0e..c21880b2820 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -22,7 +22,7 @@ from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo from models_library.api_schemas_webserver.users import ( - UserForAdminGet, + UserAccountGet, ) from models_library.products import ProductName from models_library.rest_pagination import Page @@ -81,8 +81,8 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( ): assert client.app - url = client.app.router["search_users_for_admin"].url_for() - assert url.path == "/v0/admin/users:search" + url = client.app.router["search_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:search" resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) await assert_status(resp, expected) @@ -150,7 +150,7 @@ async def test_search_and_pre_registration( # ONLY in `users` and NOT `users_pre_registration_details` resp = await client.get( - "/v0/admin/users:search", params={"email": logged_user["email"]} + "/v0/admin/user-accounts:search", params={"email": logged_user["email"]} ) assert resp.status == status.HTTP_200_OK @@ -159,11 +159,11 @@ async def test_search_and_pre_registration( nullable_fields = { name: None - for name, field in UserForAdminGet.model_fields.items() + for name, field in UserAccountGet.model_fields.items() if is_nullable(field) } - got = UserForAdminGet.model_validate({**nullable_fields, **found[0]}) + got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) expected = { "first_name": logged_user.get("first_name"), "last_name": logged_user.get("last_name"), @@ -184,15 +184,18 @@ async def test_search_and_pre_registration( # NOT in `users` and ONLY `users_pre_registration_details` # create pre-registration - resp = await client.post("/v0/admin/users:pre-register", json=account_request_form) + resp = await client.post( + "/v0/admin/user-accounts:pre-register", json=account_request_form + ) assert resp.status == status.HTTP_200_OK resp = await client.get( - "/v0/admin/users:search", params={"email": account_request_form["email"]} + "/v0/admin/user-accounts:search", + params={"email": account_request_form["email"]}, ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserForAdminGet(**found[0], state=None, status=None) + got = UserAccountGet(**found[0], state=None, status=None) assert got.model_dump(include={"registered", "status"}) == { "registered": False, @@ -211,11 +214,12 @@ async def test_search_and_pre_registration( ) resp = await client.get( - "/v0/admin/users:search", params={"email": account_request_form["email"]} + "/v0/admin/user-accounts:search", + params={"email": account_request_form["email"]}, ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserForAdminGet(**found[0], state=None) + got = UserAccountGet(**found[0], state=None) assert got.model_dump(include={"registered", "status"}) == { "registered": True, "status": new_user["status"].name, @@ -228,7 +232,7 @@ async def test_search_and_pre_registration( UserRole.PRODUCT_OWNER, ], ) -async def test_list_users_for_admin( +async def test_list_users_accounts( client: TestClient, logged_user: UserInfoDict, account_request_form: dict[str, Any], @@ -247,7 +251,7 @@ async def test_list_users_for_admin( form_data["email"] = faker.email() resp = await client.post( - "/v0/admin/users:pre-register", + "/v0/admin/user-accounts:pre-register", json=form_data, headers={X_PRODUCT_NAME_HEADER: product_name}, ) @@ -255,7 +259,7 @@ async def test_list_users_for_admin( pre_registered_users.append(pre_registered_data) # Verify all pre-registered users are in PENDING status - url = client.app.router["list_users_for_admin"].url_for() + url = client.app.router["list_users_accounts"].url_for() resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) @@ -263,7 +267,7 @@ async def test_list_users_for_admin( response_json = await resp.json() # Parse response into Page[UserForAdminGet] model - page_model = Page[UserForAdminGet].model_validate(response_json) + page_model = Page[UserAccountGet].model_validate(response_json) # Access the items field from the paginated response pending_users = [ @@ -296,13 +300,13 @@ async def test_list_users_for_admin( # 3. Test filtering by status # a. Check PENDING filter (should exclude the registered user) - url = client.app.router["list_users_for_admin"].url_for() + url = client.app.router["list_users_accounts"].url_for() resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) assert resp.status == status.HTTP_200_OK response_json = await resp.json() - pending_page = Page[UserForAdminGet].model_validate(response_json) + pending_page = Page[UserAccountGet].model_validate(response_json) # The registered user should no longer be in pending status pending_emails = [user.email for user in pending_page.data] @@ -315,7 +319,7 @@ async def test_list_users_for_admin( ) assert resp.status == status.HTTP_200_OK response_json = await resp.json() - reviewed_page = Page[UserForAdminGet].model_validate(response_json) + reviewed_page = Page[UserAccountGet].model_validate(response_json) # Find the registered user in the reviewed users active_user = next( @@ -335,7 +339,7 @@ async def test_list_users_for_admin( ) assert resp.status == status.HTTP_200_OK response_json = await resp.json() - page1 = Page[UserForAdminGet].model_validate(response_json) + page1 = Page[UserAccountGet].model_validate(response_json) assert len(page1.data) == 2 assert page1.meta.limit == 2 @@ -350,7 +354,7 @@ async def test_list_users_for_admin( ) assert resp.status == status.HTTP_200_OK response_json = await resp.json() - page2 = Page[UserForAdminGet].model_validate(response_json) + page2 = Page[UserAccountGet].model_validate(response_json) assert len(page2.data) == 2 assert page2.meta.limit == 2 @@ -369,7 +373,7 @@ async def test_list_users_for_admin( ) assert resp.status == status.HTTP_200_OK response_json = await resp.json() - filtered_page = Page[UserForAdminGet].model_validate(response_json) + filtered_page = Page[UserAccountGet].model_validate(response_json) assert len(filtered_page.data) <= 2 for user in filtered_page.data: @@ -400,7 +404,7 @@ async def test_reject_user_account( form_data["email"] = faker.email() resp = await client.post( - "/v0/admin/users:pre-register", + "/v0/admin/user-accounts:pre-register", json=form_data, headers={X_PRODUCT_NAME_HEADER: product_name}, ) @@ -408,7 +412,7 @@ async def test_reject_user_account( pre_registered_email = pre_registered_data["email"] # 2. Verify the user is in PENDING status - url = client.app.router["list_users_for_admin"].url_for() + url = client.app.router["list_users_accounts"].url_for() resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) @@ -427,7 +431,7 @@ async def test_reject_user_account( await assert_status(resp, status.HTTP_204_NO_CONTENT) # 4. Verify the user is no longer in PENDING status - url = client.app.router["list_users_for_admin"].url_for() + url = client.app.router["list_users_accounts"].url_for() resp = await client.get( f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name} ) @@ -438,7 +442,7 @@ async def test_reject_user_account( # 5. Verify the user is now in REJECTED status # First get user details to check status resp = await client.get( - "/v0/admin/users:search", + "/v0/admin/user-accounts:search", params={"email": pre_registered_email}, headers={X_PRODUCT_NAME_HEADER: product_name}, ) diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py index bd5d9e26a99..0b852ec6c61 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py @@ -9,7 +9,7 @@ import pytest import sqlalchemy as sa from aiohttp import web -from models_library.api_schemas_webserver.users import UserForAdminGet +from models_library.api_schemas_webserver.users import UserAccountGet from models_library.products import ProductName from simcore_postgres_database.models.users_details import ( users_pre_registration_details, @@ -96,7 +96,7 @@ async def test_search_users_as_admin_real_user( ) # This test user does not have a product associated with it # Verify the UserForAdminGet model is populated correctly - assert isinstance(found_user, UserForAdminGet) + assert isinstance(found_user, UserAccountGet) assert found_user.first_name == product_owner_user["first_name"] From dd3fa41189a4876ccd03176b64f2c050f8ba4c2a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:19:57 +0200 Subject: [PATCH 50/55] plural --- api/specs/web-server/_users.py | 2 +- .../server/src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- .../server/src/simcore_service_webserver/users/_users_rest.py | 4 ++-- .../server/tests/unit/with_dbs/03/test_users_rest_profiles.py | 2 +- .../tests/unit/with_dbs/03/test_users_rest_registration.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 1e2fa1d770c..624a9115bd6 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -179,7 +179,7 @@ async def reject_user_account(_body: UserReject): ... response_model=Envelope[list[UserAccountGet]], tags=_extra_tags, ) -async def search_user_account( +async def search_user_accounts( _query: Annotated[UserAccountSearchQueryParams, Depends()], ): # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods 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 a5d823dda9f..489fcc52c50 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 @@ -1443,7 +1443,7 @@ paths: - users - admin summary: Search User Account - operationId: search_user_account + operationId: search_user_accounts parameters: - name: email in: query diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index a659a1bff26..1706b99ed04 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -222,11 +222,11 @@ def _to_domain_model(user: dict[str, Any]) -> UserAccountGet: return create_json_response_from_page(page) -@routes.get(f"/{API_VTAG}/admin/user-accounts:search", name="search_user_account") +@routes.get(f"/{API_VTAG}/admin/user-accounts:search", name="search_user_accounts") @login_required @permission_required("admin.users.read") @_handle_users_exceptions -async def search_user_account(request: web.Request) -> web.Response: +async def search_user_accounts(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 8a3882444f2..29760d16607 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -199,7 +199,7 @@ async def test_search_users_by_partial_email( # SEARCH user for admin (from a USER) url = ( - client.app.router["search_user_account"] + client.app.router["search_user_accounts"] .url_for() .with_query(email=partial_email) ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index c21880b2820..7d3e456f0dc 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -81,7 +81,7 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( ): assert client.app - url = client.app.router["search_user_account"].url_for() + url = client.app.router["search_user_accounts"].url_for() assert url.path == "/v0/admin/user-accounts:search" resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) From 2050ef0366005061d9c73fdebf3ff0c108c59d55 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:22:11 +0200 Subject: [PATCH 51/55] mode names --- api/specs/web-server/_users.py | 8 ++-- .../api_schemas_webserver/users.py | 4 +- .../api/v0/openapi.yaml | 38 +++++++++---------- .../users/_users_rest.py | 8 ++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 624a9115bd6..bd957858c49 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -15,11 +15,11 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + UserAccountApprove, UserAccountGet, + UserAccountReject, UserAccountSearchQueryParams, - UserApprove, UserGet, - UserReject, UsersAccountListQueryParams, UsersSearch, ) @@ -163,7 +163,7 @@ async def list_users_accounts( status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, ) -async def approve_user_account(_body: UserApprove): ... +async def approve_user_account(_body: UserAccountApprove): ... @router.post( @@ -171,7 +171,7 @@ async def approve_user_account(_body: UserApprove): ... status_code=status.HTTP_204_NO_CONTENT, tags=_extra_tags, ) -async def reject_user_account(_body: UserReject): ... +async def reject_user_account(_body: UserAccountReject): ... @router.get( 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 de845daaefd..ed102bf746a 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 @@ -255,12 +255,12 @@ class UsersForAdminListFilter(Filters): class UsersAccountListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... -class UserApprove(InputSchema): +class UserAccountApprove(InputSchema): email: EmailStr invitation: InvitationGenerate | None = None -class UserReject(InputSchema): +class UserAccountReject(InputSchema): email: EmailStr 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 489fcc52c50..a72ee315255 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 @@ -1416,7 +1416,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserApprove' + $ref: '#/components/schemas/UserAccountApprove' required: true responses: '204': @@ -1432,7 +1432,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserReject' + $ref: '#/components/schemas/UserAccountReject' required: true responses: '204': @@ -1442,7 +1442,7 @@ paths: tags: - users - admin - summary: Search User Account + summary: Search User Accounts operationId: search_user_accounts parameters: - name: email @@ -16996,6 +16996,20 @@ components: - number - e_tag title: UploadedPart + UserAccountApprove: + properties: + email: + type: string + format: email + title: Email + invitation: + anyOf: + - $ref: '#/components/schemas/InvitationGenerate' + - type: 'null' + type: object + required: + - email + title: UserAccountApprove UserAccountGet: properties: firstName: @@ -17112,20 +17126,16 @@ components: - registered - status title: UserAccountGet - UserApprove: + UserAccountReject: properties: email: type: string format: email title: Email - invitation: - anyOf: - - $ref: '#/components/schemas/InvitationGenerate' - - type: 'null' type: object required: - email - title: UserApprove + title: UserAccountReject UserGet: properties: userId: @@ -17287,16 +17297,6 @@ components: required: - read title: UserNotificationPatch - UserReject: - properties: - email: - type: string - format: email - title: Email - type: object - required: - - email - title: UserReject UserStatus: type: string enum: diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 1706b99ed04..51afa8bf849 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -7,11 +7,11 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, + UserAccountApprove, UserAccountGet, + UserAccountReject, UserAccountSearchQueryParams, - UserApprove, UserGet, - UserReject, UsersAccountListQueryParams, UsersSearch, ) @@ -276,7 +276,7 @@ async def approve_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec - approval_data = await parse_request_body_as(UserApprove, request) + approval_data = await parse_request_body_as(UserAccountApprove, request) if approval_data.invitation: _logger.debug( @@ -309,7 +309,7 @@ async def reject_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec - rejection_data = await parse_request_body_as(UserReject, request) + rejection_data = await parse_request_body_as(UserAccountReject, request) # Reject the user account, passing the current user's ID as the reviewer pre_registration_id = await _users_service.reject_user_account( From 3e21fc7a784b490661999ae9c5357e0a41348c6b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:27:43 +0200 Subject: [PATCH 52/55] cleanup --- .../users/_users_rest.py | 4 +- .../users/_users_service.py | 251 +++++++++--------- .../with_dbs/03/users/test_users_service.py | 6 +- 3 files changed, 133 insertions(+), 128 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 51afa8bf849..66bfa26e3fd 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -196,7 +196,7 @@ async def list_users_accounts(request: web.Request) -> web.Response: # ALL filter_any_account_request_status = None - users, total_count = await _users_service.list_all_users_as_admin( + users, total_count = await _users_service.list_user_accounts( request.app, product_name=req_ctx.product_name, filter_any_account_request_status=filter_any_account_request_status, @@ -234,7 +234,7 @@ async def search_user_accounts(request: web.Request) -> web.Response: UserAccountSearchQueryParams, request ) - found = await _users_service.search_users_as_admin( + found = await _users_service.search_users_accounts( request.app, email_glob=query_params.email, include_products=True ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 15e9d01f90e..7d968373f8a 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -49,7 +49,7 @@ async def pre_register_user( product_name: ProductName, ) -> UserAccountGet: - found = await search_users_as_admin( + found = await search_users_accounts( app, email_glob=profile.email, product_name=product_name, include_products=False ) if found: @@ -83,7 +83,7 @@ async def pre_register_user( **details, ) - found = await search_users_as_admin( + found = await search_users_accounts( app, email_glob=profile.email, product_name=product_name, include_products=False ) @@ -134,69 +134,6 @@ async def get_user_id_from_gid(app: web.Application, primary_gid: GroupID) -> Us return await _users_repository.get_user_id_from_pgid(app, primary_gid=primary_gid) -async def search_users_as_admin( - app: web.Application, - *, - email_glob: str, - product_name: ProductName | None = None, - include_products: bool = False, -) -> list[UserAccountGet]: - """ - WARNING: this information is reserved for admin users. Note that the returned model include UserForAdminGet - - NOTE: Functions in the service layer typically validate the caller's access rights - using parameters like product_name and user_id. However, this function skips - such checks as it is designed for scenarios (e.g., background tasks) where - no caller or context is available. - """ - - def _glob_to_sql_like(glob_pattern: str) -> str: - # Escape SQL LIKE special characters in the glob pattern - sql_like_pattern = glob_pattern.replace("%", r"\%").replace("_", r"\_") - # Convert glob wildcards to SQL LIKE wildcards - return sql_like_pattern.replace("*", "%").replace("?", "_") - - rows = await _users_repository.search_merged_pre_and_registered_users( - get_asyncpg_engine(app), - email_like=_glob_to_sql_like(email_glob), - product_name=product_name, - ) - - async def _list_products_or_none(user_id): - if user_id is not None and include_products: - products = await _users_repository.get_user_products( - get_asyncpg_engine(app), user_id=user_id - ) - return [_.product_name for _ in products] - return None - - return [ - UserAccountGet( - first_name=r.first_name or r.pre_first_name, - last_name=r.last_name or r.pre_last_name, - email=r.email or r.pre_email, - institution=r.institution, - phone=r.phone or r.pre_phone, - address=r.address, - city=r.city, - state=r.state, - postal_code=r.postal_code, - country=r.country, - extras=r.extras or {}, - invited_by=r.invited_by, - pre_registration_id=r.id, - account_request_status=r.account_request_status, - account_request_reviewed_by=r.account_request_reviewed_by, - account_request_reviewed_at=r.account_request_reviewed_at, - products=await _list_products_or_none(r.user_id), - # NOTE: old users will not have extra details - registered=r.user_id is not None if r.pre_email else r.status is not None, - status=r.status, - ) - for r in rows - ] - - async def get_users_in_group(app: web.Application, *, gid: GroupID) -> set[UserID]: return await _users_repository.get_users_ids_in_group( get_asyncpg_engine(app), group_id=gid @@ -214,64 +151,6 @@ async def is_user_in_product( ) -async def list_all_users_as_admin( - app: web.Application, - *, - product_name: ProductName, - filter_any_account_request_status: list[AccountRequestStatus] | None = None, - pagination_limit: int = 50, - pagination_offset: int = 0, -) -> tuple[list[dict[str, Any]], int]: - """ - Get a paginated list of users for admin view with filtering options. - - Args: - app: The web application instance - filter_approved: If set, filters users by their approval status - pagination_limit: Maximum number of users to return - pagination_offset: Number of users to skip for pagination - - Returns: - A tuple containing (list of user dictionaries, total count of users) - """ - engine = get_asyncpg_engine(app) - - # Get user data with pagination - users_data, total_count = ( - await _users_repository.list_merged_pre_and_registered_users( - engine, - product_name=product_name, - filter_any_account_request_status=filter_any_account_request_status, - pagination_limit=pagination_limit, - pagination_offset=pagination_offset, - ) - ) - - # For each user, append additional information if needed - result = [] - for user in users_data: - # Add any additional processing needed for admin view - user_dict = dict(user) - - # Add products information if needed - user_id = user.get("user_id") - if user_id: - products = await _users_repository.get_user_products( - engine, user_id=user_id - ) - user_dict["products"] = [p.product_name for p in products] - - user_dict["registered"] = ( - user_id is not None - if user.get("pre_email") - else user.get("status") is not None - ) - - result.append(user_dict) - - return result, total_count - - # # GET USER PROPERTIES # @@ -456,6 +335,132 @@ async def update_my_profile( ) +# +# USER ACCOUNTS +# + + +async def list_user_accounts( + app: web.Application, + *, + product_name: ProductName, + filter_any_account_request_status: list[AccountRequestStatus] | None = None, + pagination_limit: int = 50, + pagination_offset: int = 0, +) -> tuple[list[dict[str, Any]], int]: + """ + Get a paginated list of users for admin view with filtering options. + + Args: + app: The web application instance + filter_approved: If set, filters users by their approval status + pagination_limit: Maximum number of users to return + pagination_offset: Number of users to skip for pagination + + Returns: + A tuple containing (list of user dictionaries, total count of users) + """ + engine = get_asyncpg_engine(app) + + # Get user data with pagination + users_data, total_count = ( + await _users_repository.list_merged_pre_and_registered_users( + engine, + product_name=product_name, + filter_any_account_request_status=filter_any_account_request_status, + pagination_limit=pagination_limit, + pagination_offset=pagination_offset, + ) + ) + + # For each user, append additional information if needed + result = [] + for user in users_data: + # Add any additional processing needed for admin view + user_dict = dict(user) + + # Add products information if needed + user_id = user.get("user_id") + if user_id: + products = await _users_repository.get_user_products( + engine, user_id=user_id + ) + user_dict["products"] = [p.product_name for p in products] + + user_dict["registered"] = ( + user_id is not None + if user.get("pre_email") + else user.get("status") is not None + ) + + result.append(user_dict) + + return result, total_count + + +async def search_users_accounts( + app: web.Application, + *, + email_glob: str, + product_name: ProductName | None = None, + include_products: bool = False, +) -> list[UserAccountGet]: + """ + WARNING: this information is reserved for admin users. Note that the returned model include UserForAdminGet + + NOTE: Functions in the service layer typically validate the caller's access rights + using parameters like product_name and user_id. However, this function skips + such checks as it is designed for scenarios (e.g., background tasks) where + no caller or context is available. + """ + + def _glob_to_sql_like(glob_pattern: str) -> str: + # Escape SQL LIKE special characters in the glob pattern + sql_like_pattern = glob_pattern.replace("%", r"\%").replace("_", r"\_") + # Convert glob wildcards to SQL LIKE wildcards + return sql_like_pattern.replace("*", "%").replace("?", "_") + + rows = await _users_repository.search_merged_pre_and_registered_users( + get_asyncpg_engine(app), + email_like=_glob_to_sql_like(email_glob), + product_name=product_name, + ) + + async def _list_products_or_none(user_id): + if user_id is not None and include_products: + products = await _users_repository.get_user_products( + get_asyncpg_engine(app), user_id=user_id + ) + return [_.product_name for _ in products] + return None + + return [ + UserAccountGet( + first_name=r.first_name or r.pre_first_name, + last_name=r.last_name or r.pre_last_name, + email=r.email or r.pre_email, + institution=r.institution, + phone=r.phone or r.pre_phone, + address=r.address, + city=r.city, + state=r.state, + postal_code=r.postal_code, + country=r.country, + extras=r.extras or {}, + invited_by=r.invited_by, + pre_registration_id=r.id, + account_request_status=r.account_request_status, + account_request_reviewed_by=r.account_request_reviewed_by, + account_request_reviewed_at=r.account_request_reviewed_at, + products=await _list_products_or_none(r.user_id), + # NOTE: old users will not have extra details + registered=r.user_id is not None if r.pre_email else r.status is not None, + status=r.status, + ) + for r in rows + ] + + async def approve_user_account( app: web.Application, *, diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py index 0b852ec6c61..f9f648da946 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_service.py @@ -82,7 +82,7 @@ async def test_search_users_as_admin_real_user( user_email = product_owner_user["email"] # Act - found_users = await _users_service.search_users_as_admin( + found_users = await _users_service.search_users_accounts( app, email_glob=user_email, product_name=product_name, include_products=False ) @@ -111,7 +111,7 @@ async def test_search_users_as_admin_pre_registered_user( pre_registration_details = pre_registered_user_created["details"] # Act - found_users = await _users_service.search_users_as_admin( + found_users = await _users_service.search_users_accounts( app, email_glob=pre_registered_email, product_name=product_name ) @@ -164,7 +164,7 @@ async def test_search_users_as_admin_wildcard( ) # Act - search with wildcard for the domain - found_users = await _users_service.search_users_as_admin( + found_users = await _users_service.search_users_accounts( app, email_glob=f"*{email_domain}", product_name=product_name ) From 4f2e55dfe7af93f1703336a17e26db60876462fd Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:34:30 +0200 Subject: [PATCH 53/55] cleanup doc --- .../src/simcore_service_webserver/users/_users_repository.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index d3c04a1794b..af7e3232b45 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -325,6 +325,9 @@ async def get_user_products( *, user_id: UserID, ) -> list[Row]: + """Returns the products the user is part of, i.e. + the user is registered and assigned to the product's group + """ async with pass_or_acquire_connection(engine, connection) as conn: product_name_subq = ( sa.select( From c1f3eaf3b787215a7f6b2434a3c0b02f2eb4ac97 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 19:57:31 +0200 Subject: [PATCH 54/55] fixe pylint --- .../unit/with_dbs/03/test_users_rest_registration.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 7d3e456f0dc..5d623e26032 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -12,9 +12,6 @@ import pytest import simcore_service_webserver.login._auth_service -import simcore_service_webserver.users -import simcore_service_webserver.users._users_repository -import simcore_service_webserver.users._users_service import sqlalchemy as sa from aiohttp.test_utils import TestClient from common_library.pydantic_fields_extension import is_nullable @@ -30,7 +27,7 @@ from pytest_simcore.helpers.faker_factories import ( DEFAULT_TEST_PASSWORD, ) -from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict +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 ( UserInfoDict, @@ -42,11 +39,6 @@ ) from simcore_service_webserver.db.plugin import get_asyncpg_engine -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-arguments - @pytest.fixture def app_environment( From c33cda91e298e8ef20ae99511ecd605eeaad9421 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 22 May 2025 20:00:32 +0200 Subject: [PATCH 55/55] fixes tests --- .../tests/unit/with_dbs/03/test_users_rest_registration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py index 5d623e26032..ed41138d5b8 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -70,6 +70,7 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( client: TestClient, logged_user: UserInfoDict, expected: HTTPStatus, + pre_registration_details_db_cleanup: None, ): assert client.app @@ -393,7 +394,7 @@ async def test_reject_user_account( form_data = account_request_form.copy() form_data["firstName"] = faker.first_name() form_data["lastName"] = faker.last_name() - form_data["email"] = faker.email() + form_data["email"] = "some-reject-user@email.com" resp = await client.post( "/v0/admin/user-accounts:pre-register",