Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""add_projects_optionals

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_optionals",
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_optionals_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_optionals")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import sqlalchemy as sa

from ._common import RefActions
from .base import metadata
from .projects import projects

projects_optionals = sa.Table(
"projects_optionals",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this name very weird and very unclear. That could mean anything.
Also I wonder what is the point of pushing the states here? a guest user is only temporary, what is the point of saving the states?
The outputs I understand as this can be used in subsequent nodes, but the states?

metadata,
sa.Column(
"project_uuid",
sa.String,
sa.ForeignKey(
projects.c.uuid,
name="fk_projects_optionals_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"
),
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import sqlalchemy as sa
from simcore_postgres_database.utils_repos import (
pass_or_acquire_connection,
transaction_context,
)
from sqlalchemy.ext.asyncio import AsyncEngine

from .models.projects_optionals import projects_optionals


class CouldNotCreateOrUpdateUserPreferenceError(Exception): ...


class BaseProjectOptionalsRepo:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base?

model: sa.Table = projects_optionals

@classmethod
async def allows_guests_to_push_states_and_output_ports(
cls, async_engine: AsyncEngine, *, project_uuid: str
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this is like a separate call to a separate database everytime one asks for the projects? can this not be made together with a join in one call?
at this point, if that is really needed, the table should be named something like projects_guest_properties maybe

) -> bool:
async with pass_or_acquire_connection(async_engine) as connection:
result: bool | None = await connection.scalar(
sa.select(
cls.model.c.allow_guests_to_push_states_and_output_ports
).where(cls.model.c.project_uuid == project_uuid)
)
return result if result is not None else False

@classmethod
async def set_allow_guests_to_push_states_and_output_ports(
cls, async_engine: AsyncEngine, *, project_uuid: str
) -> None:
async with transaction_context(async_engine) as connection:
await connection.execute(
sa.insert(projects_optionals).values(
project_uuid=project_uuid,
allow_guests_to_push_states_and_output_ports=True,
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# pylint: disable=redefined-outer-name

from collections.abc import Awaitable, Callable

import pytest
from aiopg.sa.connection import SAConnection
from aiopg.sa.result import RowProxy
from simcore_postgres_database.utils_projects_optionals import BaseProjectOptionalsRepo
from sqlalchemy.ext.asyncio import AsyncEngine


@pytest.fixture
async def fake_user(
connection: SAConnection,
create_fake_user: Callable[..., Awaitable[RowProxy]],
) -> RowProxy:
user: RowProxy = await create_fake_user(connection, name=f"user.{__name__}")
return user


@pytest.fixture
async def fake_project(
connection: SAConnection,
fake_user: RowProxy,
create_fake_project: Callable[..., Awaitable[RowProxy]],
create_fake_nodes: Callable[..., Awaitable[RowProxy]],
) -> RowProxy:
project: RowProxy = await create_fake_project(connection, fake_user, hidden=True)
await create_fake_nodes(project)
return project


async def test_something(
asyncpg_engine: AsyncEngine,
connection: SAConnection,
create_fake_user: Callable[..., Awaitable[RowProxy]],
create_fake_project: Callable[..., Awaitable[RowProxy]],
):
user: RowProxy = await create_fake_user(connection)
project: RowProxy = await create_fake_project(connection, user, hidden=True)

assert (
await BaseProjectOptionalsRepo.allows_guests_to_push_states_and_output_ports(
asyncpg_engine, project_uuid=project["uuid"]
)
is False
)

# add the entry in the table
await BaseProjectOptionalsRepo.set_allow_guests_to_push_states_and_output_ports(
asyncpg_engine, project_uuid=project["uuid"]
)

assert (
await BaseProjectOptionalsRepo.allows_guests_to_push_states_and_output_ports(
asyncpg_engine, project_uuid=project["uuid"]
)
is True
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
from servicelib.logging_utils import log_catch, log_context
from servicelib.utils import limited_as_completed, limited_gather
from simcore_postgres_database.utils_projects_optionals import BaseProjectOptionalsRepo

from ..db.plugin import get_asyncpg_engine
from ..dynamic_scheduler import api as dynamic_scheduler_service
from ..projects._projects_service import (
is_node_id_present_in_any_project_workbench,
Expand Down Expand Up @@ -54,6 +56,14 @@ async def _remove_service(
)
)

if (
user_role == UserRole.GUEST
and await BaseProjectOptionalsRepo.allows_guests_to_push_states_and_output_ports(
get_asyncpg_engine(app), project_uuid=f"{service.project_id}"
)
):
save_service_state = True

with (
log_catch(_logger, reraise=False),
log_context(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,13 @@
ProjectNodeCreate,
ProjectNodesNodeNotFoundError,
)
from simcore_postgres_database.utils_projects_optionals import BaseProjectOptionalsRepo
from simcore_postgres_database.webserver_models import ProjectType

from ..application_settings import get_application_settings
from ..catalog import catalog_service
from ..constants import APP_FIRE_AND_FORGET_TASKS_KEY
from ..db.plugin import get_asyncpg_engine
from ..director_v2 import director_v2_service
from ..dynamic_scheduler import api as dynamic_scheduler_service
from ..models import ClientSessionID
Expand Down Expand Up @@ -817,6 +819,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 BaseProjectOptionalsRepo.allows_guests_to_push_states_and_output_ports(
get_asyncpg_engine(request.app), project_uuid=project_uuid
)
):
save_state = True

@exclusive(
get_redis_lock_manager_client_sdk(request.app),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
from models_library.projects import ProjectID
from servicelib.aiohttp import status
from servicelib.aiohttp.typing_extension import Handler
from simcore_postgres_database.utils_projects_optionals import BaseProjectOptionalsRepo

from ..constants import INDEX_RESOURCE_NAME
from ..db.plugin import get_asyncpg_engine
from ..director_v2 import director_v2_service
from ..dynamic_scheduler import api as dynamic_scheduler_service
from ..products import products_web
Expand Down Expand Up @@ -221,6 +223,14 @@ async def copy_study_to_account(
request.app, project_id=ProjectID(project["uuid"])
)

# set the same option in the new project from the tempalte
if await BaseProjectOptionalsRepo.allows_guests_to_push_states_and_output_ports(
get_asyncpg_engine(request.app), project_uuid=template_project["uuid"]
):
await BaseProjectOptionalsRepo.set_allow_guests_to_push_states_and_output_ports(
get_asyncpg_engine(request.app), project_uuid=project["uuid"]
)

return project_uuid


Expand Down
Loading