diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index 40f08406084..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="request_captcha", + operation_id="create_captcha", status_code=status.HTTP_200_OK, responses={status.HTTP_200_OK: {"content": {"image/png": {}}}}, ) -async def request_captcha(): ... +async def create_captcha(): ... diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index d0d733a01e3..bd957858c49 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, @@ -14,13 +15,17 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, - UserForAdminGet, + UserAccountApprove, + UserAccountGet, + UserAccountReject, + UserAccountSearchQueryParams, UserGet, - UsersForAdminSearchQueryParams, + UsersAccountListQueryParams, 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 @@ -144,20 +149,46 @@ async def search_users(_body: UsersSearch): ... @router.get( - "/admin/users:search", - response_model=Envelope[list[UserForAdminGet]], + "/admin/user-accounts", + response_model=Page[UserAccountGet], tags=_extra_tags, ) -async def search_users_for_admin( - _query: Annotated[UsersForAdminSearchQueryParams, Depends()], +async def list_users_accounts( + _query: Annotated[as_query(UsersAccountListQueryParams), Depends()], +): ... + + +@router.post( + "/admin/user-accounts:approve", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def approve_user_account(_body: UserAccountApprove): ... + + +@router.post( + "/admin/user-accounts:reject", + status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, +) +async def reject_user_account(_body: UserAccountReject): ... + + +@router.get( + "/admin/user-accounts:search", + response_model=Envelope[list[UserAccountGet]], + tags=_extra_tags, +) +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 ... @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 1facf8bb1e9..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 @@ -1,13 +1,15 @@ 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 from pydantic import ( ConfigDict, EmailStr, @@ -39,6 +41,7 @@ OutputSchemaWithoutCamelCase, ) from .groups import MyGroupsGet +from .products import InvitationGenerate from .users_preferences import AggregatedPreferences # @@ -238,7 +241,30 @@ def from_domain_model(cls, data): return cls.model_validate(data, from_attributes=True) -class UsersForAdminSearchQueryParams(RequestParameters): +class UsersForAdminListFilter(Filters): + # 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 + # + review_status: Literal["PENDING", "REVIEWED"] | None = None + + model_config = ConfigDict(extra="forbid") + + +class UsersAccountListQueryParams(UsersForAdminListFilter, PageQueryParameters): ... + + +class UserAccountApprove(InputSchema): + email: EmailStr + invitation: InvitationGenerate | None = None + + +class UserAccountReject(InputSchema): + email: EmailStr + + +class UserAccountSearchQueryParams(RequestParameters): email: Annotated[ str, Field( @@ -249,7 +275,7 @@ class UsersForAdminSearchQueryParams(RequestParameters): ] -class UserForAdminGet(OutputSchema): +class UserAccountGet(OutputSchema): # ONLY for admins first_name: str | None last_name: str | None @@ -269,8 +295,12 @@ class UserForAdminGet(OutputSchema): ), ] = DEFAULT_FACTORY - # authorization + # pre-registration + pre_registration_id: int | None 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/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 3e9b538aeec..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?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/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 29e6a9c423d..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 @@ -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 @@ -371,8 +371,8 @@ paths: get: tags: - auth - summary: Request Captcha - operationId: request_captcha + summary: Create Captcha + operationId: create_captcha responses: '200': description: Successful Response @@ -1365,13 +1365,85 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_list_UserGet__' - /v0/admin/users:search: + /v0/admin/user-accounts: get: tags: - users - admin - summary: Search Users For Admin - operationId: search_users_for_admin + summary: List Users Accounts + operationId: list_users_accounts + parameters: + - 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 + - name: review_status + in: query + required: false + schema: + anyOf: + - enum: + - PENDING + - REVIEWED + type: string + - type: 'null' + title: Review Status + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_UserAccountGet_' + /v0/admin/user-accounts:approve: + post: + tags: + - users + - admin + summary: Approve User Account + operationId: approve_user_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAccountApprove' + required: true + responses: + '204': + description: Successful Response + /v0/admin/user-accounts:reject: + post: + tags: + - users + - admin + summary: Reject User Account + operationId: reject_user_account + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserAccountReject' + required: true + responses: + '204': + description: Successful Response + /v0/admin/user-accounts:search: + get: + tags: + - users + - admin + summary: Search User Accounts + operationId: search_user_accounts parameters: - name: email in: query @@ -1387,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: @@ -1407,7 +1479,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_UserForAdminGet_' + $ref: '#/components/schemas/Envelope_UserAccountGet_' /v0/wallets: get: tags: @@ -8329,6 +8401,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: @@ -10576,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: @@ -10588,7 +10668,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[UserForAdminGet] + title: Envelope[UserAccountGet] Envelope_WalletGetWithAvailableCredits_: properties: data: @@ -11132,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 @@ -11147,7 +11227,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[list[UserForAdminGet]] + title: Envelope[list[UserAccountGet]] Envelope_list_UserGet__: properties: data: @@ -13774,6 +13854,24 @@ components: - _links - data title: Page[ServiceRunGet] + Page_UserAccountGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/UserAccountGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[UserAccountGet] PatchRequestBody: properties: value: @@ -16898,7 +16996,21 @@ components: - number - e_tag title: UploadedPart - UserForAdminGet: + 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: anyOf: @@ -16954,11 +17066,33 @@ 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 - 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 @@ -16987,9 +17121,21 @@ components: - state - postalCode - country + - preRegistrationId + - accountRequestStatus - registered - status - title: UserForAdminGet + title: UserAccountGet + UserAccountReject: + properties: + email: + type: string + format: email + title: Email + type: object + required: + - email + title: UserAccountReject UserGet: properties: userId: 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..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 @@ -148,15 +148,12 @@ 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="create_captcha") @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) -async def request_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) 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/_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_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 05e4de80e8f..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 @@ -1,9 +1,11 @@ import contextlib -from typing import Any +import logging +from typing import Any, cast import sqlalchemy as sa from aiohttp import web -from common_library.users_enums import UserRole +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 from models_library.users import ( @@ -20,6 +22,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, @@ -47,6 +50,8 @@ UserNotFoundError, ) +_logger = logging.getLogger(__name__) + def _parse_as_user(user_id: Any) -> UserID: try: @@ -314,79 +319,15 @@ 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 get_user_products( engine: AsyncEngine, connection: AsyncConnection | None = None, *, 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( @@ -417,30 +358,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: @@ -577,3 +494,460 @@ async def update_user_profile( ) from err raise # not due to name duplication + + +# +# PRE-REGISTRATION +# + + +async def create_user_pre_registration( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + email: str, + 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(**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_one() + + # Get pre-registration records + result = await conn.execute(main_query) + records = result.mappings().all() + + return cast(list[dict[str, Any]], 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): + 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( + 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) + ) + + +# +# 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_pre_registration_details.c.id, + 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_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, + ) + + 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_any_account_request_status: list[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_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) + + Returns: + Tuple of (list of merged user data, total count) + """ + # 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_any_account_request_status: + pre_reg_where.append( + users_pre_registration_details.c.account_request_status.in_( + filter_any_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.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"), + 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.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, + 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, + # 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( + 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( + sa.literal(None).label("id"), + 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"), + 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"), + 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, + # 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( + 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)) + ) + + # 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 + 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) + + # Add distinct on email to eliminate duplicates + merged_query_subq = merged_query.subquery() + distinct_query = ( + sa.select(merged_query_subq) + .select_from(merged_query_subq) + .distinct(merged_query_subq.c.email) + .order_by( + merged_query_subq.c.email, + # Prioritize pre-registration records if duplicate emails exist + merged_query_subq.c.is_pre_registered.desc(), + merged_query_subq.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_subq.c.email) + .select_from(merged_query_subq) + .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_one() + + # Get user records + result = await conn.execute(distinct_query) + records = result.mappings().all() + + return cast(list[dict[str, Any]], records), 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 ced4632cec3..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 @@ -1,14 +1,22 @@ import logging from contextlib import suppress +from typing import Any from aiohttp import web +from common_library.users_enums import AccountRequestStatus from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, + UserAccountApprove, + UserAccountGet, + UserAccountReject, + UserAccountSearchQueryParams, UserGet, - UsersForAdminSearchQueryParams, + UsersAccountListQueryParams, 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,12 +37,13 @@ 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 ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + PendingPreRegistrationNotFoundError, UserNameDuplicateError, UserNotFoundError, ) @@ -43,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.", @@ -160,19 +173,68 @@ async def search_users(request: web.Request) -> web.Response: _RESPONSE_MODEL_MINIMAL_POLICY["exclude_none"] = True -@routes.get(f"/{API_VTAG}/admin/users:search", name="search_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 search_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: UsersForAdminSearchQueryParams = parse_request_query_parameters_as( - UsersForAdminSearchQueryParams, request + query_params = parse_request_query_parameters_as( + UsersAccountListQueryParams, 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_user_accounts( + request.app, + product_name=req_ctx.product_name, + filter_any_account_request_status=filter_any_account_request_status, + pagination_limit=query_params.limit, + pagination_offset=query_params.offset, + ) + + 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[UserAccountGet].model_validate( + paginate_data( + chunk=[_to_domain_model(user) for user in users], + request_url=request.url, + total=total_count, + limit=query_params.limit, + offset=query_params.offset, + ) ) - found = await _users_service.search_users_as_admin( + return create_json_response_from_page(page) + + +@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_accounts(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + query_params: UserAccountSearchQueryParams = parse_request_query_parameters_as( + UserAccountSearchQueryParams, request + ) + + found = await _users_service.search_users_accounts( request.app, email_glob=query_params.email, include_products=True ) @@ -185,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.read") +@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) @@ -200,6 +262,62 @@ 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) ) + + +@routes.post(f"/{API_VTAG}/admin/user-accounts: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(UserAccountApprove, request) + + if approval_data.invitation: + _logger.debug( + "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, + ) + + # 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) + + +@routes.post(f"/{API_VTAG}/admin/user-accounts: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(UserAccountReject, 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 1e4d820a443..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 @@ -3,7 +3,8 @@ import pycountry from aiohttp import web -from models_library.api_schemas_webserver.users import MyProfilePatch, UserForAdminGet +from common_library.users_enums import AccountRequestStatus +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 @@ -30,6 +31,7 @@ from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + PendingPreRegistrationNotFoundError, ) _logger = logging.getLogger(__name__) @@ -45,9 +47,9 @@ async def pre_register_user( profile: PreRegisteredUserGet, creator_user_id: UserID, product_name: ProductName, -) -> UserForAdminGet: +) -> 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: @@ -81,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 ) @@ -132,65 +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[UserForAdminGet]: - """ - 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_users_and_get_profile( - 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 [ - UserForAdminGet( - 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, - 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 @@ -390,3 +333,231 @@ async def update_my_profile( user_id=user_id, update=ToUserUpdateDB.from_api(update), ) + + +# +# 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, + *, + 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: + PendingPreRegistrationNotFoundError: 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: + raise PendingPreRegistrationNotFoundError( + email=pre_registration_email, product_name=product_name + ) + + # There should be only one registration matching these criteria + pre_registration = pre_registrations[0] + 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( + engine, + pre_registration_id=pre_registration_id, + reviewed_by=reviewer_id, + new_status=AccountRequestStatus.APPROVED, + ) + + 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: + PendingPreRegistrationNotFoundError: 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: + raise PendingPreRegistrationNotFoundError( + email=pre_registration_email, product_name=product_name + ) + + # There should be only one registration matching these criteria + pre_registration = pre_registrations[0] + 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( + 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/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/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index 10f28669c8a..b70a6c6897a 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]) -> web.Response: + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + # # Special models and responses for the front-end # 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_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 72% 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 79d2b82b054..29760d16607 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,25 +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 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,10 +33,6 @@ ) from servicelib.aiohttp import status from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from simcore_service_webserver.users._common.schemas import ( - MAX_BYTES_SIZE_EXTRAS, - PreRegisteredUserGet, -) from simcore_service_webserver.users._preferences_service import ( get_frontend_user_preferences_aggregation, ) @@ -212,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_accounts"] .url_for() .with_query(email=partial_email) ) @@ -222,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, @@ -604,220 +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( - "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_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py new file mode 100644 index 00000000000..ed41138d5b8 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py @@ -0,0 +1,459 @@ +# 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 collections.abc import AsyncGenerator +from http import HTTPStatus +from typing import Any + +import pytest +import simcore_service_webserver.login._auth_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 +from models_library.api_schemas_webserver.users import ( + UserAccountGet, +) +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 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 + + +@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, + pre_registration_details_db_cleanup: None, +): + assert client.app + + 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"}) + 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.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" +) +@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], + pre_registration_details_db_cleanup: None, +): + assert client.app + + # ONLY in `users` and NOT `users_pre_registration_details` + resp = await client.get( + "/v0/admin/user-accounts: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 + + nullable_fields = { + name: None + for name, field in UserAccountGet.model_fields.items() + if is_nullable(field) + } + + got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) + 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/user-accounts:pre-register", json=account_request_form + ) + assert resp.status == status.HTTP_200_OK + + resp = await client.get( + "/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 = UserAccountGet(**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/user-accounts:search", + params={"email": account_request_form["email"]}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + got = UserAccountGet(**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_accounts( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + assert client.app + + # 1. Create several pre-registered users + pre_registered_users = [] + 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() + form_data["email"] = faker.email() + + resp = await client.post( + "/v0/admin/user-accounts: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) + + # Verify all pre-registered users are in PENDING status + 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() + + # Parse response into Page[UserForAdminGet] model + page_model = Page[UserAccountGet].model_validate(response_json) + + # Access the items field from the paginated response + 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 + + # 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, + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + # 3. Test filtering by status + # a. Check PENDING filter (should exclude the registered user) + 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[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] + assert registered_email not in pending_emails + assert len(pending_emails) >= len(pre_registered_users) - 1 + + # b. Check REVIEWED users (should include the registered user) + resp = await client.get( + f"{url}?review_status=REVIEWED", headers={X_PRODUCT_NAME_HEADER: product_name} + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + reviewed_page = Page[UserAccountGet].model_validate(response_json) + + # Find the registered user in the reviewed users + active_user = next( + (user for user in reviewed_page.data if user.email == registered_email), + None, + ) + assert active_user is not None + assert active_user.account_request_status == "APPROVED" + assert active_user.status == UserStatus.ACTIVE + + # 4. Test pagination + # a. First page (limit 2) + resp = await client.get( + f"{url}", + params={"limit": 2, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + page1 = Page[UserAccountGet].model_validate(response_json) + + 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( + f"{url}", + params={"limit": 2, "offset": 2}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + page2 = Page[UserAccountGet].model_validate(response_json) + + 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 = [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 + resp = await client.get( + f"{url}", + params={"review_status": "PENDING", "limit": 2, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + filtered_page = Page[UserAccountGet].model_validate(response_json) + + 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" + + +@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, + pre_registration_details_db_cleanup: None, +): + 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"] = "some-reject-user@email.com" + + resp = await client.post( + "/v0/admin/user-accounts: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_accounts"].url_for() + 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) + + 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_accounts"].url_for() + 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) + 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/user-accounts: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["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() + 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 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 94dfec56a33..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,24 +3,51 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +from collections.abc import AsyncIterator +from dataclasses import dataclass from typing import Any +import pytest 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, ) 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 + + +@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) @@ -35,7 +62,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, @@ -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,7 +84,84 @@ async def test_create_user_pre_registration( ) record = result.first() - # Clean up - delete the test record + # Verify the record was created with correct values + assert record is not None + assert record.pre_email == test_email + 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], + pre_registration_details_db_cleanup: list[int], +): + # 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, + ) + + # 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( + 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 @@ -62,9 +169,567 @@ async def test_create_user_pre_registration( ) await conn.commit() - # Verify the record was created with correct values - assert record is not None - assert record.pre_email == test_email - assert record.created_by == created_by_user_id - assert record.product_name == product_name - assert record.institution == institution + +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) + 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) + # 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( + 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() + + +@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, + pre_registration_details_db_cleanup: list[int], +): + """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", + ) + + # 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, + 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" + + +@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 + + +@pytest.fixture +async def mixed_user_data( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], + 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"] + + # 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, + 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", + ) + + # 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, + 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 + ) + + # 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( + 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", + ) + + # 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, + pre_registration_id=approved_reg_id, + reviewed_by=created_by_user_id, + new_status=AccountRequestStatus.APPROVED, + ) + + 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, + ) + + # No explicit cleanup needed here, as pre_registration_details_db_cleanup will handle it + + +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 + # 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, + ) + ) + + # 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, + ) + ) + + # 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, + ) + ) + + # 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, + ) + ) + + # 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, + ) + + # 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..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 @@ -3,13 +3,13 @@ # 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 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, @@ -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, @@ -95,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 ) @@ -109,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"] @@ -124,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 ) @@ -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 @@ -177,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 )