diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e6f34058bd7..c142fd7d463 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,6 +22,9 @@ or from https://gitmoji.dev/ ## What do these changes do? + ## Related issue/s @@ -31,9 +34,6 @@ or from https://gitmoji.dev/ - resolves ITISFoundation/osparc-issues#428 - fixes #26 - - If openapi changes are provided, optionally point to the swagger editor with new changes - Example [openapi.json specs](https://editor.swagger.io/?url=https://raw.githubusercontent.com//osparc-simcore/is1133/create-api-for-creation-of-pricing-plan/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) --> diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index f1204f15f35..c161a7aa69a 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -7,6 +7,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, status +from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope from models_library.user_preferences import PreferenceIdentifier @@ -24,8 +25,6 @@ from simcore_service_webserver.users._tokens_handlers import _TokenPathParams from simcore_service_webserver.users.schemas import ( PermissionGet, - ProfileGet, - ProfileUpdate, ThirdPartyToken, TokenCreate, ) @@ -41,7 +40,7 @@ async def get_my_profile(): ... -@router.put( +@router.patch( "/me", status_code=status.HTTP_204_NO_CONTENT, ) @@ -49,6 +48,16 @@ async def update_my_profile(_profile: ProfileUpdate): ... +@router.put( + "/me", + status_code=status.HTTP_204_NO_CONTENT, + deprecated=True, + description="Use PATCH instead", +) +async def replace_my_profile(_profile: ProfileUpdate): + ... + + @router.patch( "/me/preferences/{preference_id}", status_code=status.HTTP_204_NO_CONTENT, 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 new file mode 100644 index 00000000000..ae7b9f89504 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -0,0 +1,130 @@ +import re +from datetime import date +from enum import Enum +from typing import Annotated, Literal + +from models_library.api_schemas_webserver.groups import MyGroupsGet +from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences +from models_library.basic_types import IDStr +from models_library.emails import LowerCaseEmailStr +from models_library.users import FirstNameStr, LastNameStr, UserID +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ._base import InputSchema, OutputSchema + + +class ProfilePrivacyGet(OutputSchema): + hide_fullname: bool + hide_email: bool + + +class ProfilePrivacyUpdate(InputSchema): + hide_fullname: bool | None = None + hide_email: bool | None = None + + +class ProfileGet(BaseModel): + # WARNING: do not use InputSchema until front-end is updated! + id: UserID + user_name: Annotated[ + IDStr, Field(description="Unique username identifier", alias="userName") + ] + first_name: FirstNameStr | None = None + last_name: LastNameStr | None = None + login: LowerCaseEmailStr + + role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] + groups: MyGroupsGet | None = None + gravatar_id: Annotated[str | None, Field(deprecated=True)] = None + + expiration_date: Annotated[ + date | None, + Field( + description="If user has a trial account, it sets the expiration date, otherwise None", + alias="expirationDate", + ), + ] = None + + privacy: ProfilePrivacyGet + preferences: AggregatedPreferences + + model_config = ConfigDict( + # NOTE: old models have an hybrid between snake and camel cases! + # Should be unified at some point + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "id": 42, + "login": "bla@foo.com", + "userName": "bla42", + "role": "admin", # pre + "expirationDate": "2022-09-14", # optional + "preferences": {}, + "privacy": {"hide_fullname": 0, "hide_email": 1}, + }, + ] + }, + ) + + @field_validator("role", mode="before") + @classmethod + def _to_upper_string(cls, v): + if isinstance(v, str): + return v.upper() + if isinstance(v, Enum): + return v.name.upper() + return v + + +class ProfileUpdate(BaseModel): + # WARNING: do not use InputSchema until front-end is updated! + first_name: FirstNameStr | None = None + last_name: LastNameStr | None = None + user_name: Annotated[IDStr | None, Field(alias="userName")] = None + + privacy: ProfilePrivacyUpdate | None = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "first_name": "Pedro", + "last_name": "Crespo", + } + } + ) + + @field_validator("user_name") + @classmethod + def _validate_user_name(cls, value: str): + # Ensure valid characters (alphanumeric + . _ -) + if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value): + msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'." + raise ValueError(msg) + + # Ensure no consecutive special characters + if re.search(r"[_.-]{2,}", value): + msg = f"Username '{value}' cannot contain consecutive special characters like '__'." + raise ValueError(msg) + + # Ensure it doesn't end with a special character + if {value[0], value[-1]}.intersection({"_", "-", "."}): + msg = f"Username '{value}' cannot end or start with a special character." + raise ValueError(msg) + + # Check reserved words (example list; extend as needed) + reserved_words = { + "admin", + "root", + "system", + "null", + "undefined", + "support", + "moderator", + # NOTE: add here extra via env vars + } + if any(w in value.lower() for w in reserved_words): + msg = f"Username '{value}' cannot be used." + raise ValueError(msg) + + return value diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py new file mode 100644 index 00000000000..4d3e141e769 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py @@ -0,0 +1,45 @@ +"""new user privacy columns + +Revision ID: 38c9ac332c58 +Revises: e5555076ef50 +Create Date: 2024-12-05 14:29:27.739650+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "38c9ac332c58" +down_revision = "e5555076ef50" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column( + "privacy_hide_fullname", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "privacy_hide_email", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "privacy_hide_email") + op.drop_column("users", "privacy_hide_fullname") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py index 61b8c321130..d42568d772f 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -2,6 +2,7 @@ from functools import total_ordering import sqlalchemy as sa +from sqlalchemy.sql import expression from ._common import RefActions from .base import metadata @@ -67,6 +68,9 @@ class UserStatus(str, Enum): users = sa.Table( "users", metadata, + # + # User Identifiers ------------------ + # sa.Column( "id", sa.BigInteger(), @@ -77,8 +81,23 @@ class UserStatus(str, Enum): "name", sa.String(), nullable=False, - doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ...", + doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ..." + "This identifier **is public**.", ), + sa.Column( + "primary_gid", + sa.BigInteger(), + sa.ForeignKey( + "groups.gid", + name="fk_users_gid_groups", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, + ), + doc="User's group ID", + ), + # + # User Information ------------------ + # sa.Column( "first_name", sa.String(), @@ -102,37 +121,52 @@ class UserStatus(str, Enum): doc="Confirmed user phone used e.g. to send a code for a two-factor-authentication." "NOTE: new policy (NK) is that the same phone can be reused therefore it does not has to be unique", ), + # + # User Secrets ------------------ + # sa.Column( "password_hash", sa.String(), nullable=False, doc="Hashed password", ), - sa.Column( - "primary_gid", - sa.BigInteger(), - sa.ForeignKey( - "groups.gid", - name="fk_users_gid_groups", - onupdate=RefActions.CASCADE, - ondelete=RefActions.RESTRICT, - ), - doc="User's group ID", - ), + # + # User Account ------------------ + # sa.Column( "status", sa.Enum(UserStatus), nullable=False, default=UserStatus.CONFIRMATION_PENDING, - doc="Status of the user account. SEE UserStatus", + doc="Current status of the user's account", ), sa.Column( "role", sa.Enum(UserRole), nullable=False, default=UserRole.USER, - doc="Use for role-base authorization", + doc="Used for role-base authorization", + ), + # + # User Privacy Rules ------------------ + # + sa.Column( + "privacy_hide_fullname", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.first_name, users.last_name to others", + ), + sa.Column( + "privacy_hide_email", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.email to others", ), + # + # Timestamps --------------- + # sa.Column( "created_at", sa.DateTime(), diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index cba31689654..9301b5ce42c 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -29,6 +29,8 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet +from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import ProfileUpdate as WebProfileUpdate from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID @@ -77,7 +79,7 @@ PricingUnitGetLegacy, WalletGetWithAvailableCreditsLegacy, ) -from ..models.schemas.profiles import Profile, ProfileUpdate +from ..models.schemas.profiles import Profile, ProfileUpdate, UserRoleEnum from ..models.schemas.solvers import SolverKeyId from ..models.schemas.studies import StudyPort from ..utils.client_base import BaseServiceClientApi, setup_client_instance @@ -243,17 +245,34 @@ async def _wait_for_long_running_task_results(self, lrt_response: httpx.Response async def get_me(self) -> Profile: response = await self.client.get("/me", cookies=self.session_cookies) response.raise_for_status() - profile: Profile | None = ( - Envelope[Profile].model_validate_json(response.text).data + + got: WebProfileGet | None = ( + Envelope[WebProfileGet].model_validate_json(response.text).data + ) + assert got is not None # nosec + + return Profile( + first_name=got.first_name, + last_name=got.last_name, + id=got.id, + login=got.login, + role=UserRoleEnum(got.role), + groups=got.groups.model_dump() if got.groups else None, # type: ignore + gravatar_id=got.gravatar_id, ) - assert profile is not None # nosec - return profile @_exception_mapper(_PROFILE_STATUS_MAP) async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: - response = await self.client.put( + + update = WebProfileUpdate.model_construct( + _fields_set=profile_update.model_fields_set, + first_name=profile_update.first_name, + last_name=profile_update.last_name, + ) + + response = await self.client.patch( "/me", - json=profile_update.model_dump(exclude_none=True), + json=update.model_dump(exclude_unset=True), cookies=self.session_cookies, ) response.raise_for_status() diff --git a/services/api-server/tests/unit/_with_db/test_api_user.py b/services/api-server/tests/unit/_with_db/test_api_user.py index 0a42177867b..24836d1b3cd 100644 --- a/services/api-server/tests/unit/_with_db/test_api_user.py +++ b/services/api-server/tests/unit/_with_db/test_api_user.py @@ -4,12 +4,12 @@ import json -from copy import deepcopy import httpx import pytest import respx from fastapi import FastAPI +from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet from respx import MockRouter from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.core.settings import ApplicationSettings @@ -32,7 +32,9 @@ def mocked_webserver_service_api(app: FastAPI): ) as respx_mock: # NOTE: webserver-api uses the same schema as api-server! # in-memory fake data - me = deepcopy(Profile.model_config["json_schema_extra"]["example"]) + me: dict = WebProfileGet.model_json_schema()["examples"][0] + me["first_name"] = "James" + me["last_name"] = "Maxwell" def _get_me(request): return httpx.Response(status.HTTP_200_OK, json={"data": me}) @@ -43,12 +45,10 @@ def _update_me(request: httpx.Request): return httpx.Response(status.HTTP_200_OK, json={"data": me}) respx_mock.get("/me", name="get_me").mock(side_effect=_get_me) - respx_mock.put("/me", name="update_me").mock(side_effect=_update_me) + respx_mock.patch("/me", name="update_me").mock(side_effect=_update_me) yield respx_mock - del me - async def test_get_profile( client: httpx.AsyncClient, diff --git a/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js b/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js index 465e4f0f391..a4f9b773ea3 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js @@ -156,7 +156,7 @@ qx.Class.define("osparc.desktop.account.ProfilePage", { if (this.__userProfileData["first_name"] !== model.getFirstName() || this.__userProfileData["last_name"] !== model.getLastName()) { if (namesValidator.validate()) { - const profileReq = new osparc.io.request.ApiRequest("/me", "PUT"); + const profileReq = new osparc.io.request.ApiRequest("/me", "PATCH"); profileReq.setRequestData({ "first_name": model.getFirstName(), "last_name": model.getLastName() diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 421ab545d9a..a758a09aae5 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.47.0 +0.48.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 628685e0ad5..0b6157ef959 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.47.0 +current_version = 0.48.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 485fb15c931..91516c62ee8 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.47.0 + version: 0.48.0 servers: - url: '' description: webserver @@ -1088,6 +1088,22 @@ paths: schema: $ref: '#/components/schemas/Envelope_ProfileGet_' put: + tags: + - user + summary: Replace My Profile + description: Use PATCH instead + operationId: replace_my_profile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileUpdate' + required: true + responses: + '204': + description: Successful Response + deprecated: true + patch: tags: - user summary: Update My Profile @@ -11520,6 +11536,12 @@ components: exclusiveMinimum: true title: Id minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + description: Unique username identifier first_name: anyOf: - type: string @@ -11555,6 +11577,7 @@ components: - type: string - type: 'null' title: Gravatar Id + deprecated: true expirationDate: anyOf: - type: string @@ -11563,6 +11586,8 @@ components: title: Expirationdate description: If user has a trial account, it sets the expiration date, otherwise None + privacy: + $ref: '#/components/schemas/ProfilePrivacyGet' preferences: additionalProperties: $ref: '#/components/schemas/Preference' @@ -11571,10 +11596,39 @@ components: type: object required: - id + - userName - login - role + - privacy - preferences title: ProfileGet + ProfilePrivacyGet: + properties: + hideFullname: + type: boolean + title: Hidefullname + hideEmail: + type: boolean + title: Hideemail + type: object + required: + - hideFullname + - hideEmail + title: ProfilePrivacyGet + ProfilePrivacyUpdate: + properties: + hideFullname: + anyOf: + - type: boolean + - type: 'null' + title: Hidefullname + hideEmail: + anyOf: + - type: boolean + - type: 'null' + title: Hideemail + type: object + title: ProfilePrivacyUpdate ProfileUpdate: properties: first_name: @@ -11589,6 +11643,17 @@ components: maxLength: 255 - type: 'null' title: Last Name + userName: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: Username + privacy: + anyOf: + - $ref: '#/components/schemas/ProfilePrivacyUpdate' + - type: 'null' type: object title: ProfileUpdate example: diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index b15cd73e1c8..07941e1e92c 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -117,13 +117,12 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): WEBSERVER_LOGLEVEL: Annotated[ LogLevel, Field( - default=LogLevel.WARNING.value, validation_alias=AliasChoices( "WEBSERVER_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL" ), # NOTE: suffix '_LOGLEVEL' is used overall ), - ] + ] = LogLevel.WARNING WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED: bool = Field( default=False, validation_alias=AliasChoices( diff --git a/services/web/server/src/simcore_service_webserver/users/_handlers.py b/services/web/server/src/simcore_service_webserver/users/_handlers.py index 4d69e9ffaab..d67d772e0ee 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -2,6 +2,7 @@ import logging from aiohttp import web +from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate from models_library.users import UserID from pydantic import BaseModel, Field from servicelib.aiohttp import status @@ -25,9 +26,9 @@ from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + UserNameDuplicateError, UserNotFoundError, ) -from .schemas import ProfileGet, ProfileUpdate _logger = logging.getLogger(__name__) @@ -48,6 +49,10 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except UserNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc + + except UserNameDuplicateError as exc: + raise web.HTTPConflict(reason=f"{exc}") from exc + except MissingGroupExtraPropertiesForProductError as exc: error_code = exc.error_code() user_error_msg = FMSG_MISSING_CONFIG_WITH_OEC.format(error_code=error_code) @@ -75,15 +80,19 @@ async def get_my_profile(request: web.Request) -> web.Response: return envelope_json_response(profile) -@routes.put(f"/{API_VTAG}/me", name="update_my_profile") +@routes.patch(f"/{API_VTAG}/me", name="update_my_profile") +@routes.put( + f"/{API_VTAG}/me", name="replace_my_profile" # deprecated. Use patch instead +) @login_required @permission_required("user.profile.update") @_handle_users_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) profile_update = await parse_request_body_as(ProfileUpdate, request) + await api.update_user_profile( - request.app, req_ctx.user_id, profile_update, as_patch=False + request.app, user_id=req_ctx.user_id, update=profile_update ) return web.json_response(status=status.HTTP_204_NO_CONTENT) 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..cd9de6a873c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -0,0 +1,47 @@ +from typing import Annotated, Any, Self + +from pydantic import BaseModel, ConfigDict, Field + +# +# DB models +# + + +def flatten_dict(d: dict, parent_key="", sep="_"): + items = [] + for key, value in d.items(): + new_key = f"{parent_key}{sep}{key}" if parent_key else key + if isinstance(value, dict): + # Recursively process nested dictionaries + items.extend(flatten_dict(value, new_key, sep=sep).items()) + else: + items.append((new_key, value)) + return dict(items) + + +class ToUserUpdateDB(BaseModel): + """ + Maps ProfileUpdate api-model into UserUpdate db-model + """ + + # NOTE: field names are UserDB columns + # NOTE: aliases are ProfileUpdate field names + + name: Annotated[str | None, Field(alias="user_name")] = None + first_name: str | None = None + last_name: str | None = None + + privacy_hide_fullname: bool | None = None + privacy_hide_email: bool | None = None + + model_config = ConfigDict(extra="forbid") + + @classmethod + def from_api(cls, profile_update) -> Self: + # The mapping of embed fields to flatten keys is done here + return cls.model_validate( + flatten_dict(profile_update.model_dump(exclude_unset=True, by_alias=False)) + ) + + def to_db(self) -> dict[str, Any]: + return self.model_dump(exclude_unset=True, by_alias=False) diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index 50dfdc4e12d..7fc2c138204 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -9,29 +9,39 @@ from collections import deque from typing import Any, NamedTuple, TypedDict +import simcore_postgres_database.errors as db_errors import sqlalchemy as sa from aiohttp import web from aiopg.sa.engine import Engine from aiopg.sa.result import RowProxy +from models_library.api_schemas_webserver.users import ( + ProfileGet, + ProfilePrivacyGet, + ProfileUpdate, +) from models_library.basic_types import IDStr from models_library.products import ProductName from models_library.users import GroupID, UserID from pydantic import EmailStr, TypeAdapter, ValidationError -from simcore_postgres_database.models.users import UserRole +from simcore_postgres_database.models.groups import GroupType, groups, user_to_groups +from simcore_postgres_database.models.users import UserRole, users from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, ) -from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_database_engine from ..groups.models import convert_groups_db_to_schema from ..login.storage import AsyncpgStorage, get_plugin_storage from ..security.api import clean_auth_policy_cache from . import _db from ._api import get_user_credentials, get_user_invoice_address, set_user_as_deleted +from ._models import ToUserUpdateDB from ._preferences_api import get_frontend_user_preferences_aggregation -from .exceptions import MissingGroupExtraPropertiesForProductError, UserNotFoundError -from .schemas import ProfileGet, ProfileUpdate +from .exceptions import ( + MissingGroupExtraPropertiesForProductError, + UserNameDuplicateError, + UserNotFoundError, +) _logger = logging.getLogger(__name__) @@ -40,7 +50,7 @@ def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) except ValidationError as err: - raise UserNotFoundError(uid=user_id) from err + raise UserNotFoundError(uid=user_id, user_id=user_id) from err async def get_user_profile( @@ -59,15 +69,12 @@ async def get_user_profile( async with engine.acquire() as conn: row: RowProxy + async for row in conn.execute( sa.select(users, groups, user_to_groups.c.access_rights) .select_from( - sa.join( - users, - sa.join( - user_to_groups, groups, user_to_groups.c.gid == groups.c.gid - ), - users.c.id == user_to_groups.c.uid, + users.join(user_to_groups, users.c.id == user_to_groups.c.uid).join( + groups, user_to_groups.c.gid == groups.c.gid ) ) .where(users.c.id == user_id) @@ -77,10 +84,13 @@ async def get_user_profile( if not user_profile: user_profile = { "id": row.users_id, + "user_name": row.users_name, "first_name": row.users_first_name, "last_name": row.users_last_name, "login": row.users_email, "role": row.users_role, + "privacy_hide_fullname": row.users_privacy_hide_fullname, + "privacy_hide_email": row.users_privacy_hide_email, "expiration_date": ( row.users_expires_at.date() if row.users_expires_at else None ), @@ -128,6 +138,7 @@ async def get_user_profile( return ProfileGet( id=user_profile["id"], + user_name=user_profile["user_name"], first_name=user_profile["first_name"], last_name=user_profile["last_name"], login=user_profile["login"], @@ -137,6 +148,10 @@ async def get_user_profile( "organizations": user_standard_groups, "all": all_group, }, + privacy=ProfilePrivacyGet( + hide_fullname=user_profile["privacy_hide_fullname"], + hide_email=user_profile["privacy_hide_email"], + ), preferences=preferences, **optional, ) @@ -144,32 +159,30 @@ async def get_user_profile( async def update_user_profile( app: web.Application, + *, user_id: UserID, update: ProfileUpdate, - *, - as_patch: bool = True, ) -> None: """ - Keyword Arguments: - as_patch -- set False if PUT and True if PATCH (default: {True}) - Raises: UserNotFoundError + UserNameAlreadyExistsError """ user_id = _parse_as_user(user_id) - async with get_database_engine(app).acquire() as conn: - to_update = update.model_dump( - include={ - "first_name", - "last_name", - }, - exclude_unset=as_patch, - ) - resp = await conn.execute( - users.update().where(users.c.id == user_id).values(**to_update) - ) - assert resp.rowcount == 1 # nosec + if updated_values := ToUserUpdateDB.from_api(update).to_db(): + async with get_database_engine(app).acquire() as conn: + query = users.update().where(users.c.id == user_id).values(**updated_values) + + try: + + resp = await conn.execute(query) + assert resp.rowcount == 1 # nosec + + except db_errors.UniqueViolation as err: + raise UserNameDuplicateError( + user_name=updated_values.get("name") + ) from err async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: diff --git a/services/web/server/src/simcore_service_webserver/users/exceptions.py b/services/web/server/src/simcore_service_webserver/users/exceptions.py index 39791ea39fe..653cfeca719 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -21,6 +21,10 @@ def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: A self.email = email +class UserNameDuplicateError(UsersBaseError): + msg_template = "Username {user_name} is already in use. Violates unique constraint" + + class TokenNotFoundError(UsersBaseError): msg_template = "Token for service {service_id} not found" 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 e2da8da8ed7..8ad46a5c317 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,16 +1,7 @@ -from datetime import date -from typing import Literal from uuid import UUID from models_library.api_schemas_webserver._base import OutputSchema -from models_library.api_schemas_webserver.groups import MyGroupsGet -from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences -from models_library.emails import LowerCaseEmailStr -from models_library.users import FirstNameStr, LastNameStr, UserID -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from simcore_postgres_database.models.users import UserRole - -from ..utils import gravatar_hash +from pydantic import BaseModel, ConfigDict, Field # @@ -41,83 +32,6 @@ class TokenCreate(ThirdPartyToken): ... -# -# PROFILE resource -# - - -class ProfileUpdate(BaseModel): - first_name: FirstNameStr | None = None - last_name: LastNameStr | None = None - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "first_name": "Pedro", - "last_name": "Crespo", - } - } - ) - - -class ProfileGet(BaseModel): - id: UserID - first_name: FirstNameStr | None = None - last_name: LastNameStr | None = None - login: LowerCaseEmailStr - role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] - groups: MyGroupsGet | None = None - gravatar_id: str | None = None - expiration_date: date | None = Field( - default=None, - description="If user has a trial account, it sets the expiration date, otherwise None", - alias="expirationDate", - ) - preferences: AggregatedPreferences - - model_config = ConfigDict( - # NOTE: old models have an hybrid between snake and camel cases! - # Should be unified at some point - populate_by_name=True, - json_schema_extra={ - "examples": [ - { - "id": 1, - "login": "bla@foo.com", - "role": "Admin", - "gravatar_id": "205e460b479e2e5b48aec07710c08d50", - "preferences": {}, - }, - { - "id": 42, - "login": "bla@foo.com", - "role": UserRole.ADMIN.value, - "expirationDate": "2022-09-14", - "preferences": {}, - }, - ] - }, - ) - - @model_validator(mode="before") - @classmethod - def _auto_generate_gravatar(cls, values): - gravatar_id = values.get("gravatar_id") - email = values.get("login") - if not gravatar_id and email: - values["gravatar_id"] = gravatar_hash(email) - return values - - @field_validator("role", mode="before") - @classmethod - def _to_upper_string(cls, v): - if isinstance(v, str): - return v.upper() - if isinstance(v, UserRole): - return v.name.upper() - return v - - # # Permissions # 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 a5670b5054e..8ff676476ee 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -1,3 +1,8 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + from copy import deepcopy from datetime import UTC, datetime from pprint import pformat @@ -5,12 +10,18 @@ import pytest from faker import Faker +from models_library.api_schemas_webserver.users import ( + ProfileGet, + ProfilePrivacyGet, + ProfileUpdate, +) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import BaseModel from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver.users.schemas import ProfileGet, ThirdPartyToken +from simcore_service_webserver.users._models import ToUserUpdateDB +from simcore_service_webserver.users.schemas import ThirdPartyToken @pytest.mark.parametrize( @@ -39,38 +50,46 @@ def test_user_models_examples( assert model_array_enveloped.error is None -def test_profile_get_expiration_date(faker: Faker): - fake_expiration = datetime.now(UTC) +@pytest.fixture +def fake_profile_get(faker: Faker) -> ProfileGet: + fake_profile: dict[str, Any] = faker.simple_profile() + first, last = fake_profile["name"].rsplit(maxsplit=1) - profile = ProfileGet( - id=1, - login=faker.email(), - role=UserRole.ADMIN, - expiration_date=fake_expiration.date(), + return ProfileGet( + id=faker.pyint(), + first_name=first, + last_name=last, + user_name=fake_profile["username"], + login=fake_profile["mail"], + role="USER", + privacy=ProfilePrivacyGet(hide_fullname=True, hide_email=True), preferences={}, ) + +def test_profile_get_expiration_date(fake_profile_get: ProfileGet): + fake_expiration = datetime.now(UTC) + + profile = fake_profile_get.model_copy( + update={"expiration_date": fake_expiration.date()} + ) + assert fake_expiration.date() == profile.expiration_date body = jsonable_encoder(profile.model_dump(exclude_unset=True, by_alias=True)) assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar(faker: Faker): +def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): - profile = ProfileGet( - id=faker.pyint(), - first_name=faker.first_name(), - last_name=faker.last_name(), - login=faker.email(), - role="USER", - preferences={}, - ) + profile = fake_profile_get.model_copy() envelope = Envelope[Any](data=profile) data = envelope.model_dump(**RESPONSE_MODEL_POLICY)["data"] - assert data["gravatar_id"] + assert ( + "gravatar_id" not in data + ), f"{ProfileGet.model_fields['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -81,7 +100,7 @@ def test_auto_compute_gravatar(faker: Faker): @pytest.mark.parametrize("user_role", [u.name for u in UserRole]) def test_profile_get_role(user_role: str): - for example in ProfileGet.model_config["json_schema_extra"]["examples"]: + for example in ProfileGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role m1 = ProfileGet(**data) @@ -95,10 +114,12 @@ def test_parsing_output_of_get_user_profile(): result_from_db_query_and_composition = { "id": 1, "login": "PtN5Ab0uv@guest-at-osparc.io", + "userName": "PtN5Ab0uv", "first_name": "PtN5Ab0uv", "last_name": "", "role": "Guest", "gravatar_id": "9d5e02c75fcd4bce1c8861f219f7f8a5", + "privacy": {"hide_email": True, "hide_fullname": False}, "groups": { "me": { "gid": 2, @@ -136,3 +157,25 @@ def test_parsing_output_of_get_user_profile(): profile = ProfileGet.model_validate(result_from_db_query_and_composition) assert "password" not in profile.model_dump(exclude_unset=True) + + +def test_mapping_update_models_from_rest_to_db(): + + profile_update = ProfileUpdate.model_validate( + # request payload + { + "first_name": "foo", + "userName": "foo1234", + "privacy": {"hideFullname": False}, + } + ) + + # to db + profile_update_db = ToUserUpdateDB.from_api(profile_update) + + # expected + assert profile_update_db.to_db() == { + "first_name": "foo", + "name": "foo1234", + "privacy_hide_fullname": False, + } diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index d99f8f1f297..762642dfb5c 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -9,6 +9,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker +from models_library.api_schemas_webserver.users import ProfileGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -31,7 +32,6 @@ get_plugin_settings, ) from simcore_service_webserver.login.storage import AsyncpgStorage -from simcore_service_webserver.users.schemas import ProfileGet @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py index 391053b40ac..4e2829c6fce 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users.py @@ -18,6 +18,7 @@ from aiopg.sa.connection import SAConnection from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo +from models_library.api_schemas_webserver.users import ProfileGet from models_library.generics import Envelope from psycopg2 import OperationalError from pytest_simcore.helpers.assert_checks import assert_status @@ -38,7 +39,6 @@ PreUserProfile, UserProfile, ) -from simcore_service_webserver.users.schemas import ProfileGet, ProfileUpdate @pytest.fixture @@ -59,16 +59,52 @@ def app_environment( "user_role,expected", [ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - (UserRole.GUEST, status.HTTP_200_OK), - (UserRole.USER, status.HTTP_200_OK), - (UserRole.TESTER, status.HTTP_200_OK), + *((r, status.HTTP_200_OK) for r in UserRole if r >= UserRole.GUEST), ], ) -async def test_get_profile( +async def test_access_rights_on_get_profile( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + url = client.app.router["get_my_profile"].url_for() + assert url.path == "/v0/me" + + resp = await client.get(f"{url}") + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + *((r, status.HTTP_204_NO_CONTENT) for r in UserRole if r >= UserRole.USER), + ], +) +async def test_access_update_profile( logged_user: UserInfoDict, client: TestClient, user_role: UserRole, expected: HTTPStatus, +): + assert client.app + + url = client.app.router["update_my_profile"].url_for() + assert url.path == "/v0/me" + + resp = await client.patch(f"{url}", json={"last_name": "Foo"}) + await assert_status(resp, expected) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_get_profile( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, primary_group: dict[str, Any], standard_groups: list[dict[str, Any]], all_group: dict[str, str], @@ -78,84 +114,171 @@ async def test_get_profile( url = client.app.router["get_my_profile"].url_for() assert url.path == "/v0/me" - resp = await client.get(url.path) - data, error = await assert_status(resp, expected) + resp = await client.get(f"{url}") + data, error = await assert_status(resp, status.HTTP_200_OK) - # check enveloped - e = Envelope[ProfileGet].model_validate(await resp.json()) - assert e.error == error - assert ( - e.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data - if e.data - else e.data == data - ) + resp_model = Envelope[ProfileGet].model_validate(await resp.json()) - if not error: - profile = ProfileGet.model_validate(data) - - product_group = { - "accessRights": {"delete": False, "read": False, "write": False}, - "description": "osparc product group", - "gid": 2, - "inclusionRules": {}, - "label": "osparc", - "thumbnail": None, - } - - assert profile.login == logged_user["email"] - assert profile.gravatar_id - assert profile.first_name == logged_user.get("first_name", None) - assert profile.last_name == logged_user.get("last_name", None) - assert profile.role == user_role.name - assert profile.groups - - got_profile_groups = profile.groups.model_dump( - **RESPONSE_MODEL_POLICY, mode="json" - ) - assert got_profile_groups["me"] == primary_group - assert got_profile_groups["all"] == all_group + assert resp_model.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data + assert resp_model.error is None - sorted_by_group_id = functools.partial(sorted, key=lambda d: d["gid"]) - assert sorted_by_group_id( - got_profile_groups["organizations"] - ) == sorted_by_group_id([*standard_groups, product_group]) + profile = resp_model.data - assert profile.preferences == await get_frontend_user_preferences_aggregation( - client.app, user_id=logged_user["id"], product_name="osparc" - ) + product_group = { + "accessRights": {"delete": False, "read": False, "write": False}, + "description": "osparc product group", + "gid": 2, + "inclusionRules": {}, + "label": "osparc", + "thumbnail": None, + } + assert profile.login == logged_user["email"] + assert profile.first_name == logged_user.get("first_name", None) + assert profile.last_name == logged_user.get("last_name", None) + assert profile.role == user_role.name + assert profile.groups -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - (UserRole.GUEST, status.HTTP_403_FORBIDDEN), - (UserRole.USER, status.HTTP_204_NO_CONTENT), - (UserRole.TESTER, status.HTTP_204_NO_CONTENT), - ], -) + got_profile_groups = profile.groups.model_dump(**RESPONSE_MODEL_POLICY, mode="json") + assert got_profile_groups["me"] == primary_group + assert got_profile_groups["all"] == all_group + + sorted_by_group_id = functools.partial(sorted, key=lambda d: d["gid"]) + assert sorted_by_group_id( + got_profile_groups["organizations"] + ) == sorted_by_group_id([*standard_groups, product_group]) + + assert profile.preferences == await get_frontend_user_preferences_aggregation( + client.app, user_id=logged_user["id"], product_name="osparc" + ) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_update_profile( logged_user: UserInfoDict, client: TestClient, user_role: UserRole, - expected: HTTPStatus, ): assert client.app + resp = await client.get("/v0/me") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + assert data["role"] == user_role.name + before = deepcopy(data) + url = client.app.router["update_my_profile"].url_for() assert url.path == "/v0/me" + resp = await client.patch( + f"{url}", + json={ + "last_name": "Foo", + }, + ) + _, error = await assert_status(resp, status.HTTP_204_NO_CONTENT) + + assert not error + + resp = await client.get("/v0/me") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + assert data["last_name"] == "Foo" + + def _copy(data: dict, exclude: set) -> dict: + return {k: v for k, v in data.items() if k not in exclude} + + exclude = {"last_name"} + assert _copy(data, exclude) == _copy(before, exclude) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_profile_workflow( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, +): + assert client.app + + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + my_profile = ProfileGet.model_validate(data) + + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "first_name": "Odei", # NOTE: still not camecase! + "userName": "odei123", + "privacy": {"hideFullname": False}, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + updated_profile = ProfileGet.model_validate(data) + + assert updated_profile.first_name != my_profile.first_name + assert updated_profile.last_name == my_profile.last_name + assert updated_profile.login == my_profile.login - resp = await client.put(url.path, json={"last_name": "Foo"}) - _, error = await assert_status(resp, expected) + assert updated_profile.user_name != my_profile.user_name + assert updated_profile.user_name == "odei123" - if not error: - resp = await client.get(f"{url}") - data, _ = await assert_status(resp, status.HTTP_200_OK) + assert updated_profile.privacy != my_profile.privacy + assert updated_profile.privacy.hide_email == my_profile.privacy.hide_email + assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname - # This is a PUT! i.e. full replace of profile variable fields! - assert data["first_name"] == ProfileUpdate.model_fields["first_name"].default - assert data["last_name"] == "Foo" - assert data["role"] == user_role.name + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize("invalid_username", ["", "_foo", "superadmin", "foo..-123"]) +async def test_update_wrong_user_name( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + invalid_username: str, +): + assert client.app + + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "userName": invalid_username, + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_update_existing_user_name( + user: UserInfoDict, + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, +): + assert client.app + + other_username = user["name"] + assert other_username != logged_user["name"] + + # update with SAME username (i.e. existing) + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + assert data["userName"] == logged_user["name"] + + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "userName": other_username, + }, + ) + await assert_status(resp, status.HTTP_409_CONFLICT) @pytest.fixture @@ -219,7 +342,7 @@ async def test_get_profile_with_failing_db_connection( (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), ], ) -async def test_only_product_owners_can_access_users_api( +async def test_access_rights_on_search_users_only_product_owners_can_access( client: TestClient, logged_user: UserInfoDict, expected: HTTPStatus, diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py index af534e9a481..6f8853337ee 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py @@ -424,7 +424,6 @@ async def test_dispatch_study_anonymously( data, _ = await assert_status(response, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project 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 3cb82c2bf20..cff892d7f00 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 @@ -282,7 +282,6 @@ async def test_access_study_anonymously( data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project @@ -448,7 +447,6 @@ async def _test_guest_user_workflow(request_index): data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project