diff --git a/api/specs/webserver/openapi-user.yaml b/api/specs/webserver/openapi-user.yaml deleted file mode 100644 index ee120802c05..00000000000 --- a/api/specs/webserver/openapi-user.yaml +++ /dev/null @@ -1,252 +0,0 @@ -paths: - /me: - get: - operationId: get_my_profile - tags: - - user - responses: - "200": - description: current user profile - content: - application/json: - schema: - $ref: "./components/schemas/me.yaml#/ProfileEnveloped" - default: - $ref: "#/components/responses/DefaultErrorResponse" - put: - operationId: update_my_profile - tags: - - user - requestBody: - content: - application/json: - schema: - $ref: "./components/schemas/me.yaml#/ProfileUpdate" - responses: - "204": - description: updated profile - default: - $ref: "#/components/responses/DefaultErrorResponse" - - /me/tokens: - get: - summary: List tokens - operationId: list_tokens - tags: - - user - responses: - "200": - description: list of tokens - content: - application/json: - schema: - $ref: "./components/schemas/me.yaml#/TokensArrayEnveloped" - default: - $ref: "#/components/responses/DefaultErrorResponse" - post: - summary: Create tokens - operationId: create_tokens - tags: - - user - requestBody: - content: - application/json: - schema: - # FIXME: this body should NOT be enveloped! - $ref: "./components/schemas/me.yaml#/TokenEnveloped" - responses: - "201": - description: token created - content: - application/json: - schema: - $ref: "./components/schemas/me.yaml#/TokenEnveloped" - - default: - $ref: "#/components/responses/DefaultErrorResponse" - - /me/tokens/{service}: - parameters: - - name: service - in: path - required: true - schema: - type: string - get: - summary: Gets specific token - operationId: get_token - tags: - - user - responses: - "200": - description: got detailed token - content: - application/json: - schema: - $ref: "./components/schemas/me.yaml#/TokenEnveloped" - put: - summary: Updates token - operationId: update_token - tags: - - user - responses: - "204": - description: token has been successfully updated - delete: - summary: Delete token - operationId: delete_token - tags: - - user - responses: - "204": - description: token has been successfully deleted - - /me/notifications: - get: - tags: - - user - summary: List of Notifications for the specific user - operationId: get_user_notifications - responses: - "200": - description: List of Notifications - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationsOutListEnveloped" - default: - $ref: "#/components/responses/DefaultErrorResponse" - post: - tags: - - user - summary: Submit a new Notification - operationId: post_user_notification - requestBody: - required: true - description: the new notification - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationIn" - responses: - "204": - description: Notification registered - default: - $ref: "#/components/responses/DefaultErrorResponse" - - /me/notifications/{id}: - parameters: - - name: id - in: path - required: true - schema: - type: string - patch: - tags: - - user - summary: Update a Notification - operationId: update_user_notification - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationUpdate" - responses: - "204": - description: All good - default: - $ref: "./openapi.yaml#/components/responses/DefaultErrorResponse" - -components: - schemas: - NotificationIn: - type: object - required: - - user_id - - category - - actionable_path - - title - - text - - date - properties: - user_id: - type: string - description: >- - the user that will receive the notification - example: "123" - category: - type: string - enum: - - NEW_ORGANIZATION - - STUDY_SHARED - - TEMPLATE_SHARED - - ANNOTATION_NOTE - description: >- - notification type, the frontend will use this to decorate the notification - example: new_organization - actionable_path: - type: string - description: >- - the frontend will use this information to trigger an action when the user click on it - example: organization/123 - title: - type: string - description: >- - the notification title to show in the frontend - example: New Organization - text: - type: string - description: >- - the notification text to show in the frontend - example: You are now part of Dummy Organization! - date: - type: string - format: date-time - description: >- - when it was created - - NotificationOut: - # extend NotificationIn - allOf: - - $ref: "#/components/schemas/NotificationIn" - - type: "object" - - required: - - id - - read - properties: - id: - type: string - description: >- - notification id - example: "123" - read: - type: boolean - description: >- - wether the notification has been read - - NotificationsOutListEnveloped: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/NotificationOut" - error: - nullable: true - default: null - - NotificationUpdate: - type: object - required: - - read - properties: - read: - type: boolean - description: >- - notification has been read - - responses: - DefaultErrorResponse: - $ref: "./openapi.yaml#/components/responses/DefaultErrorResponse" diff --git a/api/specs/webserver/openapi-users.yaml b/api/specs/webserver/openapi-users.yaml new file mode 100644 index 00000000000..66707d8daf8 --- /dev/null +++ b/api/specs/webserver/openapi-users.yaml @@ -0,0 +1,479 @@ +paths: + /me: + get: + tags: + - user + summary: Get User Profile + operationId: get_my_profile + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ProfileGet_' + put: + tags: + - user + summary: Update My Profile + operationId: update_my_profile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileUpdate' + required: true + responses: + '204': + description: Successful Response + /me/tokens: + get: + tags: + - user + summary: List Tokens + operationId: list_tokens + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.users.schemas.Token__' + post: + tags: + - user + summary: Create Token + operationId: create_token + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenCreate' + required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Token_' + /me/tokens/{service}: + get: + tags: + - user + summary: Get Token + operationId: get_token + parameters: + - required: true + schema: + title: Service + type: string + name: service + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Token_' + delete: + tags: + - user + summary: Delete Token + operationId: delete_token + parameters: + - required: true + schema: + title: Service + type: string + name: service + in: path + responses: + '204': + description: Successful Response + /me/notifications: + get: + tags: + - user + summary: List User Notifications + operationId: list_user_notifications + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_simcore_service_webserver.users._notifications.UserNotification__' + post: + tags: + - user + summary: Create User Notification + operationId: create_user_notification + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserNotificationCreate' + required: true + responses: + '204': + description: Successful Response + /me/notifications/{notification_id}: + patch: + tags: + - user + summary: Mark Notification As Read + operationId: mark_notification_as_read + parameters: + - required: true + schema: + title: Notification Id + type: string + name: notification_id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserNotificationPatch' + required: true + responses: + '204': + description: Successful Response +components: + schemas: + AllUsersGroups: + title: AllUsersGroups + type: object + properties: + me: + $ref: '#/components/schemas/UsersGroup' + organizations: + title: Organizations + type: array + items: + $ref: '#/components/schemas/UsersGroup' + all: + $ref: '#/components/schemas/UsersGroup' + product: + $ref: '#/components/schemas/UsersGroup' + example: + me: + gid: '27' + label: A user + description: A very special user + accessRights: + read: true + write: true + delete: true + organizations: + - gid: '15' + label: ITIS Foundation + description: The Foundation for Research on Information Technologies in + Society + accessRights: + read: true + write: false + delete: false + - gid: '16' + label: Blue Fundation + description: Some foundation + accessRights: + read: true + write: false + delete: false + all: + gid: '0' + label: All + description: Open to all users + accessRights: + read: true + write: false + delete: false + Envelope_ProfileGet_: + title: Envelope[ProfileGet] + type: object + properties: + data: + $ref: '#/components/schemas/ProfileGet' + error: + title: Error + Envelope_Token_: + title: Envelope[Token] + type: object + properties: + data: + $ref: '#/components/schemas/Token' + error: + title: Error + Envelope_list_simcore_service_webserver.users._notifications.UserNotification__: + title: Envelope[list[simcore_service_webserver.users._notifications.UserNotification]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/UserNotification' + error: + title: Error + Envelope_list_simcore_service_webserver.users.schemas.Token__: + title: Envelope[list[simcore_service_webserver.users.schemas.Token]] + type: object + properties: + data: + title: Data + type: array + items: + $ref: '#/components/schemas/Token' + error: + title: Error + GroupAccessRights: + title: GroupAccessRights + required: + - read + - write + - delete + type: object + properties: + read: + title: Read + type: boolean + write: + title: Write + type: boolean + delete: + title: Delete + type: boolean + description: defines acesss rights for the user + NotificationCategory: + title: NotificationCategory + enum: + - NEW_ORGANIZATION + - STUDY_SHARED + - TEMPLATE_SHARED + - ANNOTATION_NOTE + type: string + description: An enumeration. + ProfileGet: + title: ProfileGet + required: + - id + - login + - role + type: object + properties: + first_name: + title: First Name + type: string + last_name: + title: Last Name + type: string + id: + title: Id + exclusiveMinimum: true + type: integer + minimum: 0 + login: + title: Login + type: string + format: email + role: + title: Role + enum: + - Anonymous + - Guest + - User + - Tester + - Admin + type: string + groups: + $ref: '#/components/schemas/AllUsersGroups' + gravatar_id: + title: Gravatar Id + type: string + expirationDate: + title: Expirationdate + type: string + description: If user has a trial account, it sets the expiration date, otherwise + None + format: date + ProfileUpdate: + title: ProfileUpdate + type: object + properties: + first_name: + title: First Name + type: string + last_name: + title: Last Name + type: string + example: + first_name: Pedro + last_name: Crespo + Token: + title: Token + required: + - service + - token_key + type: object + properties: + service: + title: Service + type: string + description: uniquely identifies the service where this token is used + token_key: + title: Token Key + type: string + description: basic token key + format: uuid + token_secret: + title: Token Secret + type: string + format: uuid + description: Tokens used to access third-party services connected to osparc + (e.g. pennsieve, scicrunch, etc) + example: + service: github-api-v1 + token_key: 5f21abf5-c596-47b7-bfd1-c0e436ef1107 + TokenCreate: + title: TokenCreate + required: + - service + - token_key + type: object + properties: + service: + title: Service + type: string + description: uniquely identifies the service where this token is used + token_key: + title: Token Key + type: string + description: basic token key + format: uuid + token_secret: + title: Token Secret + type: string + format: uuid + description: Tokens used to access third-party services connected to osparc + (e.g. pennsieve, scicrunch, etc) + example: + service: github-api-v1 + token_key: 5f21abf5-c596-47b7-bfd1-c0e436ef1107 + UserNotification: + title: UserNotification + required: + - user_id + - category + - actionable_path + - title + - text + - date + - id + - read + type: object + properties: + user_id: + title: User Id + exclusiveMinimum: true + type: integer + minimum: 0 + category: + $ref: '#/components/schemas/NotificationCategory' + actionable_path: + title: Actionable Path + type: string + title: + title: Title + type: string + text: + title: Text + type: string + date: + title: Date + type: string + format: date-time + id: + title: Id + type: string + read: + title: Read + type: boolean + UserNotificationCreate: + title: UserNotificationCreate + required: + - user_id + - category + - actionable_path + - title + - text + - date + type: object + properties: + user_id: + title: User Id + exclusiveMinimum: true + type: integer + minimum: 0 + category: + $ref: '#/components/schemas/NotificationCategory' + actionable_path: + title: Actionable Path + type: string + title: + title: Title + type: string + text: + title: Text + type: string + date: + title: Date + type: string + format: date-time + UserNotificationPatch: + title: UserNotificationPatch + required: + - read + type: object + properties: + read: + title: Read + type: boolean + UsersGroup: + title: UsersGroup + required: + - gid + - label + - description + - accessRights + type: object + properties: + gid: + title: Gid + type: integer + description: the group ID + label: + title: Label + type: string + description: the group name + description: + title: Description + type: string + description: the group description + thumbnail: + title: Thumbnail + maxLength: 65536 + minLength: 1 + type: string + description: url to the group thumbnail + format: uri + accessRights: + $ref: '#/components/schemas/GroupAccessRights' + inclusionRules: + title: Inclusionrules + type: object + additionalProperties: + type: string + description: Maps user's column and regular expression diff --git a/api/specs/webserver/openapi.yaml b/api/specs/webserver/openapi.yaml index 03adb9bfa39..af35cef2ec8 100644 --- a/api/specs/webserver/openapi.yaml +++ b/api/specs/webserver/openapi.yaml @@ -111,19 +111,19 @@ paths: # USER SETTINGS ------------------------------------------------------------------ /me: - $ref: "./openapi-user.yaml#/paths/~1me" + $ref: "./openapi-users.yaml#/paths/~1me" /me/tokens: - $ref: "./openapi-user.yaml#/paths/~1me~1tokens" + $ref: "./openapi-users.yaml#/paths/~1me~1tokens" /me/tokens/{service}: - $ref: "./openapi-user.yaml#/paths/~1me~1tokens~1{service}" + $ref: "./openapi-users.yaml#/paths/~1me~1tokens~1{service}" /me/notifications: - $ref: "./openapi-user.yaml#/paths/~1me~1notifications" + $ref: "./openapi-users.yaml#/paths/~1me~1notifications" - /me/notifications/{id}: - $ref: "./openapi-user.yaml#/paths/~1me~1notifications~1{id}" + /me/notifications/{notification_id}: + $ref: "./openapi-users.yaml#/paths/~1me~1notifications~1{notification_id}" # GROUP SETTINGS ------------------------------------------------------------------ diff --git a/api/specs/webserver/scripts/_common.py b/api/specs/webserver/scripts/_common.py index 01ccb45f4ab..5cfbebbb93c 100644 --- a/api/specs/webserver/scripts/_common.py +++ b/api/specs/webserver/scripts/_common.py @@ -62,11 +62,11 @@ def create_openapi_specs( # Remove these sections for section in ("info", "openapi"): - openapi.pop(section) + openapi.pop(section, None) schemas = openapi["components"]["schemas"] for section in ("HTTPValidationError", "ValidationError"): - schemas.pop(section) + schemas.pop(section, None) # Removes default response 422 if drop_fastapi_default_422: @@ -94,7 +94,6 @@ class ParamSpec(NamedTuple): def assert_handler_signature_against_model( handler: Callable, model_cls: type[BaseModel] ): - sig = inspect.signature(handler) # query, path and body parameters diff --git a/api/specs/webserver/scripts/openapi_users.py b/api/specs/webserver/scripts/openapi_users.py new file mode 100644 index 00000000000..6ccdb032fc3 --- /dev/null +++ b/api/specs/webserver/scripts/openapi_users.py @@ -0,0 +1,137 @@ +""" Helper script to generate OAS automatically +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from enum import Enum +from typing import Annotated + +from fastapi import Depends, FastAPI, status +from models_library.generics import Envelope +from simcore_service_webserver.users._handlers import ( + _NotificationPathParams, + _TokenPathParams, +) +from simcore_service_webserver.users._notifications import ( + UserNotification, + UserNotificationCreate, + UserNotificationPatch, +) +from simcore_service_webserver.users.schemas import ( + ProfileGet, + ProfileUpdate, + Token, + TokenCreate, +) + +# from simcore_service_webserver.users._handlers import + +app = FastAPI(redoc_url=None) + +TAGS: list[str | Enum] = [ + "user", +] + + +@app.get( + "/me", + response_model=Envelope[ProfileGet], + tags=TAGS, + operation_id="get_my_profile", +) +async def get_user_profile(): + ... + + +@app.put( + "/me", + status_code=status.HTTP_204_NO_CONTENT, + tags=TAGS, + operation_id="update_my_profile", +) +async def update_my_profile(profile: ProfileUpdate): + ... + + +@app.get( + "/me/tokens", + response_model=Envelope[list[Token]], + tags=TAGS, + operation_id="list_tokens", +) +async def list_tokens(): + ... + + +@app.post( + "/me/tokens", + response_model=Envelope[Token], + status_code=status.HTTP_201_CREATED, + tags=TAGS, + operation_id="create_token", +) +async def create_token(token: TokenCreate): + ... + + +@app.get( + "/me/tokens/{service}", + response_model=Envelope[Token], + tags=TAGS, + operation_id="get_token", +) +async def get_token(params: Annotated[_TokenPathParams, Depends()]): + ... + + +@app.delete( + "/me/tokens/{service}", + status_code=status.HTTP_204_NO_CONTENT, + tags=TAGS, + operation_id="delete_token", +) +async def delete_token(params: Annotated[_TokenPathParams, Depends()]): + ... + + +@app.get( + "/me/notifications", + response_model=Envelope[list[UserNotification]], + tags=TAGS, + operation_id="list_user_notifications", +) +async def list_user_notifications(): + ... + + +@app.post( + "/me/notifications", + status_code=status.HTTP_204_NO_CONTENT, + tags=TAGS, + operation_id="create_user_notification", +) +async def create_user_notification(notification: UserNotificationCreate): + ... + + +@app.patch( + "/me/notifications/{notification_id}", + status_code=status.HTTP_204_NO_CONTENT, + tags=TAGS, + operation_id="mark_notification_as_read", +) +async def mark_notification_as_read( + params: Annotated[_NotificationPathParams, Depends()], + notification: UserNotificationPatch, +): + ... + + +if __name__ == "__main__": + from _common import CURRENT_DIR, create_openapi_specs + + create_openapi_specs(app, CURRENT_DIR.parent / "openapi-users.yaml") diff --git a/services/docker-compose.local.yml b/services/docker-compose.local.yml index 02e98fc161f..e73be73f8b9 100644 --- a/services/docker-compose.local.yml +++ b/services/docker-compose.local.yml @@ -70,6 +70,7 @@ services: webserver: environment: + - GUNICORN_CMD_ARGS=--timeout=0 - SC_BOOT_MODE=${SC_BOOT_MODE:-default} - REST_SWAGGER_API_DOC_ENABLED=1 ports: diff --git a/services/docker-compose.yml b/services/docker-compose.yml index d02b2e86bf0..a17f06890b4 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -270,6 +270,9 @@ services: DIRECTOR_PORT: ${DIRECTOR_PORT:-8080} DIRECTOR_V2_HOST: ${DIRECTOR_V2_HOST:-director-v2} DIRECTOR_V2_PORT: ${DIRECTOR_V2_PORT:-8000} + # see [https://docs.gunicorn.org/en/stable/settings.html#timeout], + # since we have the docker healthcheck already, this should be ok + GUNICORN_CMD_ARGS: --timeout=180 LOG_FORMAT_LOCAL_DEV_ENABLED: ${LOG_FORMAT_LOCAL_DEV_ENABLED} STORAGE_HOST: ${STORAGE_HOST:-storage} STORAGE_PORT: ${STORAGE_PORT:-8080} 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 cfc9d86108a..fb570d48ffe 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 @@ -1043,219 +1043,377 @@ paths: description: not enough permissions to delete a key /me: get: - operationId: get_my_profile tags: - user + summary: Get User Profile + operationId: get_my_profile responses: '200': - description: current user profile + description: Successful Response content: application/json: schema: + title: 'Envelope[ProfileGet]' type: object - required: - - data properties: data: - allOf: - - $ref: '#/paths/~1me/put/requestBody/content/application~1json/schema/allOf/0' - - type: object + title: ProfileGet + required: + - id + - login + - role + type: object + properties: + first_name: + title: First Name + type: string + last_name: + title: Last Name + type: string + id: + title: Id + exclusiveMinimum: true + type: integer + minimum: 0 + login: + title: Login + type: string + format: email + role: + title: Role + enum: + - Anonymous + - Guest + - User + - Tester + - Admin + type: string + groups: + title: AllUsersGroups + type: object properties: - id: - type: integer - login: - type: string - format: email - role: - type: string - groups: - $ref: '#/paths/~1groups/get/responses/200/content/application~1json/schema/properties/data' - gravatar_id: - type: string - expirationDate: - type: string - format: date - description: 'If user has a trial account, it sets the expiration date, otherwise None' + me: + title: UsersGroup + required: + - gid + - label + - description + - accessRights + type: object + properties: + gid: + title: Gid + type: integer + description: the group ID + label: + title: Label + type: string + description: the group name + description: + title: Description + type: string + description: the group description + thumbnail: + title: Thumbnail + maxLength: 65536 + minLength: 1 + type: string + description: url to the group thumbnail + format: uri + accessRights: + title: GroupAccessRights + required: + - read + - write + - delete + type: object + properties: + read: + title: Read + type: boolean + write: + title: Write + type: boolean + delete: + title: Delete + type: boolean + description: defines acesss rights for the user + inclusionRules: + title: Inclusionrules + type: object + additionalProperties: + type: string + description: Maps user's column and regular expression + organizations: + title: Organizations + type: array + items: + $ref: '#/paths/~1me/get/responses/200/content/application~1json/schema/properties/data/properties/groups/properties/me' + all: + $ref: '#/paths/~1me/get/responses/200/content/application~1json/schema/properties/data/properties/groups/properties/me' + product: + $ref: '#/paths/~1me/get/responses/200/content/application~1json/schema/properties/data/properties/groups/properties/me' + example: + me: + gid: '27' + label: A user + description: A very special user + accessRights: + read: true + write: true + delete: true + organizations: + - gid: '15' + label: ITIS Foundation + description: The Foundation for Research on Information Technologies in Society + accessRights: + read: true + write: false + delete: false + - gid: '16' + label: Blue Fundation + description: Some foundation + accessRights: + read: true + write: false + delete: false + all: + gid: '0' + label: All + description: Open to all users + accessRights: + read: true + write: false + delete: false + gravatar_id: + title: Gravatar Id + type: string + expirationDate: + title: Expirationdate + type: string + description: 'If user has a trial account, it sets the expiration date, otherwise None' + format: date error: - nullable: true - default: null - default: - $ref: '#/components/responses/DefaultErrorResponse' + title: Error put: - operationId: update_my_profile tags: - user + summary: Update My Profile + operationId: update_my_profile requestBody: content: application/json: schema: - allOf: - - type: object - properties: - first_name: - type: string - last_name: - type: string - example: - first_name: Pedro - last_name: Crespo + title: ProfileUpdate + type: object + properties: + first_name: + title: First Name + type: string + last_name: + title: Last Name + type: string + example: + first_name: Pedro + last_name: Crespo + required: true responses: '204': - description: updated profile - default: - $ref: '#/components/responses/DefaultErrorResponse' + description: Successful Response /me/tokens: get: - summary: List tokens - operationId: list_tokens tags: - user + summary: List Tokens + operationId: list_tokens responses: '200': - description: list of tokens + description: Successful Response content: application/json: schema: + title: 'Envelope[list[simcore_service_webserver.users.schemas.Token]]' type: object - required: - - data properties: data: + title: Data type: array items: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema/properties/data' + $ref: '#/paths/~1me~1tokens/post/responses/201/content/application~1json/schema/properties/data' error: - nullable: true - default: null - default: - $ref: '#/components/responses/DefaultErrorResponse' + title: Error post: - summary: Create tokens - operationId: create_tokens tags: - user + summary: Create Token + operationId: create_token requestBody: content: application/json: schema: - type: object + title: TokenCreate required: - - data + - service + - token_key + type: object properties: - data: - description: api keys for third party services - type: object - properties: - service: - description: uniquely identifies the service where this token is used - type: string - token_key: - description: basic token key - type: string - format: uuid - token_secret: - type: string - format: uuid - required: - - service - - token_key - error: - nullable: true - default: null + service: + title: Service + type: string + description: uniquely identifies the service where this token is used + token_key: + title: Token Key + type: string + description: basic token key + format: uuid + token_secret: + title: Token Secret + type: string + format: uuid + description: 'Tokens used to access third-party services connected to osparc (e.g. pennsieve, scicrunch, etc)' + example: + service: github-api-v1 + token_key: 5f21abf5-c596-47b7-bfd1-c0e436ef1107 + required: true responses: '201': - description: token created + description: Successful Response content: application/json: schema: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema' - default: - $ref: '#/components/responses/DefaultErrorResponse' + title: 'Envelope[Token]' + type: object + properties: + data: + title: Token + required: + - service + - token_key + type: object + properties: + service: + title: Service + type: string + description: uniquely identifies the service where this token is used + token_key: + title: Token Key + type: string + description: basic token key + format: uuid + token_secret: + title: Token Secret + type: string + format: uuid + description: 'Tokens used to access third-party services connected to osparc (e.g. pennsieve, scicrunch, etc)' + example: + service: github-api-v1 + token_key: 5f21abf5-c596-47b7-bfd1-c0e436ef1107 + error: + title: Error '/me/tokens/{service}': - parameters: - - name: service - in: path - required: true - schema: - type: string get: - summary: Gets specific token - operationId: get_token tags: - user + summary: Get Token + operationId: get_token + parameters: + - required: true + schema: + title: Service + type: string + name: service + in: path responses: '200': - description: got detailed token + description: Successful Response content: application/json: schema: - $ref: '#/paths/~1me~1tokens/post/requestBody/content/application~1json/schema' - put: - summary: Updates token - operationId: update_token - tags: - - user - responses: - '204': - description: token has been successfully updated + $ref: '#/paths/~1me~1tokens/post/responses/201/content/application~1json/schema' delete: - summary: Delete token - operationId: delete_token tags: - user + summary: Delete Token + operationId: delete_token + parameters: + - required: true + schema: + title: Service + type: string + name: service + in: path responses: '204': - description: token has been successfully deleted + description: Successful Response /me/notifications: get: tags: - user - summary: List of Notifications for the specific user - operationId: get_user_notifications + summary: List User Notifications + operationId: list_user_notifications responses: '200': - description: List of Notifications + description: Successful Response content: application/json: schema: + title: 'Envelope[list[simcore_service_webserver.users._notifications.UserNotification]]' type: object - required: - - data properties: data: + title: Data type: array items: - allOf: - - $ref: '#/paths/~1me~1notifications/post/requestBody/content/application~1json/schema' - - type: object - - required: - - id - - read - properties: - id: - type: string - description: notification id - example: '123' - read: - type: boolean - description: wether the notification has been read + title: UserNotification + required: + - user_id + - category + - actionable_path + - title + - text + - date + - id + - read + type: object + properties: + user_id: + title: User Id + exclusiveMinimum: true + type: integer + minimum: 0 + category: + $ref: '#/paths/~1me~1notifications/post/requestBody/content/application~1json/schema/properties/category' + actionable_path: + title: Actionable Path + type: string + title: + title: Title + type: string + text: + title: Text + type: string + date: + title: Date + type: string + format: date-time + id: + title: Id + type: string + read: + title: Read + type: boolean error: - nullable: true - default: null - default: - $ref: '#/components/responses/DefaultErrorResponse' + title: Error post: tags: - user - summary: Submit a new Notification - operationId: post_user_notification + summary: Create User Notification + operationId: create_user_notification requestBody: - required: true - description: the new notification content: application/json: schema: - type: object + title: UserNotificationCreate required: - user_id - category @@ -1263,69 +1421,68 @@ paths: - title - text - date + type: object properties: user_id: - type: string - description: the user that will receive the notification - example: '123' + title: User Id + exclusiveMinimum: true + type: integer + minimum: 0 category: - type: string + title: NotificationCategory enum: - NEW_ORGANIZATION - STUDY_SHARED - TEMPLATE_SHARED - ANNOTATION_NOTE - description: 'notification type, the frontend will use this to decorate the notification' - example: new_organization + type: string + description: An enumeration. actionable_path: + title: Actionable Path type: string - description: the frontend will use this information to trigger an action when the user click on it - example: organization/123 title: + title: Title type: string - description: the notification title to show in the frontend - example: New Organization text: + title: Text type: string - description: the notification text to show in the frontend - example: You are now part of Dummy Organization! date: + title: Date type: string format: date-time - description: when it was created + required: true responses: '204': - description: Notification registered - default: - $ref: '#/components/responses/DefaultErrorResponse' - '/me/notifications/{id}': - parameters: - - name: id - in: path - required: true - schema: - type: string + description: Successful Response + '/me/notifications/{notification_id}': patch: tags: - user - summary: Update a Notification - operationId: update_user_notification + summary: Mark Notification As Read + operationId: mark_notification_as_read + parameters: + - required: true + schema: + title: Notification Id + type: string + name: notification_id + in: path requestBody: content: application/json: schema: - type: object + title: UserNotificationPatch required: - read + type: object properties: read: + title: Read type: boolean - description: notification has been read + required: true responses: '204': - description: All good - default: - $ref: '#/components/responses/DefaultErrorResponse' + description: Successful Response /groups: get: summary: List my groups @@ -1879,7 +2036,13 @@ paths: - required: false schema: allOf: - - $ref: '#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1%7Bfile_id%7D/put/parameters/3/schema/allOf/0' + - title: LinkType + enum: + - PRESIGNED + - S3 + type: string + description: An enumeration. + default: PRESIGNED name: link_type in: query responses: @@ -1937,13 +2100,7 @@ paths: - required: false schema: allOf: - - title: LinkType - enum: - - PRESIGNED - - S3 - type: string - description: An enumeration. - default: PRESIGNED + - $ref: '#/paths/~1storage~1locations~1%7Blocation_id%7D~1files~1%7Bfile_id%7D/get/parameters/2/schema/allOf/0' name: link_type in: query - required: false diff --git a/services/web/server/src/simcore_service_webserver/security/_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_access_roles.py index 2f99923e1ab..a571a995698 100644 --- a/services/web/server/src/simcore_service_webserver/security/_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_access_roles.py @@ -35,6 +35,8 @@ class PermissionDict(TypedDict, total=False): "project.update", "storage.locations.*", "storage.files.*", + "user.notifications.read", + "user.notifications.update", "groups.read", "project.open", "project.read", @@ -63,6 +65,7 @@ class PermissionDict(TypedDict, total=False): "project.tag.*", "user.profile.update", "user.apikey.*", + "user.notifications.write", "user.tokens.*", "groups.*", "tag.crud.*", 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 9747b269a35..ec967672591 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -1,15 +1,16 @@ import functools -from typing import Any import redis.asyncio as aioredis from aiohttp import web -from models_library.generics import Envelope -from pydantic import BaseModel +from models_library.users import UserID +from pydantic import BaseModel, Field +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) from servicelib.aiohttp.typing_extension import Handler -from servicelib.json_serialization import json_dumps from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.request_keys import RQT_USERID_KEY -from servicelib.rest_constants import RESPONSE_MODEL_POLICY from .._meta import API_VTAG from ..login.decorators import login_required @@ -21,43 +22,53 @@ MAX_NOTIFICATIONS_FOR_USER_TO_KEEP, MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, UserNotification, + UserNotificationCreate, + UserNotificationPatch, get_notification_key, ) from .exceptions import TokenNotFoundError, UserNotFoundError -from .schemas import ProfileGet, ProfileUpdate +from .schemas import ProfileGet, ProfileUpdate, TokenCreate +routes = web.RouteTableDef() -# me/ ----------------------------------------------------------- -@login_required -async def get_my_profile(request: web.Request): - # NOTE: ONLY login required to see its profile. E.g. anonymous can never see its profile - uid = request[RQT_USERID_KEY] - try: - profile: ProfileGet = await api.get_user_profile(request.app, uid) - return web.Response( - text=Envelope[ProfileGet](data=profile).json(**RESPONSE_MODEL_POLICY), - content_type=MIMETYPE_APPLICATION_JSON, - ) - except UserNotFoundError as exc: - # NOTE: invalid user_id could happen due to timed-cache in AuthorizationPolicy - raise web.HTTPNotFound(reason="Could not find profile!") from exc +class _RequestContext(BaseModel): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) + + +def _handle_users_exceptions(handler: Handler): + @functools.wraps(handler) + async def wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except UserNotFoundError as exc: + raise web.HTTPNotFound(reason=f"{exc}") from exc + return wrapper + +@routes.get(f"/{API_VTAG}/me", name="get_my_profile") @login_required -@permission_required("user.profile.update") -async def update_my_profile(request: web.Request): - uid = request[RQT_USERID_KEY] - body = await request.json() - updates = ProfileUpdate.parse_obj(body) +@_handle_users_exceptions +async def get_my_profile(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + profile: ProfileGet = await api.get_user_profile(request.app, req_ctx.user_id) + return envelope_json_response(profile) + - await api.update_user_profile(request.app, uid, updates) +@routes.put(f"/{API_VTAG}/me", name="update_my_profile") +@login_required +@permission_required("user.profile.update") +@_handle_users_exceptions +async def update_my_profile(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + profile_update = await parse_request_body_as(ProfileUpdate, request) + await api.update_user_profile(request.app, req_ctx.user_id, profile_update) raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) # me/tokens/ ------------------------------------------------------ - - def _handle_tokens_errors(handler: Handler): @functools.wraps(handler) async def _wrapper(request: web.Request) -> web.StreamResponse: @@ -72,60 +83,52 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: return _wrapper +@routes.get(f"/{API_VTAG}/me/tokens", name="list_tokens") @login_required +@_handle_tokens_errors @permission_required("user.tokens.*") -async def create_tokens(request: web.Request): - uid = request[RQT_USERID_KEY] - body = await request.json() - - await _tokens.create_token(request.app, uid, body) - raise web.HTTPCreated( - text=json_dumps({"data": body}), content_type=MIMETYPE_APPLICATION_JSON - ) - - -@login_required -@permission_required("user.tokens.*") -async def list_tokens(request: web.Request): - uid = request[RQT_USERID_KEY] - all_tokens = await _tokens.list_tokens(request.app, uid) +async def list_tokens(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + all_tokens = await _tokens.list_tokens(request.app, req_ctx.user_id) return envelope_json_response(all_tokens) +@routes.post(f"/{API_VTAG}/me/tokens", name="create_tokens") @login_required @_handle_tokens_errors @permission_required("user.tokens.*") -async def get_token(request: web.Request): - uid = request[RQT_USERID_KEY] - service_id = request.match_info["service"] +async def create_tokens(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + token_create = await parse_request_body_as(TokenCreate, request) + await _tokens.create_token(request.app, req_ctx.user_id, token_create) + return envelope_json_response(token_create, web.HTTPCreated) - one_token = await _tokens.get_token(request.app, uid, service_id) - return envelope_json_response(one_token) +class _TokenPathParams(BaseModel): + service: str + +@routes.get(f"/{API_VTAG}/me/tokens/{{service}}", name="get_token") @login_required @_handle_tokens_errors @permission_required("user.tokens.*") -async def update_token(request: web.Request): - """updates token_data of a given user service - - WARNING: token_data has to be complete! - """ - uid = request[RQT_USERID_KEY] - service_id = request.match_info["service"] - token_data = await request.json() - await _tokens.update_token(request.app, uid, service_id, token_data) - raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) +async def get_token(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + req_path_params = parse_request_path_parameters_as(_TokenPathParams, request) + token = await _tokens.get_token( + request.app, req_ctx.user_id, req_path_params.service + ) + return envelope_json_response(token) +@routes.delete(f"/{API_VTAG}/me/tokens/{{service}}", name="delete_token") @login_required @_handle_tokens_errors @permission_required("user.tokens.*") -async def delete_token(request: web.Request): - uid = request[RQT_USERID_KEY] - service_id = request.match_info["service"] - - await _tokens.delete_token(request.app, uid, service_id) +async def delete_token(request: web.Request) -> web.Response: + req_ctx = _RequestContext.parse_obj(request) + req_path_params = parse_request_path_parameters_as(_TokenPathParams, request) + await _tokens.delete_token(request.app, req_ctx.user_id, req_path_params.service) raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) @@ -142,56 +145,60 @@ async def _get_user_notifications( return [UserNotification.parse_raw(x) for x in raw_notifications] -class UserNotificationsGet(BaseModel): - data: list[UserNotification] - - +@routes.get(f"/{API_VTAG}/me/notifications", name="list_user_notifications") @login_required -async def get_user_notifications(request: web.Request): +@permission_required("user.notifications.read") +async def list_user_notifications(request: web.Request) -> web.Response: redis_client = get_redis_user_notifications_client(request.app) - user_id = request[RQT_USERID_KEY] - notifications = await _get_user_notifications(redis_client, user_id) - return web.json_response(text=UserNotificationsGet(data=notifications).json()) + req_ctx = _RequestContext.parse_obj(request) + notifications = await _get_user_notifications(redis_client, req_ctx.user_id) + return envelope_json_response(notifications) +@routes.post(f"/{API_VTAG}/me/notifications", name="create_user_notification") @login_required -async def post_user_notification(request: web.Request): - redis_client = get_redis_user_notifications_client(request.app) - +@permission_required("user.notifications.write") +async def create_user_notification(request: web.Request) -> web.Response: # body includes the updated notification - notification_data: dict[str, Any] = await request.json() - user_notification = UserNotification.create_from_request_data(notification_data) + 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.json()) pipe.ltrim(key, 0, MAX_NOTIFICATIONS_FOR_USER_TO_KEEP - 1) await pipe.execute() - return web.json_response(status=web.HTTPNoContent.status_code) + raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) -routes = web.RouteTableDef() +class _NotificationPathParams(BaseModel): + notification_id: str -@routes.patch(f"/{API_VTAG}/notifications/{{id}}", name="update_user_notification") +@routes.patch( + f"/{API_VTAG}/me/notifications/{{notification_id}}", + name="mark_notification_as_read", +) @login_required -async def update_user_notification(request: web.Request): +@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) - user_id = request[RQT_USERID_KEY] - notification_id = request.match_info["id"] + req_ctx = _RequestContext.parse_obj(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(user_id) + key = get_notification_key(req_ctx.user_id) all_user_notifications: list[UserNotification] = [ UserNotification.parse_raw(x) for x in await redis_client.lrange(key, 0, -1) ] for k, user_notification in enumerate(all_user_notifications): - if notification_id == user_notification.id: - patch_data: dict[str, Any] = await request.json() - user_notification.update_from(patch_data) + if req_path_params.notification_id == user_notification.id: + user_notification.read = body.read await redis_client.lset(key, k, user_notification.json()) - return web.json_response(status=web.HTTPNoContent.status_code) + raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) - return web.json_response(status=web.HTTPNotFound.status_code) + raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications.py b/services/web/server/src/simcore_service_webserver/users/_notifications.py index 6030e8432a5..e9498d8d8fe 100644 --- a/services/web/server/src/simcore_service_webserver/users/_notifications.py +++ b/services/web/server/src/simcore_service_webserver/users/_notifications.py @@ -1,7 +1,6 @@ -from copy import deepcopy from datetime import datetime from enum import auto -from typing import Any, Final +from typing import Final from uuid import uuid4 from models_library.users import UserID @@ -23,36 +22,40 @@ class NotificationCategory(StrAutoEnum): ANNOTATION_NOTE = auto() -class UserNotification(BaseModel): - # 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 +class BaseUserNotification(BaseModel): user_id: UserID category: NotificationCategory actionable_path: str title: str text: str date: datetime - read: bool - - def update_from(self, data: dict[str, Any]) -> None: - for k, v in data.items(): - self.__setattr__(k, v) @validator("category", pre=True) @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: dict[str, Any] + cls, request_data: UserNotificationCreate ) -> "UserNotification": - params = deepcopy(request_data) - params["id"] = f"{uuid4()}" - params["read"] = False - return cls.parse_obj(params) + return cls.construct(id=f"{uuid4()}", read=False, **request_data.dict()) class Config: schema_extra = { diff --git a/services/web/server/src/simcore_service_webserver/users/_tokens.py b/services/web/server/src/simcore_service_webserver/users/_tokens.py index 4ace0ad87be..c8d742a5b88 100644 --- a/services/web/server/src/simcore_service_webserver/users/_tokens.py +++ b/services/web/server/src/simcore_service_webserver/users/_tokens.py @@ -4,42 +4,41 @@ """ import sqlalchemy as sa from aiohttp import web -from aiopg.sa.result import RowProxy from models_library.users import UserID +from models_library.utils.fastapi_encoders import jsonable_encoder from sqlalchemy import and_, literal_column from ..db.models import tokens from ..db.plugin import get_database_engine from .exceptions import TokenNotFoundError +from .schemas import Token, TokenCreate async def create_token( - app: web.Application, user_id: UserID, token_data: dict[str, str] -) -> dict[str, str]: + app: web.Application, user_id: UserID, token: TokenCreate +) -> Token: async with get_database_engine(app).acquire() as conn: await conn.execute( tokens.insert().values( user_id=user_id, - token_service=token_data["service"], - token_data=token_data, + token_service=token.service, + token_data=jsonable_encoder(token), ) ) - return token_data + return token -async def list_tokens(app: web.Application, user_id: UserID) -> list[dict[str, str]]: - user_tokens = [] +async def list_tokens(app: web.Application, user_id: UserID) -> list[Token]: + user_tokens: list[Token] = [] async with get_database_engine(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(row["token_data"]) + user_tokens.append(Token.construct(**row["token_data"])) return user_tokens -async def get_token( - app: web.Application, user_id: UserID, service_id: str -) -> dict[str, str]: +async def get_token(app: web.Application, user_id: UserID, service_id: str) -> Token: async with get_database_engine(app).acquire() as conn: result = await conn.execute( sa.select(tokens.c.token_data).where( @@ -47,13 +46,13 @@ async def get_token( ) ) if row := await result.first(): - return dict(row["token_data"]) + return Token.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] -) -> dict[str, str]: +) -> Token: async with get_database_engine(app).acquire() as conn: result = await conn.execute( sa.select(tokens.c.token_data, tokens.c.token_id).where( @@ -69,21 +68,20 @@ async def update_token( data.update(token_data) resp = await conn.execute( - # pylint: disable=no-value-for-parameter tokens.update() .where(tokens.c.token_id == tid) .values(token_data=data) .returning(literal_column("*")) ) assert resp.rowcount == 1 # nosec - updated_token: RowProxy = await resp.fetchone() - return dict(updated_token["token_data"]) + updated_token = await resp.fetchone() + assert updated_token # nosec + return Token.construct(**updated_token["token_data"]) async def delete_token(app: web.Application, user_id: UserID, service_id: str) -> None: async with get_database_engine(app).acquire() as conn: await conn.execute( - # pylint: disable=no-value-for-parameter 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/plugin.py b/services/web/server/src/simcore_service_webserver/users/plugin.py index 71c7c5eecbe..f7b0b6d6bfc 100644 --- a/services/web/server/src/simcore_service_webserver/users/plugin.py +++ b/services/web/server/src/simcore_service_webserver/users/plugin.py @@ -6,13 +6,7 @@ from aiohttp import web from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from servicelib.aiohttp.rest_routing import ( - get_handlers_from_namespace, - iter_path_operations, - map_handlers_with_operations, -) -from .._constants import APP_OPENAPI_SPECS_KEY from . import _handlers _logger = logging.getLogger(__name__) @@ -27,12 +21,4 @@ ) def setup_users(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_USERS # nosec - - # routes related with users - specs = app[APP_OPENAPI_SPECS_KEY] - routes = map_handlers_with_operations( - get_handlers_from_namespace(_handlers), - filter(lambda o: "me" in o[1].split("/"), iter_path_operations(specs)), - strict=True, - ) - app.router.add_routes(routes) + app.router.add_routes(_handlers.routes) 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 9c2da3307d5..f0279540e9d 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -40,6 +40,10 @@ class TokenID(BaseModel): __root__: str = Field(..., description="toke identifier") +class TokenCreate(Token): + ... + + # # PROFILE resource # 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 3ce00575b8b..f82822e38da 100644 --- a/services/web/server/tests/unit/isolated/test_user_notifications.py +++ b/services/web/server/tests/unit/isolated/test_user_notifications.py @@ -7,12 +7,13 @@ from simcore_service_webserver.users._notifications import ( NotificationCategory, UserNotification, + UserNotificationCreate, get_notification_key, ) @pytest.mark.parametrize("raw_data", UserNotification.Config.schema_extra["examples"]) -def test_user_notification(raw_data: dict[str, Any]) -> UserNotification: +def test_user_notification(raw_data: dict[str, Any]): assert UserNotification.parse_obj(raw_data) @@ -25,76 +26,79 @@ def test_get_notification_key(user_id: UserID): "request_data", [ pytest.param( - { - "user_id": "1", - "category": NotificationCategory.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", - }, + UserNotificationCreate.parse_obj( + { + "user_id": "1", + "category": NotificationCategory.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", + } + ), id="normal_usage", ), pytest.param( - { - "user_id": "1", - "category": NotificationCategory.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", - "read": True, - }, + UserNotificationCreate.parse_obj( + { + "user_id": "1", + "category": NotificationCategory.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", + "read": True, + } + ), id="read_is_always_set_false", ), pytest.param( - { - "user_id": "1", - "category": NotificationCategory.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", - "id": "some_id", - }, + UserNotificationCreate.parse_obj( + { + "user_id": "1", + "category": NotificationCategory.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", + "id": "some_id", + } + ), id="a_new_id_is_alway_recreated", ), pytest.param( - { - "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", - "id": "some_id", - }, + UserNotificationCreate.parse_obj( + { + "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", + "id": "some_id", + } + ), id="category_from_string", ), pytest.param( - { - "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", - "id": "some_id", - }, + UserNotificationCreate.parse_obj( + { + "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", + "id": "some_id", + } + ), id="category_from_lower_case_string", ), ], ) -def test_user_notification_crate_from_request_data(request_data: dict[str, Any]): +def test_user_notification_create_from_request_data( + request_data: UserNotificationCreate, +): user_notification = UserNotification.create_from_request_data(request_data) - assert user_notification.id != request_data.get("id", None) + assert user_notification.id assert user_notification.read is False - - -def test_user_notification_update_from(): - user_notification = UserNotification.create_from_request_data( - UserNotification.Config.schema_extra["examples"][0] - ) - assert user_notification.read is False - user_notification.update_from({"read": True}) - assert user_notification.read is True 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 705a353c6e1..85be717a9da 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 @@ -52,6 +52,7 @@ MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, NotificationCategory, UserNotification, + UserNotificationCreate, get_notification_key, ) from simcore_service_webserver.users.plugin import setup_users @@ -229,6 +230,7 @@ async def test_create_token( logged_user: UserInfoDict, tokens_db, expected: type[web.HTTPException], + faker: Faker, ): assert client.app @@ -237,8 +239,8 @@ async def test_create_token( token = { "service": "pennsieve", - "token_key": "4k9lyzBTS", - "token_secret": "my secret", + "token_key": faker.uuid4(), + "token_secret": faker.uuid4(), } resp = await client.post(f"{url}", json=token) @@ -288,46 +290,6 @@ async def test_read_token( assert data == expected_token, "list and read item are both read operations" -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, web.HTTPUnauthorized), - (UserRole.GUEST, web.HTTPForbidden), - (UserRole.USER, web.HTTPNoContent), - (UserRole.TESTER, web.HTTPNoContent), - ], -) -async def test_update_token( - client: TestClient, - logged_user: UserInfoDict, - tokens_db, - fake_tokens, - expected: type[web.HTTPException], -): - assert client.app - - selected = random.choice(fake_tokens) - sid = selected["service"] - - url = client.app.router["get_token"].url_for(service=sid) - assert "/v0/me/tokens/%s" % sid == f"{url}" - - resp = await client.put( - f"{url}", json={"token_secret": "some completely new secret"} - ) - data, error = await assert_status(resp, expected) - - if not error: - # check in db - token_in_db = await get_token_from_db(tokens_db, token_service=sid) - assert token_in_db - assert token_in_db["token_data"]["token_secret"] == "some completely new secret" - assert token_in_db["token_data"]["token_secret"] != selected["token_secret"] - - selected["token_secret"] = "some completely new secret" - assert token_in_db["token_data"] == selected - - @pytest.mark.parametrize( "user_role,expected", [ @@ -401,7 +363,7 @@ async def test_get_profile_with_failing_db_connection( mock_failing_connection.call_count == NUM_RETRY ), "Expected mock failure raised in AuthorizationPolicy.authorized_userid after severals" - data, error = await assert_status(resp, expected) + await assert_status(resp, expected) @pytest.fixture @@ -423,14 +385,16 @@ async def _create_notifications( user_notifications: list[UserNotification] = [ UserNotification.create_from_request_data( - { - "user_id": user_id, - "category": random.choice(notification_categories), - "actionable_path": "a/path", - "title": "test_title", - "text": "text_text", - "date": datetime.now(timezone.utc).isoformat(), - } + UserNotificationCreate.parse_obj( + { + "user_id": user_id, + "category": random.choice(notification_categories), + "actionable_path": "a/path", + "title": "test_title", + "text": "text_text", + "date": datetime.now(timezone.utc).isoformat(), + } + ) ) for _ in range(count) ] @@ -445,7 +409,15 @@ async def _create_notifications( await redis_client.flushall() -@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize( + "user_role,expected_response", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPOk), + (UserRole.USER, web.HTTPOk), + (UserRole.TESTER, web.HTTPOk), + ], +) @pytest.mark.parametrize( "notification_count", [ @@ -455,30 +427,44 @@ async def _create_notifications( MAX_NOTIFICATIONS_FOR_USER_TO_SHOW + 1, ], ) -async def test_get_user_notifications( +async def test_list_user_notifications( logged_user: UserInfoDict, notification_redis_client: aioredis.Redis, client: TestClient, notification_count: int, + expected_response: type[web.HTTPException], ): assert client.app - url = client.app.router["get_user_notifications"].url_for() + url = client.app.router["list_user_notifications"].url_for() assert str(url) == "/v0/me/notifications" - - async with _create_notifications( - notification_redis_client, logged_user, notification_count - ) as created_notifications: - response = await client.get(f"{url}") - json_response = await response.json() - - result = parse_obj_as(list[UserNotification], json_response["data"]) - assert len(result) <= MAX_NOTIFICATIONS_FOR_USER_TO_SHOW - assert result == list( - reversed(created_notifications[:MAX_NOTIFICATIONS_FOR_USER_TO_SHOW]) - ) + response = await client.get(f"{url}") + data, error = await assert_status(response, expected_response) + if data: + assert data == [] + assert not error + + async with _create_notifications( + notification_redis_client, logged_user, notification_count + ) as created_notifications: + response = await client.get(f"{url}") + json_response = await response.json() + + result = parse_obj_as(list[UserNotification], json_response["data"]) + assert len(result) <= MAX_NOTIFICATIONS_FOR_USER_TO_SHOW + assert result == list( + reversed(created_notifications[:MAX_NOTIFICATIONS_FOR_USER_TO_SHOW]) + ) -@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize( + "user_role,expected_response", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPForbidden), + (UserRole.USER, web.HTTPNoContent), + (UserRole.TESTER, web.HTTPNoContent), + ], +) @pytest.mark.parametrize( "notification_dict", [ @@ -508,31 +494,36 @@ async def test_get_user_notifications( ), ], ) -async def test_post_user_notification( +async def test_create_user_notification( logged_user: UserInfoDict, notification_redis_client: aioredis.Redis, client: TestClient, notification_dict: dict[str, Any], + expected_response: type[web.HTTPException], ): assert client.app - url = client.app.router["post_user_notification"].url_for() + url = client.app.router["create_user_notification"].url_for() assert str(url) == "/v0/me/notifications" notification_dict["user_id"] = logged_user["id"] resp = await client.post(f"{url}", json=notification_dict) - assert resp.status == web.HTTPNoContent.status_code, await resp.text() + data, error = await assert_status(resp, expected_response) + assert data is None # 204... - user_id = logged_user["id"] - user_notifications = await _get_user_notifications( - notification_redis_client, user_id - ) - assert len(user_notifications) == 1 - # these are always generated and overwritten, even if provided by the user, since - # we do not want to overwrite existing ones - assert user_notifications[0].read is False - assert user_notifications[0].id != notification_dict.get("id", None) + if not error: + user_id = logged_user["id"] + user_notifications = await _get_user_notifications( + notification_redis_client, user_id + ) + assert len(user_notifications) == 1 + # these are always generated and overwritten, even if provided by the user, since + # we do not want to overwrite existing ones + assert user_notifications[0].read is False + assert user_notifications[0].id != notification_dict.get("id", None) + else: + assert error is not None -@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize("user_role", [(UserRole.USER)]) @pytest.mark.parametrize( "notification_count", [ @@ -543,14 +534,14 @@ async def test_post_user_notification( MAX_NOTIFICATIONS_FOR_USER_TO_KEEP * 10, ], ) -async def test_post_user_notification_capped_list_length( +async def test_create_user_notification_capped_list_length( logged_user: UserInfoDict, notification_redis_client: aioredis.Redis, client: TestClient, notification_count: int, ): assert client.app - url = client.app.router["post_user_notification"].url_for() + url = client.app.router["create_user_notification"].url_for() assert str(url) == "/v0/me/notifications" notifications_create_results = await asyncio.gather( @@ -584,6 +575,36 @@ async def test_post_user_notification_capped_list_length( assert len(user_notifications) <= MAX_NOTIFICATIONS_FOR_USER_TO_KEEP +@pytest.mark.parametrize( + "user_role,expected_response", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPNoContent), + (UserRole.USER, web.HTTPNoContent), + (UserRole.TESTER, web.HTTPNoContent), + ], +) +async def test_update_user_notification( + logged_user: UserInfoDict, + notification_redis_client: aioredis.Redis, + client: TestClient, + expected_response: type[web.HTTPException], +): + async with _create_notifications( + notification_redis_client, logged_user, 1 + ) as created_notifications: + assert client.app + for notification in created_notifications: + url = client.app.router["mark_notification_as_read"].url_for( + notification_id=f"{notification.id}" + ) + assert str(url) == f"/v0/me/notifications/{notification.id}" + assert notification.read is False + + resp = await client.patch(f"{url}", json={"read": True}) + await assert_status(resp, expected_response) + + @pytest.mark.parametrize("user_role", [UserRole.USER]) @pytest.mark.parametrize( "notification_count", @@ -619,8 +640,8 @@ def _marked_as_read( ) as created_notifications: notifications_before_update = await _get_stored_notifications() for notification in created_notifications: - url = client.app.router["update_user_notification"].url_for( - id=f"{notification.id}" + url = client.app.router["mark_notification_as_read"].url_for( + notification_id=f"{notification.id}" ) assert str(url) == f"/v0/me/notifications/{notification.id}" assert notification.read is False