diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a85557c02d71_add_projects_extensions.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a85557c02d71_add_projects_extensions.py new file mode 100644 index 00000000000..e00823f0951 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a85557c02d71_add_projects_extensions.py @@ -0,0 +1,45 @@ +"""add_projects_extensions + +Revision ID: a85557c02d71 +Revises: 5756d9282a0a +Create Date: 2025-10-24 15:03:32.846288+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a85557c02d71" +down_revision = "5756d9282a0a" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "projects_extensions", + sa.Column("project_uuid", sa.String(), nullable=False), + sa.Column( + "allow_guests_to_push_states_and_output_ports", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["project_uuid"], + ["projects.uuid"], + name="fk_projects_extensions_project_uuid_projects", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("project_uuid"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("projects_extensions") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_extensions.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_extensions.py new file mode 100644 index 00000000000..8d99c0017da --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_extensions.py @@ -0,0 +1,32 @@ +import sqlalchemy as sa + +from ._common import RefActions +from .base import metadata +from .projects import projects + +projects_extensions = sa.Table( + "projects_extensions", + metadata, + sa.Column( + "project_uuid", + sa.String, + sa.ForeignKey( + projects.c.uuid, + name="fk_projects_extensions_project_uuid_projects", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, + ), + primary_key=True, + doc="project reference and primary key for this table", + ), + sa.Column( + "allow_guests_to_push_states_and_output_ports", + sa.Boolean(), + nullable=False, + server_default=sa.sql.expression.false(), + doc=( + "When True, guest will save the state of a service " + "and also push the data to the output ports" + ), + ), +) diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py b/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py index 83c357c3657..29f226ece2e 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py @@ -14,6 +14,7 @@ from models_library.workspaces import WorkspaceID from pydantic import NonNegativeInt, PositiveInt from simcore_postgres_database.models.projects import projects +from simcore_postgres_database.models.projects_extensions import projects_extensions from simcore_postgres_database.models.users import users from simcore_postgres_database.utils_repos import ( get_columns_from_db_model, @@ -289,3 +290,52 @@ async def delete_project( if row is None: raise ProjectNotFoundError(project_uuid=project_uuid) return ProjectDBGet.model_validate(row) + + +async def allows_guests_to_push_states_and_output_ports( + app: web.Application, + connection: AsyncConnection | None = None, + *, + project_uuid: str, +) -> bool: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result: bool | None = await conn.scalar( + sa.select( + projects_extensions.c.allow_guests_to_push_states_and_output_ports + ).where(projects_extensions.c.project_uuid == project_uuid) + ) + return result if result is not None else False + + +async def _set_allow_guests_to_push_states_and_output_ports( + app: web.Application, + connection: AsyncConnection | None = None, + *, + project_uuid: str, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.execute( + sa.insert(projects_extensions).values( + project_uuid=project_uuid, + allow_guests_to_push_states_and_output_ports=True, + ) + ) + + +async def copy_allow_guests_to_push_states_and_output_ports( + app: web.Application, + connection: AsyncConnection | None = None, + *, + from_project_uuid: str, + to_project_uuid: str, +) -> None: + # get setting from template project + allow_guests = await allows_guests_to_push_states_and_output_ports( + app, connection, project_uuid=from_project_uuid + ) + + # set same setting in new project if True + if allow_guests: + await _set_allow_guests_to_push_states_and_output_ports( + app, connection, project_uuid=to_project_uuid + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index 9bd620d3d23..f44d89a6e31 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -817,6 +817,13 @@ async def _start_dynamic_service( # pylint: disable=too-many-statements # noqa save_state = await has_user_project_access_rights( request.app, project_id=project_uuid, user_id=user_id, permission="write" ) + if ( + user_role == UserRole.GUEST + and await _projects_repository.allows_guests_to_push_states_and_output_ports( + request.app, project_uuid=f"{project_uuid}" + ) + ): + save_state = True @exclusive( get_redis_lock_manager_client_sdk(request.app), diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py index 3b027ee4fa1..d383f07db71 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py @@ -30,6 +30,7 @@ from ..director_v2 import director_v2_service from ..dynamic_scheduler import api as dynamic_scheduler_service from ..products import products_web +from ..projects import _projects_repository from ..projects._groups_repository import get_project_group from ..projects._projects_repository_legacy import ProjectDBAPI from ..projects.api import check_user_project_permission @@ -221,6 +222,12 @@ async def copy_study_to_account( request.app, project_id=ProjectID(project["uuid"]) ) + await _projects_repository.copy_allow_guests_to_push_states_and_output_ports( + request.app, + from_project_uuid=template_project["uuid"], + to_project_uuid=project["uuid"], + ) + return project_uuid diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_repository.py b/services/web/server/tests/unit/with_dbs/02/test_projects_repository.py index 146d1964f0d..7e82525cadb 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_repository.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_repository.py @@ -1,13 +1,15 @@ +# pylint: disable=protected-access # pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments # pylint: disable=unused-argument # pylint: disable=unused-variable -# pylint: disable=too-many-arguments from datetime import datetime, timedelta from uuid import UUID import arrow import pytest +from aiohttp import web from aiohttp.test_utils import TestClient from common_library.users_enums import UserRole from models_library.basic_types import IDStr @@ -195,3 +197,44 @@ async def test_batch_get_trashed_by_primary_gid( None, logged_user["primary_gid"], ] + + +async def _assert_allows_to_psuh( + app: web.Application, project_uuid: str, *, expected: bool +) -> None: + result = ( + await projects_service_repository.allows_guests_to_push_states_and_output_ports( + app, project_uuid=project_uuid + ) + ) + assert result is expected + + +async def test_guest_allowed_to_push( + client: TestClient, user_project: ProjectDict, shared_project: ProjectDict +): + assert client.app + + source_uuid = user_project["uuid"] + copy_uuid = shared_project["uuid"] + + await _assert_allows_to_psuh(client.app, source_uuid, expected=False) + + # add the entry in the table + await projects_service_repository._set_allow_guests_to_push_states_and_output_ports( # noqa: SLF001 + client.app, project_uuid=source_uuid + ) + + await _assert_allows_to_psuh(client.app, source_uuid, expected=True) + + assert ( + await projects_service_repository.allows_guests_to_push_states_and_output_ports( + client.app, project_uuid=copy_uuid + ) + is False + ) + await _assert_allows_to_psuh(client.app, copy_uuid, expected=False) + await projects_service_repository.copy_allow_guests_to_push_states_and_output_ports( + client.app, from_project_uuid=source_uuid, to_project_uuid=copy_uuid + ) + await _assert_allows_to_psuh(client.app, copy_uuid, expected=True)