diff --git a/.env-devel b/.env-devel index 218bd2e8e91..8d41f4e252e 100644 --- a/.env-devel +++ b/.env-devel @@ -136,6 +136,8 @@ TRAEFIK_SIMCORE_ZONE=internal_simcore_stack # NOTE: WEBSERVER_SESSION_SECRET_KEY = $(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key())") +WEBSERVER_CREDIT_COMPUTATION_ENABLED=0 + WEBSERVER_LOGLEVEL=INFO WEBSERVER_PROJECTS={} @@ -228,6 +230,8 @@ WEBSERVER_VERSION_CONTROL=1 AIODEBUG_SLOW_DURATION_SECS=0 + + # Webserver Garbage Collector # the same in all deployments @@ -236,31 +240,40 @@ WB_GC_LOGLEVEL=DEBUG WB_GC_GARBAGE_COLLECTOR='{"GARBAGE_COLLECTOR_INTERVAL_S": 30}' WB_GC_RESOURCE_MANAGER_RESOURCE_TTL_S=60 -WB_GC_ANNOUNCEMENTS=0 + +# +# NOTE: PC->Yuri. The env-vars below are to "disable plugins" and therefore must not +# be changed. We could directly add them in the docker-compose environment section +# instead of having them also here. That will reduce the amount of env-vars to handle +# + WB_GC_ACTIVITY=null +WB_GC_ANNOUNCEMENTS=0 WB_GC_CATALOG=null -WB_GC_NOTIFICATIONS=0 +WB_GC_CLUSTERS=0 WB_GC_DB_LISTENER=0 WB_GC_DIAGNOSTICS=null WB_GC_EMAIL=null WB_GC_EXPORTER=null WB_GC_FRONTEND=null -WB_GC_LOGIN=null -WB_GC_PROJECTS=null -WB_GC_SCICRUNCH=null -WB_GC_STATICWEB=null - -WB_GC_TRACING=null -WB_GC_CLUSTERS=0 WB_GC_GROUPS=0 +WB_GC_INVITATIONS=null +WB_GC_LOGIN=null WB_GC_META_MODELING=0 +WB_GC_NOTIFICATIONS=0 +WB_GC_PAYMENTS=null WB_GC_PRODUCTS=0 +WB_GC_PROJECTS=null WB_GC_PUBLICATIONS=0 +WB_GC_SCICRUNCH=null WB_GC_SOCKETIO=1 +WB_GC_STATICWEB=null WB_GC_STUDIES_DISPATCHER=null WB_GC_TAGS=0 +WB_GC_TRACING=null WB_GC_USERS=0 WB_GC_VERSION_CONTROL=0 +WB_GC_WALLETS=0 # WebServer DB Event Listener @@ -268,27 +281,37 @@ WB_DB_EL_LOGLEVEL=DEBUG WB_DB_EL_DB_LISTENER=1 WB_DB_EL_SOCKETIO=1 -WB_DB_EL_ANNOUNCEMENTS=0 + +# +# NOTE: PC->Yuri. The env-vars below are to "disable plugins" and therefore must not +# be changed. We could directly add them in the docker-compose environment section +# instead of having them also here. That will reduce the amount of env-vars to handle +# + WB_DB_EL_ACTIVITY=null +WB_DB_EL_ANNOUNCEMENTS=0 WB_DB_EL_CATALOG=null -WB_DB_EL_NOTIFICATIONS=0 +WB_DB_EL_CLUSTERS=0 WB_DB_EL_DIAGNOSTICS=null WB_DB_EL_EMAIL=null WB_DB_EL_EXPORTER=null WB_DB_EL_FRONTEND=null WB_DB_EL_GARBAGE_COLLECTOR=null +WB_DB_EL_GROUPS=0 +WB_DB_EL_INVITATIONS=null WB_DB_EL_LOGIN=null +WB_DB_EL_META_MODELING=0 +WB_DB_EL_NOTIFICATIONS=0 +WB_DB_EL_PAYMENTS=null +WB_DB_EL_PRODUCTS=0 WB_DB_EL_PROJECTS=null +WB_DB_EL_PUBLICATIONS=0 WB_DB_EL_SCICRUNCH=null WB_DB_EL_STATICWEB=null WB_DB_EL_STORAGE=null WB_DB_EL_STUDIES_DISPATCHER=null -WB_DB_EL_TRACING=null -WB_DB_EL_CLUSTERS=0 -WB_DB_EL_GROUPS=0 -WB_DB_EL_META_MODELING=0 -WB_DB_EL_PRODUCTS=0 -WB_DB_EL_PUBLICATIONS=0 WB_DB_EL_TAGS=0 +WB_DB_EL_TRACING=null WB_DB_EL_USERS=0 WB_DB_EL_VERSION_CONTROL=0 +WB_DB_EL_WALLETS=0 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a49cf6ad653..c1a93d38222 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,7 +11,7 @@ 📝 Add or update documentation. 🔨 Add or update development scripts. 🔒️ Fix security issues. - ⚠️ Changes in ops configuration etc. are required before deploying. + ⚠️ Changes in ops configuration etc. are required before deploying. [ Please add a link to the associated ops-issue or PR, such as in https://github.com/ITISFoundation/osparc-ops-environments or https://git.speag.com/oSparc/osparc-infra ] 🗃️ Database table changed (relevant for devops). @@ -30,6 +30,9 @@ 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/_products.py b/api/specs/web-server/_products.py index 725e9099c99..4bcccf5bc1a 100644 --- a/api/specs/web-server/_products.py +++ b/api/specs/web-server/_products.py @@ -30,7 +30,7 @@ async def get_current_product_price(): ... -@router.get( +@router.post( "/invitation:generate", response_model=Envelope[InvitationGenerated], ) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c4245e9e0f72_payment_transactions_states.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c4245e9e0f72_payment_transactions_states.py index 2f564bc8470..29f79271d61 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c4245e9e0f72_payment_transactions_states.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c4245e9e0f72_payment_transactions_states.py @@ -60,7 +60,9 @@ def upgrade(): "UPDATE payments_transactions SET state = 'PENDING' WHERE success IS NULL" ) ) - connection.execute("UPDATE payments_transactions SET state_message = errors") + connection.execute( + sa.DDL("UPDATE payments_transactions SET state_message = errors") + ) op.drop_column("payments_transactions", "success") op.drop_column("payments_transactions", "errors") diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_payments.py b/packages/postgres-database/src/simcore_postgres_database/utils_payments.py new file mode 100644 index 00000000000..5063b3b0aef --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/utils_payments.py @@ -0,0 +1,152 @@ +import datetime +from dataclasses import dataclass +from decimal import Decimal +from typing import TypeAlias + +import sqlalchemy as sa +from aiopg.sa.connection import SAConnection +from aiopg.sa.result import ResultProxy, RowProxy + +from . import errors +from .models.payments_transactions import PaymentTransactionState, payments_transactions + +PaymentID: TypeAlias = str + +PaymentTransactionRow: TypeAlias = RowProxy + + +@dataclass +class PaymentFailure: + payment_id: str + + def __bool__(self): + return False + + +class PaymentAlreadyExists(PaymentFailure): + ... + + +class PaymentNotFound(PaymentFailure): + ... + + +class PaymentAlreadyAcked(PaymentFailure): + ... + + +async def insert_init_payment_transaction( + connection: SAConnection, + *, + payment_id: str, + price_dollars: Decimal, + osparc_credits: Decimal, + product_name: str, + user_id: int, + user_email: str, + wallet_id: int, + comment: str | None, + initiated_at: datetime.datetime, +) -> PaymentID | PaymentAlreadyExists: + """Annotates 'init' transaction in the database""" + try: + await connection.execute( + payments_transactions.insert().values( + payment_id=payment_id, + price_dollars=price_dollars, + osparc_credits=osparc_credits, + product_name=product_name, + user_id=user_id, + user_email=user_email, + wallet_id=wallet_id, + comment=comment, + initiated_at=initiated_at, + ) + ) + except errors.UniqueViolation: + return PaymentAlreadyExists(payment_id) + + return payment_id + + +async def update_payment_transaction_state( + connection: SAConnection, + *, + payment_id: str, + completion_state: PaymentTransactionState, + state_message: str | None = None, +) -> PaymentTransactionRow | PaymentNotFound | PaymentAlreadyAcked: + """ACKs payment by updating state with SUCCESS, ...""" + if completion_state == PaymentTransactionState.PENDING: + msg = f"cannot update state with {completion_state=} since it is already initiated" + raise ValueError(msg) + + optional = {} + if state_message: + optional["state_message"] = state_message + + async with connection.begin(): + row = await ( + await connection.execute( + sa.select( + payments_transactions.c.initiated_at, + payments_transactions.c.completed_at, + ) + .where(payments_transactions.c.payment_id == payment_id) + .with_for_update() + ) + ).fetchone() + + if row is None: + return PaymentNotFound(payment_id=payment_id) + + if row.completed_at is not None: + assert row.initiated_at < row.completed_at # nosec + return PaymentAlreadyAcked(payment_id=payment_id) + + assert row.initiated_at # nosec + + result = await connection.execute( + payments_transactions.update() + .values(completed_at=sa.func.now(), state=completion_state, **optional) + .where(payments_transactions.c.payment_id == payment_id) + .returning(sa.literal_column("*")) + ) + row = await result.first() + assert row, "execute above should have caught this" # nosec + + return row + + +async def get_user_payments_transactions( + connection: SAConnection, + *, + user_id: int, + offset: int | None = None, + limit: int | None = None, +) -> tuple[int, list[PaymentTransactionRow]]: + total_number_of_items = await connection.scalar( + sa.select(sa.func.count()) + .select_from(payments_transactions) + .where(payments_transactions.c.user_id == user_id) + ) + assert total_number_of_items is not None # nosec + + # NOTE: what if between these two calls there are new rows? can we get this in an atomic call?å + stmt = ( + payments_transactions.select() + .where(payments_transactions.c.user_id == user_id) + .order_by(payments_transactions.c.created.desc()) + ) # newest first + + if offset is not None: + # psycopg2.errors.InvalidRowCountInResultOffsetClause: OFFSET must not be negative + stmt = stmt.offset(offset) + + if limit is not None: + # InvalidRowCountInLimitClause: LIMIT must not be negative + stmt = stmt.limit(limit) + + result: ResultProxy = await connection.execute(stmt) + rows = await result.fetchall() or [] + return total_number_of_items, rows diff --git a/packages/postgres-database/tests/test_models_payments_transactions.py b/packages/postgres-database/tests/test_models_payments_transactions.py index d475d5ad644..2e3f6432b85 100644 --- a/packages/postgres-database/tests/test_models_payments_transactions.py +++ b/packages/postgres-database/tests/test_models_payments_transactions.py @@ -17,6 +17,14 @@ PaymentTransactionState, payments_transactions, ) +from simcore_postgres_database.utils_payments import ( + PaymentAlreadyAcked, + PaymentNotFound, + PaymentTransactionRow, + get_user_payments_transactions, + insert_init_payment_transaction, + update_payment_transaction_state, +) def utcnow() -> datetime.datetime: @@ -50,7 +58,7 @@ async def test_numerics_precission_and_scale(connection: SAConnection): # precision: This parameter specifies the total number of digits that can be stored, both before and after the decimal point. # scale: This parameter specifies the number of digits that can be stored to the right of the decimal point. - for order_of_magnitude in range(0, 8): + for order_of_magnitude in range(8): expected = 10**order_of_magnitude + 0.123 got = await connection.scalar( payments_transactions.insert() @@ -71,18 +79,26 @@ async def _init(payment_id: str): values["initiated_at"] = utcnow() # insert - await connection.execute(payments_transactions.insert().values(values)) + ok = await insert_init_payment_transaction(connection, **values) + assert ok + return values return _init -async def test_create_transaction(connection: SAConnection, init_transaction: Callable): - payment_id = "5495BF38-4A98-430C-A028-19E4585ADFC7" +@pytest.fixture +def payment_id() -> str: + return "5495BF38-4A98-430C-A028-19E4585ADFC7" + + +async def test_init_transaction_sets_it_as_pending( + connection: SAConnection, init_transaction: Callable, payment_id: str +): values = await init_transaction(payment_id) assert values["payment_id"] == payment_id - # insert + # check init-ed but not completed! result = await connection.execute( sa.select( payments_transactions.c.completed_at, @@ -101,44 +117,131 @@ async def test_create_transaction(connection: SAConnection, init_transaction: Ca } -async def test_complete_transaction_with_success( - connection: SAConnection, init_transaction: Callable +@pytest.mark.parametrize( + "expected_state,expected_message", + [ + ( + state, + None if state is PaymentTransactionState.SUCCESS else f"with {state}", + ) + for state in [ + PaymentTransactionState.SUCCESS, + PaymentTransactionState.FAILED, + PaymentTransactionState.CANCELED, + ] + ], +) +async def test_complete_transaction( + connection: SAConnection, + init_transaction: Callable, + payment_id: str, + expected_state: PaymentTransactionState, + expected_message: str | None, ): - payment_id = "5495BF38-4A98-430C-A028-19E4585ADFC7" await init_transaction(payment_id) - state_message = await connection.scalar( - payments_transactions.update() - .values( - completed_at=utcnow(), - state=PaymentTransactionState.SUCCESS, - ) - .where(payments_transactions.c.payment_id == payment_id) - .returning(payments_transactions.c.state_message) + payment_row = await update_payment_transaction_state( + connection, + payment_id=payment_id, + completion_state=expected_state, + state_message=expected_message, ) - assert state_message is None + assert isinstance(payment_row, PaymentTransactionRow) + assert payment_row.state_message == expected_message + assert payment_row.state == expected_state + assert payment_row.initiated_at < payment_row.completed_at -async def test_complete_transaction_with_failure( - connection: SAConnection, init_transaction: Callable + +async def test_update_transaction_failures_and_exceptions( + connection: SAConnection, + init_transaction: Callable, + payment_id: str, ): - payment_id = "5495BF38-4A98-430C-A028-19E4585ADFC7" + kwargs = { + "connection": connection, + "payment_id": payment_id, + "completion_state": PaymentTransactionState.SUCCESS, + } + + ok = await update_payment_transaction_state(**kwargs) + assert isinstance(ok, PaymentNotFound) + assert not ok + + # init & complete await init_transaction(payment_id) + ok = await update_payment_transaction_state(**kwargs) + assert isinstance(ok, PaymentTransactionRow) + assert ok - data = await ( - await connection.execute( - payments_transactions.update() - .values( - completed_at=utcnow(), - state=PaymentTransactionState.FAILED, - state_message="some error message", - ) - .where(payments_transactions.c.payment_id == payment_id) - .returning(sa.literal_column("*")) - ) - ).fetchone() + # repeat -> fails + ok = await update_payment_transaction_state(**kwargs) + assert isinstance(ok, PaymentAlreadyAcked) + assert not ok + + with pytest.raises(ValueError): + kwargs.update(completion_state=PaymentTransactionState.PENDING) + await update_payment_transaction_state(**kwargs) + + +@pytest.fixture +def user_id() -> int: + return 1 - assert data is not None - assert data["completed_at"] - assert data["state"] == PaymentTransactionState.FAILED - assert data["state_message"] is not None + +@pytest.fixture +def create_fake_user_transactions(connection: SAConnection, user_id: int) -> Callable: + async def _go(expected_total=5): + payment_ids = [] + for _ in range(expected_total): + values = random_payment_transaction(user_id=user_id) + payment_id = await insert_init_payment_transaction(connection, **values) + assert payment_id + payment_ids.append(payment_id) + + return payment_ids + + return _go + + +async def test_get_user_payments_transactions( + connection: SAConnection, create_fake_user_transactions: Callable, user_id: int +): + expected_payments_ids = await create_fake_user_transactions() + expected_total = len(expected_payments_ids) + + # test offset and limit defaults + total, rows = await get_user_payments_transactions(connection, user_id=user_id) + assert total == expected_total + assert [r.payment_id for r in rows] == expected_payments_ids[::-1], "newest first" + + +async def test_get_user_payments_transactions_with_pagination_options( + connection: SAConnection, create_fake_user_transactions: Callable, user_id: int +): + expected_payments_ids = await create_fake_user_transactions() + expected_total = len(expected_payments_ids) + + # test offset, limit + offset = int(expected_total / 4) + limit = int(expected_total / 2) + + total, rows = await get_user_payments_transactions( + connection, user_id=user_id, limit=limit, offset=offset + ) + assert total == expected_total + assert [r.payment_id for r in rows] == expected_payments_ids[::-1][ + offset : (offset + limit) + ], "newest first" + + # test offset>=expected_total? + total, rows = await get_user_payments_transactions( + connection, user_id=user_id, offset=expected_total + ) + assert not rows + + # test limit==0? + total, rows = await get_user_payments_transactions( + connection, user_id=user_id, limit=0 + ) + assert not rows diff --git a/packages/service-library/src/servicelib/file_utils.py b/packages/service-library/src/servicelib/file_utils.py index 8ec1e60672f..fa3a0665894 100644 --- a/packages/service-library/src/servicelib/file_utils.py +++ b/packages/service-library/src/servicelib/file_utils.py @@ -15,7 +15,7 @@ _shutil_rmtree = sync_to_async(shutil.rmtree) -async def _rm(path: Path, ignore_errors: bool): +async def _rm(path: Path, *, ignore_errors: bool): """Removes file or directory""" try: await remove(path) @@ -24,11 +24,13 @@ async def _rm(path: Path, ignore_errors: bool): async def remove_directory( - path: Path, only_children: bool = False, ignore_errors: bool = False + path: Path, *, only_children: bool = False, ignore_errors: bool = False ) -> None: """Optional parameter allows to remove all children and keep directory""" if only_children: - await asyncio.gather(*[_rm(child, ignore_errors) for child in path.glob("*")]) + await asyncio.gather( + *[_rm(child, ignore_errors=ignore_errors) for child in path.glob("*")] + ) else: await _shutil_rmtree(path, ignore_errors=ignore_errors) @@ -39,9 +41,7 @@ async def read(self, size: int = -1) -> bytes: async def create_sha256_checksum( - async_stream: AsyncStream, - *, - chunk_size: ByteSize = CHUNK_4KB, + async_stream: AsyncStream, *, chunk_size: ByteSize = CHUNK_4KB ) -> str: """ Usage: @@ -54,13 +54,12 @@ async def create_sha256_checksum( WARNING: bandit reports the use of insecure MD2, MD4, MD5, or SHA1 hash function. """ sha256_hash = hashlib.sha256() # nosec - sha256check = await _eval_hash_async(async_stream, sha256_hash, chunk_size) - return sha256check + return await _eval_hash_async(async_stream, sha256_hash, chunk_size) async def _eval_hash_async( async_stream: AsyncStream, - hasher: "hashlib._Hash", + hasher: "hashlib._Hash", # noqa: SLF001 chunk_size: ByteSize, ) -> str: while chunk := await async_stream.read(chunk_size): diff --git a/packages/simcore-sdk/tests/integration/test_node_ports_common_r_clone.py b/packages/simcore-sdk/tests/integration/test_node_ports_common_r_clone.py index a1092095434..25a457fee5b 100644 --- a/packages/simcore-sdk/tests/integration/test_node_ports_common_r_clone.py +++ b/packages/simcore-sdk/tests/integration/test_node_ports_common_r_clone.py @@ -6,7 +6,7 @@ import os import re import urllib.parse -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable from pathlib import Path from typing import Final from unittest.mock import AsyncMock @@ -17,6 +17,7 @@ import pytest from faker import Faker from pydantic import AnyUrl, ByteSize, parse_obj_as +from servicelib.file_utils import remove_directory from servicelib.progress_bar import ProgressBarData from servicelib.utils import logged_gather from settings_library.r_clone import RCloneSettings @@ -37,22 +38,6 @@ WAIT_FOR_S3_BACKEND_TO_UPDATE: Final[float] = 1.0 -@pytest.fixture( - params=[ - f"{uuid4()}.bin", - "some funky name.txt", - "öä$äö2-34 no extension", - ] -) -def file_name(request: pytest.FixtureRequest) -> str: - return request.param - - -@pytest.fixture -def local_file_for_download(upload_file_dir: Path, file_name: str) -> Path: - return upload_file_dir / f"__local__{file_name}" - - @pytest.fixture async def cleanup_bucket_after_test(r_clone_settings: RCloneSettings) -> None: session = aioboto3.Session( @@ -71,9 +56,6 @@ async def cleanup_bucket_after_test(r_clone_settings: RCloneSettings) -> None: await asyncio.gather(*[o.delete() for o in s3_objects]) -# UTILS - - def _fake_s3_link(r_clone_settings: RCloneSettings, s3_object: str) -> AnyUrl: return parse_obj_as( AnyUrl, @@ -228,18 +210,26 @@ def _ensure_dir(tmp_path: Path, faker: Faker, *, dir_prefix: str) -> Path: @pytest.fixture -def dir_locally_created_files(tmp_path: Path, faker: Faker) -> Path: - return _ensure_dir(tmp_path, faker, dir_prefix="source") +async def dir_locally_created_files( + tmp_path: Path, faker: Faker +) -> AsyncIterator[Path]: + path = _ensure_dir(tmp_path, faker, dir_prefix="source") + yield path + await remove_directory(path) @pytest.fixture -def dir_downloaded_files_1(tmp_path: Path, faker: Faker) -> Path: - return _ensure_dir(tmp_path, faker, dir_prefix="downloaded-1") +async def dir_downloaded_files_1(tmp_path: Path, faker: Faker) -> AsyncIterator[Path]: + path = _ensure_dir(tmp_path, faker, dir_prefix="downloaded-1") + yield path + await remove_directory(path) @pytest.fixture -def dir_downloaded_files_2(tmp_path: Path, faker: Faker) -> Path: - return _ensure_dir(tmp_path, faker, dir_prefix="downloaded-2") +async def dir_downloaded_files_2(tmp_path: Path, faker: Faker) -> AsyncIterator[Path]: + path = _ensure_dir(tmp_path, faker, dir_prefix="downloaded-2") + yield path + await remove_directory(path) @pytest.mark.parametrize( diff --git a/scripts/download-deployed-webserver-settings.bash b/scripts/download-deployed-webserver-settings.bash new file mode 100755 index 00000000000..554f15f4556 --- /dev/null +++ b/scripts/download-deployed-webserver-settings.bash @@ -0,0 +1,14 @@ +#!/bin/bash + +# all containers running on the image `local/webserver:production` +containers=$(docker ps -a --filter "status=running" --filter "ancestor=local/webserver:production" --format "{{.ID}}") + +for container_id in $containers +do + # Get the name of the container + container_name=$(docker inspect -f '{{.Name}}' "$container_id" | cut -c 2-) + + # Execute the command in the container to create settings.json + docker exec "$container_id" simcore-service-webserver settings --as-json > "${container_name}-settings.ignore.json" + +done diff --git a/scripts/release/monitor/monitor_release/cli.py b/scripts/release/monitor/monitor_release/cli.py index d29e10fc16f..9820b4cd613 100644 --- a/scripts/release/monitor/monitor_release/cli.py +++ b/scripts/release/monitor/monitor_release/cli.py @@ -16,8 +16,12 @@ class Action(str, Enum): @app.command() -def main(deployment: Deployment, action: Action): - settings = get_settings(deployment) +def main( + deployment: Deployment, + action: Action, + env_file: str = typer.Option(".env", help="Path to .env file"), +): + settings = get_settings(env_file, deployment) console.print(f"Deployment: {deployment}") console.print(f"Action: {action}") diff --git a/scripts/release/monitor/monitor_release/settings.py b/scripts/release/monitor/monitor_release/settings.py index 4d74c2905d9..d37ce291536 100644 --- a/scripts/release/monitor/monitor_release/settings.py +++ b/scripts/release/monitor/monitor_release/settings.py @@ -13,9 +13,9 @@ class Settings(BaseModel): portainer_endpoint_version: int -def get_settings(deployment): +def get_settings(env_file, deployment): # pylint: disable=too-many-return-statements - load_dotenv("/home/matus/Projects/osparc-simcore/scripts/release/monitor/.env") + load_dotenv(env_file) if deployment == "master": portainer_url = os.getenv("MASTER_PORTAINER_URL") diff --git a/services/api-server/src/simcore_service_api_server/api/routes/files.py b/services/api-server/src/simcore_service_api_server/api/routes/files.py index 1fba8338c3a..3987880912d 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/files.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/files.py @@ -317,9 +317,7 @@ async def search_files_page( access_right="read", ) error_message: str = "Not found in storage" - if not stored_files: - raise ValueError(error_message) # noqa: TRY301 - if page_params.offset >= len(stored_files): + if page_params.offset > len(stored_files): raise ValueError(error_message) stored_files = stored_files[page_params.offset :] if len(stored_files) > page_params.limit: diff --git a/services/docker-compose.yml b/services/docker-compose.yml index e042e7b2732..8d77427f0b1 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -333,6 +333,9 @@ services: CATALOG_HOST: ${CATALOG_HOST} CATALOG_PORT: ${CATALOG_PORT} + # WEBSERVER_CREDIT_COMPUTATION + WEBSERVER_CREDIT_COMPUTATION_ENABLED: ${WEBSERVER_CREDIT_COMPUTATION_ENABLED} + # WEBSERVER_DB POSTGRES_DB: ${POSTGRES_DB} POSTGRES_ENDPOINT: ${POSTGRES_ENDPOINT} @@ -519,45 +522,39 @@ services: # WEBSERVER_RESOURCE_USAGE_TRACKER RESOURCE_USAGE_TRACKER_HOST: ${RESOURCE_USAGE_TRACKER_HOST} - # WEBSERVER_INVITATIONS - INVITATIONS_HOST: ${INVITATIONS_HOST} - INVITATIONS_LOGLEVEL: ${INVITATIONS_LOGLEVEL} - INVITATIONS_OSPARC_URL: ${INVITATIONS_OSPARC_URL} - INVITATIONS_PASSWORD: ${INVITATIONS_PASSWORD} - INVITATIONS_PORT: ${INVITATIONS_PORT} - INVITATIONS_SECRET_KEY: ${INVITATIONS_SECRET_KEY} - INVITATIONS_USERNAME: ${INVITATIONS_USERNAME} - GUNICORN_CMD_ARGS: ${WEBSERVER_GUNICORN_CMD_ARGS} LOG_FORMAT_LOCAL_DEV_ENABLED: ${LOG_FORMAT_LOCAL_DEV_ENABLED} SWARM_STACK_NAME: ${SWARM_STACK_NAME} - WEBSERVER_DB_LISTENER: ${WB_DB_EL_DB_LISTENER} - WEBSERVER_SOCKETIO: ${WB_DB_EL_SOCKETIO} - WEBSERVER_ANNOUNCEMENTS: ${WB_DB_EL_ANNOUNCEMENTS} + SESSION_SECRET_KEY: ${WEBSERVER_SESSION_SECRET_KEY} WEBSERVER_ACTIVITY: ${WB_DB_EL_ACTIVITY} + WEBSERVER_ANNOUNCEMENTS: ${WB_DB_EL_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_DB_EL_CATALOG} - WEBSERVER_NOTIFICATIONS: ${WB_DB_EL_NOTIFICATIONS} + WEBSERVER_CLUSTERS: ${WB_DB_EL_CLUSTERS} + WEBSERVER_DB_LISTENER: ${WB_DB_EL_DB_LISTENER} WEBSERVER_DIAGNOSTICS: ${WB_DB_EL_DIAGNOSTICS} WEBSERVER_EMAIL: ${WB_DB_EL_EMAIL} WEBSERVER_EXPORTER: ${WB_DB_EL_EXPORTER} WEBSERVER_FRONTEND: ${WB_DB_EL_FRONTEND} WEBSERVER_GARBAGE_COLLECTOR: ${WB_DB_EL_GARBAGE_COLLECTOR} + WEBSERVER_GROUPS: ${WB_DB_EL_GROUPS} + WEBSERVER_INVITATIONS: ${WB_DB_EL_INVITATIONS} WEBSERVER_LOGIN: ${WB_DB_EL_LOGIN} + WEBSERVER_PAYMENTS: ${WB_DB_EL_PAYMENTS} + WEBSERVER_META_MODELING: ${WB_DB_EL_META_MODELING} + WEBSERVER_NOTIFICATIONS: ${WB_DB_EL_NOTIFICATIONS} + WEBSERVER_PRODUCTS: ${WB_DB_EL_PRODUCTS} WEBSERVER_PROJECTS: ${WB_DB_EL_PROJECTS} + WEBSERVER_PUBLICATIONS: ${WB_DB_EL_PUBLICATIONS} WEBSERVER_SCICRUNCH: ${WB_DB_EL_SCICRUNCH} + WEBSERVER_SOCKETIO: ${WB_DB_EL_SOCKETIO} WEBSERVER_STATICWEB: ${WB_DB_EL_STATICWEB} WEBSERVER_STORAGE: ${WB_DB_EL_STORAGE} WEBSERVER_STUDIES_DISPATCHER: ${WB_DB_EL_STUDIES_DISPATCHER} - WEBSERVER_TRACING: ${WB_DB_EL_TRACING} - WEBSERVER_CLUSTERS: ${WB_DB_EL_CLUSTERS} - WEBSERVER_GROUPS: ${WB_DB_EL_GROUPS} - WEBSERVER_META_MODELING: ${WB_DB_EL_META_MODELING} - WEBSERVER_PRODUCTS: ${WB_DB_EL_PRODUCTS} - WEBSERVER_PUBLICATIONS: ${WB_DB_EL_PUBLICATIONS} WEBSERVER_TAGS: ${WB_DB_EL_TAGS} + WEBSERVER_TRACING: ${WB_DB_EL_TRACING} WEBSERVER_USERS: ${WB_DB_EL_USERS} WEBSERVER_VERSION_CONTROL: ${WB_DB_EL_VERSION_CONTROL} - SESSION_SECRET_KEY: ${WEBSERVER_SESSION_SECRET_KEY} + WEBSERVER_WALLETS: ${WB_DB_EL_WALLETS} # WEBSERVER_RABBITMQ RABBIT_HOST: ${RABBIT_HOST} @@ -594,14 +591,6 @@ services: REST_SWAGGER_API_DOC_ENABLED: ${REST_SWAGGER_API_DOC_ENABLED} - INVITATIONS_HOST: ${INVITATIONS_HOST} - INVITATIONS_LOGLEVEL: ${INVITATIONS_LOGLEVEL} - INVITATIONS_OSPARC_URL: ${INVITATIONS_OSPARC_URL} - INVITATIONS_PASSWORD: ${INVITATIONS_PASSWORD} - INVITATIONS_PORT: ${INVITATIONS_PORT} - INVITATIONS_SECRET_KEY: ${INVITATIONS_SECRET_KEY} - INVITATIONS_USERNAME: ${INVITATIONS_USERNAME} - # WEBSERVER_DB POSTGRES_DB: ${POSTGRES_DB} POSTGRES_ENDPOINT: ${POSTGRES_ENDPOINT} @@ -634,30 +623,33 @@ services: RESOURCE_MANAGER_RESOURCE_TTL_S: ${WB_GC_RESOURCE_MANAGER_RESOURCE_TTL_S} - WEBSERVER_ANNOUNCEMENTS: ${WB_GC_ANNOUNCEMENTS} + SESSION_SECRET_KEY: ${WEBSERVER_SESSION_SECRET_KEY} WEBSERVER_ACTIVITY: ${WB_GC_ACTIVITY} + WEBSERVER_ANNOUNCEMENTS: ${WB_GC_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_GC_CATALOG} - WEBSERVER_NOTIFICATIONS: ${WB_GC_NOTIFICATIONS} + WEBSERVER_CLUSTERS: ${WB_GC_CLUSTERS} WEBSERVER_DIAGNOSTICS: ${WB_GC_DIAGNOSTICS} WEBSERVER_EMAIL: ${WB_GC_EMAIL} WEBSERVER_EXPORTER: ${WB_GC_EXPORTER} WEBSERVER_FRONTEND: ${WB_GC_FRONTEND} - WEBSERVER_LOGIN: ${WB_GC_LOGIN} - WEBSERVER_PROJECTS: ${WB_GC_PROJECTS} - WEBSERVER_SCICRUNCH: ${WB_GC_SCICRUNCH} - WEBSERVER_STATICWEB: ${WB_GC_STATICWEB} - WEBSERVER_STUDIES_DISPATCHER: ${WB_GC_STUDIES_DISPATCHER} - WEBSERVER_TRACING: ${WB_GC_TRACING} - WEBSERVER_CLUSTERS: ${WB_GC_CLUSTERS} WEBSERVER_GROUPS: ${WB_GC_GROUPS} + WEBSERVER_INVITATIONS: ${WB_GC_INVITATIONS} + WEBSERVER_LOGIN: ${WB_GC_LOGIN} WEBSERVER_META_MODELING: ${WB_GC_META_MODELING} + WEBSERVER_NOTIFICATIONS: ${WB_GC_NOTIFICATIONS} + WEBSERVER_PAYMENTS: ${WB_GC_PAYMENTS} WEBSERVER_PRODUCTS: ${WB_GC_PRODUCTS} + WEBSERVER_PROJECTS: ${WB_GC_PROJECTS} WEBSERVER_PUBLICATIONS: ${WB_GC_PUBLICATIONS} + WEBSERVER_SCICRUNCH: ${WB_GC_SCICRUNCH} WEBSERVER_SOCKETIO: ${WB_GC_SOCKETIO} + WEBSERVER_STATICWEB: ${WB_GC_STATICWEB} + WEBSERVER_STUDIES_DISPATCHER: ${WB_GC_STUDIES_DISPATCHER} WEBSERVER_TAGS: ${WB_GC_TAGS} + WEBSERVER_TRACING: ${WB_GC_TRACING} WEBSERVER_USERS: ${WB_GC_USERS} WEBSERVER_VERSION_CONTROL: ${WB_GC_VERSION_CONTROL} - SESSION_SECRET_KEY: ${WEBSERVER_SESSION_SECRET_KEY} + WEBSERVER_WALLETS: ${WB_GC_WALLETS} # WEBSERVER_RABBITMQ RABBIT_HOST: ${RABBIT_HOST} diff --git a/services/static-webserver/client/source/class/osparc/WindowSizeTracker.js b/services/static-webserver/client/source/class/osparc/WindowSizeTracker.js index cada74e93bf..8afd29e90f7 100644 --- a/services/static-webserver/client/source/class/osparc/WindowSizeTracker.js +++ b/services/static-webserver/client/source/class/osparc/WindowSizeTracker.js @@ -50,7 +50,7 @@ qx.Class.define("osparc.WindowSizeTracker", { statics: { WIDTH_BREAKPOINT: 1280, // HD HEIGHT_BREAKPOINT: 720, // HD - WIDTH_COMPACT_BREAKPOINT: 1000 + WIDTH_COMPACT_BREAKPOINT: 1150 }, members: { diff --git a/services/static-webserver/client/source/class/osparc/auth/Data.js b/services/static-webserver/client/source/class/osparc/auth/Data.js index 9bfb7acde84..013a6f8ee51 100644 --- a/services/static-webserver/client/source/class/osparc/auth/Data.js +++ b/services/static-webserver/client/source/class/osparc/auth/Data.js @@ -85,7 +85,7 @@ qx.Class.define("osparc.auth.Data", { }, role: { - check: ["anonymous", "guest", "user", "tester", "admin"], + check: ["anonymous", "guest", "user", "tester", "product_owner", "admin"], init: null, nullable: false, event: "changeRole", @@ -109,7 +109,7 @@ qx.Class.define("osparc.auth.Data", { members: { __applyRole: function(role) { - if (role && ["user", "tester", "admin"].includes(role)) { + if (role && ["user", "tester", "product_owner", "admin"].includes(role)) { this.setGuest(false); } else { this.setGuest(true); @@ -142,6 +142,13 @@ qx.Class.define("osparc.auth.Data", { return osparc.utils.Utils.getNameFromEmail(email); } return "user"; + }, + + getFriendlyRole: function() { + const role = this.getRole(); + let friendlyRole = role.replace(/_/g, " "); + friendlyRole = osparc.utils.Utils.firstsUp(friendlyRole); + return friendlyRole; } } }); diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js index b0ec9d255f0..822a29d8cdf 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ServiceBrowser.js @@ -231,7 +231,7 @@ qx.Class.define("osparc.dashboard.ServiceBrowser", { })); if (data.files && data.files.length) { const size = data.files[0].size; - const maxSize = 10 * 1024 * 1024; // 10 MB + const maxSize = 10 * 1000 * 1000; // 10 MB if (size > maxSize) { osparc.FlashMessenger.logAs(`The file is too big. Maximum size is ${maxSize}MB. Please provide with a smaller file or a repository URL.`, "ERROR"); return; diff --git a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js index d2a37ccf0b9..58468e9de55 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js @@ -644,7 +644,7 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { return; } const size = file.size; - const maxSize = 10 * 1024 * 1024 * 1024; // 10 GB + const maxSize = 10 * 1000 * 1000 * 1000; // 10 GB if (size > maxSize) { osparc.FlashMessenger.logAs(`The file is too big. Maximum size is ${maxSize}MB. Please provide with a smaller file or a repository URL.`, "ERROR"); return; diff --git a/services/static-webserver/client/source/class/osparc/data/Permissions.js b/services/static-webserver/client/source/class/osparc/data/Permissions.js index 202133014fa..279b4a22b02 100644 --- a/services/static-webserver/client/source/class/osparc/data/Permissions.js +++ b/services/static-webserver/client/source/class/osparc/data/Permissions.js @@ -69,9 +69,13 @@ qx.Class.define("osparc.data.Permissions", { can: [], inherits: ["user"] }, - admin: { + "product_owner": { can: [], inherits: ["tester"] + }, + admin: { + can: [], + inherits: ["product_owner"] } }, @@ -124,7 +128,6 @@ qx.Class.define("osparc.data.Permissions", { "services.all.read", "services.all.reupdate", "services.filePicker.read.all", - "user.role.update", "user.clusters.create", "user.wallets.create", "study.everyone.share", @@ -136,6 +139,9 @@ qx.Class.define("osparc.data.Permissions", { "statics.read", "usage.all.read" ], + "product_owner": [ + "user.invitation.generate" + ], "admin": [] }; let fromUserToTester = []; @@ -168,7 +174,7 @@ qx.Class.define("osparc.data.Permissions", { properties: { role: { - check: ["anonymous", "guest", "user", "tester", "admin"], + check: ["anonymous", "guest", "user", "tester", "product_owner", "admin"], init: null, nullable: false, event: "changeRole" @@ -276,7 +282,11 @@ qx.Class.define("osparc.data.Permissions", { }, isTester: function() { - return ["admin", "tester"].includes(this.getRole()); + return ["tester", "product_owner", "admin"].includes(this.getRole()); + }, + + isProductOwner: function() { + return ["product_owner", "admin"].includes(this.getRole()); } } }); diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index d3198c68cf7..2650c5af23f 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -653,7 +653,7 @@ qx.Class.define("osparc.data.Resources", { } }, /* - * CREDITS PRICE + * PRODUCTS */ "credits-price": { endpoints: { @@ -663,6 +663,14 @@ qx.Class.define("osparc.data.Resources", { } } }, + "invitations": { + endpoints: { + post: { + method: "POST", + url: statics.API + "/invitation:generate" + } + } + }, /* * PAYMENTS */ @@ -928,7 +936,7 @@ qx.Class.define("osparc.data.Resources", { }, /* - * Test/Diagnonstic entrypoint + * Test/Diagnostic entrypoint */ "checkEP": { useCache: false, @@ -1184,7 +1192,7 @@ qx.Class.define("osparc.data.Resources", { /** * Add the given data to the cached version of a resource, or a collection of them. * @param {String} resource Name of the resource as defined in the static property 'resources'. - * @param {*} data Resource or collection of resources to be addded to the cache. + * @param {*} data Resource or collection of resources to be added to the cache. */ __addCached: function(resource, data) { osparc.store.Store.getInstance().append(resource, data, this.self().resources[resource].idField || "uuid"); diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/CreditsLabel.js b/services/static-webserver/client/source/class/osparc/desktop/credits/CreditsLabel.js index 172e42d7336..9d9626b44c1 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/CreditsLabel.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/CreditsLabel.js @@ -94,7 +94,7 @@ qx.Class.define("osparc.desktop.credits.CreditsLabel", { } let label = creditsAvailable.toFixed(2); if (this.isShortWording()) { - label += this.tr(" cr."); + label += this.tr(" credits"); } else { label += this.tr(" credits"); } diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/ProfilePage.js b/services/static-webserver/client/source/class/osparc/desktop/credits/ProfilePage.js index 7700f2a02d9..54f3bc944f1 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/ProfilePage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/ProfilePage.js @@ -66,35 +66,10 @@ qx.Class.define("osparc.desktop.credits.ProfilePage", { placeholder: this.tr("Last Name") }); - let role = null; - const permissions = osparc.data.Permissions.getInstance(); - if (permissions.canDo("user.role.update")) { - role = new qx.ui.form.SelectBox(); - const roles = permissions.getChildrenRoles(permissions.getRole()); - for (let i=0; i { if (expirationDay) { @@ -174,7 +148,7 @@ qx.Class.define("osparc.desktop.credits.ProfilePage", { const namesValidator = new qx.ui.form.validation.Manager(); namesValidator.add(firstName, qx.util.Validate.regExp(/[^\.\d]+/), this.tr("Avoid dots or numbers in text")); - namesValidator.add(lastName, qx.util.Validate.regExp(/^$|[^\.\d]+/), this.tr("Avoid dots or numbers in text")); // allow also emtpy last name + namesValidator.add(lastName, qx.util.Validate.regExp(/^$|[^\.\d]+/), this.tr("Avoid dots or numbers in text")); // allow also empty last name const updateBtn = new qx.ui.form.Button("Update Profile").set({ allowGrowX: false diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/UserCenter.js b/services/static-webserver/client/source/class/osparc/desktop/credits/UserCenter.js index 755b8b5e89d..e938845d8a9 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/UserCenter.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/UserCenter.js @@ -34,7 +34,7 @@ qx.Class.define("osparc.desktop.credits.UserCenter", { barPosition: "left", contentPadding: 0 }); - tabViews.getChildControl("bar").add(this.__getMiniProfileView()); + tabViews.getChildControl("bar").add(this.self().createMiniProfileView()); if (this.__walletsEnabled) { const overviewPage = this.__overviewPage = this.__getOverviewPage(); @@ -73,28 +73,7 @@ qx.Class.define("osparc.desktop.credits.UserCenter", { }, statics: { - openWindow: function(walletsEnabled = false) { - const accountWindow = new osparc.desktop.credits.UserCenter(walletsEnabled); - accountWindow.center(); - accountWindow.open(); - return accountWindow; - } - }, - - members: { - __walletsEnabled: null, - __tabsView: null, - __overviewPage: null, - __profilePage: null, - __walletsPage: null, - __buyCreditsPage: null, - __paymentMethodsPage: null, - __transactionsPage: null, - __usageOverviewPage: null, - __buyCredits: null, - __transactionsTable: null, - - __getMiniProfileView: function() { + createMiniProfileView: function() { const layout = new qx.ui.container.Composite(new qx.ui.layout.VBox(8)).set({ alignX: "center", minWidth: 120, @@ -127,18 +106,37 @@ qx.Class.define("osparc.desktop.credits.UserCenter", { converter: lastName => authData.getFirstName() + " " + lastName }); + const role = authData.getFriendlyRole(); + const roleLabel = new qx.ui.basic.Label(role).set({ + font: "text-13", + alignX: "center" + }); + layout.add(roleLabel); + const emailLabel = new qx.ui.basic.Label(email).set({ font: "text-13", alignX: "center" }); layout.add(emailLabel); - layout.add(new qx.ui.core.Spacer(15, 15), { - flex: 1 - }); + layout.add(new qx.ui.core.Spacer(15, 15)); return layout; - }, + } + }, + + members: { + __walletsEnabled: null, + __tabsView: null, + __overviewPage: null, + __profilePage: null, + __walletsPage: null, + __buyCreditsPage: null, + __paymentMethodsPage: null, + __transactionsPage: null, + __usageOverviewPage: null, + __buyCredits: null, + __transactionsTable: null, __getOverviewPage: function() { const title = this.tr("Overview"); @@ -216,11 +214,11 @@ qx.Class.define("osparc.desktop.credits.UserCenter", { const iconSrc = "@FontAwesome5Solid/credit-card/22"; const page = new osparc.desktop.preferences.pages.BasePage(title, iconSrc); page.showLabelOnTab(); - const paymentPethods = new osparc.desktop.paymentMethods.PaymentMethods(); - paymentPethods.set({ + const paymentMethods = new osparc.desktop.paymentMethods.PaymentMethods(); + paymentMethods.set({ margin: 10 }); - page.add(paymentPethods); + page.add(paymentMethods); return page; }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/credits/WalletsMiniViewer.js b/services/static-webserver/client/source/class/osparc/desktop/credits/WalletsMiniViewer.js index b2d2c510e8a..456d3d97b6c 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/credits/WalletsMiniViewer.js +++ b/services/static-webserver/client/source/class/osparc/desktop/credits/WalletsMiniViewer.js @@ -26,9 +26,21 @@ qx.Class.define("osparc.desktop.credits.WalletsMiniViewer", { osparc.utils.Utils.setIdToWidget(this, "walletsMiniViewer"); this.set({ - cursor: "pointer", + alignX: "center", padding: 5, - paddingRight: 10 + margin: 10, + marginRight: 20 + }); + + // make it look like a button + this.set({ + cursor: "pointer", + backgroundColor: "background-main-3" + }); + this.addListener("pointerover", () => this.setBackgroundColor("background-main-4"), this); + this.addListener("pointerout", () => this.setBackgroundColor("background-main-3"), this); + this.getContentElement().setStyles({ + "border-radius": "4px" }); this.__walletListeners = []; diff --git a/services/static-webserver/client/source/class/osparc/navigation/NavigationBar.js b/services/static-webserver/client/source/class/osparc/navigation/NavigationBar.js index 63bf5c09004..49016c110fb 100644 --- a/services/static-webserver/client/source/class/osparc/navigation/NavigationBar.js +++ b/services/static-webserver/client/source/class/osparc/navigation/NavigationBar.js @@ -273,8 +273,6 @@ qx.Class.define("osparc.navigation.NavigationBar", { } case "wallets-viewer": control = new osparc.desktop.credits.WalletsMiniViewer().set({ - maxWidth: 100, - minWidth: 100, maxHeight: this.self().HEIGHT }); this.getChildControl("right-items").add(control); diff --git a/services/static-webserver/client/source/class/osparc/navigation/UserMenuButton.js b/services/static-webserver/client/source/class/osparc/navigation/UserMenuButton.js index 7181695ef28..51ad841fb53 100644 --- a/services/static-webserver/client/source/class/osparc/navigation/UserMenuButton.js +++ b/services/static-webserver/client/source/class/osparc/navigation/UserMenuButton.js @@ -87,17 +87,25 @@ qx.Class.define("osparc.navigation.UserMenuButton", { control.addListener("execute", () => window.open(window.location.href, "_blank")); this.getMenu().add(control); break; - case "account": + case "user-center": control = new qx.ui.menu.Button(this.tr("User Center")); control.addListener("execute", () => { osparc.desktop.credits.Utils.areWalletsEnabled() .then(walletsEnabled => { - const creditsWindow = osparc.desktop.credits.UserCenterWindow.openWindow(walletsEnabled); - creditsWindow.openOverview(); + const userCenterWindow = osparc.desktop.credits.UserCenterWindow.openWindow(walletsEnabled); + userCenterWindow.openOverview(); }); }, this); this.getMenu().add(control); break; + case "po-center": + control = new qx.ui.menu.Button(this.tr("PO Center")); + control.addListener("execute", () => { + const poCenterWindow = osparc.po.POCenterWindow.openWindow(); + poCenterWindow.openInvitations(); + }, this); + this.getMenu().add(control); + break; case "preferences": control = new qx.ui.menu.Button(this.tr("Preferences")); control.addListener("execute", () => osparc.navigation.UserMenuButton.openPreferences(), this); @@ -160,12 +168,13 @@ qx.Class.define("osparc.navigation.UserMenuButton", { populateMenu: function() { this.getMenu().removeAll(); - - const authData = osparc.auth.Data.getInstance(); - if (authData.isGuest()) { + if (osparc.auth.Data.getInstance().isGuest()) { this.getChildControl("log-in"); } else { - this.getChildControl("account"); + this.getChildControl("user-center"); + if (osparc.data.Permissions.getInstance().isProductOwner()) { + this.getChildControl("po-center"); + } this.getChildControl("preferences"); this.getChildControl("organizations"); this.getChildControl("clusters"); @@ -197,7 +206,7 @@ qx.Class.define("osparc.navigation.UserMenuButton", { if (authData.isGuest()) { this.getChildControl("log-in"); } else { - this.getChildControl("account"); + this.getChildControl("user-center"); this.getChildControl("preferences"); this.getChildControl("organizations"); this.getChildControl("clusters"); diff --git a/services/static-webserver/client/source/class/osparc/po/Invitations.js b/services/static-webserver/client/source/class/osparc/po/Invitations.js new file mode 100644 index 00000000000..2e1aa7a9521 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/po/Invitations.js @@ -0,0 +1,191 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2023 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.po.Invitations", { + extend: qx.ui.core.Widget, + + construct: function() { + this.base(arguments); + + this._setLayout(new qx.ui.layout.VBox(10)); + + this.__buildLayout(); + }, + + statics: { + createGroupBox: function(title) { + const box = new qx.ui.groupbox.GroupBox(title).set({ + appearance: "settings-groupbox", + layout: new qx.ui.layout.VBox(5), + alignX: "center" + }); + box.getChildControl("legend").set({ + font: "text-14" + }); + box.getChildControl("frame").set({ + backgroundColor: "transparent" + }); + return box; + }, + + createHelpLabel: function(text) { + const label = new qx.ui.basic.Label(text).set({ + font: "text-13", + rich: true, + alignX: "left" + }); + return label; + } + }, + + members: { + __generatedInvitationLayout: null, + + _createChildControlImpl: function(id) { + let control; + switch (id) { + case "create-invitation": + control = this.__createInvitations(); + this._add(control); + break; + } + return control || this.base(arguments, id); + }, + + __buildLayout: function() { + this._createChildControlImpl("create-invitation"); + }, + + __createInvitations: function() { + const invitationGroupBox = this.self().createGroupBox(this.tr("Create invitation")); + + const disclaimer = this.self().createHelpLabel(this.tr("There is no invitation required in this product/deployment.")).set({ + textColor: "warning-yellow" + }); + disclaimer.exclude(); + osparc.data.Resources.getOne("config") + .then(config => { + if ("invitation_required" in config && config["invitation_required"] === false) { + disclaimer.show(); + } + }); + invitationGroupBox.add(disclaimer); + + const newTokenForm = this.__createInvitationForm(); + const form = new qx.ui.form.renderer.Single(newTokenForm); + invitationGroupBox.add(form); + + return invitationGroupBox; + }, + + __createInvitationForm: function() { + const form = new qx.ui.form.Form(); + + const userEmail = new qx.ui.form.TextField().set({ + placeholder: this.tr("new.user@email.address") + }); + form.add(userEmail, this.tr("User Email")); + + const withExpiration = new qx.ui.form.CheckBox().set({ + value: false + }); + form.add(withExpiration, this.tr("With expiration")); + + const trialDays = new qx.ui.form.Spinner().set({ + minimum: 1, + maximum: 1000, + value: 1 + }); + withExpiration.bind("value", trialDays, "visibility", { + converter: val => val ? "visible" : "excluded" + }); + form.add(trialDays, this.tr("Trial Days")); + + const generateInvitationBtn = new osparc.ui.form.FetchButton(this.tr("Generate")); + generateInvitationBtn.addListener("execute", () => { + if (!osparc.data.Permissions.getInstance().canDo("user.invitation.generate", true)) { + return; + } + if (this.__generatedInvitationLayout) { + this._remove(this.__generatedInvitationLayout); + } + const params = { + data: { + "guest": userEmail.getValue() + } + }; + if (withExpiration.getValue()) { + params.data["trialAccountDays"] = trialDays.getValue(); + } + generateInvitationBtn.setFetching(true); + osparc.data.Resources.fetch("invitations", "post", params) + .then(data => { + const generatedInvitationLayout = this.__generatedInvitationLayout = this.__createGeneratedInvitationLayout(data); + this._add(generatedInvitationLayout); + }) + .catch(err => { + console.error(err); + osparc.FlashMessenger.logAs(err.message, "ERROR"); + }) + .finally(() => generateInvitationBtn.setFetching(false)); + }, this); + form.addButton(generateInvitationBtn); + + return form; + }, + + __createGeneratedInvitationLayout: function(respData) { + const vBox = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + + const label = new qx.ui.basic.Label().set({ + value: this.tr("Remember that this is a one time use link") + }); + vBox.add(label); + + const hBox = new qx.ui.container.Composite(new qx.ui.layout.HBox(10).set({ + alignY: "middle" + })); + vBox.add(hBox); + + const invitationField = new qx.ui.form.TextField(respData["invitation_link"]).set({ + readOnly: true + }); + hBox.add(invitationField, { + flex: 1 + }); + + const copyInvitationBtn = new qx.ui.form.Button(this.tr("Copy invitation link")); + copyInvitationBtn.addListener("execute", () => { + if (osparc.utils.Utils.copyTextToClipboard(respData["invitation_link"])) { + copyInvitationBtn.setIcon("@FontAwesome5Solid/check/12"); + } + }); + hBox.add(copyInvitationBtn); + + const respLabel = new qx.ui.basic.Label(this.tr("Data encrypted in the invitation")); + vBox.add(respLabel); + + delete respData["invitation_link"]; + const invitationRespViewer = new osparc.ui.basic.JsonTreeWidget(respData, "invitation-data"); + const container = new qx.ui.container.Scroll(); + container.add(invitationRespViewer); + vBox.add(container); + + return vBox; + } + } +}); diff --git a/services/static-webserver/client/source/class/osparc/po/POCenter.js b/services/static-webserver/client/source/class/osparc/po/POCenter.js new file mode 100644 index 00000000000..37fa5ec69b0 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/po/POCenter.js @@ -0,0 +1,71 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2023 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.po.POCenter", { + extend: qx.ui.core.Widget, + + construct: function() { + this.base(arguments); + + this._setLayout(new qx.ui.layout.VBox()); + + this.set({ + padding: 10 + }); + + const tabViews = this.__tabsView = new qx.ui.tabview.TabView().set({ + barPosition: "left", + contentPadding: 0 + }); + tabViews.getChildControl("bar").add(osparc.desktop.credits.UserCenter.createMiniProfileView()); + + const invitationsPage = this.__invitationsPage = this.__getInvitationsPage(); + tabViews.add(invitationsPage); + + this._add(tabViews); + }, + + members: { + __tabsView: null, + __invitationsPage: null, + + __getInvitationsPage: function() { + const title = this.tr("Invitations"); + const iconSrc = "@FontAwesome5Solid/envelope/22"; + const page = new osparc.desktop.preferences.pages.BasePage(title, iconSrc); + page.showLabelOnTab(); + const overview = new osparc.po.Invitations(); + overview.set({ + margin: 10 + }); + page.add(overview); + return page; + }, + + __openPage: function(page) { + if (page) { + this.__tabsView.setSelection([page]); + } + }, + + openInvitations: function() { + if (this.__invitationsPage) { + this.__openPage(this.__invitationsPage); + } + } + } +}); diff --git a/services/static-webserver/client/source/class/osparc/po/POCenterWindow.js b/services/static-webserver/client/source/class/osparc/po/POCenterWindow.js new file mode 100644 index 00000000000..cc0e6cb0307 --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/po/POCenterWindow.js @@ -0,0 +1,58 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2023 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.po.POCenterWindow", { + extend: osparc.ui.window.SingletonWindow, + + construct: function() { + this.base(arguments, "po-center", this.tr("PO Center")); + + const viewWidth = 800; + const viewHeight = 600; + + this.set({ + layout: new qx.ui.layout.Grow(), + modal: true, + width: viewWidth, + height: viewHeight, + showMaximize: false, + showMinimize: false, + resizable: true, + appearance: "service-window" + }); + + const poCenter = this.__poCenter = new osparc.po.POCenter(); + this.add(poCenter); + }, + + statics: { + openWindow: function() { + const accountWindow = new osparc.po.POCenterWindow(); + accountWindow.center(); + accountWindow.open(); + return accountWindow; + } + }, + + members: { + __poCenter: null, + + openInvitations: function() { + this.__poCenter.openInvitations(); + } + } +}); diff --git a/services/static-webserver/client/source/class/osparc/product/Utils.js b/services/static-webserver/client/source/class/osparc/product/Utils.js index a7ad2c80a8f..b0091573a01 100644 --- a/services/static-webserver/client/source/class/osparc/product/Utils.js +++ b/services/static-webserver/client/source/class/osparc/product/Utils.js @@ -129,7 +129,7 @@ qx.Class.define("osparc.product.Utils", { const product = qx.core.Environment.get("product.name"); switch (product) { case "s4l": - logosPath = lightLogo ? "osparc/s4l-white.png" : "osparc/s4l-black.png"; + logosPath = lightLogo ? "osparc/s4l_logo_short_white.svg" : "osparc/s4l_logo_short_black.svg"; break; case "s4llite": logosPath = lightLogo ? "osparc/s4llite-white.png" : "osparc/s4llite-black.png"; diff --git a/services/static-webserver/client/source/class/osparc/share/Collaborators.js b/services/static-webserver/client/source/class/osparc/share/Collaborators.js index 7c3c7725c5b..71814a6c821 100644 --- a/services/static-webserver/client/source/class/osparc/share/Collaborators.js +++ b/services/static-webserver/client/source/class/osparc/share/Collaborators.js @@ -358,9 +358,9 @@ qx.Class.define("osparc.share.Collaborators", { _reloadCollaboratorsList: function() { this.__collaboratorsModel.removeAll(); - const aceessRights = this._serializedData["accessRights"]; + const accessRights = this._serializedData["accessRights"]; const collaboratorsList = []; - Object.keys(aceessRights).forEach(gid => { + Object.keys(accessRights).forEach(gid => { if (Object.prototype.hasOwnProperty.call(this.__collaborators, gid)) { const collab = this.__collaborators[gid]; // Do not override collaborator object @@ -369,7 +369,7 @@ qx.Class.define("osparc.share.Collaborators", { collaborator["thumbnail"] = osparc.utils.Avatar.getUrl(collaborator["login"], 32); collaborator["name"] = osparc.utils.Utils.firstsUp(collaborator["first_name"], collaborator["last_name"]); } - collaborator["accessRights"] = aceessRights[gid]; + collaborator["accessRights"] = accessRights[gid]; collaborator["showOptions"] = (this._resourceType === "service") ? this._canIWrite() : this._canIDelete(); collaboratorsList.push(collaborator); } diff --git a/services/static-webserver/client/source/class/osparc/study/Import.js b/services/static-webserver/client/source/class/osparc/study/Import.js index 2361d17ac0a..87b2e58bbfb 100644 --- a/services/static-webserver/client/source/class/osparc/study/Import.js +++ b/services/static-webserver/client/source/class/osparc/study/Import.js @@ -47,7 +47,7 @@ qx.Class.define("osparc.study.Import", { const file = fileInput.getFile(); if (file) { const size = file.size; - const maxSize = 10 * 1024 * 1024 * 1024; // 10 GB + const maxSize = 10 * 1000 * 1000 * 1000; // 10 GB if (size > maxSize) { osparc.FlashMessenger.logAs(`The file is too big. Maximum size is ${maxSize}MB. Please provide with a smaller file or a repository URL.`, "ERROR"); return; diff --git a/services/static-webserver/client/source/class/osparc/utils/Utils.js b/services/static-webserver/client/source/class/osparc/utils/Utils.js index d58b8f6b9d3..586d6242ce4 100644 --- a/services/static-webserver/client/source/class/osparc/utils/Utils.js +++ b/services/static-webserver/client/source/class/osparc/utils/Utils.js @@ -497,18 +497,18 @@ qx.Class.define("osparc.utils.Utils", { if (bytes == 0) { return "0 Bytes"; } - const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); - return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1000))); + return Math.round((bytes / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i]; }, bytesToGB: function(bytes) { - const b2gb = 1024*1024*1024; + const b2gb = 1000*1000*1000; return Math.round(100*bytes/b2gb)/100; }, - gBToBytes: function(gbytes) { - const b2gb = 1024*1024*1024; - return gbytes*b2gb; + gBToBytes: function(gBytes) { + const b2gb = 1000*1000*1000; + return gBytes*b2gb; }, retrieveURLAndDownload: function(locationId, fileId) { diff --git a/services/static-webserver/client/source/resource/osparc/s4l-black.png b/services/static-webserver/client/source/resource/osparc/s4l-black.png deleted file mode 100644 index 6f42d87cc4f..00000000000 Binary files a/services/static-webserver/client/source/resource/osparc/s4l-black.png and /dev/null differ diff --git a/services/static-webserver/client/source/resource/osparc/s4l-white.png b/services/static-webserver/client/source/resource/osparc/s4l-white.png deleted file mode 100644 index 1607db785f7..00000000000 Binary files a/services/static-webserver/client/source/resource/osparc/s4l-white.png and /dev/null differ diff --git a/services/static-webserver/client/source/resource/osparc/s4l_logo_black.svg b/services/static-webserver/client/source/resource/osparc/s4l_logo_black.svg new file mode 100644 index 00000000000..c603990bd40 --- /dev/null +++ b/services/static-webserver/client/source/resource/osparc/s4l_logo_black.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/services/static-webserver/client/source/resource/osparc/s4l_logo_short_black.svg b/services/static-webserver/client/source/resource/osparc/s4l_logo_short_black.svg new file mode 100644 index 00000000000..6d131733fa0 --- /dev/null +++ b/services/static-webserver/client/source/resource/osparc/s4l_logo_short_black.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/services/static-webserver/client/source/resource/osparc/s4l_logo_short_white.svg b/services/static-webserver/client/source/resource/osparc/s4l_logo_short_white.svg new file mode 100644 index 00000000000..3dce80ee1d3 --- /dev/null +++ b/services/static-webserver/client/source/resource/osparc/s4l_logo_short_white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/services/static-webserver/client/source/resource/osparc/s4l_logo_white.svg b/services/static-webserver/client/source/resource/osparc/s4l_logo_white.svg new file mode 100644 index 00000000000..20154a971f7 --- /dev/null +++ b/services/static-webserver/client/source/resource/osparc/s4l_logo_white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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 50d800b45c7..684bca41c8e 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 @@ -1775,7 +1775,7 @@ paths: schema: $ref: '#/components/schemas/Envelope_CreditPriceGet_' /v0/invitation:generate: - get: + post: tags: - products summary: Generate Invitation 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 bd7787b3c5a..f30c17a08b6 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -102,6 +102,9 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): default=False, description="Enables development features. WARNING: make sure it is disabled in production .env file!", ) + WEBSERVER_CREDIT_COMPUTATION_ENABLED: bool = Field( + default=False, description="Enables credit computation features." + ) WEBSERVER_LOGLEVEL: LogLevel = Field( default=LogLevel.WARNING.value, env=["WEBSERVER_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL"], diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py index 19559feebf7..567d9a2c8f9 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py @@ -102,7 +102,7 @@ async def start_computation(request: web.Request) -> web.Response: request.app, project_id=project_id ) app_settings = get_settings(request.app) - if project_wallet and app_settings.WEBSERVER_DEV_FEATURES_ENABLED: + if project_wallet and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED: # Check whether user has access to the wallet await wallets_api.get_wallet_by_user( request.app, req_ctx.user_id, project_wallet.wallet_id, req_ctx.product_name diff --git a/services/web/server/src/simcore_service_webserver/payments/_db.py b/services/web/server/src/simcore_service_webserver/payments/_db.py index 2a0a6819379..723d50a2464 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_db.py +++ b/services/web/server/src/simcore_service_webserver/payments/_db.py @@ -2,10 +2,8 @@ import logging from decimal import Decimal -import simcore_postgres_database.errors as db_errors import sqlalchemy as sa from aiohttp import web -from aiopg.sa.result import ResultProxy from models_library.api_schemas_webserver.wallets import PaymentID from models_library.emails import LowerCaseEmailStr from models_library.products import ProductName @@ -16,8 +14,14 @@ PaymentTransactionState, payments_transactions, ) -from sqlalchemy import literal_column -from sqlalchemy.sql import func +from simcore_postgres_database.utils_payments import ( + PaymentAlreadyAcked, + PaymentAlreadyExists, + PaymentNotFound, + get_user_payments_transactions, + insert_init_payment_transaction, + update_payment_transaction_state, +) from ..db.plugin import get_database_engine from .errors import ( @@ -64,22 +68,24 @@ async def create_payment_transaction( # noqa: PLR0913 initiated_at: datetime.datetime, ) -> None: async with get_database_engine(app).acquire() as conn: - try: - await conn.execute( - payments_transactions.insert().values( - payment_id=payment_id, - price_dollars=price_dollars, - osparc_credits=osparc_credits, - product_name=product_name, - user_id=user_id, - user_email=user_email, - wallet_id=wallet_id, - comment=comment, - initiated_at=initiated_at, - ) - ) - except db_errors.UniqueViolation as err: - raise PaymentUniqueViolationError(payment_id=payment_id) from err + ok = await insert_init_payment_transaction( + conn, + payment_id=payment_id, + price_dollars=price_dollars, + osparc_credits=osparc_credits, + product_name=product_name, + user_id=user_id, + user_email=user_email, + wallet_id=wallet_id, + comment=comment, + initiated_at=initiated_at, + ) + if isinstance(ok, PaymentAlreadyExists): + assert not ok # nosec + assert ok.payment_id == payment_id # nosec + raise PaymentUniqueViolationError(payment_id=payment_id) + + assert ok == payment_id # nosec async def list_user_payment_transactions( @@ -89,31 +95,14 @@ async def list_user_payment_transactions( offset: PositiveInt, limit: PositiveInt, ) -> tuple[int, list[PaymentsTransactionsDB]]: - """List payments done by a give user + """List payments done by a give user (any wallet) Sorted by newest-first """ async with get_database_engine(app).acquire() as conn: - total_number_of_items = await conn.scalar( - sa.select(sa.func.count()) - .select_from(payments_transactions) - .where(payments_transactions.c.user_id == user_id) + total_number_of_items, rows = await get_user_payments_transactions( + conn, user_id=user_id, offset=offset, limit=limit ) - assert total_number_of_items is not None # nosec - - # NOTE: what if between these two calls there are new rows? can we get this in an atomic call? - if offset > total_number_of_items: - msg = f"{offset=} exceeds {total_number_of_items=}" - raise ValueError(msg) - - result: ResultProxy = await conn.execute( - payments_transactions.select() - .where(payments_transactions.c.user_id == user_id) - .order_by(payments_transactions.c.created.desc()) # newest first - .offset(offset) - .limit(limit) - ) - rows = await result.fetchall() or [] page = parse_obj_as(list[PaymentsTransactionsDB], rows) return total_number_of_items, page @@ -140,41 +129,21 @@ async def complete_payment_transaction( Raises: PaymentNotFoundError - + PaymentCompletedError """ - if completion_state == PaymentTransactionState.PENDING: - raise ValueError(f"{completion_state} is not a completion state") + async with get_database_engine(app).acquire() as conn: + row = await update_payment_transaction_state( + conn, + payment_id=payment_id, + completion_state=completion_state, + state_message=state_message, + ) - optional = {} - if state_message: - optional["state_message"] = state_message + if isinstance(row, PaymentNotFound): + raise PaymentNotFoundError(payment_id=row.payment_id) - async with get_database_engine(app).acquire() as conn: - async with conn.begin(): - row = await ( - await conn.execute( - sa.select( - payments_transactions.c.initiated_at, - payments_transactions.c.completed_at, - ) - .where(payments_transactions.c.payment_id == payment_id) - .with_for_update() - ) - ).fetchone() - - if row is None: - raise PaymentNotFoundError(payment_id=payment_id) - - if row.completed_at is not None: - raise PaymentCompletedError(payment_id=payment_id) - - result = await conn.execute( - payments_transactions.update() - .values(completed_at=func.now(), state=completion_state, **optional) - .where(payments_transactions.c.payment_id == payment_id) - .returning(literal_column("*")) - ) - row = await result.first() - assert row, "execute above should have caught this" # nosec - - return PaymentsTransactionsDB.from_orm(row) + if isinstance(row, PaymentAlreadyAcked): + raise PaymentCompletedError(payment_id=row.payment_id) + + assert row # nosec + return PaymentsTransactionsDB.from_orm(row) diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index 39280583401..7f969021d99 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -268,7 +268,7 @@ async def _start_dynamic_service( wallet_info = None project_wallet = await get_project_wallet(request.app, project_id=project_uuid) app_settings = get_settings(request.app) - if project_wallet and app_settings.WEBSERVER_DEV_FEATURES_ENABLED: + if project_wallet and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED: # Check whether user has access to the wallet await wallets_api.get_wallet_by_user( request.app, user_id, project_wallet.wallet_id, product_name diff --git a/services/web/server/tests/unit/with_dbs/test__openapi_specs.py b/services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/test__openapi_specs.py rename to services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py