diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 29da07364b7..093434bac1c 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -14,6 +14,7 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + TokenPathParams, UserGet, UsersSearch, ) @@ -21,13 +22,14 @@ from models_library.generics import Envelope from models_library.user_preferences import PreferenceIdentifier from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.users._notifications import ( +from simcore_service_webserver.user_notifications._controller.rest.user_notification_rest import ( + NotificationPathParams, +) +from simcore_service_webserver.user_notifications._models import ( UserNotification, UserNotificationCreate, UserNotificationPatch, ) -from simcore_service_webserver.users._notifications_rest import _NotificationPathParams -from simcore_service_webserver.users._tokens_rest import _TokenPathParams router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) @@ -76,7 +78,7 @@ async def create_token(_body: MyTokenCreate): ... response_model=Envelope[MyTokenGet], ) async def get_token( - _path: Annotated[_TokenPathParams, Depends()], + _path: Annotated[TokenPathParams, Depends()], ): ... @@ -84,7 +86,7 @@ async def get_token( "/me/tokens/{service}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_token(_path: Annotated[_TokenPathParams, Depends()]): ... +async def delete_token(_path: Annotated[TokenPathParams, Depends()]): ... @router.get( @@ -108,7 +110,7 @@ async def create_user_notification( status_code=status.HTTP_204_NO_CONTENT, ) async def mark_notification_as_read( - _path: Annotated[_NotificationPathParams, Depends()], + _path: Annotated[NotificationPathParams, Depends()], _body: UserNotificationPatch, ): ... 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 d2971ae68a6..ab3d8b18e2d 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 @@ -11,6 +11,7 @@ from models_library.rest_filters import Filters from models_library.rest_pagination import PageQueryParameters from pydantic import ( + BaseModel, ConfigDict, EmailStr, Field, @@ -334,6 +335,10 @@ def _consistency_check(cls, v, info: ValidationInfo): # +class TokenPathParams(BaseModel): + service: str + + class MyTokenCreate(InputSchemaWithoutCamelCase): service: Annotated[ IDStr, diff --git a/packages/service-library/src/servicelib/aiohttp/observer.py b/packages/service-library/src/servicelib/aiohttp/observer.py index e0dfd6a579e..a80b5d90e15 100644 --- a/packages/service-library/src/servicelib/aiohttp/observer.py +++ b/packages/service-library/src/servicelib/aiohttp/observer.py @@ -8,6 +8,7 @@ from collections.abc import Callable from aiohttp import web +from servicelib.aiohttp.application_setup import ensure_single_setup from ..utils import logged_gather @@ -17,10 +18,10 @@ _APP_OBSERVER_EVENTS_REGISTRY_KEY = "{__name__}.event_registry" -class ObserverRegistryNotFoundError(RuntimeError): - ... +class ObserverRegistryNotFoundError(RuntimeError): ... +@ensure_single_setup(__name__, logger=log) def setup_observer_registry(app: web.Application): # only once app.setdefault(_APP_OBSERVER_EVENTS_REGISTRY_KEY, defaultdict(list)) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py index 5b4f8397648..f7ed53961ee 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py @@ -17,7 +17,7 @@ from models_library.users import UserID # Import or define SocketMessageDict -from ..users.api import get_user_primary_group_id +from ..users import users_service from . import _conversation_message_repository from ._conversation_service import _get_recipients from ._socketio import ( @@ -39,7 +39,7 @@ async def create_message( content: str, type_: ConversationMessageType, ) -> ConversationMessageGetDB: - _user_group_id = await get_user_primary_group_id(app, user_id=user_id) + _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) created_message = await _conversation_message_repository.create( app, @@ -110,7 +110,7 @@ async def delete_message( message_id=message_id, ) - _user_group_id = await get_user_primary_group_id(app, user_id=user_id) + _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) await notify_conversation_message_deleted( app, diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py index fda9dde006a..5918a58bc67 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_service.py @@ -22,8 +22,8 @@ notify_conversation_updated, ) from ..projects._groups_repository import list_project_groups +from ..users import users_service from ..users._users_service import get_users_in_group -from ..users.api import get_user_primary_group_id from . import _conversation_repository _logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ async def create_conversation( if project_uuid is None: raise NotImplementedError - _user_group_id = await get_user_primary_group_id(app, user_id=user_id) + _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) created_conversation = await _conversation_repository.create( app, @@ -121,7 +121,7 @@ async def delete_conversation( conversation_id=conversation_id, ) - _user_group_id = await get_user_primary_group_id(app, user_id=user_id) + _user_group_id = await users_service.get_user_primary_group_id(app, user_id=user_id) await notify_conversation_deleted( app, diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_director_v2_service.py b/services/web/server/src/simcore_service_webserver/director_v2/_director_v2_service.py index b52be5f1554..426ea55d329 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_director_v2_service.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_director_v2_service.py @@ -28,7 +28,7 @@ from ..products import products_service from ..products.models import Product from ..projects import projects_wallets_service -from ..users import preferences_api as user_preferences_service +from ..user_preferences import user_preferences_service from ..users.exceptions import UserDefaultWalletNotFoundError from ..wallets import api as wallets_service from ._client import DirectorV2RestClient diff --git a/services/web/server/src/simcore_service_webserver/exporter/_handlers.py b/services/web/server/src/simcore_service_webserver/exporter/_handlers.py index db7466c8e73..1b131dc0e54 100644 --- a/services/web/server/src/simcore_service_webserver/exporter/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/exporter/_handlers.py @@ -18,7 +18,7 @@ from ..projects._projects_service import create_user_notification_cb from ..redis import get_redis_lock_manager_client_sdk from ..security.decorators import permission_required -from ..users.api import get_user_fullname +from ..users import users_service from ._formatter.archive import get_sds_archive_path from .exceptions import SDSException from .utils import CleanupFileResponse @@ -52,7 +52,8 @@ async def export_project(request: web.Request): project_uuid=project_uuid, status=ProjectStatus.EXPORTING, owner=Owner( - user_id=user_id, **await get_user_fullname(request.app, user_id=user_id) + user_id=user_id, + **await users_service.get_user_fullname(request.app, user_id=user_id), ), notification_cb=create_user_notification_cb( user_id, ProjectID(f"{project_uuid}"), request.app diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_service.py b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py index 8b0b63465b0..3b92126cc45 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_service.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py @@ -13,7 +13,7 @@ from pydantic import NonNegativeInt from ..projects._projects_service import delete_project_by_user -from ..users.api import get_user +from ..users.users_service import get_user from ..workspaces.api import check_user_workspace_access from ..workspaces.errors import ( WorkspaceAccessForbiddenError, diff --git a/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py b/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py index 9535ec3fd7c..3b8951deab7 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py +++ b/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py @@ -12,7 +12,7 @@ from ..projects import _groups_repository as projects_groups_repository from ..projects import _projects_repository as _projects_repository from ..projects._access_rights_service import check_user_project_permission -from ..users.api import get_user +from ..users import users_service from ..workspaces.api import check_user_workspace_access from . import _folders_repository @@ -122,7 +122,7 @@ async def move_folder_into_workspace( ) # 9. Remove all project permissions, leave only the user who moved the project - user = await get_user(app, user_id=user_id) + user = await users_service.get_user(app, user_id=user_id) for project_id in project_ids: await projects_groups_repository.delete_all_project_groups( app, connection=conn, project_id=project_id diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py index 7f9daf295d1..cec64473248 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py @@ -88,7 +88,7 @@ from ..db.plugin import get_asyncpg_engine from ..groups.api import list_all_user_groups_ids -from ..users.api import get_user_primary_group_id +from ..users import users_service _FUNCTIONS_TABLE_COLS = get_columns_from_db_model(functions_table, RegisteredFunctionDB) _FUNCTION_JOBS_TABLE_COLS = get_columns_from_db_model( @@ -148,7 +148,9 @@ async def create_function( # noqa: PLR0913 registered_function = RegisteredFunctionDB.model_validate(row) - user_primary_group_id = await get_user_primary_group_id(app, user_id=user_id) + user_primary_group_id = await users_service.get_user_primary_group_id( + app, user_id=user_id + ) await set_group_permissions( app, connection=transaction, @@ -206,7 +208,9 @@ async def create_function_job( # noqa: PLR0913 registered_function_job = RegisteredFunctionJobDB.model_validate(row) - user_primary_group_id = await get_user_primary_group_id(app, user_id=user_id) + user_primary_group_id = await users_service.get_user_primary_group_id( + app, user_id=user_id + ) await set_group_permissions( app, connection=transaction, @@ -291,7 +295,9 @@ async def create_function_job_collection( ) # nosec job_collection_entries.append(entry) - user_primary_group_id = await get_user_primary_group_id(app, user_id=user_id) + user_primary_group_id = await users_service.get_user_primary_group_id( + app, user_id=user_id + ) await set_group_permissions( app, connection=transaction, diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_guests.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_guests.py index 20f3bb45e8b..f00e0133b50 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_guests.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_guests.py @@ -19,13 +19,7 @@ from ..projects.exceptions import ProjectDeleteError, ProjectNotFoundError from ..redis import get_redis_lock_manager_client from ..resource_manager.registry import RedisResourceRegistry -from ..users import exceptions -from ..users.api import ( - delete_user_without_projects, - get_guest_user_ids_and_names, - get_user_primary_group_id, - get_user_role, -) +from ..users import exceptions, users_service from ..users.exceptions import UserNotFoundError from ._core_utils import get_new_project_owner_gid, replace_current_owner from .settings import GUEST_USER_RC_LOCK_FORMAT @@ -48,7 +42,7 @@ async def _delete_all_projects_for_user(app: web.Application, user_id: int) -> N """ # recover user's primary_gid try: - project_owner_primary_gid = await get_user_primary_group_id( + project_owner_primary_gid = await users_service.get_user_primary_group_id( app=app, user_id=user_id ) except exceptions.UserNotFoundError: @@ -149,7 +143,7 @@ async def remove_guest_user_with_all_its_resources( """Removes a GUEST user with all its associated projects and S3/MinIO files""" try: - user_role: UserRole = await get_user_role(app, user_id=user_id) + user_role: UserRole = await users_service.get_user_role(app, user_id=user_id) if user_role > UserRole.GUEST: # NOTE: This acts as a protection barrier to avoid removing resources to more # priviledge users @@ -165,7 +159,7 @@ async def remove_guest_user_with_all_its_resources( "Deleting user %s because it is a GUEST", f"{user_id=}", ) - await delete_user_without_projects(app, user_id) + await users_service.delete_user_without_projects(app, user_id) except ( DatabaseError, @@ -205,8 +199,8 @@ async def remove_users_manually_marked_as_guests( } # Prevent creating this list if a guest user - guest_users: list[tuple[UserID, UserNameID]] = await get_guest_user_ids_and_names( - app + guest_users: list[tuple[UserID, UserNameID]] = ( + await users_service.get_guest_user_ids_and_names(app) ) for guest_user_id, guest_user_name in guest_users: @@ -246,3 +240,8 @@ async def remove_users_manually_marked_as_guests( app=app, user_id=guest_user_id, ) + + await remove_guest_user_with_all_its_resources( + app=app, + user_id=guest_user_id, + ) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py index 2d23be47b82..a30fe7f1d74 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_orphans.py @@ -21,7 +21,7 @@ ) from ..projects.api import has_user_project_access_rights from ..resource_manager.registry import RedisResourceRegistry -from ..users.api import get_user_role +from ..users import users_service from ..users.exceptions import UserNotFoundError _logger = logging.getLogger(__name__) @@ -38,17 +38,21 @@ async def _remove_service( save_service_state = False else: try: - if await get_user_role(app, user_id=service.user_id) <= UserRole.GUEST: - save_service_state = False - else: - save_service_state = await has_user_project_access_rights( + user_role: UserRole = await users_service.get_user_role( + app, user_id=service.user_id + ) + except (UserNotFoundError, ValueError): + save_service_state = False + else: + save_service_state = ( + user_role > UserRole.GUEST + and await has_user_project_access_rights( app, project_id=service.project_id, user_id=service.user_id, permission="write", ) - except (UserNotFoundError, ValueError): - save_service_state = False + ) with log_catch(_logger, reraise=False), log_context( _logger, diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py index 67106abddcc..306f90f3970 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py @@ -17,7 +17,7 @@ delete_project_group_without_checking_permissions, ) from ..projects.exceptions import ProjectNotFoundError -from ..users.api import get_user, get_user_id_from_gid, get_users_in_group +from ..users import users_service from ..users.exceptions import UserNotFoundError _logger = logging.getLogger(__name__) @@ -34,15 +34,17 @@ async def _fetch_new_project_owner_from_groups( # go through user_to_groups table and fetch all uid for matching gid for group_gid in standard_groups: # remove the current owner from the bunch - target_group_users = await get_users_in_group(app=app, gid=int(group_gid)) - { - user_id - } + target_group_users = await users_service.get_users_in_group( + app=app, gid=int(group_gid) + ) - {user_id} _logger.info("Found group users '%s'", target_group_users) for possible_user_id in target_group_users: # check if the possible_user is still present in the db try: - possible_user = await get_user(app=app, user_id=possible_user_id) + possible_user = await users_service.get_user( + app=app, user_id=possible_user_id + ) return int(possible_user["primary_gid"]) except UserNotFoundError: # noqa: PERF203 _logger.warning( @@ -130,7 +132,7 @@ async def replace_current_owner( project: dict, ) -> None: try: - new_project_owner_id = await get_user_id_from_gid( + new_project_owner_id = await users_service.get_user_id_from_gid( app=app, primary_gid=new_project_owner_gid ) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_api_keys.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_api_keys.py index 6cb27316b0f..a0a67a833f8 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_api_keys.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_api_keys.py @@ -10,9 +10,9 @@ from aiohttp import web from servicelib.background_task_utils import exclusive_periodic from servicelib.logging_utils import log_context -from simcore_service_webserver.redis import get_redis_lock_manager_client_sdk from ..api_keys import api_keys_service +from ..redis import get_redis_lock_manager_client_sdk from ._tasks_utils import CleanupContextFunc, periodic_task_lifespan _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_core.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_core.py index 7097af49d9f..f29634ec7de 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_core.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_core.py @@ -11,8 +11,8 @@ from aiohttp import web from servicelib.background_task_utils import exclusive_periodic from servicelib.logging_utils import log_context -from simcore_service_webserver.redis import get_redis_lock_manager_client_sdk +from ..redis import get_redis_lock_manager_client_sdk from ._core import collect_garbage from ._tasks_utils import CleanupContextFunc, periodic_task_lifespan from .settings import GarbageCollectorSettings, get_plugin_settings diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py index 48b3a16e328..2307fee336a 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py @@ -10,8 +10,8 @@ from aiohttp import web from servicelib.background_task_utils import exclusive_periodic from servicelib.logging_utils import log_context -from simcore_service_webserver.redis import get_redis_lock_manager_client_sdk +from ..redis import get_redis_lock_manager_client_sdk from ..trash import trash_service from ._tasks_utils import CleanupContextFunc, periodic_task_lifespan diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py index 95d5e5e3a47..f5043cbef16 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_users.py @@ -11,11 +11,11 @@ from models_library.users import UserID from servicelib.background_task_utils import exclusive_periodic from servicelib.logging_utils import get_log_record_extra, log_context -from simcore_service_webserver.redis import get_redis_lock_manager_client_sdk from ..login import login_service +from ..redis import get_redis_lock_manager_client_sdk from ..security import security_service -from ..users.api import update_expired_users +from ..users import users_service from ._tasks_utils import CleanupContextFunc, periodic_task_lifespan _logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ async def notify_user_logout_all_sessions( async def _update_expired_users(app: web.Application): - if updated := await update_expired_users(app): + if updated := await users_service.update_expired_users(app): # expired users might be cached in the auth. If so, any request # with this user-id will get thru producing unexpected side-effects await security_service.clean_auth_policy_cache(app) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py index f53a7be17c6..d9e5465efd2 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py @@ -14,7 +14,7 @@ from models_library.users import UserID from pydantic import EmailStr -from ..users.api import get_user +from ..users import users_service from . import _groups_repository from .exceptions import GroupsError @@ -219,7 +219,7 @@ async def is_user_by_email_in_group( async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: - user: dict = await get_user(app, user_id) + user: dict = await users_service.get_user(app, user_id) return await _groups_repository.auto_add_user_to_groups(app, user=user) @@ -246,7 +246,6 @@ async def add_user_in_group( new_by_user_id: UserID | None = None, new_by_user_name: IDStr | None = None, new_by_user_email: EmailStr | None = None, - # payload access_rights: AccessRightsDict | None = None, ) -> None: """Adds new_user (either by id or email) in group (with gid) owned by user_id @@ -259,12 +258,17 @@ async def add_user_in_group( msg = "Invalid method call, required one of these: user id, username or user email, none provided" raise GroupsError(msg=msg) + # get target user to add to group if new_by_user_email: user = await _groups_repository.get_user_from_email( app, email=new_by_user_email, caller_id=user_id ) new_by_user_id = user.id + if new_by_user_id is not None: + new_user = await users_service.get_user(app, new_by_user_id) + new_by_user_name = new_user["name"] + return await _groups_repository.add_new_user_in_group( app, caller_id=user_id, diff --git a/services/web/server/src/simcore_service_webserver/invitations/_rest.py b/services/web/server/src/simcore_service_webserver/invitations/_rest.py index ec0b8cbb1c0..0af64235560 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/_rest.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_rest.py @@ -17,7 +17,7 @@ from ..constants import RQ_PRODUCT_KEY from ..login.decorators import login_required from ..security.decorators import permission_required -from ..users.api import get_user_name_and_email +from ..users import users_service from ..utils_aiohttp import envelope_json_response from . import api @@ -39,7 +39,9 @@ async def generate_invitation(request: web.Request): req_ctx = _ProductsRequestContext.model_validate(request) body = await parse_request_body_as(InvitationGenerate, request) - _, user_email = await get_user_name_and_email(request.app, user_id=req_ctx.user_id) + _, user_email = await users_service.get_user_name_and_email( + request.app, user_id=req_ctx.user_id + ) # NOTE: check if invitations are activated in this product or raise generated = await api.generate_invitation( diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py index ed70c51bc8f..1b477799411 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py @@ -16,7 +16,7 @@ ) from ..rabbitmq import get_rabbitmq_rpc_client -from ..users.api import get_user +from ..users import users_service from ..wallets.api import get_wallet_by_user from . import _licensed_items_repository from ._licensed_items_checkouts_models import ( @@ -135,7 +135,7 @@ async def checkout_licensed_item_for_wallet( product_name=product_name, ) - user = await get_user(app, user_id=user_id) + user = await users_service.get_user(app, user_id=user_id) licensed_item_db = await _licensed_items_repository.get( app, licensed_item_id=licensed_item_id, product_name=product_name diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py index 90303ff1369..925cc8aee93 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py @@ -31,7 +31,7 @@ from ..rabbitmq import get_rabbitmq_rpc_client from ..resource_usage.service import get_pricing_plan, get_pricing_plan_unit -from ..users.api import get_user +from ..users import users_service from ..wallets.api import get_wallet_with_available_credits_by_user_and_wallet from ..wallets.errors import WalletNotEnoughCreditsError from . import _licensed_items_repository @@ -137,7 +137,7 @@ async def purchase_licensed_item( reason=f"Wallet '{wallet.name}' has {wallet.available_credits} credits." ) - user = await get_user(app, user_id=user_id) + user = await users_service.get_user(app, user_id=user_id) _data = LicensedItemsPurchasesCreate( product_name=product_name, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py index 1b39b703f04..0c232c61fd8 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py @@ -19,7 +19,7 @@ on_success_grant_session_access_to, session_access_required, ) -from ....users import preferences_api as user_preferences_api +from ....user_preferences import user_preferences_service from ....web_utils import envelope_response, flash_response from ... import _auth_service, _login_service, _security_service, _twofa_service from ...constants import ( @@ -88,18 +88,18 @@ async def login(request: web.Request): return await _security_service.login_granted_response(request, user=user) # 2FA login process continuation - user_2fa_preference = await user_preferences_api.get_frontend_user_preference( + user_2fa_preference = await user_preferences_service.get_frontend_user_preference( request.app, user_id=user["id"], product_name=product.name, - preference_class=user_preferences_api.TwoFAFrontendUserPreference, + preference_class=user_preferences_service.TwoFAFrontendUserPreference, ) if not user_2fa_preference: user_2fa_authentification_method = TwoFactorAuthentificationMethod.SMS preference_id = ( - user_preferences_api.TwoFAFrontendUserPreference().preference_identifier + user_preferences_service.TwoFAFrontendUserPreference().preference_identifier ) - await user_preferences_api.set_frontend_user_preference( + await user_preferences_service.set_frontend_user_preference( request.app, user_id=user["id"], product_name=product.name, diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index 4506a934582..57842919ec1 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -13,7 +13,7 @@ from ....products import products_web from ....products.models import Product from ....security import security_service -from ....users import api as users_service +from ....users import users_service from ....utils import HOUR from ....utils_rate_limiting import global_rate_limit_route from ....web_utils import flash_response diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index bd1a466e2ba..ae32d360d90 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -11,7 +11,6 @@ ) from settings_library.email import SMTPSettings from settings_library.postgres import PostgresSettings -from simcore_service_webserver.login_accounts.plugin import setup_login_account from ..constants import ( APP_PUBLIC_CONFIG_PER_PRODUCT, @@ -23,6 +22,7 @@ from ..email.plugin import setup_email from ..email.settings import get_plugin_settings as get_email_plugin_settings from ..invitations.plugin import setup_invitations +from ..login_accounts.plugin import setup_login_account from ..login_auth.plugin import setup_login_auth from ..products import products_service from ..products.models import ProductName diff --git a/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py b/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py index 99eb486693b..4ba2ae25541 100644 --- a/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py +++ b/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py @@ -28,8 +28,8 @@ from ..security import security_service, security_web from ..security.decorators import permission_required from ..session import api as session_service -from ..users import api as users_service -from ..users._common.schemas import PreRegisteredUserGet +from ..users import users_service +from ..users.schemas import PreRegisteredUserGet from ..utils import MINUTE from ..utils_rate_limiting import global_rate_limit_route from ..web_utils import flash_response diff --git a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py index d19313d5bcc..a5fefc9ebd2 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py @@ -20,7 +20,7 @@ from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from yarl import URL -from ..users.api import get_user_display_and_id_names +from ..users import users_service from ..wallets.api import get_wallet_by_user from . import _rpc from ._autorecharge_db import get_wallet_autorecharge @@ -278,7 +278,7 @@ async def init_creation_of_wallet_payment_method( ) assert user_wallet.wallet_id == wallet_id # nosec - user = await get_user_display_and_id_names(app, user_id=user_id) + user = await users_service.get_user_display_and_id_names(app, user_id=user_id) return await _rpc.init_creation_of_payment_method( app, wallet_id=wallet_id, diff --git a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py index e2fda301f98..6bf7ef4f1c1 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py @@ -27,7 +27,7 @@ from ..db.plugin import get_database_engine_legacy from ..products import products_service from ..resource_usage.service import add_credits_to_wallet -from ..users.api import get_user_display_and_id_names, get_user_invoice_address +from ..users import users_service from ..wallets.api import get_wallet_by_user, get_wallet_with_permissions_by_user from ..wallets.errors import WalletAccessForbiddenError from . import _onetime_db, _rpc @@ -81,7 +81,6 @@ async def _fake_init_payment( user_email, comment, ): - # (1) Init payment payment_id = f"{_FAKE_PAYMENT_TRANSACTION_ID_PREFIX}_{uuid4()}" # get_form_payment_url settings: PaymentsSettings = get_plugin_settings(app) @@ -297,8 +296,10 @@ async def init_creation_of_wallet_payment( assert user_wallet.wallet_id == wallet_id # nosec # user info - user = await get_user_display_and_id_names(app, user_id=user_id) - user_invoice_address = await get_user_invoice_address(app, user_id=user_id) + user = await users_service.get_user_display_and_id_names(app, user_id=user_id) + user_invoice_address = await users_service.get_user_invoice_address( + app, user_id=user_id + ) # stripe info product_stripe_info = await products_service.get_product_stripe_info( @@ -390,8 +391,10 @@ async def pay_with_payment_method( ) # user info - user = await get_user_display_and_id_names(app, user_id=user_id) - user_invoice_address = await get_user_invoice_address(app, user_id=user_id) + user_info = await users_service.get_user_display_and_id_names(app, user_id=user_id) + user_invoice_address = await users_service.get_user_invoice_address( + app, user_id=user_id + ) settings: PaymentsSettings = get_plugin_settings(app) if settings.PAYMENTS_FAKE_COMPLETION: @@ -404,8 +407,8 @@ async def pay_with_payment_method( wallet_id=wallet_id, wallet_name=user_wallet.name, user_id=user_id, - user_name=user.full_name, - user_email=user.email, + user_name=user_info.full_name, + user_email=user_info.email, comment=comment, ) @@ -420,8 +423,8 @@ async def pay_with_payment_method( wallet_id=wallet_id, wallet_name=user_wallet.name, user_id=user_id, - user_name=user.full_name, - user_email=user.email, + user_name=user_info.full_name, + user_email=user_info.email, user_address=user_invoice_address, stripe_price_id=product_stripe_info.stripe_price_id, stripe_tax_rate_id=product_stripe_info.stripe_tax_rate_id, diff --git a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py index d799d04fe6f..b714b271574 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py +++ b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py @@ -11,7 +11,7 @@ from ..products import products_service from ..products.models import CreditResult from ..rabbitmq import get_rabbitmq_rpc_server -from ..users.api import get_user_display_and_id_names, get_user_invoice_address +from ..users import users_service router = RPCRouter() @@ -30,10 +30,10 @@ async def get_invoice_data( product_stripe_info = await products_service.get_product_stripe_info( app, product_name=product_name ) - user_invoice_address: UserInvoiceAddress = await get_user_invoice_address( - app, user_id=user_id + user_invoice_address: UserInvoiceAddress = ( + await users_service.get_user_invoice_address(app, user_id=user_id) ) - user_info = await get_user_display_and_id_names(app, user_id=user_id) + user_info = await users_service.get_user_display_and_id_names(app, user_id=user_id) return InvoiceDataGet( credit_amount=credit_result.credit_amount, diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py index a44355851d9..b42be8077c3 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py @@ -59,7 +59,7 @@ from ...groups.exceptions import GroupNotFoundError from ...login.decorators import login_required from ...security.decorators import permission_required -from ...users.api import get_user_id_from_gid, get_user_role +from ...users import users_service from ...utils_aiohttp import envelope_json_response, get_api_base_url from .. import _access_rights_service as access_rights_service from .. import _nodes_service, _projects_service, nodes_utils @@ -328,7 +328,7 @@ async def stop_node(request: web.Request) -> web.Response: permission="write", ) - user_role = await get_user_role(request.app, user_id=req_ctx.user_id) + user_role = await users_service.get_user_role(request.app, user_id=req_ctx.user_id) if user_role is None or user_role <= UserRole.GUEST: save_state = False @@ -562,7 +562,7 @@ async def get_project_services_access_for_gid(request: web.Request) -> web.Respo # Update groups to compare based on the type of sharing group if _sharing_with_group.group_type == GroupType.PRIMARY: - _user_id = await get_user_id_from_gid( + _user_id = await users_service.get_user_id_from_gid( app=request.app, primary_gid=query_params.for_gid ) user_groups_ids = await list_all_user_groups_ids( diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py index 479cb96a269..0db92ca68c5 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py @@ -33,7 +33,7 @@ from ...resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource from ...security import security_web from ...security.decorators import permission_required -from ...users.api import get_user_fullname +from ...users import users_service from ...utils_aiohttp import envelope_json_response, get_api_base_url from .. import _crud_api_create, _crud_api_read, _projects_service from .._permalink_service import update_or_pop_permalink_in_project @@ -367,7 +367,7 @@ async def delete_project(request: web.Request): ) if project_users: other_user_names = { - f"{await get_user_fullname(request.app, user_id=uid)}" + f"{await users_service.get_user_fullname(request.app, user_id=uid)}" for uid in project_users } raise web.HTTPForbidden( diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py index 934fbcb9df6..627dce27f0f 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py @@ -26,7 +26,7 @@ from ...products import products_web from ...products.models import Product from ...security.decorators import permission_required -from ...users import api +from ...users import users_service from ...utils_aiohttp import envelope_json_response, get_api_base_url from .. import _projects_service, projects_wallets_service from ..exceptions import ProjectStartsTooManyDynamicNodesError @@ -69,7 +69,7 @@ async def open_project(request: web.Request) -> web.Response: project_type: ProjectType = await _projects_service.get_project_type( request.app, path_params.project_id ) - user_role: UserRole = await api.get_user_role( + user_role: UserRole = await users_service.get_user_role( request.app, user_id=req_ctx.user_id ) if project_type is ProjectType.TEMPLATE and user_role < UserRole.USER: diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py index 8ab398bcdac..1116e8f2709 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py @@ -37,7 +37,7 @@ copy_data_folders_from_project, get_project_total_size_simcore_s3, ) -from ..users.api import get_user_fullname +from ..users import users_service from ..workspaces.api import check_user_workspace_access, get_user_workspace from ..workspaces.errors import WorkspaceAccessForbiddenError from . import _folders_repository, _projects_service @@ -203,7 +203,8 @@ async def _copy() -> None: project_uuid=source_project["uuid"], status=ProjectStatus.CLONING, owner=Owner( - user_id=user_id, **await get_user_fullname(app, user_id=user_id) + user_id=user_id, + **await users_service.get_user_fullname(app, user_id=user_id), ), notification_cb=_projects_service.create_user_notification_cb( user_id, ProjectID(f"{source_project['uuid']}"), app diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py index 866609110f3..5309c67cb49 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py @@ -15,8 +15,8 @@ from ..director_v2 import director_v2_service from ..storage.api import delete_data_folders_of_project -from ..users.api import FullNameDict from ..users.exceptions import UserNotFoundError +from ..users.users_service import FullNameDict from ._access_rights_service import check_user_project_permission from ._projects_repository_legacy import ProjectDBAPI from .exceptions import ( diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index 9ac6efd730f..8e9bdd70584 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -21,7 +21,7 @@ ) from ..folders import _folders_repository -from ..users.api import get_user_email_legacy +from ..users import users_service from ..workspaces.api import check_user_workspace_access from . import _projects_service from ._access_rights_repository import batch_get_project_access_rights @@ -107,7 +107,7 @@ async def _legacy_convert_db_projects_to_api_projects( db_prj_dict = db_prj db_prj_dict.pop("product_name", None) db_prj_dict["tags"] = await db.get_tags_by_project(project_id=f"{db_prj['id']}") - user_email = await get_user_email_legacy(app, db_prj["prj_owner"]) + user_email = await users_service.get_user_email_legacy(app, db_prj["prj_owner"]) api_projects.append(convert_to_schema_names(db_prj_dict, user_email)) return api_projects diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_service.py b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py index 4ad89126a49..88858ac11c2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py @@ -12,7 +12,7 @@ from models_library.users import UserID from pydantic import BaseModel, EmailStr, TypeAdapter -from ..users import api as users_service +from ..users import users_service from . import _groups_repository from ._access_rights_service import check_user_project_permission from ._groups_models import ProjectGroupGetDB diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index aebe6dab43c..6152340916d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -112,13 +112,13 @@ send_message_to_user, ) from ..storage import api as storage_service -from ..users.api import FullNameDict, get_user, get_user_fullname, get_user_role -from ..users.exceptions import UserNotFoundError -from ..users.preferences_api import ( +from ..user_preferences import user_preferences_service +from ..user_preferences.user_preferences_service import ( PreferredWalletIdFrontendUserPreference, - UserDefaultWalletNotFoundError, - get_frontend_user_preference, ) +from ..users import users_service +from ..users.exceptions import UserDefaultWalletNotFoundError, UserNotFoundError +from ..users.users_service import FullNameDict from ..wallets import api as wallets_service from ..wallets.errors import WalletNotEnoughCreditsError from ..workspaces import _workspaces_repository as workspaces_workspaces_repository @@ -322,7 +322,7 @@ async def patch_project( "write": True, "delete": True, } - user: dict = await get_user(app, project_db.prj_owner) + user: dict = await users_service.get_user(app, project_db.prj_owner) _prj_owner_primary_group = f"{user['primary_gid']}" if _prj_owner_primary_group not in new_prj_access_rights: raise ProjectOwnerNotFoundInTheProjectAccessRightsError @@ -332,7 +332,7 @@ async def patch_project( # 4. If patching template type if new_template_type := patch_project_data.get("template_type"): # 4.1 Check if user is a tester - current_user: dict = await get_user(app, user_id) + current_user: dict = await users_service.get_user(app, user_id) if UserRole(current_user["role"]) < UserRole.TESTER: raise InsufficientRoleForProjectTemplateTypeUpdateError # 4.2 Check the compatibility of the template type with the project @@ -667,7 +667,9 @@ async def _start_dynamic_service( # noqa: C901 raise save_state = False - user_role: UserRole = await get_user_role(request.app, user_id=user_id) + user_role: UserRole = await users_service.get_user_role( + request.app, user_id=user_id + ) if user_role > UserRole.GUEST: save_state = await has_user_project_access_rights( request.app, project_id=project_uuid, user_id=user_id, permission="write" @@ -707,11 +709,13 @@ async def _() -> None: request.app, project_id=project_uuid ) if project_wallet is None: - user_default_wallet_preference = await get_frontend_user_preference( - request.app, - user_id=user_id, - product_name=product_name, - preference_class=PreferredWalletIdFrontendUserPreference, + user_default_wallet_preference = ( + await user_preferences_service.get_frontend_user_preference( + request.app, + user_id=user_id, + product_name=product_name, + preference_class=PreferredWalletIdFrontendUserPreference, + ) ) if user_default_wallet_preference is None: raise UserDefaultWalletNotFoundError(uid=user_id) @@ -1405,7 +1409,8 @@ async def try_open_project_for_user( project_uuid=project_uuid, status=ProjectStatus.OPENING, owner=Owner( - user_id=user_id, **await get_user_fullname(app, user_id=user_id) + user_id=user_id, + **await users_service.get_user_fullname(app, user_id=user_id), ), notification_cb=None, ) @@ -1582,7 +1587,7 @@ async def _get_project_lock_state( f"{set_user_ids=}", ) usernames: list[FullNameDict] = [ - await get_user_fullname(app, user_id=uid) for uid in set_user_ids + await users_service.get_user_fullname(app, user_id=uid) for uid in set_user_ids ] # let's check if the project is opened by the same user, maybe already opened or closed in a orphaned session if set_user_ids.issubset({user_id}) and not await _user_has_another_client_open( @@ -1889,13 +1894,13 @@ async def remove_project_dynamic_services( user_id, ) - user_name_data: FullNameDict = user_name or await get_user_fullname( + user_name_data: FullNameDict = user_name or await users_service.get_user_fullname( app, user_id=user_id ) user_role: UserRole | None = None try: - user_role = await get_user_role(app, user_id=user_id) + user_role = await users_service.get_user_role(app, user_id=user_id) except UserNotFoundError: user_role = None diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py index e671b7eac6e..12864cbc24a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py @@ -16,7 +16,7 @@ ) from ..rabbitmq import get_rabbitmq_rpc_client -from ..users import api as users_service +from ..users import users_service from ..wallets import _api as wallets_service from ._projects_repository_legacy import ProjectDBAPI from .exceptions import ( diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py b/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py index fdf40f27371..33af944fbd7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py @@ -8,7 +8,7 @@ from simcore_postgres_database.utils_repos import transaction_context from ..db.plugin import get_asyncpg_engine -from ..users.api import get_user +from ..users import users_service from ..workspaces.api import check_user_workspace_access from . import _folders_repository, _groups_repository, _projects_repository from ._access_rights_service import get_user_project_access_rights @@ -59,7 +59,7 @@ async def move_project_into_workspace( ) # 5. Remove all project permissions, leave only the user who moved the project - user = await get_user(app, user_id=user_id) + user = await users_service.get_user(app, user_id=user_id) await _groups_repository.delete_all_project_groups( app, connection=conn, project_id=project_id ) diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py index 6615386d947..626c3a19752 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py @@ -32,7 +32,7 @@ from ..products import products_web from ..redis import get_redis_lock_manager_client from ..security import security_service, security_web -from ..users.api import get_user +from ..users import users_service from ..users.exceptions import UserNotFoundError from ._constants import MSG_GUESTS_NOT_ALLOWED from ._errors import GuestUsersLimitError @@ -56,7 +56,7 @@ async def get_authorized_user(request: web.Request) -> dict: """ with suppress(web.HTTPUnauthorized, UserNotFoundError): user_id = await security_web.check_user_authorized(request) - user: dict = await get_user(request.app, user_id) + user: dict = await users_service.get_user(request.app, user_id) return user return {} @@ -126,7 +126,7 @@ async def create_temporary_guest_user(request: web.Request): "expires_at": expires_at, } ) - user = await get_user(request.app, usr["id"]) + user = await users_service.get_user(request.app, usr["id"]) await auto_add_user_to_product_group( request.app, user_id=user["id"], product_name=product_name ) diff --git a/services/web/server/src/simcore_service_webserver/tags/_service.py b/services/web/server/src/simcore_service_webserver/tags/_service.py index 0c28c2a462f..b12dcc31354 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_service.py +++ b/services/web/server/src/simcore_service_webserver/tags/_service.py @@ -1,5 +1,4 @@ -""" Implements `tags` plugin **service layer** -""" +"""Implements `tags` plugin **service layer**""" from aiohttp import web from common_library.groups_dicts import AccessRightsDict @@ -12,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine from ..products import products_service -from ..users.api import get_user_role +from ..users import users_service from .errors import ( InsufficientTagShareAccessError, ShareTagWithEveryoneNotAllowedError, @@ -94,7 +93,9 @@ async def _validate_tag_sharing_permissions( ) if _is_product_group(app, group_id=group_id): - user_role: UserRole = await get_user_role(app, user_id=caller_user_id) + user_role: UserRole = await users_service.get_user_role( + app, user_id=caller_user_id + ) if user_role < UserRole.TESTER: raise ShareTagWithProductGroupNotAllowedError( user_id=caller_user_id, diff --git a/services/web/server/src/simcore_service_webserver/users/_common/__init__.py b/services/web/server/src/simcore_service_webserver/user_notifications/__init__.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/users/_common/__init__.py rename to services/web/server/src/simcore_service_webserver/user_notifications/__init__.py diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/user_notifications/_controller/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_controller/rest/__init__.py b/services/web/server/src/simcore_service_webserver/user_notifications/_controller/rest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_controller/rest/user_notification_rest.py b/services/web/server/src/simcore_service_webserver/user_notifications/_controller/rest/user_notification_rest.py new file mode 100644 index 00000000000..67917985702 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_notifications/_controller/rest/user_notification_rest.py @@ -0,0 +1,86 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.users import MyPermissionGet +from models_library.users import UserPermission +from pydantic import BaseModel +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) +from servicelib.tracing import with_profiled_span + +from ...._meta import API_VTAG +from ....login.decorators import login_required +from ....products import products_web +from ....security.decorators import permission_required +from ....users import _users_service +from ....users.schemas import UsersRequestContext +from ....utils_aiohttp import envelope_json_response +from ... import _service +from ..._models import UserNotificationCreate, UserNotificationPatch + +_logger = logging.getLogger(__name__) + + +class NotificationPathParams(BaseModel): + notification_id: str + + +routes = web.RouteTableDef() + + +@routes.get(f"/{API_VTAG}/me/notifications", name="list_user_notifications") +@login_required +@permission_required("user.notifications.read") +async def list_user_notifications(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + product_name = products_web.get_product_name(request) + notifications = await _service.list_user_notifications( + request.app, req_ctx.user_id, product_name + ) + return envelope_json_response(notifications) + + +@routes.post(f"/{API_VTAG}/me/notifications", name="create_user_notification") +@login_required +@permission_required("user.notifications.write") +async def create_user_notification(request: web.Request) -> web.Response: + body = await parse_request_body_as(UserNotificationCreate, request) + await _service.create_user_notification(request.app, body) + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.patch( + f"/{API_VTAG}/me/notifications/{{notification_id}}", + name="mark_notification_as_read", +) +@login_required +@permission_required("user.notifications.update") +async def mark_notification_as_read(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + req_path_params = parse_request_path_parameters_as(NotificationPathParams, request) + body = await parse_request_body_as(UserNotificationPatch, request) + + await _service.update_user_notification( + request.app, + req_ctx.user_id, + req_path_params.notification_id, + body.model_dump(exclude_unset=True), + ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.get(f"/{API_VTAG}/me/permissions", name="list_user_permissions") +@login_required +@with_profiled_span +@permission_required("user.permissions.read") +async def list_user_permissions(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + list_permissions: list[UserPermission] = await _users_service.list_user_permissions( + request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name + ) + return envelope_json_response( + [MyPermissionGet.from_domain_model(p) for p in list_permissions] + ) diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_models.py b/services/web/server/src/simcore_service_webserver/user_notifications/_models.py new file mode 100644 index 00000000000..633ce62c4c9 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_notifications/_models.py @@ -0,0 +1,149 @@ +from datetime import datetime +from enum import auto +from typing import Final, Literal +from uuid import uuid4 + +from models_library.products import ProductName +from models_library.users import UserID +from models_library.utils.enums import StrAutoEnum +from pydantic import BaseModel, ConfigDict, NonNegativeInt, field_validator +from pydantic.config import JsonDict + +MAX_NOTIFICATIONS_FOR_USER_TO_SHOW: Final[NonNegativeInt] = 10 +MAX_NOTIFICATIONS_FOR_USER_TO_KEEP: Final[NonNegativeInt] = 100 + + +def get_notification_key(user_id: UserID) -> str: + return f"user_id={user_id}" + + +class NotificationCategory(StrAutoEnum): + NEW_ORGANIZATION = auto() + STUDY_SHARED = auto() + TEMPLATE_SHARED = auto() + CONVERSATION_NOTIFICATION = auto() + ANNOTATION_NOTE = auto() + WALLET_SHARED = auto() + + +class BaseUserNotification(BaseModel): + user_id: UserID + category: NotificationCategory + actionable_path: str + title: str + text: str + date: datetime + product: Literal["UNDEFINED"] | ProductName = "UNDEFINED" + resource_id: Literal[""] | str = "" + user_from_id: Literal[None] | UserID = None + + @field_validator("category", mode="before") + @classmethod + def category_to_upper(cls, value: str) -> str: + return value.upper() + + +class UserNotificationCreate(BaseUserNotification): ... + + +class UserNotificationPatch(BaseModel): + read: bool + + +class UserNotification(BaseUserNotification): + # Ideally the `id` field, will be a UUID type in the future. + # Since there is no Redis data migration service, data type + # will not change to UUID nor Union[str, UUID] + id: str + read: bool + + @classmethod + def create_from_request_data( + cls, request_data: UserNotificationCreate + ) -> "UserNotification": + return cls.model_construct( + id=f"{uuid4()}", read=False, **request_data.model_dump() + ) + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ # NOSONAR + { + "id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20eb8", + "user_id": "1", + "category": "NEW_ORGANIZATION", + "actionable_path": "organization/40", + "title": "New organization", + "text": "You're now member of a new Organization", + "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", + "read": True, + }, + { + "id": "ba64ffce-c58c-4382-aad6-96a7787251d6", + "user_id": "1", + "category": "STUDY_SHARED", + "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", # NOSONAR + "title": "Study shared", + "text": "A study was shared with you", + "date": "2023-02-23T16:25:13.122Z", + "product": "osparc", + "read": False, + }, + { + "id": "390053c9-3931-40e1-839f-585268f6fd3c", + "user_id": "1", + "category": "TEMPLATE_SHARED", + "actionable_path": "template/f60477b6-a07e-11ed-8d29-02420a00002d", + "title": "Template shared", + "text": "A template was shared with you", + "date": "2023-02-23T16:28:13.122Z", + "product": "osparc", + "read": False, + }, + { + "id": "390053c9-3931-40e1-839f-585268f6fd3d", + "user_id": "1", + "category": "CONVERSATION_NOTIFICATION", + "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", # NOSONAR + "title": "New notification", + "text": "You were notified in a conversation", + "date": "2023-02-23T16:28:13.122Z", + "product": "s4l", + "read": False, + "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e12", + "user_from_id": "2", + }, + { + "id": "390053c9-3931-40e1-839f-585268f6fd3d", + "user_id": "1", + "category": "ANNOTATION_NOTE", + "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", # NOSONAR + "title": "Note added", + "text": "A Note was added for you", + "date": "2023-02-23T16:28:13.122Z", + "product": "s4l", + "read": False, + "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e12", + "user_from_id": "2", + }, + { + "id": "390053c9-3931-40e1-839f-585268f6fd3e", + "user_id": "1", + "category": "WALLET_SHARED", + "actionable_path": "wallet/21", + "title": "Credits shared", + "text": "A Credit account was shared with you", + "date": "2023-09-29T16:28:13.122Z", + "product": "tis", + "read": False, + "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e13", + "user_from_id": "2", + }, + ] + } + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_repository.py b/services/web/server/src/simcore_service_webserver/user_notifications/_repository.py new file mode 100644 index 00000000000..cfff603fd21 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_notifications/_repository.py @@ -0,0 +1,80 @@ +from typing import Any + +import redis.asyncio as aioredis +from aiohttp import web +from common_library.json_serialization import json_loads +from models_library.users import UserID +from servicelib.redis import handle_redis_returns_union_types + +from ..redis import get_redis_user_notifications_client +from ._models import ( + MAX_NOTIFICATIONS_FOR_USER_TO_KEEP, + MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, + UserNotification, + get_notification_key, +) + + +class UserNotificationsRepository: + def __init__(self, redis_client: aioredis.Redis) -> None: + self._redis_client = redis_client + + @classmethod + def create_from_app(cls, app: web.Application) -> "UserNotificationsRepository": + return cls(redis_client=get_redis_user_notifications_client(app)) + + async def list_notifications( + self, user_id: UserID, product_name: str + ) -> list[UserNotification]: + """Returns a list of notifications where the latest notification is at index 0""" + raw_notifications: list[str] = await handle_redis_returns_union_types( + self._redis_client.lrange( + get_notification_key(user_id), + -1 * MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, + -1, + ) + ) + notifications = [json_loads(x) for x in raw_notifications] + + # Make it backwards compatible + for n in notifications: + if "product" not in n: + n["product"] = "UNDEFINED" + + # Filter by product + included = [product_name, "UNDEFINED"] + filtered_notifications = [n for n in notifications if n["product"] in included] + return [UserNotification.model_validate(x) for x in filtered_notifications] + + async def create_notification(self, user_notification: UserNotification) -> None: + """Insert at the head of the list and discard extra notifications""" + key = get_notification_key(user_notification.user_id) + async with self._redis_client.pipeline(transaction=True) as pipe: + pipe.lpush(key, user_notification.model_dump_json()) + pipe.ltrim(key, 0, MAX_NOTIFICATIONS_FOR_USER_TO_KEEP - 1) + await pipe.execute() + + async def update_notification( + self, user_id: UserID, notification_id: str, update_data: dict[str, Any] + ) -> bool: + """Update a specific notification. Returns True if found and updated.""" + key = get_notification_key(user_id) + all_user_notifications: list[UserNotification] = [ + UserNotification.model_validate_json(x) + for x in await handle_redis_returns_union_types( + self._redis_client.lrange(key, 0, -1) + ) + ] + + for k, user_notification in enumerate(all_user_notifications): + if notification_id == user_notification.id: + # Update the notification with new data + for field, value in update_data.items(): + if hasattr(user_notification, field): + setattr(user_notification, field, value) + + await handle_redis_returns_union_types( + self._redis_client.lset(key, k, user_notification.model_dump_json()) + ) + return True + return False diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/_service.py b/services/web/server/src/simcore_service_webserver/user_notifications/_service.py new file mode 100644 index 00000000000..95216363750 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_notifications/_service.py @@ -0,0 +1,37 @@ +from typing import Any + +from aiohttp import web +from models_library.users import UserID + +from ._models import UserNotification, UserNotificationCreate +from ._repository import UserNotificationsRepository + + +async def list_user_notifications( + app: web.Application, user_id: UserID, product_name: str +) -> list[UserNotification]: + """List user notifications filtered by product""" + repo = UserNotificationsRepository.create_from_app(app) + return await repo.list_notifications(user_id=user_id, product_name=product_name) + + +async def create_user_notification( + app: web.Application, notification_data: UserNotificationCreate +) -> None: + """Create a new user notification""" + repo = UserNotificationsRepository.create_from_app(app) + user_notification = UserNotification.create_from_request_data(notification_data) + await repo.create_notification(user_notification) + + +async def update_user_notification( + app: web.Application, + user_id: UserID, + notification_id: str, + update_data: dict[str, Any], +) -> bool: + """Update a user notification. Returns True if found and updated.""" + repo = UserNotificationsRepository.create_from_app(app) + return await repo.update_notification( + user_id=user_id, notification_id=notification_id, update_data=update_data + ) diff --git a/services/web/server/src/simcore_service_webserver/user_notifications/bootstrap.py b/services/web/server/src/simcore_service_webserver/user_notifications/bootstrap.py new file mode 100644 index 00000000000..61fd24dd7a9 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_notifications/bootstrap.py @@ -0,0 +1,14 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.application_setup import ensure_single_setup + +from ._controller.rest import user_notification_rest + +_logger = logging.getLogger(__name__) + + +@ensure_single_setup(__name__, logger=_logger) +def setup_user_notification_feature(app: web.Application): + + app.router.add_routes(user_notification_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/__init__.py b/services/web/server/src/simcore_service_webserver/user_preferences/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/__init__.py b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py new file mode 100644 index 00000000000..b1479e671ee --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/_rest_exceptions.py @@ -0,0 +1,31 @@ +from common_library.user_messages import user_message +from servicelib.aiohttp import status +from simcore_postgres_database.utils_user_preferences import ( + CouldNotCreateOrUpdateUserPreferenceError, +) + +from ....exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ....users.exceptions import FrontendUserPreferenceIsNotDefinedError + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + CouldNotCreateOrUpdateUserPreferenceError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "Could not create or modify preferences", + ), + ), + FrontendUserPreferenceIsNotDefinedError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + user_message("Provided {frontend_preference_name} not found"), + ), +} + + +handle_rest_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_rest.py b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/user_preferences_rest.py similarity index 52% rename from services/web/server/src/simcore_service_webserver/users/_preferences_rest.py rename to services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/user_preferences_rest.py index dc31ee7e09c..69abf89d47b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_rest.py +++ b/services/web/server/src/simcore_service_webserver/user_preferences/_controller/rest/user_preferences_rest.py @@ -1,5 +1,3 @@ -import functools - from aiohttp import web from models_library.api_schemas_webserver.users_preferences import ( PatchPathParams, @@ -10,47 +8,28 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler -from simcore_postgres_database.utils_user_preferences import ( - CouldNotCreateOrUpdateUserPreferenceError, -) -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..models import AuthenticatedRequestContext -from . import _preferences_service -from .exceptions import FrontendUserPreferenceIsNotDefinedError +from ...._meta import API_VTAG +from ....login.decorators import login_required +from ....models import AuthenticatedRequestContext +from ... import _service +from ._rest_exceptions import handle_rest_requests_exceptions routes = web.RouteTableDef() -def _handle_users_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ( - CouldNotCreateOrUpdateUserPreferenceError, - FrontendUserPreferenceIsNotDefinedError, - ) as exc: - raise web.HTTPNotFound(text=f"{exc}") from exc - - return wrapper - - @routes.patch( f"/{API_VTAG}/me/preferences/{{preference_id}}", name="set_frontend_preference", ) @login_required -@_handle_users_exceptions +@handle_rest_requests_exceptions async def set_frontend_preference(request: web.Request) -> web.Response: req_ctx = AuthenticatedRequestContext.model_validate(request) req_body = await parse_request_body_as(PatchRequestBody, request) req_path_params = parse_request_path_parameters_as(PatchPathParams, request) - await _preferences_service.set_frontend_user_preference( + await _service.set_frontend_user_preference( request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py b/services/web/server/src/simcore_service_webserver/user_preferences/_models.py similarity index 98% rename from services/web/server/src/simcore_service_webserver/users/_preferences_models.py rename to services/web/server/src/simcore_service_webserver/user_preferences/_models.py index 6a871bcfafe..053e366fc22 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_models.py +++ b/services/web/server/src/simcore_service_webserver/user_preferences/_models.py @@ -12,7 +12,7 @@ ) from pydantic import Field, NonNegativeInt -from .settings import UsersSettings, get_plugin_settings +from ..users.settings import UsersSettings, get_plugin_settings _MINUTE: Final[NonNegativeInt] = 60 diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/_repository.py b/services/web/server/src/simcore_service_webserver/user_preferences/_repository.py new file mode 100644 index 00000000000..e979f26d0e8 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_preferences/_repository.py @@ -0,0 +1,62 @@ +from models_library.products import ProductName +from models_library.user_preferences import FrontendUserPreference, PreferenceName +from models_library.users import UserID +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from simcore_postgres_database.utils_user_preferences import FrontendUserPreferencesRepo +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.base_repository import BaseRepository + + +class UserPreferencesRepository(BaseRepository): + @staticmethod + def _get_user_preference_name( + user_id: UserID, preference_name: PreferenceName + ) -> str: + return f"{user_id}/{preference_name}" + + async def get_user_preference( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + preference_class: type[FrontendUserPreference], + ) -> FrontendUserPreference | None: + async with pass_or_acquire_connection(self.engine, connection) as conn: + preference_payload: dict | None = await FrontendUserPreferencesRepo.load( + conn, + user_id=user_id, + preference_name=self._get_user_preference_name( + user_id, preference_class.get_preference_name() + ), + product_name=product_name, + ) + + return ( + None + if preference_payload is None + else preference_class.model_validate(preference_payload) + ) + + async def set_user_preference( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + preference: FrontendUserPreference, + ) -> None: + async with transaction_context(self.engine, connection) as conn: + await FrontendUserPreferencesRepo.save( + conn, + user_id=user_id, + preference_name=self._get_user_preference_name( + user_id, preference.get_preference_name() + ), + product_name=product_name, + payload=preference.to_db(), + ) diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_service.py b/services/web/server/src/simcore_service_webserver/user_preferences/_service.py similarity index 90% rename from services/web/server/src/simcore_service_webserver/users/_preferences_service.py rename to services/web/server/src/simcore_service_webserver/user_preferences/_service.py index b5083732d70..a46db19ff21 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_service.py +++ b/services/web/server/src/simcore_service_webserver/user_preferences/_service.py @@ -20,14 +20,14 @@ ) from ..db.plugin import get_database_engine_legacy -from . import _preferences_repository -from ._preferences_models import ( +from ..users.exceptions import FrontendUserPreferenceIsNotDefinedError +from ._models import ( ALL_FRONTEND_PREFERENCES, TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference, get_preference_identifier, get_preference_name, ) -from .exceptions import FrontendUserPreferenceIsNotDefinedError +from ._repository import UserPreferencesRepository _MAX_PARALLEL_DB_QUERIES: Final[NonNegativeInt] = 2 @@ -37,10 +37,11 @@ async def _get_frontend_user_preferences( user_id: UserID, product_name: ProductName, ) -> list[FrontendUserPreference]: + repo = UserPreferencesRepository.create_from_app(app) + saved_user_preferences: list[FrontendUserPreference | None] = await logged_gather( *( - _preferences_repository.get_user_preference( - app, + repo.get_user_preference( user_id=user_id, product_name=product_name, preference_class=preference_class, @@ -64,8 +65,8 @@ async def get_frontend_user_preference( product_name: ProductName, preference_class: type[FrontendUserPreference], ) -> AnyUserPreference | None: - return await _preferences_repository.get_user_preference( - app, + repo = UserPreferencesRepository.create_from_app(app) + return await repo.get_user_preference( user_id=user_id, product_name=product_name, preference_class=preference_class, @@ -127,8 +128,8 @@ async def set_frontend_user_preference( FrontendUserPreference.get_preference_class_from_name(preference_name), ) - await _preferences_repository.set_user_preference( - app, + repo = UserPreferencesRepository.create_from_app(app) + await repo.set_user_preference( user_id=user_id, preference=TypeAdapter(preference_class).validate_python({"value": value}), # type: ignore[arg-type] # GitHK this is suspicious product_name=product_name, diff --git a/services/web/server/src/simcore_service_webserver/user_preferences/bootstrap.py b/services/web/server/src/simcore_service_webserver/user_preferences/bootstrap.py new file mode 100644 index 00000000000..6f72647cdac --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_preferences/bootstrap.py @@ -0,0 +1,16 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.application_setup import ensure_single_setup + +from ._controller.rest import user_preferences_rest +from ._models import overwrite_user_preferences_defaults + +_logger = logging.getLogger(__name__) + + +@ensure_single_setup(__name__, logger=_logger) +def setup_user_preferences_feature(app: web.Application): + + overwrite_user_preferences_defaults(app) + app.router.add_routes(user_preferences_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/users/preferences_api.py b/services/web/server/src/simcore_service_webserver/user_preferences/user_preferences_service.py similarity index 62% rename from services/web/server/src/simcore_service_webserver/users/preferences_api.py rename to services/web/server/src/simcore_service_webserver/user_preferences/user_preferences_service.py index 9f51b52e8b3..6f593985fbf 100644 --- a/services/web/server/src/simcore_service_webserver/users/preferences_api.py +++ b/services/web/server/src/simcore_service_webserver/user_preferences/user_preferences_service.py @@ -1,17 +1,18 @@ -from ._preferences_models import ( +from ._models import ( PreferredWalletIdFrontendUserPreference, TwoFAFrontendUserPreference, ) -from ._preferences_service import ( +from ._service import ( get_frontend_user_preference, + get_frontend_user_preferences_aggregation, set_frontend_user_preference, ) -from .exceptions import UserDefaultWalletNotFoundError -__all__ = ( - "get_frontend_user_preference", +__all__: tuple[str, ...] = ( "PreferredWalletIdFrontendUserPreference", "TwoFAFrontendUserPreference", + "get_frontend_user_preference", + "get_frontend_user_preferences_aggregation", "set_frontend_user_preference", - "UserDefaultWalletNotFoundError", ) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/__init__.py b/services/web/server/src/simcore_service_webserver/user_tokens/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/__init__.py b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/_rest_exceptions.py new file mode 100644 index 00000000000..5f8717f3c83 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/_rest_exceptions.py @@ -0,0 +1,25 @@ +from common_library.user_messages import user_message +from servicelib.aiohttp import status + +from ....exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ....users.exceptions import TokenNotFoundError + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + TokenNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + user_message( + "The API token for '{service}' could not be found.", + _version=1, + ), + ), +} + + +handle_rest_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/user_tokens_rest.py similarity index 50% rename from services/web/server/src/simcore_service_webserver/users/_tokens_rest.py rename to services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/user_tokens_rest.py index 19c5b9d23a3..26bc0f6b9a7 100644 --- a/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py +++ b/services/web/server/src/simcore_service_webserver/user_tokens/_controller/rest/user_tokens_rest.py @@ -1,23 +1,24 @@ -import functools import logging from aiohttp import web -from models_library.api_schemas_webserver.users import MyTokenCreate, MyTokenGet -from pydantic import BaseModel +from models_library.api_schemas_webserver.users import ( + MyTokenCreate, + MyTokenGet, + TokenPathParams, +) from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response -from . import _tokens_service -from ._common.schemas import UsersRequestContext -from .exceptions import TokenNotFoundError +from ...._meta import API_VTAG +from ....login.decorators import login_required +from ....security.decorators import permission_required +from ....users.schemas import UsersRequestContext +from ....utils_aiohttp import envelope_json_response +from ... import _service +from ._rest_exceptions import handle_rest_requests_exceptions _logger = logging.getLogger(__name__) @@ -25,59 +26,41 @@ routes = web.RouteTableDef() -def _handle_tokens_errors(handler: Handler): - @functools.wraps(handler) - async def _wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except TokenNotFoundError as exc: - raise web.HTTPNotFound( - text=f"Token for {exc.service_id} not found" - ) from exc - - return _wrapper - - @routes.get(f"/{API_VTAG}/me/tokens", name="list_tokens") @login_required -@_handle_tokens_errors +@handle_rest_requests_exceptions @permission_required("user.tokens.*") async def list_tokens(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - all_tokens = await _tokens_service.list_tokens(request.app, req_ctx.user_id) + all_tokens = await _service.list_tokens(request.app, req_ctx.user_id) return envelope_json_response([MyTokenGet.from_domain_model(t) for t in all_tokens]) @routes.post(f"/{API_VTAG}/me/tokens", name="create_token") @login_required -@_handle_tokens_errors +@handle_rest_requests_exceptions @permission_required("user.tokens.*") async def create_token(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) token_create = await parse_request_body_as(MyTokenCreate, request) - token = await _tokens_service.create_token( - request.app, req_ctx.user_id, token_create.to_domain_model() + token = await _service.create_token( + request.app, user_id=req_ctx.user_id, token=token_create.to_domain_model() ) return envelope_json_response(MyTokenGet.from_domain_model(token), web.HTTPCreated) -class _TokenPathParams(BaseModel): - service: str - - @routes.get(f"/{API_VTAG}/me/tokens/{{service}}", name="get_token") @login_required -@_handle_tokens_errors +@handle_rest_requests_exceptions @permission_required("user.tokens.*") async def get_token(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - req_path_params = parse_request_path_parameters_as(_TokenPathParams, request) + req_path_params = parse_request_path_parameters_as(TokenPathParams, request) - token = await _tokens_service.get_token( - request.app, req_ctx.user_id, req_path_params.service + token = await _service.get_token( + request.app, user_id=req_ctx.user_id, service_id=req_path_params.service ) return envelope_json_response(MyTokenGet.from_domain_model(token)) @@ -85,14 +68,14 @@ async def get_token(request: web.Request) -> web.Response: @routes.delete(f"/{API_VTAG}/me/tokens/{{service}}", name="delete_token") @login_required -@_handle_tokens_errors +@handle_rest_requests_exceptions @permission_required("user.tokens.*") async def delete_token(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - req_path_params = parse_request_path_parameters_as(_TokenPathParams, request) + req_path_params = parse_request_path_parameters_as(TokenPathParams, request) - await _tokens_service.delete_token( - request.app, req_ctx.user_id, req_path_params.service + await _service.delete_token( + request.app, user_id=req_ctx.user_id, service_id=req_path_params.service ) return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/_repository.py b/services/web/server/src/simcore_service_webserver/user_tokens/_repository.py new file mode 100644 index 00000000000..2478ace13f5 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_tokens/_repository.py @@ -0,0 +1,121 @@ +"""Private user tokens from external services (e.g. dat-core) + +Implemented as a stand-alone API but currently only exposed to the handlers +""" + +import sqlalchemy as sa +from models_library.users import UserID, UserThirdPartyToken +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy import and_, literal_column +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.base_repository import BaseRepository +from ..db.models import tokens +from ..users.exceptions import TokenNotFoundError + + +class UserTokensRepository(BaseRepository): + async def create_token( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + token: UserThirdPartyToken, + ) -> UserThirdPartyToken: + async with transaction_context(self.engine, connection) as conn: + await conn.execute( + tokens.insert().values( + user_id=user_id, + token_service=token.service, + token_data=token.model_dump(mode="json"), + ) + ) + return token + + async def list_tokens( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + ) -> list[UserThirdPartyToken]: + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute( + sa.select(tokens.c.token_data).where(tokens.c.user_id == user_id) + ) + return [ + UserThirdPartyToken.model_construct(**row["token_data"]) + for row in result.fetchall() + ] + + async def get_token( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + service_id: str, + ) -> UserThirdPartyToken: + async with pass_or_acquire_connection(self.engine, connection) as conn: + result = await conn.execute( + sa.select(tokens.c.token_data).where( + and_( + tokens.c.user_id == user_id, + tokens.c.token_service == service_id, + ) + ) + ) + if row := result.one_or_none(): + return UserThirdPartyToken.model_construct(**row["token_data"]) + raise TokenNotFoundError(service_id=service_id) + + async def update_token( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + service_id: str, + token_data: dict[str, str], + ) -> UserThirdPartyToken: + async with transaction_context(self.engine, connection) as conn: + result = await conn.execute( + sa.select(tokens.c.token_data, tokens.c.token_id).where( + (tokens.c.user_id == user_id) + & (tokens.c.token_service == service_id) + ) + ) + row = result.one_or_none() + if not row: + raise TokenNotFoundError(service_id=service_id) + + data = dict(row["token_data"]) + tid = row["token_id"] + data.update(token_data) + + result = await conn.execute( + tokens.update() + .where(tokens.c.token_id == tid) + .values(token_data=data) + .returning(literal_column("*")) + ) + updated_token = result.one() + assert updated_token # nosec + return UserThirdPartyToken.model_construct(**updated_token["token_data"]) + + async def delete_token( + self, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + service_id: str, + ) -> None: + async with transaction_context(self.engine, connection) as conn: + await conn.execute( + tokens.delete().where( + and_( + tokens.c.user_id == user_id, + tokens.c.token_service == service_id, + ) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/_service.py b/services/web/server/src/simcore_service_webserver/user_tokens/_service.py new file mode 100644 index 00000000000..eeb320e50fc --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_tokens/_service.py @@ -0,0 +1,38 @@ +"""Service interface for user tokens operations""" + +from aiohttp import web +from models_library.users import UserID, UserThirdPartyToken + +from ._repository import UserTokensRepository + + +async def list_tokens( + app: web.Application, user_id: UserID +) -> list[UserThirdPartyToken]: + """List all tokens for a user""" + repo = UserTokensRepository.create_from_app(app) + return await repo.list_tokens(user_id=user_id) + + +async def create_token( + app: web.Application, *, user_id: UserID, token: UserThirdPartyToken +) -> UserThirdPartyToken: + """Create a new token for a user""" + repo = UserTokensRepository.create_from_app(app) + return await repo.create_token(user_id=user_id, token=token) + + +async def get_token( + app: web.Application, *, user_id: UserID, service_id: str +) -> UserThirdPartyToken: + """Get a specific token for a user and service""" + repo = UserTokensRepository.create_from_app(app) + return await repo.get_token(user_id=user_id, service_id=service_id) + + +async def delete_token( + app: web.Application, *, user_id: UserID, service_id: str +) -> None: + """Delete a token for a user and service""" + repo = UserTokensRepository.create_from_app(app) + await repo.delete_token(user_id=user_id, service_id=service_id) diff --git a/services/web/server/src/simcore_service_webserver/user_tokens/bootstrap.py b/services/web/server/src/simcore_service_webserver/user_tokens/bootstrap.py new file mode 100644 index 00000000000..707f8becd36 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/user_tokens/bootstrap.py @@ -0,0 +1,13 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.application_setup import ensure_single_setup + +from ._controller.rest import user_tokens_rest + +_logger = logging.getLogger(__name__) + + +@ensure_single_setup(__name__, logger=_logger) +def setup_user_tokens_feature(app: web.Application): + app.add_routes(user_tokens_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/users/_controller/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/__init__.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py new file mode 100644 index 00000000000..89b7a94cd57 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py @@ -0,0 +1,61 @@ +from common_library.user_messages import user_message +from servicelib.aiohttp import status + +from ....exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ...exceptions import ( + AlreadyPreRegisteredError, + MissingGroupExtraPropertiesForProductError, + PendingPreRegistrationNotFoundError, + UserNameDuplicateError, + UserNotFoundError, +) + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + PendingPreRegistrationNotFoundError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "No pending registration request found for email {email} in {product_name}.", + _version=2, + ), + ), + UserNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + user_message( + "The requested user could not be found. " + "This may be because the user is not registered or has privacy settings enabled.", + _version=1, + ), + ), + UserNameDuplicateError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + user_message( + "The username '{user_name}' is already in use. " + "Please try '{alternative_user_name}' instead.", + _version=1, + ), + ), + AlreadyPreRegisteredError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + user_message( + "Found {num_found} existing account(s) for '{email}'. Unable to pre-register an existing user.", + _version=1, + ), + ), + MissingGroupExtraPropertiesForProductError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + user_message( + "This product is currently being configured and is not yet ready for use. " + "Please try again later.", + _version=1, + ), + ), +} + +handle_rest_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py similarity index 98% rename from services/web/server/src/simcore_service_webserver/users/_common/schemas.py rename to services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py index cf30b9360b1..cb5ebcee15f 100644 --- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from servicelib.request_keys import RQT_USERID_KEY -from ...constants import RQ_PRODUCT_KEY +from ....constants import RQ_PRODUCT_KEY class UsersRequestContext(BaseModel): diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py similarity index 76% rename from services/web/server/src/simcore_service_webserver/users/_users_rest.py rename to services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index b96623ad56d..1d22c70d16e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,7 +3,6 @@ from typing import Any from aiohttp import web -from common_library.user_messages import user_message from common_library.users_enums import AccountRequestStatus from models_library.api_schemas_invitations.invitations import ApiInvitationInputs from models_library.api_schemas_webserver.users import ( @@ -27,81 +26,22 @@ from servicelib.logging_utils import log_context from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from .._meta import API_VTAG -from ..exception_handling import ( - ExceptionToHttpErrorMap, - HttpErrorInfo, - exception_handling_decorator, - to_exceptions_handlers_map, -) -from ..groups import api as groups_api -from ..groups.exceptions import GroupNotFoundError -from ..invitations import api as invitations_service -from ..login.decorators import login_required -from ..products import products_web -from ..products.models import Product -from ..security.decorators import permission_required -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, -) +from ...._meta import API_VTAG +from ....groups import api as groups_service +from ....groups.exceptions import GroupNotFoundError +from ....invitations import api as invitations_service +from ....login.decorators import login_required +from ....products import products_web +from ....products.models import Product +from ....security.decorators import permission_required +from ....utils_aiohttp import create_json_response_from_page, envelope_json_response +from ... import _users_service +from ._rest_exceptions import handle_rest_requests_exceptions +from ._rest_schemas import PreRegisteredUserGet, UsersRequestContext _logger = logging.getLogger(__name__) -_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { - PendingPreRegistrationNotFoundError: HttpErrorInfo( - status.HTTP_400_BAD_REQUEST, - user_message( - "No pending registration request found for email {email} in {product_name}.", - _version=2, - ), - ), - UserNotFoundError: HttpErrorInfo( - status.HTTP_404_NOT_FOUND, - user_message( - "The requested user could not be found. " - "This may be because the user is not registered or has privacy settings enabled.", - _version=1, - ), - ), - UserNameDuplicateError: HttpErrorInfo( - status.HTTP_409_CONFLICT, - user_message( - "The username '{user_name}' is already in use. " - "Please try '{alternative_user_name}' instead.", - _version=1, - ), - ), - AlreadyPreRegisteredError: HttpErrorInfo( - status.HTTP_409_CONFLICT, - user_message( - "Found {num_found} existing account(s) for '{email}'. Unable to pre-register an existing user.", - _version=1, - ), - ), - MissingGroupExtraPropertiesForProductError: HttpErrorInfo( - status.HTTP_503_SERVICE_UNAVAILABLE, - user_message( - "This product is currently being configured and is not yet ready for use. " - "Please try again later.", - _version=1, - ), - ), -} - -_handle_users_exceptions = exception_handling_decorator( - # Transforms raised service exceptions into controller-errors (i.e. http 4XX,5XX responses) - to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) -) - - routes = web.RouteTableDef() # @@ -111,12 +51,12 @@ @routes.get(f"/{API_VTAG}/me", name="get_my_profile") @login_required -@_handle_users_exceptions +@handle_rest_requests_exceptions async def get_my_profile(request: web.Request) -> web.Response: product: Product = products_web.get_current_product(request) req_ctx = UsersRequestContext.model_validate(request) - groups_by_type = await groups_api.list_user_groups_with_read_access( + groups_by_type = await groups_service.list_user_groups_with_read_access( request.app, user_id=req_ctx.user_id ) @@ -128,7 +68,7 @@ async def get_my_profile(request: web.Request) -> web.Response: if product.group_id: with suppress(GroupNotFoundError): # Product is optional - my_product_group = await groups_api.get_product_group_for_user( + my_product_group = await groups_service.get_product_group_for_user( app=request.app, user_id=req_ctx.user_id, product_gid=product.group_id, @@ -148,7 +88,7 @@ async def get_my_profile(request: web.Request) -> web.Response: @routes.patch(f"/{API_VTAG}/me", name="update_my_profile") @login_required @permission_required("user.profile.update") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) profile_update = await parse_request_body_as(MyProfilePatch, request) @@ -167,7 +107,7 @@ async def update_my_profile(request: web.Request) -> web.Response: @routes.post(f"/{API_VTAG}/users:search", name="search_users") @login_required @permission_required("user.read") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def search_users(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec @@ -196,7 +136,7 @@ async def search_users(request: web.Request) -> web.Response: @routes.get(f"/{API_VTAG}/admin/user-accounts", name="list_users_accounts") @login_required @permission_required("admin.users.read") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def list_users_accounts(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec @@ -248,7 +188,7 @@ def _to_domain_model(user: dict[str, Any]) -> UserAccountGet: @routes.get(f"/{API_VTAG}/admin/user-accounts:search", name="search_user_accounts") @login_required @permission_required("admin.users.read") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def search_user_accounts(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec @@ -274,7 +214,7 @@ async def search_user_accounts(request: web.Request) -> web.Response: ) @login_required @permission_required("admin.users.write") -@_handle_users_exceptions +@handle_rest_requests_exceptions 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) @@ -294,7 +234,7 @@ async def pre_register_user_account(request: web.Request) -> web.Response: @routes.post(f"/{API_VTAG}/admin/user-accounts:approve", name="approve_user_account") @login_required @permission_required("admin.users.write") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def approve_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec @@ -358,7 +298,7 @@ async def approve_user_account(request: web.Request) -> web.Response: @routes.post(f"/{API_VTAG}/admin/user-accounts:reject", name="reject_user_account") @login_required @permission_required("admin.users.write") -@_handle_users_exceptions +@handle_rest_requests_exceptions async def reject_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_common/models.py b/services/web/server/src/simcore_service_webserver/users/_models.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/users/_common/models.py rename to services/web/server/src/simcore_service_webserver/users/_models.py diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications.py b/services/web/server/src/simcore_service_webserver/users/_notifications.py deleted file mode 100644 index 37f902b0950..00000000000 --- a/services/web/server/src/simcore_service_webserver/users/_notifications.py +++ /dev/null @@ -1,144 +0,0 @@ -from datetime import datetime -from enum import auto -from typing import Final, Literal -from uuid import uuid4 - -from models_library.products import ProductName -from models_library.users import UserID -from models_library.utils.enums import StrAutoEnum -from pydantic import BaseModel, ConfigDict, NonNegativeInt, field_validator - -MAX_NOTIFICATIONS_FOR_USER_TO_SHOW: Final[NonNegativeInt] = 10 -MAX_NOTIFICATIONS_FOR_USER_TO_KEEP: Final[NonNegativeInt] = 100 - - -def get_notification_key(user_id: UserID) -> str: - return f"user_id={user_id}" - - -class NotificationCategory(StrAutoEnum): - NEW_ORGANIZATION = auto() - STUDY_SHARED = auto() - TEMPLATE_SHARED = auto() - CONVERSATION_NOTIFICATION = auto() - ANNOTATION_NOTE = auto() - WALLET_SHARED = auto() - - -class BaseUserNotification(BaseModel): - user_id: UserID - category: NotificationCategory - actionable_path: str - title: str - text: str - date: datetime - product: Literal["UNDEFINED"] | ProductName = "UNDEFINED" - resource_id: Literal[""] | str = "" - user_from_id: Literal[None] | UserID = None - - @field_validator("category", mode="before") - @classmethod - def category_to_upper(cls, value: str) -> str: - return value.upper() - - -class UserNotificationCreate(BaseUserNotification): ... - - -class UserNotificationPatch(BaseModel): - read: bool - - -class UserNotification(BaseUserNotification): - # Ideally the `id` field, will be a UUID type in the future. - # Since there is no Redis data migration service, data type - # will not change to UUID nor Union[str, UUID] - id: str - read: bool - - @classmethod - def create_from_request_data( - cls, request_data: UserNotificationCreate - ) -> "UserNotification": - return cls.model_construct( - id=f"{uuid4()}", read=False, **request_data.model_dump() - ) - - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20eb8", - "user_id": "1", - "category": "NEW_ORGANIZATION", - "actionable_path": "organization/40", - "title": "New organization", - "text": "You're now member of a new Organization", - "date": "2023-02-23T16:23:13.122Z", - "product": "osparc", - "read": True, - }, - { - "id": "ba64ffce-c58c-4382-aad6-96a7787251d6", - "user_id": "1", - "category": "STUDY_SHARED", - "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", - "title": "Study shared", - "text": "A study was shared with you", - "date": "2023-02-23T16:25:13.122Z", - "product": "osparc", - "read": False, - }, - { - "id": "390053c9-3931-40e1-839f-585268f6fd3c", - "user_id": "1", - "category": "TEMPLATE_SHARED", - "actionable_path": "template/f60477b6-a07e-11ed-8d29-02420a00002d", - "title": "Template shared", - "text": "A template was shared with you", - "date": "2023-02-23T16:28:13.122Z", - "product": "osparc", - "read": False, - }, - { - "id": "390053c9-3931-40e1-839f-585268f6fd3d", - "user_id": "1", - "category": "CONVERSATION_NOTIFICATION", - "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", - "title": "New notification", - "text": "You were notified in a conversation", - "date": "2023-02-23T16:28:13.122Z", - "product": "s4l", - "read": False, - "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e12", - "user_from_id": "2", - }, - { - "id": "390053c9-3931-40e1-839f-585268f6fd3d", - "user_id": "1", - "category": "ANNOTATION_NOTE", - "actionable_path": "study/27edd65c-b360-11ed-93d7-02420a000014", - "title": "Note added", - "text": "A Note was added for you", - "date": "2023-02-23T16:28:13.122Z", - "product": "s4l", - "read": False, - "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e12", - "user_from_id": "2", - }, - { - "id": "390053c9-3931-40e1-839f-585268f6fd3e", - "user_id": "1", - "category": "WALLET_SHARED", - "actionable_path": "wallet/21", - "title": "Credits shared", - "text": "A Credit account was shared with you", - "date": "2023-09-29T16:28:13.122Z", - "product": "tis", - "read": False, - "resource_id": "3fb96d89-ff5d-4d27-b5aa-d20d46e20e13", - "user_from_id": "2", - }, - ] - } - ) diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py deleted file mode 100644 index ee2479b7377..00000000000 --- a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging - -import redis.asyncio as aioredis -from aiohttp import web -from common_library.json_serialization import json_loads -from models_library.api_schemas_webserver.users import MyPermissionGet -from models_library.users import UserPermission -from pydantic import BaseModel -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import ( - parse_request_body_as, - parse_request_path_parameters_as, -) -from servicelib.redis import handle_redis_returns_union_types -from servicelib.tracing import with_profiled_span - -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..products import products_web -from ..redis import get_redis_user_notifications_client -from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response -from . import _users_service -from ._common.schemas import UsersRequestContext -from ._notifications import ( - MAX_NOTIFICATIONS_FOR_USER_TO_KEEP, - MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, - UserNotification, - UserNotificationCreate, - UserNotificationPatch, - get_notification_key, -) - -_logger = logging.getLogger(__name__) - - -routes = web.RouteTableDef() - - -async def _get_user_notifications( - redis_client: aioredis.Redis, user_id: int, product_name: str -) -> list[UserNotification]: - """returns a list of notifications where the latest notification is at index 0""" - raw_notifications: list[str] = await handle_redis_returns_union_types( - redis_client.lrange( - get_notification_key(user_id), -1 * MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, -1 - ) - ) - notifications = [json_loads(x) for x in raw_notifications] - # Make it backwards compatible - for n in notifications: - if "product" not in n: - n["product"] = "UNDEFINED" - # Filter by product - included = [product_name, "UNDEFINED"] - filtered_notifications = [n for n in notifications if n["product"] in included] - return [UserNotification.model_validate(x) for x in filtered_notifications] - - -@routes.get(f"/{API_VTAG}/me/notifications", name="list_user_notifications") -@login_required -@permission_required("user.notifications.read") -async def list_user_notifications(request: web.Request) -> web.Response: - redis_client = get_redis_user_notifications_client(request.app) - req_ctx = UsersRequestContext.model_validate(request) - product_name = products_web.get_product_name(request) - notifications = await _get_user_notifications( - redis_client, req_ctx.user_id, product_name - ) - return envelope_json_response(notifications) - - -@routes.post(f"/{API_VTAG}/me/notifications", name="create_user_notification") -@login_required -@permission_required("user.notifications.write") -async def create_user_notification(request: web.Request) -> web.Response: - # body includes the updated notification - body = await parse_request_body_as(UserNotificationCreate, request) - user_notification = UserNotification.create_from_request_data(body) - key = get_notification_key(user_notification.user_id) - - # insert at the head of the list and discard extra notifications - redis_client = get_redis_user_notifications_client(request.app) - async with redis_client.pipeline(transaction=True) as pipe: - pipe.lpush(key, user_notification.model_dump_json()) - pipe.ltrim(key, 0, MAX_NOTIFICATIONS_FOR_USER_TO_KEEP - 1) - await pipe.execute() - - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -class _NotificationPathParams(BaseModel): - notification_id: str - - -@routes.patch( - f"/{API_VTAG}/me/notifications/{{notification_id}}", - name="mark_notification_as_read", -) -@login_required -@permission_required("user.notifications.update") -async def mark_notification_as_read(request: web.Request) -> web.Response: - redis_client = get_redis_user_notifications_client(request.app) - req_ctx = UsersRequestContext.model_validate(request) - req_path_params = parse_request_path_parameters_as(_NotificationPathParams, request) - body = await parse_request_body_as(UserNotificationPatch, request) - - # NOTE: only the user's notifications can be patched - key = get_notification_key(req_ctx.user_id) - all_user_notifications: list[UserNotification] = [ - UserNotification.model_validate_json(x) - for x in await handle_redis_returns_union_types(redis_client.lrange(key, 0, -1)) - ] - for k, user_notification in enumerate(all_user_notifications): - if req_path_params.notification_id == user_notification.id: - user_notification.read = body.read - await handle_redis_returns_union_types( - redis_client.lset(key, k, user_notification.model_dump_json()) - ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -@routes.get(f"/{API_VTAG}/me/permissions", name="list_user_permissions") -@login_required -@with_profiled_span -@permission_required("user.permissions.read") -async def list_user_permissions(request: web.Request) -> web.Response: - req_ctx = UsersRequestContext.model_validate(request) - list_permissions: list[UserPermission] = await _users_service.list_user_permissions( - request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) - return envelope_json_response( - [MyPermissionGet.from_domain_model(p) for p in list_permissions] - ) diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_repository.py b/services/web/server/src/simcore_service_webserver/users/_preferences_repository.py deleted file mode 100644 index 316da7534bc..00000000000 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_repository.py +++ /dev/null @@ -1,54 +0,0 @@ -from aiohttp import web -from models_library.products import ProductName -from models_library.user_preferences import FrontendUserPreference, PreferenceName -from models_library.users import UserID -from simcore_postgres_database.utils_user_preferences import FrontendUserPreferencesRepo - -from ..db.plugin import get_asyncpg_engine - - -def _get_user_preference_name(user_id: UserID, preference_name: PreferenceName) -> str: - return f"{user_id}/{preference_name}" - - -async def get_user_preference( - app: web.Application, - *, - user_id: UserID, - product_name: ProductName, - preference_class: type[FrontendUserPreference], -) -> FrontendUserPreference | None: - async with get_asyncpg_engine(app).connect() as conn: - preference_payload: dict | None = await FrontendUserPreferencesRepo.load( - conn, - user_id=user_id, - preference_name=_get_user_preference_name( - user_id, preference_class.get_preference_name() - ), - product_name=product_name, - ) - - return ( - None - if preference_payload is None - else preference_class.model_validate(preference_payload) - ) - - -async def set_user_preference( - app: web.Application, - *, - user_id: UserID, - product_name: ProductName, - preference: FrontendUserPreference, -) -> None: - async with get_asyncpg_engine(app).begin() as conn: - await FrontendUserPreferencesRepo.save( - conn, - user_id=user_id, - preference_name=_get_user_preference_name( - user_id, preference.get_preference_name() - ), - product_name=product_name, - payload=preference.to_db(), - ) diff --git a/services/web/server/src/simcore_service_webserver/users/_tokens_service.py b/services/web/server/src/simcore_service_webserver/users/_tokens_service.py deleted file mode 100644 index f8ceba3651c..00000000000 --- a/services/web/server/src/simcore_service_webserver/users/_tokens_service.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Private user tokens from external services (e.g. dat-core) - -Implemented as a stand-alone API but currently only exposed to the handlers -""" - -import sqlalchemy as sa -from aiohttp import web -from models_library.users import UserID, UserThirdPartyToken -from sqlalchemy import and_, literal_column - -from ..db.models import tokens -from ..db.plugin import get_database_engine_legacy -from .exceptions import TokenNotFoundError - - -async def create_token( - app: web.Application, user_id: UserID, token: UserThirdPartyToken -) -> UserThirdPartyToken: - async with get_database_engine_legacy(app).acquire() as conn: - await conn.execute( - tokens.insert().values( - user_id=user_id, - token_service=token.service, - token_data=token.model_dump(mode="json"), - ) - ) - return token - - -async def list_tokens( - app: web.Application, user_id: UserID -) -> list[UserThirdPartyToken]: - user_tokens: list[UserThirdPartyToken] = [] - async with get_database_engine_legacy(app).acquire() as conn: - async for row in conn.execute( - sa.select(tokens.c.token_data).where(tokens.c.user_id == user_id) - ): - user_tokens.append(UserThirdPartyToken.model_construct(**row["token_data"])) - return user_tokens - - -async def get_token( - app: web.Application, user_id: UserID, service_id: str -) -> UserThirdPartyToken: - async with get_database_engine_legacy(app).acquire() as conn: - result = await conn.execute( - sa.select(tokens.c.token_data).where( - and_(tokens.c.user_id == user_id, tokens.c.token_service == service_id) - ) - ) - if row := await result.first(): - return UserThirdPartyToken.model_construct(**row["token_data"]) - raise TokenNotFoundError(service_id=service_id) - - -async def update_token( - app: web.Application, user_id: UserID, service_id: str, token_data: dict[str, str] -) -> UserThirdPartyToken: - async with get_database_engine_legacy(app).acquire() as conn: - result = await conn.execute( - sa.select(tokens.c.token_data, tokens.c.token_id).where( - (tokens.c.user_id == user_id) & (tokens.c.token_service == service_id) - ) - ) - row = await result.first() - if not row: - raise TokenNotFoundError(service_id=service_id) - - data = dict(row["token_data"]) - tid = row["token_id"] - data.update(token_data) - - resp = await conn.execute( - tokens.update() - .where(tokens.c.token_id == tid) - .values(token_data=data) - .returning(literal_column("*")) - ) - assert resp.rowcount == 1 # nosec - updated_token = await resp.fetchone() - assert updated_token # nosec - return UserThirdPartyToken.model_construct(**updated_token["token_data"]) - - -async def delete_token(app: web.Application, user_id: UserID, service_id: str) -> None: - async with get_database_engine_legacy(app).acquire() as conn: - await conn.execute( - tokens.delete().where( - and_(tokens.c.user_id == user_id, tokens.c.token_service == service_id) - ) - ) 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 acfa107abe6..f3c695233b6 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 @@ -43,7 +43,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from ..db.plugin import get_asyncpg_engine -from ._common.models import FullNameDict, ToUserUpdateDB +from ._models import FullNameDict, ToUserUpdateDB from .exceptions import ( BillingDetailsNotFoundError, UserNameDuplicateError, 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 e812c9e8d05..c9ac050f7a0 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 @@ -19,20 +19,21 @@ from ..db.plugin import get_asyncpg_engine from ..security import security_service -from . import _preferences_service, _users_repository -from ._common.models import ( +from ..user_preferences import user_preferences_service +from . import _users_repository +from ._models import ( FullNameDict, ToUserUpdateDB, UserCredentialsTuple, UserDisplayAndIdNamesTuple, UserIdNamesTuple, ) -from ._common.schemas import PreRegisteredUserGet from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, PendingPreRegistrationNotFoundError, ) +from .schemas import PreRegisteredUserGet _logger = logging.getLogger(__name__) @@ -317,7 +318,7 @@ async def get_my_profile( try: preferences = ( - await _preferences_service.get_frontend_user_preferences_aggregation( + await user_preferences_service.get_frontend_user_preferences_aggregation( app, user_id=user_id, product_name=product_name ) ) diff --git a/services/web/server/src/simcore_service_webserver/users/models.py b/services/web/server/src/simcore_service_webserver/users/models.py new file mode 100644 index 00000000000..bcc04f1b57b --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/models.py @@ -0,0 +1,6 @@ +# mypy: disable-error-code=truthy-function + +from ._models import FullNameDict, UserDisplayAndIdNamesTuple + +__all__: tuple[str, ...] = ("FullNameDict", "UserDisplayAndIdNamesTuple") +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/users/plugin.py b/services/web/server/src/simcore_service_webserver/users/plugin.py index e9fb7d2ea53..cf64c2e9d09 100644 --- a/services/web/server/src/simcore_service_webserver/users/plugin.py +++ b/services/web/server/src/simcore_service_webserver/users/plugin.py @@ -1,6 +1,4 @@ -""" users management subsystem - -""" +"""users management subsystem""" import logging @@ -9,8 +7,12 @@ from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from servicelib.aiohttp.observer import setup_observer_registry -from . import _notifications_rest, _preferences_rest, _tokens_rest, _users_rest -from ._preferences_models import overwrite_user_preferences_defaults +from ..user_notifications.bootstrap import ( + setup_user_notification_feature, +) +from ..user_preferences.bootstrap import setup_user_preferences_feature +from ..user_tokens.bootstrap import setup_user_tokens_feature +from ._controller.rest import users_rest _logger = logging.getLogger(__name__) @@ -25,9 +27,9 @@ def setup_users(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_USERS # nosec setup_observer_registry(app) - overwrite_user_preferences_defaults(app) - app.router.add_routes(_users_rest.routes) - app.router.add_routes(_tokens_rest.routes) - app.router.add_routes(_notifications_rest.routes) - app.router.add_routes(_preferences_rest.routes) + app.router.add_routes(users_rest.routes) + + setup_user_notification_feature(app) + setup_user_preferences_feature(app) + setup_user_tokens_feature(app) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index c15f6c3c359..7c9d7997abd 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,6 +1,6 @@ -from ._common.schemas import PreRegisteredUserGet +from ._controller.rest._rest_schemas import PreRegisteredUserGet, UsersRequestContext -__all__: tuple[str, ...] = ("PreRegisteredUserGet",) +__all__: tuple[str, ...] = ("PreRegisteredUserGet", "UsersRequestContext") # nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/users_service.py similarity index 91% rename from services/web/server/src/simcore_service_webserver/users/api.py rename to services/web/server/src/simcore_service_webserver/users/users_service.py index 7a581fad556..f7327d8349d 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/users_service.py @@ -1,6 +1,6 @@ # mypy: disable-error-code=truthy-function -from ._common.models import FullNameDict, UserDisplayAndIdNamesTuple +from ._models import FullNameDict from ._users_service import ( delete_user_without_projects, get_guest_user_ids_and_names, @@ -23,7 +23,6 @@ __all__: tuple[str, ...] = ( "FullNameDict", - "UserDisplayAndIdNamesTuple", "delete_user_without_projects", "get_guest_user_ids_and_names", "get_user", diff --git a/services/web/server/src/simcore_service_webserver/wallets/_api.py b/services/web/server/src/simcore_service_webserver/wallets/_api.py index de52fdda769..5ac8f9851bb 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_api.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_api.py @@ -16,8 +16,8 @@ from pydantic import TypeAdapter from ..resource_usage.service import get_wallet_total_available_credits -from ..users import api as users_service -from ..users import preferences_api as user_preferences_api +from ..user_preferences import user_preferences_service +from ..users import users_service from ..users.exceptions import UserDefaultWalletNotFoundError from . import _db as db from .errors import WalletAccessForbiddenError @@ -141,11 +141,11 @@ async def get_user_default_wallet_with_available_credits( user_id: UserID, product_name: ProductName, ) -> WalletGetWithAvailableCredits: - user_default_wallet_preference = await user_preferences_api.get_frontend_user_preference( + user_default_wallet_preference = await user_preferences_service.get_frontend_user_preference( app, user_id=user_id, product_name=product_name, - preference_class=user_preferences_api.PreferredWalletIdFrontendUserPreference, + preference_class=user_preferences_service.PreferredWalletIdFrontendUserPreference, ) if user_default_wallet_preference is None: raise UserDefaultWalletNotFoundError(uid=user_id) diff --git a/services/web/server/src/simcore_service_webserver/wallets/_events.py b/services/web/server/src/simcore_service_webserver/wallets/_events.py index fefd9900603..ace45203b50 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_events.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_events.py @@ -9,8 +9,8 @@ from ..products import products_service from ..resource_usage.service import add_credits_to_wallet -from ..users import preferences_api -from ..users.api import get_user_display_and_id_names +from ..user_preferences import user_preferences_service +from ..users import users_service from ._api import any_wallet_owned_by_user, create_wallet _WALLET_NAME_TEMPLATE = "{} Credits" @@ -27,7 +27,7 @@ async def _auto_add_default_wallet( if not await any_wallet_owned_by_user( app, user_id=user_id, product_name=product_name ): - user = await get_user_display_and_id_names(app, user_id=user_id) + user = await users_service.get_user_display_and_id_names(app, user_id=user_id) product = products_service.get_product(app, product_name) wallet = await create_wallet( @@ -54,9 +54,9 @@ async def _auto_add_default_wallet( ) preference_id = ( - preferences_api.PreferredWalletIdFrontendUserPreference().preference_identifier + user_preferences_service.PreferredWalletIdFrontendUserPreference().preference_identifier ) - await preferences_api.set_frontend_user_preference( + await user_preferences_service.set_frontend_user_preference( app, user_id=user_id, product_name=product_name, diff --git a/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py b/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py index 05b6625ae5e..56d6830c1de 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py @@ -8,7 +8,7 @@ from models_library.wallets import UserWalletDB, WalletID from pydantic import BaseModel, ConfigDict -from ..users import api as users_service +from ..users import users_service from . import _db as wallets_db from . import _groups_db as wallets_groups_db from ._groups_db import WalletGroupGetDB diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py index d2ac47751b7..b21021f2ff5 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py @@ -8,7 +8,7 @@ from models_library.workspaces import UserWorkspaceWithAccessRights, WorkspaceID from pydantic import BaseModel, ConfigDict -from ..users import api as users_service +from ..users import users_service from . import _groups_repository as workspaces_groups_db from . import _workspaces_repository as workspaces_workspaces_repository from ._groups_repository import WorkspaceGroupGetDB @@ -182,12 +182,14 @@ async def delete_workspace_group( raise WorkspaceAccessForbiddenError( reason=f"User does not have delete access to workspace {workspace_id}" ) - if workspace.owner_primary_gid == group_id: - if user["primary_gid"] != workspace.owner_primary_gid: - # Only the owner of the workspace can delete the owner group - raise WorkspaceAccessForbiddenError( - reason=f"User does not have access to modify owner workspace group in workspace {workspace_id}" - ) + if ( + workspace.owner_primary_gid == group_id + and user["primary_gid"] != workspace.owner_primary_gid + ): + # Only the owner of the workspace can delete the owner group + raise WorkspaceAccessForbiddenError( + reason=f"User does not have access to modify owner workspace group in workspace {workspace_id}" + ) await workspaces_groups_db.delete_workspace_group( app=app, workspace_id=workspace_id, group_id=group_id diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py index 4779e39fbd9..619d630a206 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py @@ -20,7 +20,7 @@ from ..folders.service import delete_folder_with_all_content, list_folders from ..projects.api import delete_project_by_user, list_projects from ..projects.models import ProjectTypeAPI -from ..users.api import get_user +from ..users import users_service from . import _workspaces_repository as db from ._workspaces_service_crud_read import check_user_workspace_access @@ -36,7 +36,7 @@ async def create_workspace( thumbnail: str | None, product_name: ProductName, ) -> UserWorkspaceWithAccessRights: - user = await get_user(app, user_id=user_id) + user = await users_service.get_user(app, user_id=user_id) created = await db.create_workspace( app, product_name=product_name, diff --git a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py index a6722b3dafc..9415491f3a0 100644 --- a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py +++ b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py @@ -156,7 +156,9 @@ async def mock_get_user_role( mocker: MockerFixture, user_role: UserRole ) -> mock.AsyncMock: return mocker.patch( - f"{MODULE_GC_CORE_ORPHANS}.get_user_role", autospec=True, return_value=user_role + f"{MODULE_GC_CORE_ORPHANS}.users_service.get_user_role", + autospec=True, + return_value=user_role, ) diff --git a/services/web/server/tests/unit/isolated/test_user_notifications.py b/services/web/server/tests/unit/isolated/test_user_notifications.py index b8b1d3e06fd..a450600ba8c 100644 --- a/services/web/server/tests/unit/isolated/test_user_notifications.py +++ b/services/web/server/tests/unit/isolated/test_user_notifications.py @@ -4,7 +4,7 @@ import pytest from models_library.users import UserID -from simcore_service_webserver.users._notifications import ( +from simcore_service_webserver.user_notifications._models import ( NotificationCategory, UserNotification, UserNotificationCreate, @@ -12,9 +12,7 @@ ) -@pytest.mark.parametrize( - "raw_data", UserNotification.model_config["json_schema_extra"]["examples"] -) +@pytest.mark.parametrize("raw_data", UserNotification.model_json_schema()["examples"]) def test_user_notification(raw_data: dict[str, Any]): assert UserNotification.model_validate(raw_data) diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py index e61f543e211..f11385225cf 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -18,7 +18,7 @@ from models_library.utils.fastapi_encoders import jsonable_encoder from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database import utils_users -from simcore_service_webserver.users._common.models import ToUserUpdateDB +from simcore_service_webserver.users._models import ToUserUpdateDB @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py index a7d27b6175b..000694d6fff 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py @@ -31,7 +31,7 @@ MSG_USER_EXPIRED, ) from simcore_service_webserver.login.settings import LoginOptions -from simcore_service_webserver.users import api as users_service +from simcore_service_webserver.users import users_service as users_service from yarl import URL # diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py index 15f77ed013c..69cbc3b2ccd 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py @@ -38,7 +38,7 @@ from simcore_service_webserver.products import products_web from simcore_service_webserver.products.errors import UnknownProductError from simcore_service_webserver.products.models import Product -from simcore_service_webserver.users import preferences_api as user_preferences_api +from simcore_service_webserver.user_preferences import user_preferences_service from twilio.base.exceptions import TwilioRestException @@ -260,9 +260,9 @@ def _get_confirmation_link_from_email(): # login (via EMAIL) --------------------------------------------------------- # Change 2fa user preference _preference_id = ( - user_preferences_api.TwoFAFrontendUserPreference().preference_identifier + user_preferences_service.TwoFAFrontendUserPreference().preference_identifier ) - await user_preferences_api.set_frontend_user_preference( + await user_preferences_service.set_frontend_user_preference( client.app, user_id=user["id"], product_name="osparc", @@ -291,7 +291,7 @@ def _get_confirmation_link_from_email(): assert "support" in parsed_context["support_email"] # login (2FA Disabled) --------------------------------------------------------- - await user_preferences_api.set_frontend_user_preference( + await user_preferences_service.set_frontend_user_preference( client.app, user_id=user["id"], product_name="osparc", diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py b/services/web/server/tests/unit/with_dbs/03/test_user_notifications.py similarity index 97% rename from services/web/server/tests/unit/with_dbs/03/test_users__notifications.py rename to services/web/server/tests/unit/with_dbs/03/test_user_notifications.py index 570d2914dda..6cc78e61b44 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py +++ b/services/web/server/tests/unit/with_dbs/03/test_user_notifications.py @@ -26,7 +26,7 @@ from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserRole from simcore_service_webserver.redis import get_redis_user_notifications_client -from simcore_service_webserver.users._notifications import ( +from simcore_service_webserver.user_notifications._models import ( MAX_NOTIFICATIONS_FOR_USER_TO_KEEP, MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, NotificationCategory, @@ -34,7 +34,18 @@ UserNotificationCreate, get_notification_key, ) -from simcore_service_webserver.users._notifications_rest import _get_user_notifications +from simcore_service_webserver.user_notifications._repository import ( + UserNotificationsRepository, +) + + +async def _get_user_notifications( + redis_client, + user_id, + product_name, +): + repo = UserNotificationsRepository(redis_client=redis_client) + return await repo.list_notifications(user_id=user_id, product_name=product_name) @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_models.py similarity index 98% rename from services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py rename to services/web/server/tests/unit/with_dbs/03/test_user_preferences_models.py index 0a33d8f8921..db9527c62f7 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py +++ b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_models.py @@ -16,7 +16,7 @@ from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.constants import APP_SETTINGS_KEY -from simcore_service_webserver.users._preferences_models import ( +from simcore_service_webserver.user_preferences._models import ( ALL_FRONTEND_PREFERENCES, TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference, get_preference_identifier, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_handlers.py b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_rest.py similarity index 97% rename from services/web/server/tests/unit/with_dbs/03/test_users__preferences_handlers.py rename to services/web/server/tests/unit/with_dbs/03/test_user_preferences_rest.py index b6a913e3a5e..9887d2fbce1 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_rest.py @@ -18,7 +18,9 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserRole, UserStatus -from simcore_service_webserver.users._preferences_models import ALL_FRONTEND_PREFERENCES +from simcore_service_webserver.user_preferences._models import ( + ALL_FRONTEND_PREFERENCES, +) @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_service.py similarity index 98% rename from services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py rename to services/web/server/tests/unit/with_dbs/03/test_user_preferences_service.py index 96f6ba52241..37c3eb8f0c0 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_api.py +++ b/services/web/server/tests/unit/with_dbs/03/test_user_preferences_service.py @@ -24,11 +24,11 @@ groups_extra_properties, ) from simcore_postgres_database.models.users import UserStatus -from simcore_service_webserver.users._preferences_models import ( +from simcore_service_webserver.user_preferences._models import ( ALL_FRONTEND_PREFERENCES, BillingCenterUsageColumnOrderFrontendUserPreference, ) -from simcore_service_webserver.users._preferences_service import ( +from simcore_service_webserver.user_preferences._service import ( _get_frontend_user_preferences, get_frontend_user_preferences_aggregation, set_frontend_user_preference, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__tokens.py b/services/web/server/tests/unit/with_dbs/03/test_user_tokens.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/test_users__tokens.py rename to services/web/server/tests/unit/with_dbs/03/test_user_tokens.py 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 index ef68295a7f0..752ce41c21a 100644 --- 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 @@ -14,7 +14,7 @@ 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 ( +from simcore_service_webserver.users._controller.rest._rest_schemas import ( MAX_BYTES_SIZE_EXTRAS, PreRegisteredUserGet, ) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index d5fcf456cc7..8b8779e39b4 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -32,7 +32,7 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from servicelib.aiohttp import status from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from simcore_service_webserver.users._preferences_service import ( +from simcore_service_webserver.user_preferences._service import ( get_frontend_user_preferences_aggregation, ) from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_api.py b/services/web/server/tests/unit/with_dbs/03/test_users_service.py similarity index 98% rename from services/web/server/tests/unit/with_dbs/03/test_users_api.py rename to services/web/server/tests/unit/with_dbs/03/test_users_service.py index 15d5fb7e6de..a9a62763e67 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_api.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_service.py @@ -16,7 +16,8 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserStatus -from simcore_service_webserver.users.api import ( +from simcore_service_webserver.users.exceptions import UserNotFoundError +from simcore_service_webserver.users.users_service import ( delete_user_without_projects, get_guest_user_ids_and_names, get_user, @@ -30,7 +31,6 @@ set_user_as_deleted, update_expired_users, ) -from simcore_service_webserver.users.exceptions import UserNotFoundError @pytest.fixture 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 similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py rename to services/web/server/tests/unit/with_dbs/03/users/test_users__repository.py 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 similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/users/test_users_service.py rename to services/web/server/tests/unit/with_dbs/03/users/test_users__service.py diff --git a/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py b/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py index 357eb33ca07..874182e86a4 100644 --- a/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py +++ b/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py @@ -61,9 +61,9 @@ from simcore_service_webserver.session.plugin import setup_session from simcore_service_webserver.socketio.messages import SOCKET_IO_PROJECT_UPDATED_EVENT from simcore_service_webserver.socketio.plugin import setup_socketio -from simcore_service_webserver.users.api import delete_user_without_projects from simcore_service_webserver.users.exceptions import UserNotFoundError from simcore_service_webserver.users.plugin import setup_users +from simcore_service_webserver.users.users_service import delete_user_without_projects from tenacity.asyncio import AsyncRetrying from tenacity.retry import retry_if_exception_type from tenacity.stop import stop_after_delay diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py index b05757d70bc..75e09bf72fc 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py @@ -27,7 +27,7 @@ _create_project_with_filepicker_and_service, _create_project_with_service, ) -from simcore_service_webserver.users.api import get_user +from simcore_service_webserver.users.users_service import get_user FAKE_FILE_VIEWS = list_fake_file_consumers() diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py index 42756529404..9057497e0b5 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py @@ -41,7 +41,7 @@ ) from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.projects.utils import NodesMap -from simcore_service_webserver.users.api import ( +from simcore_service_webserver.users.users_service import ( delete_user_without_projects, get_user_role, ) diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py index d35be5074ae..80fd0aa0029 100644 --- a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py +++ b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py @@ -30,7 +30,7 @@ from simcore_service_webserver.login._login_service import notify_user_confirmation from simcore_service_webserver.products.products_service import get_product from simcore_service_webserver.projects.models import ProjectDict -from simcore_service_webserver.users.api import UserDisplayAndIdNamesTuple +from simcore_service_webserver.users.models import UserDisplayAndIdNamesTuple from simcore_service_webserver.wallets._events import ( _WALLET_DESCRIPTION_TEMPLATE, _WALLET_NAME_TEMPLATE,