From 649fa4d549b7bcb2965284aee1782fa291c225ee 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 1/7] 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 f6e4fb7a49f7c00300f1f4269f460bbd1f79c82e 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 2/7] =?UTF-8?q?=F0=9F=8E=A8=20[Admin]=20Add=20endpoint=20t?= =?UTF-8?q?o=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 4ba28dd4bee..9266c993cdf 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: @@ -9374,6 +9411,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: @@ -12816,6 +12866,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 bbcd9a88b0390cbb371ce0b76409d193e1f5f45b 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 3/7] 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 6c5c26b686136714ad5833c86832e2eed614284c 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 4/7] =?UTF-8?q?=F0=9F=8E=A8=20[Admin]=20Implement=20endpoi?= =?UTF-8?q?nt=20to=20list=20users=20for=20admin=20with=20pagination=20supp?= =?UTF-8?q?ort?= 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 5a13e108201..14d87abd960 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -8,6 +8,7 @@ from aiohttp.web_routedef import RouteDef, RouteTableDef from common_library.json_serialization import json_dumps 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 @@ -76,6 +77,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 9515f1e90b5dea19af45d09e5e1d560811d4a7de 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 5/7] =?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 88b7446c4b5ffd1b1ab8016cbbe689ef02b1126e 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 6/7] 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 8f13169e147..955a68bd783 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 ( @@ -489,6 +489,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 998c81c5c7c3cf43ca2f2e73324d2dcfb450cdce 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 7/7] 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 #