From 82be0e210fc3543b8cf0bb7db8771ff15d2761ba Mon Sep 17 00:00:00 2001 From: Odei Maiz <33152403+odeimaiz@users.noreply.github.com> Date: Tue, 9 Apr 2024 21:08:01 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=F0=9F=90=9B=20Frontend:=20Notifica?= =?UTF-8?q?tions=20support=20products=20(#5604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../osparc/notification/Notifications.js | 61 ++++--- .../source/class/osparc/theme/Appearance.js | 22 +-- .../source/class/osparc/ui/form/EditLabel.js | 154 ++++++++++-------- .../users/_notifications.py | 9 +- .../users/_notifications_handlers.py | 17 +- .../unit/isolated/test_user_notifications.py | 11 +- .../with_dbs/03/test_users__notifications.py | 115 ++++++++++--- 7 files changed, 263 insertions(+), 126 deletions(-) diff --git a/services/static-webserver/client/source/class/osparc/notification/Notifications.js b/services/static-webserver/client/source/class/osparc/notification/Notifications.js index 7c0a80a094f..54e9b8c2a9b 100644 --- a/services/static-webserver/client/source/class/osparc/notification/Notifications.js +++ b/services/static-webserver/client/source/class/osparc/notification/Notifications.js @@ -25,14 +25,25 @@ qx.Class.define("osparc.notification.Notifications", { }, statics: { - __newOrganizationObj: function(userId, orgId) { + __newNotificationBase: function(userId) { return { "user_id": userId.toString(), + "date": new Date().toISOString(), + "product": osparc.product.Utils.getProductName() + }; + }, + + __newOrganizationObj: function(userId, orgId) { + const baseNotification = this.__newNotificationBase(userId); + const specNotification = { "category": "NEW_ORGANIZATION", "actionable_path": "organization/"+orgId, "title": "New organization", - "text": "You're now member of a new Organization", - "date": new Date().toISOString() + "text": "You're now member of a new Organization" + }; + return { + ...baseNotification, + ...specNotification }; }, @@ -40,13 +51,16 @@ qx.Class.define("osparc.notification.Notifications", { const study = osparc.product.Utils.getStudyAlias({ firstUpperCase: true }); - return { - "user_id": userId.toString(), + const baseNotification = this.__newNotificationBase(userId); + const specNotification = { "category": "STUDY_SHARED", "actionable_path": "study/"+studyId, "title": `${study} shared`, - "text": `A ${study} was shared with you`, - "date": new Date().toISOString() + "text": `A ${study} was shared with you` + }; + return { + ...baseNotification, + ...specNotification }; }, @@ -54,35 +68,44 @@ qx.Class.define("osparc.notification.Notifications", { const template = osparc.product.Utils.getTemplateAlias({ firstUpperCase: true }); - return { - "user_id": userId.toString(), + const baseNotification = this.__newNotificationBase(userId); + const specNotification = { "category": "TEMPLATE_SHARED", "actionable_path": "template/"+templateId, "title": `${template} shared`, - "text": `A ${template} was shared with you`, - "date": new Date().toISOString() + "text": `A ${template} was shared with you` + }; + return { + ...baseNotification, + ...specNotification }; }, __newAnnotationNoteObj: function(userId, studyId) { - return { - "user_id": userId.toString(), + const baseNotification = this.__newNotificationBase(userId); + const specNotification = { "category": "ANNOTATION_NOTE", "actionable_path": "study/"+studyId, "title": "Note added", - "text": "A Note was added for you", - "date": new Date().toISOString() + "text": "A Note was added for you" + }; + return { + ...baseNotification, + ...specNotification }; }, __newWalletObj: function(userId, walletId) { - return { - "user_id": userId.toString(), + const baseNotification = this.__newNotificationBase(userId); + const specNotification = { "category": "WALLET_SHARED", "actionable_path": "wallet/"+walletId, "title": "Credits shared", - "text": "A Credit account was shared with you", - "date": new Date().toISOString() + "text": "A Credit account was shared with you" + }; + return { + ...baseNotification, + ...specNotification }; }, diff --git a/services/static-webserver/client/source/class/osparc/theme/Appearance.js b/services/static-webserver/client/source/class/osparc/theme/Appearance.js index f34950ebc66..3e51f88a0a3 100644 --- a/services/static-webserver/client/source/class/osparc/theme/Appearance.js +++ b/services/static-webserver/client/source/class/osparc/theme/Appearance.js @@ -1083,22 +1083,22 @@ qx.Theme.define("osparc.theme.Appearance", { EditLabel --------------------------------------------------------------------------- */ - "editlabel": {}, - "editlabel/label": { - include: "atom/label", + "editlabel-label": { + include: "label", style: state => ({ - decorator: state.hovered && state.editable ? "border-editable" : null, + decorator: state.hovered && state.editable ? "border-editable" : "rounded", marginLeft: state.hovered && state.editable ? 0 : 1, - padding: [2, state.hovered && state.editable ? 2 : 3, 2, 2], - cursor: state.editable ? "text" : "auto" + padding: 5, + cursor: state.editable ? "text" : "auto", + backgroundColor: "input_background" }) }, - "editlabel/input": { + + "editlabel-input": { include: "textfield", - style: state => ({ - paddingTop: 4, - paddingLeft: 3, - minWidth: 80, + style: () => ({ + padding: 5, + minWidth: 120, backgroundColor: "transparent" }) }, diff --git a/services/static-webserver/client/source/class/osparc/ui/form/EditLabel.js b/services/static-webserver/client/source/class/osparc/ui/form/EditLabel.js index 47750b1acd8..6bb9f337437 100644 --- a/services/static-webserver/client/source/class/osparc/ui/form/EditLabel.js +++ b/services/static-webserver/client/source/class/osparc/ui/form/EditLabel.js @@ -12,37 +12,40 @@ */ qx.Class.define("osparc.ui.form.EditLabel", { extend: qx.ui.core.Widget, + /** * The constructor can be provided with an initial value for the label. * @param {String} value This will be the initial value of the label */ construct: function(value) { this.base(arguments); - if (value) { - this.setValue(value); - } + this._setLayout(new qx.ui.layout.HBox().set({ alignY: "middle" })); - this.setCursor("text"); + + this.__renderLayout(); + + if (value) { + this.setValue(value); + } + this.__loadingIcon = new qx.ui.basic.Image("@FontAwesome5Solid/circle-notch/12"); this.__loadingIcon.getContentElement().addClass("rotate"); - this.__renderLayout(); }, + events: { "editValue": "qx.event.type.Data" }, + statics: { - modes: { + MODES: { DISPLAY: "display", EDIT: "edit" } }, + properties: { - appearance: { - init: "editlabel", - refine: true - }, /** * Controls the two modes of the widget. In edit mode, it shows an input, in display mode, * it shows the edited label. @@ -53,6 +56,7 @@ qx.Class.define("osparc.ui.form.EditLabel", { init: "display", apply: "_applyMode" }, + /** * Master value of the widget. The label in display mode will always show this value. */ @@ -62,6 +66,7 @@ qx.Class.define("osparc.ui.form.EditLabel", { init: "", apply: "_applyValue" }, + /** * When set to true, adds a little spinner to indicate the the value is being updated, for example * while waiting for an API call to resolve. @@ -72,6 +77,7 @@ qx.Class.define("osparc.ui.form.EditLabel", { nullable: false, apply: "_applyFetching" }, + /** * Lets you choose the font for the label in display mode. */ @@ -79,6 +85,7 @@ qx.Class.define("osparc.ui.form.EditLabel", { check: "Font", apply: "_applySpecificFont" }, + /** * Lets you choose the font for the input in edit mode. */ @@ -86,6 +93,7 @@ qx.Class.define("osparc.ui.form.EditLabel", { check: "Font", apply: "_applySpecificFont" }, + /** * Enables the edit mode. If false, the label is not editable. */ @@ -96,102 +104,108 @@ qx.Class.define("osparc.ui.form.EditLabel", { apply: "_applyEditable" } }, + members: { - __label: null, - __input: null, __labelWidth: null, __loadingIcon: null, + + _createChildControlImpl: function(id) { + let control; + switch (id) { + case "text": + control = new qx.ui.basic.Label().set({ + appearance: "editlabel-label" + }); + this.bind("value", control, "value"); + if (this.isEditable()) { + control.addState("editable"); + } + control.addListener("pointerover", () => control.addState("hovered"), this); + control.addListener("pointerout", () => control.removeState("hovered"), this); + control.addListener("tap", () => this.setMode(this.isEditable() ? this.self().MODES.EDIT : this.self().modes.DISPLAY), this); + this._add(control); + break; + case "input": + control = new qx.ui.form.TextField(this.getValue()).set({ + appearance: "editlabel-input" + }); + control.addListener("focusout", () => this.setMode(this.self().MODES.DISPLAY), this); + control.addListener("focus", () => control.selectAllText(), this); + control.addListener("changeValue", evt => { + this.setMode(this.self().MODES.DISPLAY); + this.fireDataEvent("editValue", evt.getData()); + }, this); + control.addListener("keydown", e => { + if (e.getKeyIdentifier() === "Enter") { + this.setMode(this.self().MODES.DISPLAY); + } + }, this); + this._add(control); + break; + } + return control || this.base(arguments, id); + }, + /** * This method takes charge of rendering the widget. It relies on the property mode * to render the correct version of the widget. */ __renderLayout: function() { + const label = this.getChildControl("text"); + const input = this.getChildControl("input") switch (this.getMode()) { - case this.self().modes.EDIT: - this.getChildControl("input").show(); - this.getChildControl("label").exclude(); + case this.self().MODES.EDIT: + input.show(); + label.exclude(); if (this.__labelWidth) { - this.__input.setWidth(this.__labelWidth); + input.setWidth(this.__labelWidth); } - this.__input.focus(); - this.__label.removeState("hovered"); + input.focus(); + label.removeState("hovered"); break; default: - this.getChildControl("label").show(); - this.getChildControl("input").exclude(); + label.show(); + input.exclude(); } }, - _createChildControlImpl: function(id) { - let control; - switch (id) { - case "label": - if (this.__label === null) { - this.__label = new qx.ui.basic.Label(this.getValue()); - if (this.isEditable()) { - this.__label.addState("editable"); - } - this.__label.addListener("pointerover", () => this.__label.addState("hovered"), this); - this.__label.addListener("pointerout", () => this.__label.removeState("hovered"), this); - this.__label.addListener("tap", () => this.setMode(this.isEditable() ? this.self().modes.EDIT : this.self().modes.DISPLAY), this); - this.bind("value", this.__label, "value"); - this._add(this.__label); - } - control = this.__label; - break; - case "input": - if (this.__input === null) { - this.__input = new qx.ui.form.TextField(this.getValue()); - this.__input.addListener("focusout", () => this.setMode(this.self().modes.DISPLAY), this); - this.__input.addListener("focus", () => this.__input.selectAllText(), this); - this.__input.addListener("changeValue", evt => { - this.setMode(this.self().modes.DISPLAY); - this.fireDataEvent("editValue", evt.getData()); - }, this); - this.__input.addListener("keydown", e => { - if (e.getKeyIdentifier() === "Enter") { - this.setMode(this.self().modes.DISPLAY); - } - }, this); - this._add(this.__input); - } - control = this.__input; - break; - } - return control || this.base(arguments, id); - }, + _applyMode: function(mode) { - if (mode === this.self().modes.EDIT) { - this.__labelWidth = this.__label.getSizeHint().width; + if (mode === this.self().MODES.EDIT) { + this.__labelWidth = this.getChildControl("text").getSizeHint().width; } this.__renderLayout(); }, + _applyFetching: function(isFetching) { + const label = this.getChildControl("text"); if (isFetching) { - this.__label.setEnabled(false); + label.setEnabled(false); this._add(this.__loadingIcon); } else { - this.__label.setEnabled(true); + label.setEnabled(true); this._remove(this.__loadingIcon); } }, + _applyValue: function(value) { - this.setMode(this.self().modes.DISPLAY); - if (this.__input) { - this.__input.setValue(value); - } + this.setMode(this.self().MODES.DISPLAY); + this.getChildControl("input").setValue(value); }, + _applySpecificFont: function(font, oldFont, name) { if (name === "labelFont") { - this.__label.setFont(font); + this.getChildControl("text").setFont(font); } else if (name === "inputFont") { - this.__input.setFont(font); + this.getChildControl("input").setFont(font); } }, + _applyEditable: function(isEditable) { + const label = this.getChildControl("text"); if (isEditable) { - this.__label.addState("editable"); + label.addState("editable"); } else { - this.__label.removeState("editable"); + label.removeState("editable"); } } } 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 43e5ac76c7c..028d7044453 100644 --- a/services/web/server/src/simcore_service_webserver/users/_notifications.py +++ b/services/web/server/src/simcore_service_webserver/users/_notifications.py @@ -1,10 +1,11 @@ from datetime import datetime from enum import auto -from typing import Any, ClassVar, Final +from typing import Any, ClassVar, Final, Literal from uuid import uuid4 from models_library.users import UserID from models_library.utils.enums import StrAutoEnum +from models_library.products import ProductName from pydantic import BaseModel, NonNegativeInt, validator MAX_NOTIFICATIONS_FOR_USER_TO_SHOW: Final[NonNegativeInt] = 10 @@ -30,6 +31,7 @@ class BaseUserNotification(BaseModel): title: str text: str date: datetime + product: Literal["UNDEFINED"] | ProductName @validator("category", pre=True) @classmethod @@ -69,6 +71,7 @@ class Config: "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", "read": True, }, { @@ -79,6 +82,7 @@ class Config: "title": "Study shared", "text": "A study was shared with you", "date": "2023-02-23T16:25:13.122Z", + "product": "osparc", "read": False, }, { @@ -89,6 +93,7 @@ class Config: "title": "Template shared", "text": "A template was shared with you", "date": "2023-02-23T16:28:13.122Z", + "product": "osparc", "read": False, }, { @@ -99,6 +104,7 @@ class Config: "title": "Note added", "text": "A Note was added for you", "date": "2023-02-23T16:28:13.122Z", + "product": "s4l", "read": False, }, { @@ -109,6 +115,7 @@ class Config: "title": "Credits shared", "text": "A Credit account was shared with you", "date": "2023-09-29T16:28:13.122Z", + "product": "tis", "read": False, }, ] diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications_handlers.py b/services/web/server/src/simcore_service_webserver/users/_notifications_handlers.py index 73453667052..8b8fcbdd2d3 100644 --- a/services/web/server/src/simcore_service_webserver/users/_notifications_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_notifications_handlers.py @@ -1,4 +1,5 @@ import logging +import json import redis.asyncio as aioredis from aiohttp import web @@ -11,6 +12,7 @@ from .._meta import API_VTAG from ..login.decorators import login_required +from ..products.api import get_product_name from ..redis import get_redis_user_notifications_client from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response @@ -33,13 +35,21 @@ async def _get_user_notifications( - redis_client: aioredis.Redis, user_id: int + redis_client: aioredis.Redis, user_id: int, product_name: str ) -> list[UserNotification]: """returns a list of notifications where the latest notification is at index 0""" raw_notifications: list[str] = await redis_client.lrange( get_notification_key(user_id), -1 * MAX_NOTIFICATIONS_FOR_USER_TO_SHOW, -1 ) - return [UserNotification.parse_raw(x) for x in raw_notifications] + notifications = [json.loads(x) for x in raw_notifications] + # Make it backwards compatible + for n in notifications: + if "product" not in n: + n["product"] = "UNDEFINED" + # Filter by product + included = [product_name, "UNDEFINED"] + filtered_notifications = [n for n in notifications if n["product"] in included] + return [UserNotification.parse_obj(x) for x in filtered_notifications] @routes.get(f"/{API_VTAG}/me/notifications", name="list_user_notifications") @@ -48,7 +58,8 @@ async def _get_user_notifications( async def list_user_notifications(request: web.Request) -> web.Response: redis_client = get_redis_user_notifications_client(request.app) req_ctx = UsersRequestContext.parse_obj(request) - notifications = await _get_user_notifications(redis_client, req_ctx.user_id) + product_name = get_product_name(request) + notifications = await _get_user_notifications(redis_client, req_ctx.user_id, product_name) return envelope_json_response(notifications) 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 f82822e38da..d606a84297f 100644 --- a/services/web/server/tests/unit/isolated/test_user_notifications.py +++ b/services/web/server/tests/unit/isolated/test_user_notifications.py @@ -34,6 +34,7 @@ def test_get_notification_key(user_id: UserID): "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", } ), id="normal_usage", @@ -47,6 +48,7 @@ def test_get_notification_key(user_id: UserID): "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", "read": True, } ), @@ -55,13 +57,14 @@ def test_get_notification_key(user_id: UserID): pytest.param( UserNotificationCreate.parse_obj( { + "id": "some_id", "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", + "product": "osparc", } ), id="a_new_id_is_alway_recreated", @@ -69,13 +72,14 @@ def test_get_notification_key(user_id: UserID): pytest.param( UserNotificationCreate.parse_obj( { + "id": "some_id", "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", + "product": "s4l", } ), id="category_from_string", @@ -83,13 +87,14 @@ def test_get_notification_key(user_id: UserID): pytest.param( UserNotificationCreate.parse_obj( { + "id": "some_id", "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", + "product": "tis", } ), id="category_from_lower_case_string", diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py b/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py index b3cd4df7239..f359a0c2f49 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users__notifications.py @@ -17,6 +17,7 @@ import pytest import redis.asyncio as aioredis from aiohttp.test_utils import TestClient +from models_library.products import ProductName from pydantic import parse_obj_as from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_envs import EnvVarsDict, setenvs_from_dict @@ -62,29 +63,47 @@ async def notification_redis_client( await redis_client.flushall() +def _create_notification( + logged_user: UserInfoDict, + product_name: ProductName, +) -> UserNotification: + user_id = logged_user["id"] + notification_categories = tuple(NotificationCategory) + + notification: UserNotification = UserNotification.create_from_request_data( + 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(), + "product": product_name, + } + ) + ) + + return notification + + @asynccontextmanager async def _create_notifications( - redis_client: aioredis.Redis, logged_user: UserInfoDict, count: int + redis_client: aioredis.Redis, + logged_user: UserInfoDict, + product_name: ProductName, + count: int ) -> AsyncIterator[list[UserNotification]]: - user_id = logged_user["id"] - notification_categories = tuple(NotificationCategory) user_notifications: list[UserNotification] = [ - UserNotification.create_from_request_data( - 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(), - } - ) + _create_notification( + logged_user=logged_user, + product_name=product_name ) for _ in range(count) ] + user_id = logged_user["id"] redis_key = get_notification_key(user_id) if user_notifications: for notification in user_notifications: @@ -130,7 +149,10 @@ async def test_list_user_notifications( assert not error async with _create_notifications( - notification_redis_client, logged_user, notification_count + redis_client=notification_redis_client, + logged_user=logged_user, + product_name="osparc", + count=notification_count, ) as created_notifications: response = await client.get(url.path) json_response = await response.json() @@ -162,6 +184,7 @@ async def test_list_user_notifications( "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", }, id="with_expected_data", ), @@ -174,6 +197,7 @@ async def test_list_user_notifications( "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", "read": True, }, id="with_extra_params_that_will_get_overwritten", @@ -198,7 +222,9 @@ async def test_create_user_notification( if not error: user_id = logged_user["id"] user_notifications = await _get_user_notifications( - notification_redis_client, user_id + redis_client=notification_redis_client, + user_id=user_id, + product_name="osparc", ) assert len(user_notifications) == 1 # these are always generated and overwritten, even if provided by the user, since @@ -241,6 +267,7 @@ async def test_create_user_notification_capped_list_length( "title": "New organization", "text": "You're now member of a new Organization", "date": "2023-02-23T16:23:13.122Z", + "product": "osparc", }, ) for _ in range(notification_count) @@ -255,11 +282,55 @@ async def test_create_user_notification_capped_list_length( user_id = logged_user["id"] user_notifications = await _get_user_notifications( - notification_redis_client, user_id + redis_client=notification_redis_client, + user_id=user_id, + product_name="osparc", ) assert len(user_notifications) <= MAX_NOTIFICATIONS_FOR_USER_TO_KEEP +@pytest.mark.parametrize("user_role", [(UserRole.USER)]) +async def test_create_user_notification_per_product( + logged_user: UserInfoDict, + notification_redis_client: aioredis.Redis, + client: TestClient, +): + assert client.app + n_notifications_per_product = 2 + + async with ( + # create notifications in "osparc" + _create_notifications( + redis_client=notification_redis_client, + logged_user=logged_user, + product_name="osparc", + count=n_notifications_per_product, + ) as _, + # create notifications in "s4l" + _create_notifications( + redis_client=notification_redis_client, + logged_user=logged_user, + product_name="s4l", + count=n_notifications_per_product, + ) as _ + ): + user_id = logged_user["id"] + + osparc_notifications = await _get_user_notifications( + redis_client=notification_redis_client, + user_id=user_id, + product_name="osparc", + ) + assert len(osparc_notifications) == n_notifications_per_product + + s4l_notifications = await _get_user_notifications( + redis_client=notification_redis_client, + user_id=user_id, + product_name="s4l", + ) + assert len(s4l_notifications) == n_notifications_per_product + + @pytest.mark.parametrize( "user_role,expected_response", [ @@ -276,7 +347,10 @@ async def test_update_user_notification( expected_response: HTTPStatus, ): async with _create_notifications( - notification_redis_client, logged_user, 1 + redis_client=notification_redis_client, + logged_user=logged_user, + product_name="osparc", + count=1, ) as created_notifications: assert client.app for notification in created_notifications: @@ -321,7 +395,10 @@ def _marked_as_read( return results async with _create_notifications( - notification_redis_client, logged_user, notification_count + redis_client=notification_redis_client, + logged_user=logged_user, + product_name="osparc", + count=notification_count, ) as created_notifications: notifications_before_update = await _get_stored_notifications() for notification in created_notifications: