.*)$",
- name,
- ):
- return m.groupdict()
- return {}
-
-
-# GET/CREATE iterations ------------------------------------------------------------
-
-
-async def get_or_create_runnable_projects(
- request: web.Request,
- project_uuid: ProjectID,
-) -> tuple[list[ProjectID], list[CommitID]]:
- """
- Returns ids and refid of projects that can run
- If project_uuid is a std-project, then it returns itself
- If project_uuid is a meta-project, then it returns iterations
- """
-
- vc_repo = VersionControlForMetaModeling.create_from_request(request)
- assert vc_repo.user_id # nosec
- product_name = request[RQ_PRODUCT_KEY]
-
- try:
- project: ProjectDict = await vc_repo.get_project(str(project_uuid))
- except UserUndefinedError as err:
- raise web.HTTPForbidden(reason="Unauthenticated request") from err
-
- project_nodes: dict[NodeID, Node] = {
- nid: Node.model_validate(n) for nid, n in project["workbench"].items()
- }
-
- # init returns
- runnable_project_vc_commits: list[CommitID] = []
- runnable_project_ids: list[ProjectID] = [
- project_uuid,
- ]
-
- # auto-commit
- # because it will run in parallel -> needs an independent working copy
- repo_id = await vc_repo.get_repo_id(project_uuid)
- if repo_id is None:
- repo_id = await vc_repo.init_repo(project_uuid)
-
- main_commit_id = await vc_repo.commit(
- repo_id,
- tag=f"auto:main/{project_uuid}",
- message=f"auto-commit {now_str()}",
- )
- runnable_project_vc_commits.append(main_commit_id)
-
- # std-project
- is_meta_project = any(
- is_iterator_service(node.key) and not node.outputs
- for node in project_nodes.values()
- )
- if not is_meta_project:
- return runnable_project_ids, runnable_project_vc_commits
-
- # meta-project: resolve project iterations
- runnable_project_ids = []
- runnable_project_vc_commits = []
-
- iterations = _build_project_iterations(project_nodes)
- _logger.debug(
- "Project %s with %s parameters, produced %s variants",
- project_uuid,
- len(iterations[0]) if iterations else 0,
- len(iterations),
- )
-
- # Each iteration generates a set of 'parameters'
- # - parameters are set in the corresponding outputs of the meta-nodes
- #
- parameters: Parameters
- updated_nodes: NodesDict
- total_count = len(iterations)
- original_name = project["name"]
-
- for iteration_index, (parameters, updated_nodes) in enumerate(iterations, start=1):
- _logger.debug(
- "Creating snapshot of project %s with parameters=%s [%s]",
- f"{project_uuid=}",
- f"{parameters=}",
- f"{updated_nodes=}",
- )
-
- project["name"] = f"{original_name}/{iteration_index}"
- project["workbench"].update(
- {
- # converts model in dict patching first thumbnail
- nid: n.model_copy(update={"thumbnail": n.thumbnail or ""}).model_dump(
- by_alias=True, exclude_unset=True
- )
- for nid, n in updated_nodes.items()
- }
- )
-
- project_iteration = ProjectIteration(
- repo_id=repo_id,
- repo_commit_id=main_commit_id,
- iteration_index=iteration_index,
- total_count=total_count,
- parameters_checksum=_compute_params_checksum(parameters),
- )
-
- # tag to identify this iteration
- branch_name = tag_name = project_iteration.to_tag_name()
-
- commit_id = await vc_repo.create_workcopy_and_branch_from_commit(
- repo_id,
- start_commit_id=main_commit_id,
- project=project,
- branch_name=branch_name,
- tag_name=tag_name,
- tag_message=json_dumps(parameters),
- product_name=product_name,
- )
-
- workcopy_project_id = await vc_repo.get_workcopy_project_id(repo_id, commit_id)
-
- runnable_project_ids.append(ProjectID(workcopy_project_id))
- runnable_project_vc_commits.append(commit_id)
-
- return runnable_project_ids, runnable_project_vc_commits
-
-
-async def get_runnable_projects_ids(
- request: web.Request,
- project_uuid: ProjectID,
-) -> list[ProjectID]:
- vc_repo = VersionControlForMetaModeling.create_from_request(request)
- assert vc_repo.user_id # nosec
-
- project: ProjectDict = await vc_repo.get_project(str(project_uuid))
- assert project["uuid"] == str(project_uuid) # nosec
- project_nodes: dict[NodeID, Node] = {
- nid: Node.model_validate(n) for nid, n in project["workbench"].items()
- }
-
- # init returns
- runnable_project_ids: list[ProjectID] = []
-
- # std-project
- is_meta_project = any(
- is_iterator_service(node.key) for node in project_nodes.values()
- )
- if not is_meta_project:
- runnable_project_ids.append(project_uuid)
- return runnable_project_ids
-
- raise NotImplementedError
- # SEE https://github.com/ITISFoundation/osparc-simcore/issues/2735
diff --git a/services/web/server/src/simcore_service_webserver/meta_modeling/_projects.py b/services/web/server/src/simcore_service_webserver/meta_modeling/_projects.py
deleted file mode 100644
index cd2a09b195b..00000000000
--- a/services/web/server/src/simcore_service_webserver/meta_modeling/_projects.py
+++ /dev/null
@@ -1,99 +0,0 @@
-""" Access to the to projects module
-
- - Adds a middleware to intercept /projects/* requests
- - Implements a MetaProjectRunPolicy policy (see director_v2_abc.py) to define how meta-projects run
-
-"""
-
-
-import logging
-import re
-
-from aiohttp import web
-from aiohttp.typedefs import Handler
-from models_library.basic_regex import UUID_RE
-from models_library.projects import ProjectID
-
-from .._meta import API_VTAG as VTAG
-from ..director_v2.api import AbstractProjectRunPolicy
-from ..projects._crud_handlers import RQ_REQUESTED_REPO_PROJECT_UUID_KEY
-from ..version_control.models import CommitID
-from ._iterations import get_or_create_runnable_projects, get_runnable_projects_ids
-from ._version_control import VersionControlForMetaModeling
-
-_logger = logging.getLogger(__name__)
-
-
-# SEE https://github.com/ITISFoundation/osparc-simcore/blob/master/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml#L8563
-URL_PATTERN = re.compile(rf"^\/{VTAG}\/projects\/({UUID_RE})[\/]{{0,1}}")
-
-
-def _match_project_id(request: web.Request):
- # OAS uses both 'project_id' and also 'project_uuid' :-(
- for path_param in ("project_id", "project_uuid"):
- if project_id := request.match_info.get(path_param):
- return project_id, path_param
- return None, None
-
-
-@web.middleware
-async def projects_redirection_middleware(request: web.Request, handler: Handler):
- """Intercepts /projects/{project_uuid}* requests and redirect them to the copy @HEAD
-
- Any given project has a unique identifier 'project_id' but, when activated,
- it also has a version history (denoted 'checkpoints' in the API).
-
- In that case, GET /projects/1234 shall refer to the HEAD version of the project
- with id 1234, also denoted the project's working copy (in short 'workcopy project')
-
- All metaprojects are versioned so this middleware intercepts calls to GET project
- and ensures that the response body includes the correct workcopy of the requested
- project.
- """
-
- if URL_PATTERN.match(f"{request.rel_url}"):
- #
- # WARNING: because hierarchical design is not guaranteed, we find ourselves with
- # entries like /v0/computations/{project_id}:start which might also neeed
- # indirection
- #
-
- project_id, path_param = _match_project_id(request)
- if project_id and path_param:
- vc_repo = VersionControlForMetaModeling.create_from_request(request)
-
- if repo_id := await vc_repo.get_repo_id(ProjectID(project_id)):
- # Changes resolved project_id parameter with working copy instead
- workcopy_project_id = await vc_repo.get_workcopy_project_id(repo_id)
- request.match_info[path_param] = f"{workcopy_project_id}"
-
- if f"{workcopy_project_id}" != f"{project_id}":
- request[RQ_REQUESTED_REPO_PROJECT_UUID_KEY] = workcopy_project_id
- _logger.debug(
- "Redirecting request with %s to working copy %s",
- f"{project_id=}",
- f"{workcopy_project_id=}",
- )
-
- response = await handler(request)
-
- return response
-
-
-class MetaProjectRunPolicy(AbstractProjectRunPolicy):
- async def get_runnable_projects_ids(
- self,
- request: web.Request,
- project_uuid: ProjectID,
- ) -> list[ProjectID]:
- return await get_runnable_projects_ids(request, project_uuid)
-
- async def get_or_create_runnable_projects(
- self,
- request: web.Request,
- project_uuid: ProjectID,
- ) -> tuple[list[ProjectID], list[CommitID]]:
- return await get_or_create_runnable_projects(request, project_uuid)
-
-
-meta_project_policy = MetaProjectRunPolicy()
diff --git a/services/web/server/src/simcore_service_webserver/meta_modeling/_results.py b/services/web/server/src/simcore_service_webserver/meta_modeling/_results.py
deleted file mode 100644
index 150c2b8f680..00000000000
--- a/services/web/server/src/simcore_service_webserver/meta_modeling/_results.py
+++ /dev/null
@@ -1,114 +0,0 @@
-""" Access to the to projects module
-
- - Adds a middleware to intercept /projects/* requests
- - Implements a MetaProjectRunPolicy policy (see director_v2_abc.py) to define how meta-projects run
-
-"""
-
-
-import logging
-from typing import Annotated, Any
-
-from models_library.projects_nodes import OutputsDict
-from models_library.projects_nodes_io import NodeIDStr
-from pydantic import BaseModel, ConfigDict, Field
-
-_logger = logging.getLogger(__name__)
-
-
-ProgressInt = Annotated[int, Field(ge=0, le=100)]
-
-
-class ExtractedResults(BaseModel):
- progress: dict[NodeIDStr, ProgressInt] = Field(
- ..., description="Progress in each computational node"
- )
- labels: dict[NodeIDStr, str] = Field(
- ..., description="Maps captured node with a label"
- )
- values: dict[NodeIDStr, OutputsDict] = Field(
- ..., description="Captured outputs per node"
- )
- model_config = ConfigDict(
- json_schema_extra={
- "example": {
- # sample with 2 computational services, 2 data sources (iterator+parameter) and 2 observers (probes)
- "progress": {
- "4c08265a-427b-4ac3-9eab-1d11c822ada4": 0,
- "e33c6880-1b1d-4419-82d7-270197738aa9": 100,
- },
- "labels": {
- "0f1e38c9-dcb7-443c-a745-91b97ac28ccc": "Integer iterator",
- "2d0ce8b9-c9c3-43ce-ad2f-ad493898de37": "Probe Sensor - Integer",
- "445b44d1-59b3-425c-ac48-7c13e0f2ea5b": "Probe Sensor - Integer_2",
- "d76fca06-f050-4790-88a8-0aac10c87b39": "Boolean Parameter",
- },
- "values": {
- "0f1e38c9-dcb7-443c-a745-91b97ac28ccc": {
- "out_1": 1,
- "out_2": [3, 4],
- },
- "2d0ce8b9-c9c3-43ce-ad2f-ad493898de37": {"in_1": 7},
- "445b44d1-59b3-425c-ac48-7c13e0f2ea5b": {"in_1": 1},
- "d76fca06-f050-4790-88a8-0aac10c87b39": {"out_1": True},
- },
- }
- }
- )
-
-
-def extract_project_results(workbench: dict[str, Any]) -> ExtractedResults:
- """Extracting results from a project's workbench section (i.e. pipeline). Specifically:
-
- - data sources (e.g. outputs from iterators, paramters)
- - progress of evaluators (e.g. a computational service)
- - data observers (basically inputs from probes)
-
- NOTE: all projects produces from iterations preserve the same node uuids so
- running this extraction on all projects from a iterations allows to create a
- row for a table of results
- """
- # nodeid -> % progress
- progress = {}
- # nodeid -> label (this map is necessary because cannot guaratee labels to be unique)
- labels = {}
- # nodeid -> { port: value , ...} # results have two levels deep: node/port
- results = {}
-
- for noid, node in workbench.items():
- key_parts = node["key"].split("/")
-
- # evaluate progress
- if "comp" in key_parts:
- progress[noid] = node.get("progress", 0)
-
- # evaluate results
- if "probe" in key_parts:
- label = node["label"]
- values = {}
- for port_name, node_input in node["inputs"].items():
- try:
- values[port_name] = workbench[node_input["nodeUuid"]]["outputs"][
- node_input["output"]
- ]
- except KeyError:
- # if not run, we know name but NOT value
- values[port_name] = "n/a"
- results[noid], labels[noid] = values, label
-
- elif "data-iterator" in key_parts:
- label = node["label"]
- try:
- values = node["outputs"] # {oid: value, ...}
- except KeyError:
- # if not iterated, we do not know NEITHER name NOT values
- values = {}
- results[noid], labels[noid] = values, label
-
- elif "parameter" in key_parts:
- label = node["label"]
- values = node["outputs"]
- results[noid], labels[noid] = values, label
-
- res = ExtractedResults(progress=progress, labels=labels, values=results)
- return res
diff --git a/services/web/server/src/simcore_service_webserver/meta_modeling/_version_control.py b/services/web/server/src/simcore_service_webserver/meta_modeling/_version_control.py
deleted file mode 100644
index 2f0d73d104e..00000000000
--- a/services/web/server/src/simcore_service_webserver/meta_modeling/_version_control.py
+++ /dev/null
@@ -1,193 +0,0 @@
-""" Access to the to version_control add-on
-
-"""
-
-import logging
-from types import SimpleNamespace
-from typing import cast
-
-from aiopg.sa.result import RowProxy
-from models_library.products import ProductName
-from models_library.projects import ProjectIDStr
-from models_library.utils.fastapi_encoders import jsonable_encoder
-from simcore_postgres_database.models.projects_to_products import projects_to_products
-
-from ..projects.models import ProjectDict
-from ..version_control.db import VersionControlRepository
-from ..version_control.errors import UserUndefinedError
-from ..version_control.models import CommitID, TagProxy
-from ..version_control.vc_changes import (
- compute_workbench_checksum,
- eval_workcopy_project_id,
-)
-from ..version_control.vc_tags import compose_workcopy_project_tag_name
-
-_logger = logging.getLogger(__name__)
-
-
-class VersionControlForMetaModeling(VersionControlRepository):
- async def get_workcopy_project_id(
- self, repo_id: int, commit_id: int | None = None
- ) -> ProjectIDStr:
- async with self.engine.acquire() as conn:
- if commit_id is None:
- commit = await self._get_HEAD_commit(repo_id, conn)
- assert commit # nosec
- commit_id = commit.id
- assert commit_id
-
- return await self._fetch_workcopy_project_id(repo_id, commit_id, conn)
-
- async def get_workcopy_project(self, repo_id: int, commit_id: int) -> ProjectDict:
- async with self.engine.acquire() as conn:
- project_id = await self._fetch_workcopy_project_id(repo_id, commit_id, conn)
- project = await self.ProjectsOrm(conn).set_filter(uuid=project_id).fetch()
- assert project # nosec
- return dict(project.items())
-
- async def get_project(
- self, project_id: ProjectIDStr, *, include: list[str] | None = None
- ) -> ProjectDict:
- async with self.engine.acquire() as conn:
- if self.user_id is None:
- raise UserUndefinedError
-
- if include is None:
- include = [
- "type",
- "uuid",
- "name",
- "description",
- "thumbnail",
- "prj_owner",
- "access_rights",
- "workbench",
- "ui",
- "classifiers",
- "dev",
- "quality",
- "published",
- "hidden",
- ]
-
- project = (
- await self.ProjectsOrm(conn)
- .set_filter(uuid=f"{project_id}", prj_owner=self.user_id)
- .fetch(include)
- )
- assert project # nosec
- project_as_dict = dict(project.items())
-
- # -------------
- # NOTE: hack to avoid validation error. Revisit when models_library.utils.pydantic_models_factory is
- # used to create a reliable project's model to validate http API
- if "thumbnail" in project_as_dict:
- project_as_dict["thumbnail"] = project_as_dict["thumbnail"] or ""
- # ---------------
- return project_as_dict
-
- async def create_workcopy_and_branch_from_commit(
- self,
- repo_id: int,
- start_commit_id: int,
- project: ProjectDict,
- branch_name: str,
- tag_name: str,
- tag_message: str,
- product_name: ProductName,
- ) -> CommitID:
- """Creates a new branch with an explicit working copy 'project' on 'start_commit_id'"""
- IS_INTERNAL_OPERATION = True
-
- # NOTE: this avoid having non-compatible types embedded in the dict that
- # make operations with the db to fail
- # SEE https://fastapi.tiangolo.com/tutorial/encoder/
- project = jsonable_encoder(project, sqlalchemy_safe=True)
-
- async with self.engine.acquire() as conn:
- # existance check prevents errors later
- if (
- existing_tag := await self.TagsOrm(conn)
- .set_filter(name=tag_name)
- .fetch()
- ):
- return cast(CommitID, existing_tag.commit_id)
-
- # get workcopy for start_commit_id and update with 'project'
- repo = (
- await self.ReposOrm(conn).set_filter(id=repo_id).fetch("project_uuid")
- )
- assert repo # nosec
-
- async with conn.begin():
- # take snapshot of forced project
- snapshot_checksum = compute_workbench_checksum(project["workbench"])
-
- await self._upsert_snapshot(
- snapshot_checksum, SimpleNamespace(**project), conn
- )
-
- # commit new snapshot in history
- commit_id = await self.CommitsOrm(conn).insert(
- repo_id=repo_id,
- parent_commit_id=start_commit_id,
- message=tag_message,
- snapshot_checksum=snapshot_checksum,
- )
- assert commit_id # nosec
- assert isinstance(commit_id, int) # nosec
-
- # creates unique identifier for variant
- project["uuid"] = eval_workcopy_project_id(
- repo.project_uuid, snapshot_checksum
- )
- project["hidden"] = True
-
- # creates runnable version in project
- await self.ProjectsOrm(conn).insert(**project)
-
- await conn.execute(
- projects_to_products.insert().values(
- project_uuid=project["uuid"], product_name=product_name
- )
- )
-
- # create branch and set head to last commit_id
- branch = await self.BranchesOrm(conn).insert(
- returning_cols="id head_commit_id",
- repo_id=repo_id,
- head_commit_id=commit_id,
- name=branch_name,
- )
- assert isinstance(branch, RowProxy) # nosec
-
- for tag in [
- tag_name,
- compose_workcopy_project_tag_name(project["uuid"]),
- ]:
- await self.TagsOrm(conn).insert(
- repo_id=repo_id,
- commit_id=commit_id,
- name=tag,
- message=tag_message if tag == tag_name else None,
- hidden=IS_INTERNAL_OPERATION,
- )
-
- return cast(CommitID, branch.head_commit_id)
-
- async def get_children_tags(
- self, repo_id: int, commit_id: int
- ) -> list[list[TagProxy]]:
- async with self.engine.acquire() as conn:
- commits = (
- await self.CommitsOrm(conn)
- .set_filter(repo_id=repo_id, parent_commit_id=commit_id)
- .fetch_all(returning_cols="id")
- )
- tags = []
- for commit in commits:
- tags_in_commit = (
- await self.TagsOrm(conn).set_filter(commit_id=commit.id).fetch_all()
- )
- tags.append(tags_in_commit)
- return tags
diff --git a/services/web/server/src/simcore_service_webserver/meta_modeling/plugin.py b/services/web/server/src/simcore_service_webserver/meta_modeling/plugin.py
deleted file mode 100644
index c17ac9fdbf4..00000000000
--- a/services/web/server/src/simcore_service_webserver/meta_modeling/plugin.py
+++ /dev/null
@@ -1,41 +0,0 @@
-""" Meta-modeling app module
-
- Manages version control of studies, both the project document and the associated data
-
-"""
-import logging
-
-from aiohttp import web
-from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-
-from .._constants import APP_SETTINGS_KEY
-from ..director_v2.api import get_project_run_policy, set_project_run_policy
-from . import _handlers
-from ._projects import meta_project_policy, projects_redirection_middleware
-
-_logger = logging.getLogger(__name__)
-
-
-@app_module_setup(
- __name__,
- ModuleCategory.ADDON,
- depends=[
- "simcore_service_webserver.projects",
- ],
- settings_name="WEBSERVER_META_MODELING",
- logger=_logger,
-)
-def setup_meta_modeling(app: web.Application):
- assert app[APP_SETTINGS_KEY].WEBSERVER_META_MODELING # nosec
-
- _logger.warning(
- "'meta_modeling' plugin is STILL UNDER DEVELOPMENT and should not be used in production."
- "Can only be activated with WEBSERVER_DEV_FEATURES_ENABLED=1"
- )
-
- app.add_routes(_handlers.routes)
- app.middlewares.append(projects_redirection_middleware)
-
- # Overrides run-policy from directorv2
- assert get_project_run_policy(app) # nosec
- set_project_run_policy(app, meta_project_policy)
diff --git a/services/web/server/src/simcore_service_webserver/models.py b/services/web/server/src/simcore_service_webserver/models.py
index 48ffd369586..0b816268baa 100644
--- a/services/web/server/src/simcore_service_webserver/models.py
+++ b/services/web/server/src/simcore_service_webserver/models.py
@@ -3,7 +3,7 @@
from pydantic import Field
from servicelib.request_keys import RQT_USERID_KEY
-from ._constants import RQ_PRODUCT_KEY
+from .constants import RQ_PRODUCT_KEY
class RequestContext(RequestParameters):
diff --git a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py
index 4193c6fce7f..7c3925b8c50 100644
--- a/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py
+++ b/services/web/server/src/simcore_service_webserver/notifications/_rabbitmq_exclusive_queue_consumers.py
@@ -18,7 +18,7 @@
from servicelib.rabbitmq import RabbitMQClient
from servicelib.utils import logged_gather
-from ..projects import projects_service
+from ..projects import _projects_service
from ..projects.exceptions import ProjectNotFoundError
from ..rabbitmq import get_rabbitmq_client
from ..socketio.messages import (
@@ -30,7 +30,7 @@
send_message_to_user,
)
from ..socketio.models import WebSocketNodeProgress, WebSocketProjectProgress
-from ..wallets import api as wallets_api
+from ..wallets import api as wallets_service
from ._rabbitmq_consumers_common import SubcribeArgumentsTuple, subscribe_to_rabbitmq
_logger = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ async def _convert_to_node_update_event(
app: web.Application, message: ProgressRabbitMessageNode
) -> SocketMessageDict | None:
try:
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
app, f"{message.project_id}", message.user_id
)
if f"{message.node_id}" in project["workbench"]:
@@ -127,7 +127,7 @@ async def _events_message_parser(app: web.Application, data: bytes) -> bool:
async def _osparc_credits_message_parser(app: web.Application, data: bytes) -> bool:
rabbit_message = TypeAdapter(WalletCreditsMessage).validate_json(data)
- wallet_groups = await wallets_api.list_wallet_groups_with_read_access_by_wallet(
+ wallet_groups = await wallets_service.list_wallet_groups_with_read_access_by_wallet(
app, wallet_id=rabbit_message.wallet_id
)
rooms_to_notify: Generator[GroupID, None, None] = (
diff --git a/services/web/server/src/simcore_service_webserver/payments/_events.py b/services/web/server/src/simcore_service_webserver/payments/_events.py
index fbc4ebc2047..e9f63d26c20 100644
--- a/services/web/server/src/simcore_service_webserver/payments/_events.py
+++ b/services/web/server/src/simcore_service_webserver/payments/_events.py
@@ -6,7 +6,7 @@
from aiohttp import web
-from ..products.api import list_products
+from ..products import products_service
from ..products.errors import BelowMinimumPaymentError
from .settings import get_plugin_settings
@@ -16,7 +16,7 @@
async def validate_prices_in_product_settings_on_startup(app: web.Application):
payment_settings = get_plugin_settings(app)
- for product in list_products(app):
+ for product in products_service.list_products(app):
if product.min_payment_amount_usd is not None:
if (
product.min_payment_amount_usd
diff --git a/services/web/server/src/simcore_service_webserver/payments/_methods_db.py b/services/web/server/src/simcore_service_webserver/payments/_methods_db.py
index 3b2bcf8ede8..135eaf41a9e 100644
--- a/services/web/server/src/simcore_service_webserver/payments/_methods_db.py
+++ b/services/web/server/src/simcore_service_webserver/payments/_methods_db.py
@@ -1,7 +1,7 @@
import datetime
import logging
-import simcore_postgres_database.errors as db_errors
+import simcore_postgres_database.aiopg_errors as db_errors
import sqlalchemy as sa
from aiohttp import web
from aiopg.sa.result import ResultProxy
diff --git a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py
index fbe69e07f83..488189a81a5 100644
--- a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py
+++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py
@@ -24,7 +24,7 @@
from yarl import URL
from ..db.plugin import get_database_engine
-from ..products.api import get_product_stripe_info
+from ..products import products_service
from ..resource_usage.service import add_credits_to_wallet
from ..users.api import get_user_display_and_id_names, get_user_invoice_address
from ..wallets.api import get_wallet_by_user, get_wallet_with_permissions_by_user
@@ -296,7 +296,9 @@ async def init_creation_of_wallet_payment(
user_invoice_address = await get_user_invoice_address(app, user_id=user_id)
# stripe info
- product_stripe_info = await get_product_stripe_info(app, product_name=product_name)
+ product_stripe_info = await products_service.get_product_stripe_info(
+ app, product_name=product_name
+ )
settings: PaymentsSettings = get_plugin_settings(app)
payment_inited: WalletPaymentInitiated
@@ -378,7 +380,9 @@ async def pay_with_payment_method(
assert user_wallet.wallet_id == wallet_id # nosec
# stripe info
- product_stripe_info = await get_product_stripe_info(app, product_name=product_name)
+ product_stripe_info = await products_service.get_product_stripe_info(
+ app, product_name=product_name
+ )
# user info
user = await get_user_display_and_id_names(app, user_id=user_id)
diff --git a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py
index 359f8cbf4cb..d799d04fe6f 100644
--- a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py
+++ b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py
@@ -4,11 +4,12 @@
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
from models_library.emails import LowerCaseEmailStr
from models_library.payments import InvoiceDataGet, UserInvoiceAddress
-from models_library.products import CreditResultGet, ProductName, ProductStripeInfoGet
+from models_library.products import ProductName
from models_library.users import UserID
from servicelib.rabbitmq import RPCRouter
-from ..products.api import get_credit_amount, get_product_stripe_info
+from ..products import products_service
+from ..products.models import CreditResult
from ..rabbitmq import get_rabbitmq_rpc_server
from ..users.api import get_user_display_and_id_names, get_user_invoice_address
@@ -23,10 +24,10 @@ async def get_invoice_data(
dollar_amount: Decimal,
product_name: ProductName,
) -> InvoiceDataGet:
- credit_result_get: CreditResultGet = await get_credit_amount(
+ credit_result: CreditResult = await products_service.get_credit_amount(
app, dollar_amount=dollar_amount, product_name=product_name
)
- product_stripe_info_get: ProductStripeInfoGet = await get_product_stripe_info(
+ product_stripe_info = await products_service.get_product_stripe_info(
app, product_name=product_name
)
user_invoice_address: UserInvoiceAddress = await get_user_invoice_address(
@@ -35,9 +36,9 @@ async def get_invoice_data(
user_info = await get_user_display_and_id_names(app, user_id=user_id)
return InvoiceDataGet(
- credit_amount=credit_result_get.credit_amount,
- stripe_price_id=product_stripe_info_get.stripe_price_id,
- stripe_tax_rate_id=product_stripe_info_get.stripe_tax_rate_id,
+ credit_amount=credit_result.credit_amount,
+ stripe_price_id=product_stripe_info.stripe_price_id,
+ stripe_tax_rate_id=product_stripe_info.stripe_tax_rate_id,
user_invoice_address=user_invoice_address,
user_display_name=user_info.full_name,
user_email=LowerCaseEmailStr(user_info.email),
diff --git a/services/web/server/src/simcore_service_webserver/payments/plugin.py b/services/web/server/src/simcore_service_webserver/payments/plugin.py
index 777ba5b599a..3e8bbecc56e 100644
--- a/services/web/server/src/simcore_service_webserver/payments/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/payments/plugin.py
@@ -6,11 +6,11 @@
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from simcore_service_webserver.rabbitmq import setup_rabbitmq
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
from ..db.plugin import setup_db
from ..products.plugin import setup_products
+from ..rabbitmq import setup_rabbitmq
from ..users.plugin import setup_users
from . import _events, _rpc_invoice
from ._tasks import create_background_task_to_fake_payment_completion
diff --git a/services/web/server/src/simcore_service_webserver/payments/settings.py b/services/web/server/src/simcore_service_webserver/payments/settings.py
index ef825a5c1e9..1d424000d57 100644
--- a/services/web/server/src/simcore_service_webserver/payments/settings.py
+++ b/services/web/server/src/simcore_service_webserver/payments/settings.py
@@ -22,7 +22,7 @@
URLPart,
)
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class PaymentsSettings(BaseCustomSettings, MixinServiceSettings):
diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py
deleted file mode 100644
index ed5b08b5ee1..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_api.py
+++ /dev/null
@@ -1,168 +0,0 @@
-from decimal import Decimal
-from pathlib import Path
-from typing import cast
-
-import aiofiles
-from aiohttp import web
-from models_library.products import CreditResultGet, ProductName, ProductStripeInfoGet
-from simcore_postgres_database.utils_products_prices import ProductPriceInfo
-
-from .._constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY
-from .._resources import webserver_resources
-from ._db import ProductRepository
-from ._events import APP_PRODUCTS_TEMPLATES_DIR_KEY
-from ._model import Product
-from .errors import BelowMinimumPaymentError, ProductPriceNotDefinedError
-
-
-def get_product_name(request: web.Request) -> str:
- """Returns product name in request but might be undefined"""
- product_name: str = request[RQ_PRODUCT_KEY]
- return product_name
-
-
-def get_product(app: web.Application, product_name: ProductName) -> Product:
- product: Product = app[APP_PRODUCTS_KEY][product_name]
- return product
-
-
-def get_current_product(request: web.Request) -> Product:
- """Returns product associated to current request"""
- product_name: ProductName = get_product_name(request)
- current_product: Product = get_product(request.app, product_name=product_name)
- return current_product
-
-
-def list_products(app: web.Application) -> list[Product]:
- products: list[Product] = list(app[APP_PRODUCTS_KEY].values())
- return products
-
-
-async def get_current_product_credit_price_info(
- request: web.Request,
-) -> ProductPriceInfo | None:
- """Gets latest credit price for this product.
-
- NOTE: Contrary to other product api functions (e.g. get_current_product) this function
- gets the latest update from the database. Otherwise, products are loaded
- on startup and cached therefore in those cases would require a restart
- of the service for the latest changes to take effect.
- """
- current_product_name = get_product_name(request)
- repo = ProductRepository.create_from_request(request)
- return cast( # mypy: not sure why
- ProductPriceInfo | None,
- await repo.get_product_latest_price_info_or_none(current_product_name),
- )
-
-
-async def get_credit_amount(
- app: web.Application,
- *,
- dollar_amount: Decimal,
- product_name: ProductName,
-) -> CreditResultGet:
- """For provided dollars and product gets credit amount.
-
- NOTE: Contrary to other product api functions (e.g. get_current_product) this function
- gets the latest update from the database. Otherwise, products are loaded
- on startup and cached therefore in those cases would require a restart
- of the service for the latest changes to take effect.
-
- Raises:
- ProductPriceNotDefinedError
- BelowMinimumPaymentError
-
- """
- repo = ProductRepository.create_from_app(app)
- price_info = await repo.get_product_latest_price_info_or_none(product_name)
- if price_info is None or not price_info.usd_per_credit:
- # '0 or None' should raise
- raise ProductPriceNotDefinedError(
- reason=f"Product {product_name} usd_per_credit is either not defined or zero"
- )
-
- if dollar_amount < price_info.min_payment_amount_usd:
- raise BelowMinimumPaymentError(
- amount_usd=dollar_amount,
- min_payment_amount_usd=price_info.min_payment_amount_usd,
- )
-
- credit_amount = dollar_amount / price_info.usd_per_credit
- return CreditResultGet(product_name=product_name, credit_amount=credit_amount)
-
-
-async def get_product_stripe_info(
- app: web.Application, *, product_name: ProductName
-) -> ProductStripeInfoGet:
- repo = ProductRepository.create_from_app(app)
- product_stripe_info = await repo.get_product_stripe_info(product_name)
- if (
- not product_stripe_info
- or "missing!!" in product_stripe_info.stripe_price_id
- or "missing!!" in product_stripe_info.stripe_tax_rate_id
- ):
- msg = f"Missing product stripe for product {product_name}"
- raise ValueError(msg)
- return cast(ProductStripeInfoGet, product_stripe_info) # mypy: not sure why
-
-
-#
-# helpers for get_product_template_path
-#
-
-
-def _themed(dirname: str, template: str) -> Path:
- path: Path = webserver_resources.get_path(f"{Path(dirname) / template}")
- return path
-
-
-async def _get_content(request: web.Request, template_name: str):
- repo = ProductRepository.create_from_request(request)
- content = await repo.get_template_content(template_name)
- if not content:
- msg = f"Missing template {template_name} for product"
- raise ValueError(msg)
- return content
-
-
-def _safe_get_current_product(request: web.Request) -> Product | None:
- try:
- product: Product = get_current_product(request)
- return product
- except KeyError:
- return None
-
-
-async def get_product_template_path(request: web.Request, filename: str) -> Path:
- if product := _safe_get_current_product(request):
- if template_name := product.get_template_name_for(filename):
- template_dir: Path = request.app[APP_PRODUCTS_TEMPLATES_DIR_KEY]
- template_path = template_dir / template_name
- if not template_path.exists():
- # cache
- content = await _get_content(request, template_name)
- try:
- async with aiofiles.open(template_path, "wt") as fh:
- await fh.write(content)
- except Exception:
- # fails to write
- if template_path.exists():
- template_path.unlink()
- raise
-
- return template_path
-
- # check static resources under templates/
- if (
- template_path := _themed(f"templates/{product.name}", filename)
- ) and template_path.exists():
- return template_path
-
- # If no product or template for product defined, we fall back to common templates
- common_template = _themed("templates/common", filename)
- if not common_template.exists():
- msg = f"{filename} is not part of the templates/common"
- raise ValueError(msg)
-
- return common_template
diff --git a/services/web/server/src/simcore_service_webserver/products/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/products/_controller/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/services/web/server/src/simcore_service_webserver/products/_controller/rest.py b/services/web/server/src/simcore_service_webserver/products/_controller/rest.py
new file mode 100644
index 00000000000..77c72afe3b0
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_controller/rest.py
@@ -0,0 +1,81 @@
+import logging
+
+from aiohttp import web
+from models_library.api_schemas_webserver.products import (
+ CreditPriceGet,
+ ProductGet,
+ ProductUIGet,
+)
+from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
+
+from ..._meta import API_VTAG as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _service, products_web
+from .._repository import ProductRepository
+from ..models import Product
+from .rest_exceptions import handle_rest_requests_exceptions
+from .rest_schemas import ProductsRequestContext, ProductsRequestParams
+
+routes = web.RouteTableDef()
+
+
+_logger = logging.getLogger(__name__)
+
+
+@routes.get(f"/{VTAG}/credits-price", name="get_current_product_price")
+@login_required
+@permission_required("product.price.read")
+@handle_rest_requests_exceptions
+async def _get_current_product_price(request: web.Request):
+ req_ctx = ProductsRequestContext.model_validate(request)
+ price_info = await products_web.get_current_product_credit_price_info(request)
+
+ credit_price = CreditPriceGet(
+ product_name=req_ctx.product_name,
+ usd_per_credit=price_info.usd_per_credit if price_info else None,
+ min_payment_amount_usd=(
+ price_info.min_payment_amount_usd # type: ignore[arg-type]
+ if price_info
+ else None
+ ),
+ )
+ return envelope_json_response(credit_price)
+
+
+@routes.get(f"/{VTAG}/products/{{product_name}}", name="get_product")
+@login_required
+@permission_required("product.details.*")
+@handle_rest_requests_exceptions
+async def _get_product(request: web.Request):
+ req_ctx = ProductsRequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(ProductsRequestParams, request)
+
+ if path_params.product_name == "current":
+ product_name = req_ctx.product_name
+ else:
+ product_name = path_params.product_name
+
+ product: Product = _service.get_product(request.app, product_name=product_name)
+
+ assert "extra" in ProductGet.model_config # nosec
+ assert ProductGet.model_config["extra"] == "ignore" # nosec
+ data = ProductGet(**product.model_dump(), templates=[])
+ return envelope_json_response(data)
+
+
+@routes.get(f"/{VTAG}/products/current/ui", name="get_current_product_ui")
+@login_required
+@permission_required("product.ui.read")
+@handle_rest_requests_exceptions
+async def _get_current_product_ui(request: web.Request):
+ req_ctx = ProductsRequestContext.model_validate(request)
+ product_name = req_ctx.product_name
+
+ ui = await _service.get_product_ui(
+ ProductRepository.create_from_request(request), product_name=product_name
+ )
+
+ data = ProductUIGet(product_name=product_name, ui=ui)
+ return envelope_json_response(data)
diff --git a/services/web/server/src/simcore_service_webserver/products/_controller/rest_exceptions.py b/services/web/server/src/simcore_service_webserver/products/_controller/rest_exceptions.py
new file mode 100644
index 00000000000..a9e8cb13f00
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_controller/rest_exceptions.py
@@ -0,0 +1,26 @@
+from servicelib.aiohttp import status
+
+from ...constants import MSG_TRY_AGAIN_OR_SUPPORT
+from ...exception_handling import (
+ ExceptionToHttpErrorMap,
+ HttpErrorInfo,
+ exception_handling_decorator,
+ to_exceptions_handlers_map,
+)
+from ..errors import MissingStripeConfigError, ProductNotFoundError
+
+_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+ ProductNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "{product_name} was not found",
+ ),
+ MissingStripeConfigError: HttpErrorInfo(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "{product_name} service is currently unavailable." + MSG_TRY_AGAIN_OR_SUPPORT,
+ ),
+}
+
+
+handle_rest_requests_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+)
diff --git a/services/web/server/src/simcore_service_webserver/products/_controller/rest_schemas.py b/services/web/server/src/simcore_service_webserver/products/_controller/rest_schemas.py
new file mode 100644
index 00000000000..6a4ac2100b1
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_controller/rest_schemas.py
@@ -0,0 +1,26 @@
+import logging
+from typing import Annotated, Literal
+
+from aiohttp import web
+from models_library.basic_types import IDStr
+from models_library.products import ProductName
+from models_library.rest_base import RequestParameters, StrictRequestParameters
+from models_library.users import UserID
+from pydantic import Field
+from servicelib.request_keys import RQT_USERID_KEY
+
+from ...constants import RQ_PRODUCT_KEY
+
+routes = web.RouteTableDef()
+
+
+_logger = logging.getLogger(__name__)
+
+
+class ProductsRequestContext(RequestParameters):
+ user_id: Annotated[UserID, Field(alias=RQT_USERID_KEY)]
+ product_name: Annotated[ProductName, Field(..., alias=RQ_PRODUCT_KEY)]
+
+
+class ProductsRequestParams(StrictRequestParameters):
+ product_name: IDStr | Literal["current"]
diff --git a/services/web/server/src/simcore_service_webserver/products/_controller/rpc.py b/services/web/server/src/simcore_service_webserver/products/_controller/rpc.py
new file mode 100644
index 00000000000..852cf2e4f8c
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_controller/rpc.py
@@ -0,0 +1,38 @@
+from decimal import Decimal
+
+from aiohttp import web
+from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
+from models_library.api_schemas_webserver.products import CreditResultRpcGet
+from models_library.products import ProductName
+from servicelib.rabbitmq import RPCRouter
+
+from ...constants import APP_SETTINGS_KEY
+from ...rabbitmq import get_rabbitmq_rpc_server, setup_rabbitmq
+from .. import _service
+from .._models import CreditResult
+
+router = RPCRouter()
+
+
+@router.expose()
+async def get_credit_amount(
+ app: web.Application,
+ *,
+ dollar_amount: Decimal,
+ product_name: ProductName,
+) -> CreditResultRpcGet:
+ credit_result: CreditResult = await _service.get_credit_amount(
+ app, dollar_amount=dollar_amount, product_name=product_name
+ )
+ return CreditResultRpcGet.model_validate(credit_result, from_attributes=True)
+
+
+async def _register_rpc_routes_on_startup(app: web.Application):
+ rpc_server = get_rabbitmq_rpc_server(app)
+ await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app)
+
+
+def setup_rpc(app: web.Application):
+ setup_rabbitmq(app)
+ if app[APP_SETTINGS_KEY].WEBSERVER_RABBITMQ:
+ app.on_startup.append(_register_rpc_routes_on_startup)
diff --git a/services/web/server/src/simcore_service_webserver/products/_db.py b/services/web/server/src/simcore_service_webserver/products/_db.py
deleted file mode 100644
index a59f6077dfc..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_db.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import logging
-from decimal import Decimal
-from typing import AsyncIterator, NamedTuple
-
-import sqlalchemy as sa
-from aiopg.sa.connection import SAConnection
-from aiopg.sa.result import ResultProxy, RowProxy
-from models_library.products import ProductName, ProductStripeInfoGet
-from simcore_postgres_database.constants import QUANTIZE_EXP_ARG
-from simcore_postgres_database.models.jinja2_templates import jinja2_templates
-from simcore_postgres_database.utils_products_prices import (
- ProductPriceInfo,
- get_product_latest_price_info_or_none,
- get_product_latest_stripe_info,
-)
-
-from ..db.base_repository import BaseRepository
-from ..db.models import products
-from ._model import Product
-
-_logger = logging.getLogger(__name__)
-
-
-#
-# REPOSITORY
-#
-
-# NOTE: This also asserts that all model fields are in sync with sqlalchemy columns
-_PRODUCTS_COLUMNS = [
- products.c.name,
- products.c.display_name,
- products.c.short_name,
- products.c.host_regex,
- products.c.support_email,
- products.c.product_owners_email,
- products.c.twilio_messaging_sid,
- products.c.vendor,
- products.c.issues,
- products.c.manuals,
- products.c.support,
- products.c.login_settings,
- products.c.registration_email_template,
- products.c.max_open_studies_per_user,
- products.c.group_id,
-]
-
-
-class PaymentFieldsTuple(NamedTuple):
- enabled: bool
- credits_per_usd: Decimal | None
- min_payment_amount_usd: Decimal | None
-
-
-async def get_product_payment_fields(
- conn: SAConnection, product_name: ProductName
-) -> PaymentFieldsTuple:
- price_info = await get_product_latest_price_info_or_none(
- conn, product_name=product_name
- )
- if price_info is None or price_info.usd_per_credit == 0:
- return PaymentFieldsTuple(
- enabled=False,
- credits_per_usd=None,
- min_payment_amount_usd=None,
- )
-
- assert price_info.usd_per_credit > 0
- assert price_info.min_payment_amount_usd > 0
-
- return PaymentFieldsTuple(
- enabled=True,
- credits_per_usd=Decimal(1 / price_info.usd_per_credit).quantize(
- QUANTIZE_EXP_ARG
- ),
- min_payment_amount_usd=price_info.min_payment_amount_usd,
- )
-
-
-async def iter_products(conn: SAConnection) -> AsyncIterator[ResultProxy]:
- """Iterates on products sorted by priority i.e. the first is considered the default"""
- async for row in conn.execute(
- sa.select(*_PRODUCTS_COLUMNS).order_by(products.c.priority)
- ):
- assert row # nosec
- yield row
-
-
-class ProductRepository(BaseRepository):
- async def get_product(self, product_name: str) -> Product | None:
- async with self.engine.acquire() as conn:
- result: ResultProxy = await conn.execute(
- sa.select(*_PRODUCTS_COLUMNS).where(products.c.name == product_name)
- )
- row: RowProxy | None = await result.first()
- if row:
- # NOTE: MD Observation: Currently we are not defensive, we assume automatically
- # that the product is not billable when there is no product in the products_prices table
- # or it's price is 0. We should change it and always assume that the product is billable, unless
- # explicitely stated that it is free
- payments = await get_product_payment_fields(conn, product_name=row.name)
- return Product(
- **dict(row.items()),
- is_payment_enabled=payments.enabled,
- credits_per_usd=payments.credits_per_usd,
- )
- return None
-
- async def get_product_latest_price_info_or_none(
- self, product_name: str
- ) -> ProductPriceInfo | None:
- """newest price of a product or None if not billable"""
- async with self.engine.acquire() as conn:
- return await get_product_latest_price_info_or_none(
- conn, product_name=product_name
- )
-
- async def get_product_stripe_info(self, product_name: str) -> ProductStripeInfoGet:
- async with self.engine.acquire() as conn:
- row = await get_product_latest_stripe_info(conn, product_name=product_name)
- return ProductStripeInfoGet(
- stripe_price_id=row[0], stripe_tax_rate_id=row[1]
- )
-
- async def get_template_content(
- self,
- template_name: str,
- ) -> str | None:
- async with self.engine.acquire() as conn:
- template_content: str | None = await conn.scalar(
- sa.select(jinja2_templates.c.content).where(
- jinja2_templates.c.name == template_name
- )
- )
- return template_content
-
- async def get_product_template_content(
- self,
- product_name: str,
- product_template: sa.Column = products.c.registration_email_template,
- ) -> str | None:
- async with self.engine.acquire() as conn:
- oj = sa.join(
- products,
- jinja2_templates,
- product_template == jinja2_templates.c.name,
- isouter=True,
- )
- content = await conn.scalar(
- sa.select(jinja2_templates.c.content)
- .select_from(oj)
- .where(products.c.name == product_name)
- )
- return f"{content}" if content else None
diff --git a/services/web/server/src/simcore_service_webserver/products/_events.py b/services/web/server/src/simcore_service_webserver/products/_events.py
deleted file mode 100644
index 836e43a902f..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_events.py
+++ /dev/null
@@ -1,108 +0,0 @@
-import logging
-import tempfile
-from collections import OrderedDict
-from pathlib import Path
-
-from aiohttp import web
-from aiopg.sa.engine import Engine
-from aiopg.sa.result import RowProxy
-from pydantic import ValidationError
-from servicelib.exceptions import InvalidConfig
-from simcore_postgres_database.utils_products import (
- get_default_product_name,
- get_or_create_product_group,
-)
-
-from .._constants import APP_PRODUCTS_KEY
-from ..db.plugin import get_database_engine
-from ..statics._constants import FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE
-from ._db import get_product_payment_fields, iter_products
-from ._model import Product
-
-_logger = logging.getLogger(__name__)
-
-APP_PRODUCTS_TEMPLATES_DIR_KEY = f"{__name__}.template_dir"
-
-
-async def setup_product_templates(app: web.Application):
- """
- builds a directory and download product templates
- """
- with tempfile.TemporaryDirectory(
- suffix=APP_PRODUCTS_TEMPLATES_DIR_KEY
- ) as templates_dir:
- app[APP_PRODUCTS_TEMPLATES_DIR_KEY] = Path(templates_dir)
-
- yield
-
- # cleanup
-
-
-async def auto_create_products_groups(app: web.Application) -> None:
- """Ensures all products have associated group ids
-
- Avoids having undefined groups in products with new products.group_id column
-
- NOTE: could not add this in 'setup_groups' (groups plugin)
- since it has to be executed BEFORE 'load_products_on_startup'
- """
- engine = get_database_engine(app)
-
- async with engine.acquire() as connection:
- async for row in iter_products(connection):
- product_name = row.name # type: ignore[attr-defined] # sqlalchemy
- product_group_id = await get_or_create_product_group(
- connection, product_name
- )
- _logger.debug(
- "Product with %s has an associated group with %s",
- f"{product_name=}",
- f"{product_group_id=}",
- )
-
-
-def _set_app_state(
- app: web.Application,
- app_products: OrderedDict[str, Product],
- default_product_name: str,
-):
- app[APP_PRODUCTS_KEY] = app_products
- assert default_product_name in app_products # nosec
- app[f"{APP_PRODUCTS_KEY}_default"] = default_product_name
-
-
-async def load_products_on_startup(app: web.Application):
- """
- Loads info on products stored in the database into app's storage (i.e. memory)
- """
- app_products: OrderedDict[str, Product] = OrderedDict()
- engine: Engine = get_database_engine(app)
- async with engine.acquire() as connection:
- async for row in iter_products(connection):
- assert isinstance(row, RowProxy) # nosec
- try:
- name = row.name
-
- payments = await get_product_payment_fields(
- connection, product_name=name
- )
-
- app_products[name] = Product(
- **dict(row.items()),
- is_payment_enabled=payments.enabled,
- credits_per_usd=payments.credits_per_usd,
- )
-
- assert name in FRONTEND_APPS_AVAILABLE # nosec
-
- except ValidationError as err:
- msg = f"Invalid product configuration in db '{row}':\n {err}"
- raise InvalidConfig(msg) from err
-
- assert FRONTEND_APP_DEFAULT in app_products # nosec
-
- default_product_name = await get_default_product_name(connection)
-
- _set_app_state(app, app_products, default_product_name)
-
- _logger.debug("Product loaded: %s", [p.name for p in app_products.values()])
diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py
deleted file mode 100644
index 738dcd3c84f..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_handlers.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import logging
-from typing import Literal
-
-from aiohttp import web
-from models_library.api_schemas_webserver.product import GetCreditPrice, GetProduct
-from models_library.basic_types import IDStr
-from models_library.rest_base import RequestParameters, StrictRequestParameters
-from models_library.users import UserID
-from pydantic import Field
-from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
-from servicelib.request_keys import RQT_USERID_KEY
-
-from .._constants import RQ_PRODUCT_KEY
-from .._meta import API_VTAG as VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import _api, api
-from ._model import Product
-
-routes = web.RouteTableDef()
-
-
-_logger = logging.getLogger(__name__)
-
-
-class _ProductsRequestContext(RequestParameters):
- user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required]
- product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required]
-
-
-@routes.get(f"/{VTAG}/credits-price", name="get_current_product_price")
-@login_required
-@permission_required("product.price.read")
-async def _get_current_product_price(request: web.Request):
- req_ctx = _ProductsRequestContext.model_validate(request)
- price_info = await _api.get_current_product_credit_price_info(request)
-
- credit_price = GetCreditPrice(
- product_name=req_ctx.product_name,
- usd_per_credit=price_info.usd_per_credit if price_info else None,
- min_payment_amount_usd=(
- price_info.min_payment_amount_usd # type: ignore[arg-type]
- if price_info
- else None
- ),
- )
- return envelope_json_response(credit_price)
-
-
-class _ProductsRequestParams(StrictRequestParameters):
- product_name: IDStr | Literal["current"]
-
-
-@routes.get(f"/{VTAG}/products/{{product_name}}", name="get_product")
-@login_required
-@permission_required("product.details.*")
-async def _get_product(request: web.Request):
- req_ctx = _ProductsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_ProductsRequestParams, request)
-
- if path_params.product_name == "current":
- product_name = req_ctx.product_name
- else:
- product_name = path_params.product_name
-
- try:
- product: Product = api.get_product(request.app, product_name=product_name)
- except KeyError as err:
- raise web.HTTPNotFound(reason=f"{product_name=} not found") from err
-
- assert "extra" in GetProduct.model_config # nosec
- assert GetProduct.model_config["extra"] == "ignore" # nosec
- data = GetProduct(**product.model_dump(), templates=[])
- return envelope_json_response(data)
-
-
-class _ProductTemplateParams(_ProductsRequestParams):
- template_id: IDStr
-
-
-@routes.put(
- f"/{VTAG}/products/{{product_name}}/templates/{{template_id}}",
- name="update_product_template",
-)
-@login_required
-@permission_required("product.details.*")
-async def update_product_template(request: web.Request):
- req_ctx = _ProductsRequestContext.model_validate(request)
- path_params = parse_request_path_parameters_as(_ProductTemplateParams, request)
-
- assert req_ctx # nosec
- assert path_params # nosec
-
- raise NotImplementedError
diff --git a/services/web/server/src/simcore_service_webserver/products/_model.py b/services/web/server/src/simcore_service_webserver/products/_model.py
deleted file mode 100644
index ef4b7f9498c..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_model.py
+++ /dev/null
@@ -1,289 +0,0 @@
-import logging
-import re
-import string
-from typing import ( # noqa: UP035 # pydantic does not validate with re.Pattern
- Annotated,
- Any,
-)
-
-from models_library.basic_regex import (
- PUBLIC_VARIABLE_NAME_RE,
- TWILIO_ALPHANUMERIC_SENDER_ID_RE,
-)
-from models_library.basic_types import NonNegativeDecimal
-from models_library.emails import LowerCaseEmailStr
-from models_library.products import ProductName
-from models_library.utils.change_case import snake_to_camel
-from pydantic import (
- BaseModel,
- BeforeValidator,
- ConfigDict,
- Field,
- PositiveInt,
- field_serializer,
- field_validator,
-)
-from simcore_postgres_database.models.products import (
- EmailFeedback,
- Forum,
- IssueTracker,
- Manual,
- ProductLoginSettingsDict,
- Vendor,
- WebFeedback,
-)
-from sqlalchemy import Column
-
-from ..db.models import products
-from ..statics._constants import FRONTEND_APPS_AVAILABLE
-
-_logger = logging.getLogger(__name__)
-
-
-class Product(BaseModel):
- """Model used to parse a row of pg product's table
-
- The info in this model is static and read-only
-
- SEE descriptions in packages/postgres-database/src/simcore_postgres_database/models/products.py
- """
-
- name: ProductName = Field(pattern=PUBLIC_VARIABLE_NAME_RE, validate_default=True)
-
- display_name: Annotated[str, Field(..., description="Long display name")]
- short_name: str | None = Field(
- None,
- pattern=re.compile(TWILIO_ALPHANUMERIC_SENDER_ID_RE),
- min_length=2,
- max_length=11,
- description="Short display name for SMS",
- )
-
- host_regex: Annotated[re.Pattern, BeforeValidator(str.strip)] = Field(
- ..., description="Host regex"
- )
-
- support_email: Annotated[
- LowerCaseEmailStr,
- Field(
- description="Main support email."
- " Other support emails can be defined under 'support' field",
- ),
- ]
-
- product_owners_email: Annotated[
- LowerCaseEmailStr | None,
- Field(description="Used e.g. for account requests forms"),
- ] = None
-
- twilio_messaging_sid: str | None = Field(
- default=None, min_length=34, max_length=34, description="Identifier for SMS"
- )
-
- vendor: Vendor | None = Field(
- None,
- description="Vendor information such as company name, address, copyright, ...",
- )
-
- issues: list[IssueTracker] | None = None
-
- manuals: list[Manual] | None = None
-
- support: list[Forum | EmailFeedback | WebFeedback] | None = Field(None)
-
- login_settings: ProductLoginSettingsDict = Field(
- ...,
- description="Product customization of login settings. "
- "Note that these are NOT the final plugin settings but those are obtained from login.settings.get_plugin_settings",
- )
-
- registration_email_template: str | None = Field(
- None, json_schema_extra={"x_template_name": "registration_email"}
- )
-
- max_open_studies_per_user: PositiveInt | None = Field(
- default=None,
- description="Limits the number of studies a user may have open concurently (disabled if NULL)",
- )
-
- group_id: int | None = Field(
- default=None, description="Groups associated to this product"
- )
-
- is_payment_enabled: bool = Field(
- default=False,
- description="True if this product offers credits",
- )
-
- credits_per_usd: NonNegativeDecimal | None = Field(
- default=None,
- description="Price of the credits in this product given in credit/USD. None for free product.",
- )
-
- min_payment_amount_usd: NonNegativeDecimal | None = Field(
- default=None,
- description="Price of the credits in this product given in credit/USD. None for free product.",
- )
-
- @field_validator("*", mode="before")
- @classmethod
- def _parse_empty_string_as_null(cls, v):
- """Safe measure: database entries are sometimes left blank instead of null"""
- if isinstance(v, str) and len(v.strip()) == 0:
- return None
- return v
-
- @field_validator("name", mode="before")
- @classmethod
- def _validate_name(cls, v):
- if v not in FRONTEND_APPS_AVAILABLE:
- msg = f"{v} is not in available front-end apps {FRONTEND_APPS_AVAILABLE}"
- raise ValueError(msg)
- return v
-
- @field_serializer("issues", "vendor")
- @staticmethod
- def _preserve_snake_case(v: Any) -> Any:
- return v
-
- @property
- def twilio_alpha_numeric_sender_id(self) -> str:
- return self.short_name or self.display_name.replace(string.punctuation, "")[:11]
-
- model_config = ConfigDict(
- alias_generator=snake_to_camel,
- populate_by_name=True,
- str_strip_whitespace=True,
- frozen=True,
- from_attributes=True,
- extra="ignore",
- json_schema_extra={
- "examples": [
- {
- # fake mandatory
- "name": "osparc",
- "host_regex": r"([\.-]{0,1}osparc[\.-])",
- "twilio_messaging_sid": "1" * 34,
- "registration_email_template": "osparc_registration_email",
- "login_settings": {
- "LOGIN_2FA_REQUIRED": False,
- },
- # defaults from sqlalchemy table
- **{
- str(c.name): c.server_default.arg # type: ignore[union-attr]
- for c in products.columns
- if isinstance(c, Column)
- and c.server_default
- and isinstance(c.server_default.arg, str) # type: ignore[union-attr]
- },
- },
- # Example of data in the dabase with a url set with blanks
- {
- "name": "tis",
- "display_name": "TI PT",
- "short_name": "TIPI",
- "host_regex": r"(^tis[\.-])|(^ti-solutions\.)|(^ti-plan\.)",
- "support_email": "support@foo.com",
- "manual_url": "https://foo.com",
- "issues_login_url": None,
- "issues_new_url": "https://foo.com/new",
- "feedback_form_url": "", # <-- blanks
- "login_settings": {
- "LOGIN_2FA_REQUIRED": False,
- },
- },
- # full example
- {
- "name": "osparc",
- "display_name": "o²S²PARC FOO",
- "short_name": "osparcf",
- "host_regex": "([\\.-]{0,1}osparcf[\\.-])",
- "support_email": "foo@osparcf.io",
- "vendor": {
- "url": "https://acme.com",
- "license_url": "https://acme.com/license",
- "invitation_form": True,
- "name": "ACME",
- "copyright": "© ACME correcaminos",
- },
- "issues": [
- {
- "label": "github",
- "login_url": "https://github.com/ITISFoundation/osparc-simcore",
- "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
- },
- {
- "label": "fogbugz",
- "login_url": "https://fogbugz.com/login",
- "new_url": "https://fogbugz.com/new?project=123",
- },
- ],
- "manuals": [
- {"url": "doc.acme.com", "label": "main"},
- {"url": "yet-another-manual.acme.com", "label": "z43"},
- ],
- "support": [
- {
- "url": "forum.acme.com",
- "kind": "forum",
- "label": "forum",
- },
- {
- "kind": "email",
- "email": "more-support@acme.com",
- "label": "email",
- },
- {
- "url": "support.acme.com",
- "kind": "web",
- "label": "web-form",
- },
- ],
- "login_settings": {
- "LOGIN_2FA_REQUIRED": False,
- },
- "group_id": 12345,
- "is_payment_enabled": False,
- },
- ]
- },
- )
-
- # helpers ----
-
- def to_statics(self) -> dict[str, Any]:
- """
- Selects **public** fields from product's info
- and prefixes it with its name to produce
- items for statics.json (reachable by front-end)
- """
-
- # SECURITY WARNING: do not expose sensitive information here
- # keys will be named as e.g. displayName, supportEmail, ...
- return self.model_dump(
- include={
- "display_name": True,
- "support_email": True,
- "vendor": True,
- "issues": True,
- "manuals": True,
- "support": True,
- "is_payment_enabled": True,
- "is_dynamic_services_telemetry_enabled": True,
- },
- exclude_none=True,
- exclude_unset=True,
- by_alias=True,
- )
-
- def get_template_name_for(self, filename: str) -> str | None:
- """Checks for field marked with 'x_template_name' that fits the argument"""
- template_name = filename.removesuffix(".jinja2")
- for name, field in self.model_fields.items():
- if (
- field.json_schema_extra
- and field.json_schema_extra.get("x_template_name") == template_name # type: ignore[union-attr]
- ):
- template_name_attribute: str = getattr(self, name)
- return template_name_attribute
- return None
diff --git a/services/web/server/src/simcore_service_webserver/products/_models.py b/services/web/server/src/simcore_service_webserver/products/_models.py
new file mode 100644
index 00000000000..dbab8b60a9b
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_models.py
@@ -0,0 +1,334 @@
+import logging
+import re
+import string
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Annotated, Any
+
+from models_library.basic_regex import (
+ PUBLIC_VARIABLE_NAME_RE,
+ TWILIO_ALPHANUMERIC_SENDER_ID_RE,
+)
+from models_library.basic_types import NonNegativeDecimal
+from models_library.emails import LowerCaseEmailStr
+from models_library.products import ProductName, StripePriceID, StripeTaxRateID
+from models_library.utils.change_case import snake_to_camel
+from pydantic import (
+ BaseModel,
+ BeforeValidator,
+ ConfigDict,
+ Field,
+ PositiveInt,
+ field_serializer,
+ field_validator,
+)
+from pydantic.config import JsonDict
+from simcore_postgres_database.models.products import (
+ EmailFeedback,
+ Forum,
+ IssueTracker,
+ Manual,
+ ProductLoginSettingsDict,
+ Vendor,
+ WebFeedback,
+ products,
+)
+
+from ..constants import FRONTEND_APPS_AVAILABLE
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class CreditResult:
+ product_name: ProductName
+ credit_amount: Decimal
+
+
+@dataclass(frozen=True)
+class ProductStripeInfo:
+ stripe_price_id: StripePriceID
+ stripe_tax_rate_id: StripeTaxRateID
+
+
+@dataclass(frozen=True)
+class PaymentFields:
+ enabled: bool
+ credits_per_usd: Decimal | None
+ min_payment_amount_usd: Decimal | None
+
+
+class Product(BaseModel):
+ """Model used to parse a row of pg product's table
+
+ The info in this model is static and read-only
+
+ SEE descriptions in packages/postgres-database/src/simcore_postgres_database/models/products.py
+ """
+
+ name: Annotated[
+ ProductName,
+ Field(pattern=PUBLIC_VARIABLE_NAME_RE, validate_default=True),
+ ]
+
+ display_name: Annotated[str, Field(..., description="Long display name")]
+ short_name: Annotated[
+ str | None,
+ Field(
+ None,
+ pattern=re.compile(TWILIO_ALPHANUMERIC_SENDER_ID_RE),
+ min_length=2,
+ max_length=11,
+ description="Short display name for SMS",
+ ),
+ ]
+
+ host_regex: Annotated[
+ re.Pattern, BeforeValidator(str.strip), Field(..., description="Host regex")
+ ]
+
+ support_email: Annotated[
+ LowerCaseEmailStr,
+ Field(
+ description="Main support email."
+ " Other support emails can be defined under 'support' field",
+ ),
+ ]
+
+ product_owners_email: Annotated[
+ LowerCaseEmailStr | None,
+ Field(description="Used e.g. for account requests forms"),
+ ] = None
+
+ twilio_messaging_sid: Annotated[
+ str | None,
+ Field(min_length=34, max_length=34, description="Identifier for SMS"),
+ ] = None
+
+ vendor: Annotated[
+ Vendor | None,
+ Field(
+ description="Vendor information such as company name, address, copyright, ...",
+ ),
+ ] = None
+
+ issues: list[IssueTracker] | None = None
+
+ manuals: list[Manual] | None = None
+
+ support: list[Forum | EmailFeedback | WebFeedback] | None = Field(None)
+
+ login_settings: Annotated[
+ ProductLoginSettingsDict,
+ Field(
+ description="Product customization of login settings. "
+ "Note that these are NOT the final plugin settings but those are obtained from login.settings.get_plugin_settings",
+ ),
+ ]
+
+ registration_email_template: Annotated[
+ str | None, Field(json_schema_extra={"x_template_name": "registration_email"})
+ ] = None
+
+ max_open_studies_per_user: Annotated[
+ PositiveInt | None,
+ Field(
+ description="Limits the number of studies a user may have open concurently (disabled if NULL)",
+ ),
+ ] = None
+
+ group_id: Annotated[
+ int | None, Field(description="Groups associated to this product")
+ ] = None
+
+ is_payment_enabled: Annotated[
+ bool,
+ Field(
+ description="True if this product offers credits",
+ ),
+ ] = False
+
+ credits_per_usd: Annotated[
+ NonNegativeDecimal | None,
+ Field(
+ description="Price of the credits in this product given in credit/USD. None for free product.",
+ ),
+ ] = None
+
+ min_payment_amount_usd: Annotated[
+ NonNegativeDecimal | None,
+ Field(
+ description="Price of the credits in this product given in credit/USD. None for free product.",
+ ),
+ ] = None
+
+ ## Guarantees when loaded from a database ---------------
+
+ @field_validator("*", mode="before")
+ @classmethod
+ def _parse_empty_string_as_null(cls, v):
+ """Safe measure: database entries are sometimes left blank instead of null"""
+ if isinstance(v, str) and len(v.strip()) == 0:
+ return None
+ return v
+
+ @field_validator("name", mode="before")
+ @classmethod
+ def _check_is_valid_product_name(cls, v):
+ if v not in FRONTEND_APPS_AVAILABLE:
+ msg = f"{v} is not in available front-end apps {FRONTEND_APPS_AVAILABLE}"
+ raise ValueError(msg)
+ return v
+
+ @field_serializer("issues", "vendor")
+ @staticmethod
+ def _preserve_snake_case(v: Any) -> Any:
+ return v
+
+ @property
+ def twilio_alpha_numeric_sender_id(self) -> str:
+ return self.short_name or self.display_name.replace(string.punctuation, "")[:11]
+
+ @staticmethod
+ def _update_json_schema_extra(schema: JsonDict) -> None:
+ from sqlalchemy import Column
+
+ schema.update(
+ {
+ "examples": [
+ {
+ # fake mandatory
+ "name": "osparc",
+ "host_regex": r"([\.-]{0,1}osparc[\.-])",
+ "twilio_messaging_sid": "1" * 34,
+ "registration_email_template": "osparc_registration_email",
+ "login_settings": {
+ "LOGIN_2FA_REQUIRED": False,
+ },
+ # defaults from sqlalchemy table
+ **{
+ str(c.name): c.server_default.arg # type: ignore[union-attr]
+ for c in products.columns
+ if isinstance(c, Column)
+ and c.server_default
+ and isinstance(c.server_default.arg, str) # type: ignore[union-attr]
+ },
+ },
+ # Example of data in the dabase with a url set with blanks
+ {
+ "name": "tis",
+ "display_name": "TI PT",
+ "short_name": "TIPI",
+ "host_regex": r"(^tis[\.-])|(^ti-solutions\.)|(^ti-plan\.)",
+ "support_email": "support@foo.com",
+ "manual_url": "https://foo.com",
+ "issues_login_url": None,
+ "issues_new_url": "https://foo.com/new",
+ "feedback_form_url": "", # <-- blanks
+ "login_settings": {
+ "LOGIN_2FA_REQUIRED": False,
+ },
+ },
+ # Full example
+ {
+ "name": "osparc",
+ "display_name": "o²S²PARC FOO",
+ "short_name": "osparcf",
+ "host_regex": "([\\.-]{0,1}osparcf[\\.-])",
+ "support_email": "foo@osparcf.io",
+ "vendor": {
+ "url": "https://acme.com",
+ "license_url": "https://acme.com/license",
+ "invitation_form": True,
+ "name": "ACME",
+ "copyright": "© ACME correcaminos",
+ },
+ "issues": [
+ {
+ "label": "github",
+ "login_url": "https://github.com/ITISFoundation/osparc-simcore",
+ "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
+ },
+ {
+ "label": "fogbugz",
+ "login_url": "https://fogbugz.com/login",
+ "new_url": "https://fogbugz.com/new?project=123",
+ },
+ ],
+ "manuals": [
+ {"url": "doc.acme.com", "label": "main"},
+ {"url": "yet-another-manual.acme.com", "label": "z43"},
+ ],
+ "support": [
+ {
+ "url": "forum.acme.com",
+ "kind": "forum",
+ "label": "forum",
+ },
+ {
+ "kind": "email",
+ "email": "more-support@acme.com",
+ "label": "email",
+ },
+ {
+ "url": "support.acme.com",
+ "kind": "web",
+ "label": "web-form",
+ },
+ ],
+ "login_settings": {
+ "LOGIN_2FA_REQUIRED": False,
+ },
+ "group_id": 12345,
+ "is_payment_enabled": False,
+ },
+ ]
+ },
+ )
+
+ model_config = ConfigDict(
+ alias_generator=snake_to_camel,
+ populate_by_name=True,
+ str_strip_whitespace=True,
+ frozen=True,
+ from_attributes=True,
+ extra="ignore",
+ json_schema_extra=_update_json_schema_extra,
+ )
+
+ def to_statics(self) -> dict[str, Any]:
+ """
+ Selects **public** fields from product's info
+ and prefixes it with its name to produce
+ items for statics.json (reachable by front-end)
+ """
+
+ # SECURITY WARNING: do not expose sensitive information here
+ # keys will be named as e.g. displayName, supportEmail, ...
+ return self.model_dump(
+ include={
+ "display_name": True,
+ "support_email": True,
+ "vendor": True,
+ "issues": True,
+ "manuals": True,
+ "support": True,
+ "is_payment_enabled": True,
+ "is_dynamic_services_telemetry_enabled": True,
+ },
+ exclude_none=True,
+ exclude_unset=True,
+ by_alias=True,
+ )
+
+ def get_template_name_for(self, filename: str) -> str | None:
+ """Checks for field marked with 'x_template_name' that fits the argument"""
+ template_name = filename.removesuffix(".jinja2")
+ for name, field in self.model_fields.items():
+ if (
+ field.json_schema_extra
+ and field.json_schema_extra.get("x_template_name") == template_name # type: ignore[union-attr]
+ ):
+ template_name_attribute: str = getattr(self, name)
+ return template_name_attribute
+ return None
diff --git a/services/web/server/src/simcore_service_webserver/products/_repository.py b/services/web/server/src/simcore_service_webserver/products/_repository.py
new file mode 100644
index 00000000000..16a677b0c82
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_repository.py
@@ -0,0 +1,233 @@
+import logging
+from decimal import Decimal
+from typing import Any
+
+import sqlalchemy as sa
+from models_library.groups import GroupID
+from models_library.products import ProductName
+from simcore_postgres_database.constants import QUANTIZE_EXP_ARG
+from simcore_postgres_database.models.jinja2_templates import jinja2_templates
+from simcore_postgres_database.models.products import products
+from simcore_postgres_database.utils_products import (
+ get_default_product_name,
+ get_or_create_product_group,
+)
+from simcore_postgres_database.utils_products_prices import (
+ ProductPriceInfo,
+ get_product_latest_price_info_or_none,
+ get_product_latest_stripe_info_or_none,
+)
+from simcore_postgres_database.utils_repos import (
+ pass_or_acquire_connection,
+ transaction_context,
+)
+from simcore_service_webserver.constants import FRONTEND_APPS_AVAILABLE
+from sqlalchemy.engine import Row
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from ..constants import FRONTEND_APPS_AVAILABLE
+from ..db.base_repository import BaseRepository
+from ._models import PaymentFields, Product, ProductStripeInfo
+
+_logger = logging.getLogger(__name__)
+
+
+#
+# REPOSITORY
+#
+
+# NOTE: This also asserts that all model fields are in sync with sqlalchemy columns
+_PRODUCTS_COLUMNS = [
+ products.c.name,
+ products.c.display_name,
+ products.c.short_name,
+ products.c.host_regex,
+ products.c.support_email,
+ products.c.product_owners_email,
+ products.c.twilio_messaging_sid,
+ products.c.vendor,
+ products.c.issues,
+ products.c.manuals,
+ products.c.support,
+ products.c.login_settings,
+ products.c.registration_email_template,
+ products.c.max_open_studies_per_user,
+ products.c.group_id,
+]
+
+assert {column.name for column in _PRODUCTS_COLUMNS}.issubset( # nosec
+ set(Product.model_fields)
+)
+
+
+def _to_domain(products_row: Row, payments: PaymentFields) -> Product:
+ return Product(
+ **products_row._asdict(),
+ is_payment_enabled=payments.enabled,
+ credits_per_usd=payments.credits_per_usd,
+ )
+
+
+async def _get_product_payment_fields(
+ conn: AsyncConnection, product_name: ProductName
+) -> PaymentFields:
+ price_info = await get_product_latest_price_info_or_none(
+ conn, product_name=product_name
+ )
+ if price_info is None or price_info.usd_per_credit == 0:
+ return PaymentFields(
+ enabled=False,
+ credits_per_usd=None,
+ min_payment_amount_usd=None,
+ )
+
+ assert price_info.usd_per_credit > 0 # nosec
+ assert price_info.min_payment_amount_usd > 0 # nosec
+
+ return PaymentFields(
+ enabled=True,
+ credits_per_usd=Decimal(1 / price_info.usd_per_credit).quantize(
+ QUANTIZE_EXP_ARG
+ ),
+ min_payment_amount_usd=price_info.min_payment_amount_usd,
+ )
+
+
+class ProductRepository(BaseRepository):
+
+ async def list_products(
+ self,
+ connection: AsyncConnection | None = None,
+ ) -> list[Product]:
+ """
+ Raises:
+ ValidationError:if products are not setup correctly in the database
+ """
+ app_products: list[Product] = []
+
+ query = sa.select(*_PRODUCTS_COLUMNS).order_by(products.c.priority)
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ rows = await conn.stream(query)
+ async for row in rows:
+ name = row.name
+ payments = await _get_product_payment_fields(conn, product_name=name)
+ app_products.append(_to_domain(row, payments))
+
+ assert name in FRONTEND_APPS_AVAILABLE # nosec
+
+ return app_products
+
+ async def list_products_names(
+ self,
+ connection: AsyncConnection | None = None,
+ ) -> list[ProductName]:
+ query = sa.select(products.c.name).order_by(products.c.priority)
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ rows = await conn.stream(query)
+ return [ProductName(row.name) async for row in rows]
+
+ async def get_product(
+ self, product_name: str, connection: AsyncConnection | None = None
+ ) -> Product | None:
+ query = sa.select(*_PRODUCTS_COLUMNS).where(products.c.name == product_name)
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ result = await conn.execute(query)
+ if row := result.one_or_none():
+ payments = await _get_product_payment_fields(
+ conn, product_name=row.name
+ )
+ return _to_domain(row, payments)
+ return None
+
+ async def get_default_product_name(
+ self, connection: AsyncConnection | None = None
+ ) -> ProductName:
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ return await get_default_product_name(conn)
+
+ async def get_product_latest_price_info_or_none(
+ self, product_name: str, connection: AsyncConnection | None = None
+ ) -> ProductPriceInfo | None:
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ return await get_product_latest_price_info_or_none(
+ conn, product_name=product_name
+ )
+
+ async def get_product_stripe_info_or_none(
+ self, product_name: str, connection: AsyncConnection | None = None
+ ) -> ProductStripeInfo | None:
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ latest_stripe_info = await get_product_latest_stripe_info_or_none(
+ conn, product_name=product_name
+ )
+ if latest_stripe_info is None:
+ return None
+
+ stripe_price_id, stripe_tax_rate_id = latest_stripe_info
+ return ProductStripeInfo(
+ stripe_price_id=stripe_price_id, stripe_tax_rate_id=stripe_tax_rate_id
+ )
+
+ async def get_template_content(
+ self, template_name: str, connection: AsyncConnection | None = None
+ ) -> str | None:
+ query = sa.select(jinja2_templates.c.content).where(
+ jinja2_templates.c.name == template_name
+ )
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ template_content: str | None = await conn.scalar(query)
+ return template_content
+
+ async def get_product_template_content(
+ self,
+ product_name: str,
+ product_template: sa.Column = products.c.registration_email_template,
+ connection: AsyncConnection | None = None,
+ ) -> str | None:
+ query = (
+ sa.select(jinja2_templates.c.content)
+ .select_from(
+ sa.join(
+ products,
+ jinja2_templates,
+ product_template == jinja2_templates.c.name,
+ isouter=True,
+ )
+ )
+ .where(products.c.name == product_name)
+ )
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ template_content: str | None = await conn.scalar(query)
+ return template_content
+
+ async def get_product_ui(
+ self, product_name: ProductName, connection: AsyncConnection | None = None
+ ) -> dict[str, Any] | None:
+ query = sa.select(products.c.ui).where(products.c.name == product_name)
+
+ async with pass_or_acquire_connection(self.engine, connection) as conn:
+ result = await conn.execute(query)
+ row = result.one_or_none()
+ return dict(**row.ui) if row else None
+
+ async def auto_create_products_groups(
+ self,
+ connection: AsyncConnection | None = None,
+ ) -> dict[ProductName, GroupID]:
+ product_groups_map: dict[ProductName, GroupID] = {}
+
+ product_names = await self.list_products_names(connection)
+ for product_name in product_names:
+ # NOTE: transaction is per product. fail-fast!
+ async with transaction_context(self.engine, connection) as conn:
+ product_group_id: GroupID = await get_or_create_product_group(
+ conn, product_name
+ )
+ product_groups_map[product_name] = product_group_id
+
+ return product_groups_map
diff --git a/services/web/server/src/simcore_service_webserver/products/_rpc.py b/services/web/server/src/simcore_service_webserver/products/_rpc.py
deleted file mode 100644
index 4a4ee46a655..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/_rpc.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from decimal import Decimal
-
-from aiohttp import web
-from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
-from models_library.products import CreditResultGet, ProductName
-from servicelib.rabbitmq import RPCRouter
-
-from ..rabbitmq import get_rabbitmq_rpc_server
-from . import _api
-
-router = RPCRouter()
-
-
-@router.expose()
-async def get_credit_amount(
- app: web.Application,
- *,
- dollar_amount: Decimal,
- product_name: ProductName,
-) -> CreditResultGet:
- credit_result_get: CreditResultGet = await _api.get_credit_amount(
- app, dollar_amount=dollar_amount, product_name=product_name
- )
- return credit_result_get
-
-
-async def register_rpc_routes_on_startup(app: web.Application):
- rpc_server = get_rabbitmq_rpc_server(app)
- await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app)
diff --git a/services/web/server/src/simcore_service_webserver/products/_service.py b/services/web/server/src/simcore_service_webserver/products/_service.py
new file mode 100644
index 00000000000..032f20d8083
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_service.py
@@ -0,0 +1,143 @@
+from decimal import Decimal
+from typing import Any
+
+from aiohttp import web
+from models_library.groups import GroupID
+from models_library.products import ProductName
+from pydantic import ValidationError
+from servicelib.exceptions import InvalidConfig
+from simcore_postgres_database.utils_products_prices import ProductPriceInfo
+
+from ..constants import APP_PRODUCTS_KEY
+from ._models import CreditResult, ProductStripeInfo
+from ._repository import ProductRepository
+from .errors import (
+ BelowMinimumPaymentError,
+ MissingStripeConfigError,
+ ProductNotFoundError,
+ ProductPriceNotDefinedError,
+ ProductTemplateNotFoundError,
+)
+from .models import Product
+
+
+async def load_products(app: web.Application) -> list[Product]:
+ repo = ProductRepository.create_from_app(app)
+ try:
+ # NOTE: list_products implemented as fails-fast!
+ return await repo.list_products()
+ except ValidationError as err:
+ msg = f"Invalid product configuration in db:\n {err}"
+ raise InvalidConfig(msg) from err
+
+
+async def get_default_product_name(app: web.Application) -> ProductName:
+ repo = ProductRepository.create_from_app(app)
+ return await repo.get_default_product_name()
+
+
+def get_product(app: web.Application, product_name: ProductName) -> Product:
+ try:
+ product: Product = app[APP_PRODUCTS_KEY][product_name]
+ return product
+ except KeyError as exc:
+ raise ProductNotFoundError(product_name=product_name) from exc
+
+
+def list_products(app: web.Application) -> list[Product]:
+ products: list[Product] = list(app[APP_PRODUCTS_KEY].values())
+ return products
+
+
+async def list_products_names(app: web.Application) -> list[ProductName]:
+ repo = ProductRepository.create_from_app(app)
+ names: list[ProductName] = await repo.list_products_names()
+ return names
+
+
+async def get_credit_price_info(
+ app: web.Application, product_name: ProductName
+) -> ProductPriceInfo | None:
+ repo = ProductRepository.create_from_app(app)
+ return await repo.get_product_latest_price_info_or_none(product_name)
+
+
+async def get_product_ui(
+ repo: ProductRepository, product_name: ProductName
+) -> dict[str, Any]:
+ ui = await repo.get_product_ui(product_name=product_name)
+ if ui is not None:
+ return ui
+
+ raise ProductNotFoundError(product_name=product_name)
+
+
+async def get_credit_amount(
+ app: web.Application,
+ *,
+ dollar_amount: Decimal,
+ product_name: ProductName,
+) -> CreditResult:
+ """For provided dollars and product gets credit amount.
+
+ NOTE: Contrary to other product api functions (e.g. get_current_product) this function
+ gets the latest update from the database. Otherwise, products are loaded
+ on startup and cached therefore in those cases would require a restart
+ of the service for the latest changes to take effect.
+
+ Raises:
+ ProductPriceNotDefinedError
+ BelowMinimumPaymentError
+
+ """
+ repo = ProductRepository.create_from_app(app)
+ price_info = await repo.get_product_latest_price_info_or_none(product_name)
+ if price_info is None or not price_info.usd_per_credit:
+ # '0 or None' should raise
+ raise ProductPriceNotDefinedError(
+ reason=f"Product {product_name} usd_per_credit is either not defined or zero"
+ )
+
+ if dollar_amount < price_info.min_payment_amount_usd:
+ raise BelowMinimumPaymentError(
+ amount_usd=dollar_amount,
+ min_payment_amount_usd=price_info.min_payment_amount_usd,
+ )
+
+ credit_amount = dollar_amount / price_info.usd_per_credit
+ return CreditResult(product_name=product_name, credit_amount=credit_amount)
+
+
+async def get_product_stripe_info(
+ app: web.Application, *, product_name: ProductName
+) -> ProductStripeInfo:
+ repo = ProductRepository.create_from_app(app)
+
+ product_stripe_info = await repo.get_product_stripe_info_or_none(product_name)
+ if (
+ product_stripe_info is None
+ or "missing!!" in product_stripe_info.stripe_price_id
+ or "missing!!" in product_stripe_info.stripe_tax_rate_id
+ ):
+ exc = MissingStripeConfigError(
+ product_name=product_name,
+ product_stripe_info=product_stripe_info,
+ )
+ exc.add_note("Probably stripe side is not configured")
+ raise exc
+ return product_stripe_info
+
+
+async def get_template_content(app: web.Application, *, template_name: str):
+ repo = ProductRepository.create_from_app(app)
+ content = await repo.get_template_content(template_name)
+ if not content:
+ raise ProductTemplateNotFoundError(template_name=template_name)
+ return content
+
+
+async def auto_create_products_groups(
+ app: web.Application,
+) -> dict[ProductName, GroupID]:
+ repo = ProductRepository.create_from_app(app)
+ return await repo.auto_create_products_groups()
diff --git a/services/web/server/src/simcore_service_webserver/products/_web_events.py b/services/web/server/src/simcore_service_webserver/products/_web_events.py
new file mode 100644
index 00000000000..7000cb21b1e
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_web_events.py
@@ -0,0 +1,79 @@
+import logging
+import tempfile
+from pathlib import Path
+from pprint import pformat
+
+from aiohttp import web
+from models_library.products import ProductName
+
+from ..constants import APP_PRODUCTS_KEY
+from . import _service
+from ._models import Product
+
+_logger = logging.getLogger(__name__)
+
+APP_PRODUCTS_TEMPLATES_DIR_KEY = f"{__name__}.template_dir"
+
+
+async def _auto_create_products_groups(app: web.Application) -> None:
+ """Ensures all products have associated group ids
+
+ Avoids having undefined groups in products with new products.group_id column
+
+ NOTE: could not add this in 'setup_groups' (groups plugin)
+ since it has to be executed BEFORE 'load_products_on_startup'
+ """
+ product_groups_map = await _service.auto_create_products_groups(app)
+ _logger.debug("Products group IDs: %s", pformat(product_groups_map))
+
+
+def _set_app_state(
+ app: web.Application,
+ app_products: dict[ProductName, Product],
+ default_product_name: str,
+):
+ # NOTE: products are checked on every request, therefore we
+ # cache them in the `app` upon startup
+ app[APP_PRODUCTS_KEY] = app_products
+ assert default_product_name in app_products # nosec
+ app[f"{APP_PRODUCTS_KEY}_default"] = default_product_name
+
+
+async def _load_products_on_startup(app: web.Application):
+ """
+ Loads info on products stored in the database into app's storage (i.e. memory)
+ """
+ app_products: dict[ProductName, Product] = {
+ product.name: product for product in await _service.load_products(app)
+ }
+
+ default_product_name = await _service.get_default_product_name(app)
+
+ _set_app_state(app, app_products, default_product_name)
+ assert APP_PRODUCTS_KEY in app # nosec
+
+ _logger.debug("Product loaded: %s", list(app_products))
+
+
+async def _setup_product_templates(app: web.Application):
+ """
+ builds a directory and download product templates
+ """
+ with tempfile.TemporaryDirectory(
+ suffix=APP_PRODUCTS_TEMPLATES_DIR_KEY
+ ) as templates_dir:
+ app[APP_PRODUCTS_TEMPLATES_DIR_KEY] = Path(templates_dir)
+
+ yield
+
+ # cleanup
+
+
+def setup_web_events(app: web.Application):
+
+ app.on_startup.append(
+ # NOTE: must go BEFORE _load_products_on_startup
+ _auto_create_products_groups
+ )
+ app.on_startup.append(_load_products_on_startup)
+ app.cleanup_ctx.append(_setup_product_templates)
diff --git a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py
new file mode 100644
index 00000000000..859793d9e0a
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py
@@ -0,0 +1,117 @@
+import contextlib
+from pathlib import Path
+
+import aiofiles
+from aiohttp import web
+from models_library.products import ProductName
+from simcore_postgres_database.utils_products_prices import ProductPriceInfo
+from simcore_service_webserver.products.errors import (
+ FileTemplateNotFoundError,
+ ProductNotFoundError,
+ UnknownProductError,
+)
+
+from .._resources import webserver_resources
+from ..constants import RQ_PRODUCT_KEY
+from . import _service
+from ._web_events import APP_PRODUCTS_TEMPLATES_DIR_KEY
+from .models import Product
+
+
+def get_product_name(request: web.Request) -> str:
+ """Returns product name in request but might be undefined"""
+ # NOTE: introduced by middleware
+ try:
+ product_name: str = request[RQ_PRODUCT_KEY]
+ except KeyError as exc:
+ error = UnknownProductError()
+ error.add_note("TIP: Check products middleware")
+ raise error from exc
+ return product_name
+
+
+def get_current_product(request: web.Request) -> Product:
+ """Returns product associated to current request"""
+ product_name: ProductName = get_product_name(request)
+ current_product: Product = _service.get_product(
+ request.app, product_name=product_name
+ )
+ return current_product
+
+
+def _get_current_product_or_none(request: web.Request) -> Product | None:
+ with contextlib.suppress(ProductNotFoundError, UnknownProductError):
+ product: Product = get_current_product(request)
+ return product
+ return None
+
+
+async def get_current_product_credit_price_info(
+ request: web.Request,
+) -> ProductPriceInfo | None:
+ """Gets latest credit price for this product.
+
+ NOTE: Contrary to other product api functions (e.g. get_current_product) this function
+ gets the latest update from the database. Otherwise, products are loaded
+ on startup and cached therefore in those cases would require a restart
+ of the service for the latest changes to take effect.
+ """
+ current_product_name = get_product_name(request)
+ return await _service.get_credit_price_info(
+ request.app, product_name=current_product_name
+ )
+
+
+def _themed(dirname: str, template: str) -> Path:
+ path: Path = webserver_resources.get_path(f"{Path(dirname) / template}")
+ return path
+
+
+async def _get_common_template_path(filename: str) -> Path:
+ common_template = _themed("templates/common", filename)
+ if not common_template.exists():
+ raise FileTemplateNotFoundError(filename=filename)
+ return common_template
+
+
+async def _cache_template_content(
+ request: web.Request, template_path: Path, template_name: str
+) -> None:
+ content = await _service.get_template_content(
+ request.app, template_name=template_name
+ )
+ try:
+ async with aiofiles.open(template_path, "w") as fh:
+ await fh.write(content)
+ except Exception:
+ if template_path.exists():
+ template_path.unlink()
+ raise
+
+
+async def _get_product_specific_template_path(
+ request: web.Request, product: Product, filename: str
+) -> Path | None:
+ if template_name := product.get_template_name_for(filename):
+ template_dir: Path = request.app[APP_PRODUCTS_TEMPLATES_DIR_KEY]
+ template_path = template_dir / template_name
+ if not template_path.exists():
+ await _cache_template_content(request, template_path, template_name)
+ return template_path
+
+ template_path = _themed(f"templates/{product.name}", filename)
+ if template_path.exists():
+ return template_path
+
+ return None
+
+
+async def get_product_template_path(request: web.Request, filename: str) -> Path:
+ if (product := _get_current_product_or_none(request)) and (
+ template_path := await _get_product_specific_template_path(
+ request, product, filename
+ )
+ ):
+ return template_path
+
+ return await _get_common_template_path(filename)
diff --git a/services/web/server/src/simcore_service_webserver/products/_middlewares.py b/services/web/server/src/simcore_service_webserver/products/_web_middlewares.py
similarity index 97%
rename from services/web/server/src/simcore_service_webserver/products/_middlewares.py
rename to services/web/server/src/simcore_service_webserver/products/_web_middlewares.py
index 5a962e25ef7..e82a1a54f5b 100644
--- a/services/web/server/src/simcore_service_webserver/products/_middlewares.py
+++ b/services/web/server/src/simcore_service_webserver/products/_web_middlewares.py
@@ -6,9 +6,9 @@
from servicelib.aiohttp.typing_extension import Handler
from servicelib.rest_constants import X_PRODUCT_NAME_HEADER
-from .._constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY
from .._meta import API_VTAG
-from ._model import Product
+from ..constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY
+from .models import Product
_logger = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/products/api.py b/services/web/server/src/simcore_service_webserver/products/api.py
deleted file mode 100644
index 81b7718dc5e..00000000000
--- a/services/web/server/src/simcore_service_webserver/products/api.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from models_library.products import ProductName
-
-from ._api import (
- get_credit_amount,
- get_current_product,
- get_product,
- get_product_name,
- get_product_stripe_info,
- get_product_template_path,
- list_products,
-)
-from ._model import Product
-
-__all__: tuple[str, ...] = (
- "get_credit_amount",
- "get_current_product",
- "get_product_name",
- "get_product_stripe_info",
- "get_product_template_path",
- "get_product",
- "list_products",
- "Product",
- "ProductName",
-)
-
-# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/products/errors.py b/services/web/server/src/simcore_service_webserver/products/errors.py
index 77c24849965..3b0da3564f5 100644
--- a/services/web/server/src/simcore_service_webserver/products/errors.py
+++ b/services/web/server/src/simcore_service_webserver/products/errors.py
@@ -1,13 +1,15 @@
-"""
- API plugin errors
-"""
+from ..errors import WebServerBaseError
-from ..errors import WebServerBaseError
+class ProductError(WebServerBaseError, ValueError): ...
+
+class UnknownProductError(ProductError):
+ msg_template = "Cannot determine which is the product in the current context"
-class ProductError(WebServerBaseError, ValueError):
- ...
+
+class ProductNotFoundError(ProductError):
+ msg_template = "Undefined product '{product_name}'"
class ProductPriceNotDefinedError(ProductError):
@@ -16,3 +18,18 @@ class ProductPriceNotDefinedError(ProductError):
class BelowMinimumPaymentError(ProductError):
msg_template = "Payment of {amount_usd} USD is below the required minimum of {min_payment_amount_usd} USD"
+
+
+class ProductTemplateNotFoundError(ProductError):
+ msg_template = "Missing template {template_name} for product"
+
+
+class MissingStripeConfigError(ProductError):
+ msg_template = (
+ "Missing product stripe for product {product_name}.\n"
+ "NOTE: This is currently setup manually by the operator in pg database via adminer and also in the stripe platform."
+ )
+
+
+class FileTemplateNotFoundError(ProductError):
+ msg_template = "{filename} is not part of the templates/common"
diff --git a/services/web/server/src/simcore_service_webserver/products/models.py b/services/web/server/src/simcore_service_webserver/products/models.py
new file mode 100644
index 00000000000..4625012a484
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/models.py
@@ -0,0 +1,11 @@
+from models_library.products import ProductName
+
+from ._models import CreditResult, Product, ProductStripeInfo
+
+__all__: tuple[str, ...] = (
+ "CreditResult",
+ "Product",
+ "ProductName",
+ "ProductStripeInfo",
+)
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/products/plugin.py b/services/web/server/src/simcore_service_webserver/products/plugin.py
index 70483623419..5aea6edcf7e 100644
--- a/services/web/server/src/simcore_service_webserver/products/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/products/plugin.py
@@ -8,22 +8,11 @@
"""
-
import logging
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import APP_SETTINGS_KEY
-from ..rabbitmq import setup_rabbitmq
-from . import _handlers, _invitations_handlers, _rpc
-from ._events import (
- auto_create_products_groups,
- load_products_on_startup,
- setup_product_templates,
-)
-from ._middlewares import discover_product_middleware
-
_logger = logging.getLogger(__name__)
@@ -35,24 +24,20 @@
logger=_logger,
)
def setup_products(app: web.Application):
+ #
+ # NOTE: internal import speeds up booting app
+ # specially if this plugin is not set up to be loaded
+ #
+ from ..constants import APP_SETTINGS_KEY
+ from . import _web_events, _web_middlewares
+ from ._controller import rest, rpc
+
assert app[APP_SETTINGS_KEY].WEBSERVER_PRODUCTS is True # nosec
- # middlewares
- app.middlewares.append(discover_product_middleware)
-
- # routes
- app.router.add_routes(_handlers.routes)
- app.router.add_routes(_invitations_handlers.routes)
-
- # rpc api
- setup_rabbitmq(app)
- if app[APP_SETTINGS_KEY].WEBSERVER_RABBITMQ:
- app.on_startup.append(_rpc.register_rpc_routes_on_startup)
-
- # events
- app.on_startup.append(
- # NOTE: must go BEFORE load_products_on_startup
- auto_create_products_groups
- )
- app.on_startup.append(load_products_on_startup)
- app.cleanup_ctx.append(setup_product_templates)
+ app.middlewares.append(_web_middlewares.discover_product_middleware)
+
+ app.router.add_routes(rest.routes)
+
+ rpc.setup_rpc(app)
+
+ _web_events.setup_web_events(app)
diff --git a/services/web/server/src/simcore_service_webserver/products/products_service.py b/services/web/server/src/simcore_service_webserver/products/products_service.py
new file mode 100644
index 00000000000..d21a0e9a27e
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/products_service.py
@@ -0,0 +1,19 @@
+from ._service import (
+ get_credit_amount,
+ get_product,
+ get_product_stripe_info,
+ get_product_ui,
+ list_products,
+ list_products_names,
+)
+
+__all__: tuple[str, ...] = (
+ "get_credit_amount",
+ "get_product",
+ "get_product_stripe_info",
+ "get_product_ui",
+ "list_products",
+ "list_products_names",
+)
+
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/products/products_web.py b/services/web/server/src/simcore_service_webserver/products/products_web.py
new file mode 100644
index 00000000000..38ddb1634ec
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/products/products_web.py
@@ -0,0 +1,14 @@
+from ._web_helpers import (
+ get_current_product,
+ get_current_product_credit_price_info,
+ get_product_name,
+ get_product_template_path,
+)
+
+__all__: tuple[str, ...] = (
+ "get_current_product",
+ "get_current_product_credit_price_info",
+ "get_product_name",
+ "get_product_template_path",
+)
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_db.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_repository.py
similarity index 100%
rename from services/web/server/src/simcore_service_webserver/projects/_access_rights_db.py
rename to services/web/server/src/simcore_service_webserver/projects/_access_rights_repository.py
diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_service.py
similarity index 95%
rename from services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_access_rights_service.py
index 805b46fa65e..72f94eba0dd 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_service.py
@@ -2,12 +2,12 @@
from models_library.products import ProductName
from models_library.projects import ProjectID
from models_library.users import UserID
-from simcore_service_webserver.projects._db_utils import PermissionStr
from ..db.plugin import get_database_engine
from ..workspaces.api import get_workspace
-from ._access_rights_db import get_project_owner
-from .db import APP_PROJECT_DBAPI, ProjectDBAPI
+from ._access_rights_repository import get_project_owner
+from ._projects_repository_legacy import APP_PROJECT_DBAPI, ProjectDBAPI
+from ._projects_repository_legacy_utils import PermissionStr
from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
from .models import UserProjectAccessRightsWithWorkspace
diff --git a/services/web/server/src/simcore_service_webserver/projects/_comments_db.py b/services/web/server/src/simcore_service_webserver/projects/_comments_repository.py
similarity index 96%
rename from services/web/server/src/simcore_service_webserver/projects/_comments_db.py
rename to services/web/server/src/simcore_service_webserver/projects/_comments_repository.py
index 0cc52bea1e7..1a871f12ed3 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_comments_db.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_comments_repository.py
@@ -1,8 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
import logging
from aiopg.sa.result import ResultProxy
diff --git a/services/web/server/src/simcore_service_webserver/projects/_comments_api.py b/services/web/server/src/simcore_service_webserver/projects/_comments_service.py
similarity index 92%
rename from services/web/server/src/simcore_service_webserver/projects/_comments_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_comments_service.py
index 55cfedac30c..7999d1e591c 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_comments_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_comments_service.py
@@ -10,7 +10,7 @@
from models_library.users import UserID
from pydantic import PositiveInt
-from .db import APP_PROJECT_DBAPI, ProjectDBAPI
+from ._projects_repository_legacy import APP_PROJECT_DBAPI, ProjectDBAPI
log = logging.getLogger(__name__)
@@ -39,9 +39,9 @@ async def list_project_comments(
) -> list[ProjectsCommentsAPI]:
db: ProjectDBAPI = request.app[APP_PROJECT_DBAPI]
- projects_comments_db_model: list[
- ProjectsCommentsDB
- ] = await db.list_project_comments(project_uuid, offset, limit)
+ projects_comments_db_model: list[ProjectsCommentsDB] = (
+ await db.list_project_comments(project_uuid, offset, limit)
+ )
projects_comments_api_model = [
ProjectsCommentsAPI(**comment.model_dump())
for comment in projects_comments_db_model
diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/__init__.py b/services/web/server/src/simcore_service_webserver/projects/_controller/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py
new file mode 100644
index 00000000000..ad2db124670
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py
@@ -0,0 +1,217 @@
+import itertools
+import logging
+from collections import Counter
+
+from servicelib.aiohttp import status
+from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
+ CatalogForbiddenError,
+ CatalogItemNotFoundError,
+ CatalogNotAvailableError,
+)
+
+from ...exception_handling import (
+ ExceptionToHttpErrorMap,
+ HttpErrorInfo,
+ exception_handling_decorator,
+ to_exceptions_handlers_map,
+)
+from ...folders.errors import FolderAccessForbiddenError, FolderNotFoundError
+from ...resource_usage.errors import DefaultPricingPlanNotFoundError
+from ...users.exceptions import UserDefaultWalletNotFoundError
+from ...wallets.errors import WalletAccessForbiddenError, WalletNotEnoughCreditsError
+from ...workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError
+from ..exceptions import (
+ ClustersKeeperNotAvailableError,
+ DefaultPricingUnitNotFoundError,
+ NodeNotFoundError,
+ ParentNodeNotFoundError,
+ ProjectDeleteError,
+ ProjectGroupNotFoundError,
+ ProjectInDebtCanNotChangeWalletError,
+ ProjectInDebtCanNotOpenError,
+ ProjectInvalidRightsError,
+ ProjectInvalidUsageError,
+ ProjectNodeRequiredInputsNotSetError,
+ ProjectNotFoundError,
+ ProjectOwnerNotFoundInTheProjectAccessRightsError,
+ ProjectStartsTooManyDynamicNodesError,
+ ProjectTooManyProjectOpenedError,
+ ProjectWalletPendingTransactionError,
+ WrongTagIdsInQueryError,
+)
+
+_logger = logging.getLogger(__name__)
+
+
+_FOLDER_ERRORS: ExceptionToHttpErrorMap = {
+ FolderAccessForbiddenError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Access to folder forbidden",
+ ),
+ FolderNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Folder not found: {reason}",
+ ),
+}
+
+
+_NODE_ERRORS: ExceptionToHttpErrorMap = {
+ NodeNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Node '{node_uuid}' not found in project '{project_uuid}'",
+ ),
+ ParentNodeNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Parent node '{node_uuid}' not found",
+ ),
+ ProjectNodeRequiredInputsNotSetError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "Project node is required but input is not set",
+ ),
+}
+
+
+_PROJECT_ERRORS: ExceptionToHttpErrorMap = {
+ ProjectDeleteError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "Failed to complete deletion of '{project_uuid}': {reason}",
+ ),
+ ProjectGroupNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Project group not found: {reason}",
+ ),
+ ProjectInvalidRightsError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Do not have sufficient access rights on project {project_uuid} for this action",
+ ),
+ ProjectInvalidUsageError: HttpErrorInfo(
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
+ "Invalid usage for project",
+ ),
+ ProjectNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Project {project_uuid} not found",
+ ),
+ ProjectOwnerNotFoundInTheProjectAccessRightsError: HttpErrorInfo(
+ status.HTTP_400_BAD_REQUEST,
+ "Project owner identifier was not found in the project's access-rights field",
+ ),
+ ProjectTooManyProjectOpenedError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "You cannot open more than {max_num_projects} study/ies at once. Please close another study and retry.",
+ ),
+ ProjectStartsTooManyDynamicNodesError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "The maximal amount of concurrently running dynamic services was reached. Please manually stop a service and retry.",
+ ),
+ ProjectWalletPendingTransactionError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "Project has currently pending transactions. It is forbidden to change wallet.",
+ ),
+ ProjectInDebtCanNotChangeWalletError: HttpErrorInfo(
+ status.HTTP_402_PAYMENT_REQUIRED,
+ "Unable to change the credit account linked to the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative.",
+ ),
+ ProjectInDebtCanNotOpenError: HttpErrorInfo(
+ status.HTTP_402_PAYMENT_REQUIRED,
+ "Unable to open the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative.",
+ ),
+ WrongTagIdsInQueryError: HttpErrorInfo(
+ status.HTTP_400_BAD_REQUEST,
+ "Wrong tag IDs in query",
+ ),
+}
+
+
+_WORKSPACE_ERRORS: ExceptionToHttpErrorMap = {
+ WorkspaceAccessForbiddenError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Access to workspace forbidden: {reason}",
+ ),
+ WorkspaceNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Workspace not found: {reason}",
+ ),
+}
+
+
+_WALLET_ERRORS: ExceptionToHttpErrorMap = {
+ UserDefaultWalletNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Wallet not found: {reason}",
+ ),
+ WalletAccessForbiddenError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Payment required, but the user lacks access to the project's linked wallet: Wallet access forbidden. {reason}",
+ ),
+ WalletNotEnoughCreditsError: HttpErrorInfo(
+ status.HTTP_402_PAYMENT_REQUIRED,
+ "Wallet does not have enough credits. {reason}",
+ ),
+}
+
+
+_PRICING_ERRORS: ExceptionToHttpErrorMap = {
+ DefaultPricingPlanNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Default pricing plan not found",
+ ),
+ DefaultPricingUnitNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Default pricing unit not found",
+ ),
+}
+
+
+_OTHER_ERRORS: ExceptionToHttpErrorMap = {
+ CatalogNotAvailableError: HttpErrorInfo(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "This service is currently not available",
+ ),
+ ClustersKeeperNotAvailableError: HttpErrorInfo(
+ status.HTTP_503_SERVICE_UNAVAILABLE,
+ "Clusters-keeper service is not available",
+ ),
+ CatalogForbiddenError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Catalog forbidden: Insufficient access rights for {name}",
+ ),
+ CatalogItemNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND, "{name} was not found"
+ ),
+}
+
+
+_ERRORS = [
+ _FOLDER_ERRORS,
+ _NODE_ERRORS,
+ _OTHER_ERRORS,
+ _PRICING_ERRORS,
+ _PROJECT_ERRORS,
+ _WALLET_ERRORS,
+ _WORKSPACE_ERRORS,
+]
+
+
+def _assert_duplicate():
+ duplicates = {
+ exc.__name__: count
+ for exc, count in Counter(itertools.chain(*[d.keys() for d in _ERRORS])).items()
+ if count > 1
+ }
+ if duplicates:
+ msg = f"Found duplicated exceptions: {duplicates}"
+ raise AssertionError(msg)
+ return True
+
+
+assert _assert_duplicate() # nosec
+
+_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+ k: v for dikt in _ERRORS for k, v in dikt.items()
+}
+
+
+handle_plugin_requests_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_common/models.py b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_schemas.py
similarity index 70%
rename from services/web/server/src/simcore_service_webserver/projects/_common/models.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/_rest_schemas.py
index 6f358378f60..9618a73bb4c 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_common/models.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_schemas.py
@@ -1,11 +1,9 @@
-""" Handlers for STANDARD methods on /projects colletions
-
-Standard methods or CRUD that states for Create+Read(Get&List)+Update+Delete
-
-"""
-
from models_library.projects import ProjectID
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ Field,
+)
from ...models import RequestContext
diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_utils.py b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_utils.py
new file mode 100644
index 00000000000..beab5959668
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_utils.py
@@ -0,0 +1,48 @@
+from aiohttp import web
+from models_library.api_schemas_webserver.projects import ProjectListItem
+from models_library.rest_pagination import Page
+from models_library.rest_pagination_utils import paginate_data
+from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
+from servicelib.rest_constants import RESPONSE_MODEL_POLICY
+
+from .. import _permalink_service
+from .._crud_api_read import _paralell_update
+from ..models import ProjectDict
+
+
+async def aggregate_data_to_projects_from_request(
+ request: web.Request,
+ projects: list[ProjectDict],
+) -> list[ProjectDict]:
+
+ update_permalink_per_project = [
+ # permalink
+ _permalink_service.aggregate_permalink_in_project(request, project=prj)
+ for prj in projects
+ ]
+
+ updated_projects: list[ProjectDict] = await _paralell_update(
+ *update_permalink_per_project,
+ )
+ return updated_projects
+
+
+def create_page_response(projects, request_url, total, limit, offset) -> web.Response:
+ page = Page[ProjectListItem].model_validate(
+ paginate_data(
+ chunk=[
+ ProjectListItem.from_domain_model(prj).model_dump(
+ by_alias=True, exclude_unset=True
+ )
+ for prj in projects
+ ],
+ request_url=request_url,
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+ )
+ return web.Response(
+ text=page.model_dump_json(**RESPONSE_MODEL_POLICY),
+ content_type=MIMETYPE_APPLICATION_JSON,
+ )
diff --git a/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/comments_rest.py
similarity index 78%
rename from services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/comments_rest.py
index 04ac3d5ca35..183cf1fa3b6 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_comments_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/comments_rest.py
@@ -1,8 +1,3 @@
-""" Handlers for project comments operations
-
-"""
-
-import functools
import logging
from typing import Any
@@ -22,35 +17,19 @@
parse_request_path_parameters_as,
parse_request_query_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
-from .._meta import API_VTAG as VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import _comments_api, projects_service
-from ._common.models import RequestContext
-from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
+from ..._meta import API_VTAG as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _comments_service, _projects_service
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import RequestContext
_logger = logging.getLogger(__name__)
-
-def _handle_project_comments_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
- except ProjectInvalidRightsError as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
-
-
#
# projects/*/comments COLLECTION -------------------------
#
@@ -79,21 +58,21 @@ class _ProjectCommentsBodyParams(BaseModel):
)
@login_required
@permission_required("project.read")
-@_handle_project_comments_exceptions
+@handle_plugin_requests_exceptions
async def create_project_comment(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectCommentsPathParams, request)
body_params = await parse_request_body_as(_ProjectCommentsBodyParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_uuid}",
user_id=req_ctx.user_id,
include_state=False,
)
- comment_id = await _comments_api.create_project_comment(
+ comment_id = await _comments_service.create_project_comment(
request=request,
project_uuid=path_params.project_uuid,
user_id=req_ctx.user_id,
@@ -119,7 +98,7 @@ class _ListProjectCommentsQueryParams(BaseModel):
@routes.get(f"/{VTAG}/projects/{{project_uuid}}/comments", name="list_project_comments")
@login_required
@permission_required("project.read")
-@_handle_project_comments_exceptions
+@handle_plugin_requests_exceptions
async def list_project_comments(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectCommentsPathParams, request)
@@ -128,19 +107,19 @@ async def list_project_comments(request: web.Request):
)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_uuid}",
user_id=req_ctx.user_id,
include_state=False,
)
- total_project_comments = await _comments_api.total_project_comments(
+ total_project_comments = await _comments_service.total_project_comments(
request=request,
project_uuid=path_params.project_uuid,
)
- project_comments = await _comments_api.list_project_comments(
+ project_comments = await _comments_service.list_project_comments(
request=request,
project_uuid=path_params.project_uuid,
offset=query_params.offset,
@@ -168,6 +147,7 @@ async def list_project_comments(request: web.Request):
)
@login_required
@permission_required("project.read")
+@handle_plugin_requests_exceptions
async def update_project_comment(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(
@@ -176,19 +156,20 @@ async def update_project_comment(request: web.Request):
body_params = await parse_request_body_as(_ProjectCommentsBodyParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_uuid}",
user_id=req_ctx.user_id,
include_state=False,
)
- return await _comments_api.update_project_comment(
+ updated_comment = await _comments_service.update_project_comment(
request=request,
comment_id=path_params.comment_id,
project_uuid=path_params.project_uuid,
contents=body_params.contents,
)
+ return envelope_json_response(updated_comment)
@routes.delete(
@@ -197,7 +178,7 @@ async def update_project_comment(request: web.Request):
)
@login_required
@permission_required("project.read")
-@_handle_project_comments_exceptions
+@handle_plugin_requests_exceptions
async def delete_project_comment(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(
@@ -205,14 +186,14 @@ async def delete_project_comment(request: web.Request):
)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_uuid}",
user_id=req_ctx.user_id,
include_state=False,
)
- await _comments_api.delete_project_comment(
+ await _comments_service.delete_project_comment(
request=request,
comment_id=path_params.comment_id,
)
@@ -225,7 +206,7 @@ async def delete_project_comment(request: web.Request):
)
@login_required
@permission_required("project.read")
-@_handle_project_comments_exceptions
+@handle_plugin_requests_exceptions
async def get_project_comment(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(
@@ -233,14 +214,15 @@ async def get_project_comment(request: web.Request):
)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_uuid}",
user_id=req_ctx.user_id,
include_state=False,
)
- return await _comments_api.get_project_comment(
+ comment = await _comments_service.get_project_comment(
request=request,
comment_id=path_params.comment_id,
)
+ return envelope_json_response(comment)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/folders_rest.py
similarity index 60%
rename from services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/folders_rest.py
index c4f1828237b..7d7a7f6f954 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_folders_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/folders_rest.py
@@ -1,4 +1,3 @@
-import functools
import logging
from aiohttp import web
@@ -8,33 +7,17 @@
from pydantic import BaseModel, ConfigDict, field_validator
from servicelib.aiohttp import status
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
-from servicelib.aiohttp.typing_extension import Handler
-from .._meta import api_version_prefix as VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from . import _folders_api
-from ._common.models import RequestContext
-from .exceptions import ProjectGroupNotFoundError, ProjectNotFoundError
+from ..._meta import api_version_prefix as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from .. import _folders_service
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import RequestContext
_logger = logging.getLogger(__name__)
-def _handle_projects_folders_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except ProjectGroupNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except ProjectNotFoundError as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
-
-
routes = web.RouteTableDef()
@@ -55,12 +38,12 @@ class _ProjectsFoldersPathParams(BaseModel):
)
@login_required
@permission_required("project.folders.*")
-@_handle_projects_folders_exceptions
+@handle_plugin_requests_exceptions
async def replace_project_folder(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectsFoldersPathParams, request)
- await _folders_api.move_project_into_folder(
+ await _folders_service.move_project_into_folder(
app=request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/groups_rest.py
similarity index 66%
rename from services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/groups_rest.py
index d507a2b1eff..7d79461144a 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_groups_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/groups_rest.py
@@ -1,8 +1,3 @@
-""" Handlers for project comments operations
-
-"""
-
-import functools
import logging
from aiohttp import web
@@ -14,39 +9,19 @@
parse_request_body_as,
parse_request_path_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
-from .._meta import api_version_prefix as VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import _groups_api
-from ._common.models import ProjectPathParams, RequestContext
-from ._groups_api import ProjectGroupGet
-from .exceptions import ProjectGroupNotFoundError, ProjectNotFoundError
+from ..._meta import api_version_prefix as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _groups_service
+from .._groups_service import ProjectGroupGet
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
_logger = logging.getLogger(__name__)
-def _handle_projects_groups_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except ProjectGroupNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except ProjectNotFoundError as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
-
-
-#
-# projects groups COLLECTION -------------------------
-#
-
routes = web.RouteTableDef()
@@ -68,13 +43,13 @@ class _ProjectsGroupsBodyParams(BaseModel):
)
@login_required
@permission_required("project.access_rights.update")
-@_handle_projects_groups_exceptions
+@handle_plugin_requests_exceptions
async def create_project_group(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request)
body_params = await parse_request_body_as(_ProjectsGroupsBodyParams, request)
- project_groups: ProjectGroupGet = await _groups_api.create_project_group(
+ project_groups: ProjectGroupGet = await _groups_service.create_project_group(
request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
@@ -91,18 +66,18 @@ async def create_project_group(request: web.Request):
@routes.get(f"/{VTAG}/projects/{{project_id}}/groups", name="list_project_groups")
@login_required
@permission_required("project.read")
-@_handle_projects_groups_exceptions
+@handle_plugin_requests_exceptions
async def list_project_groups(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
- project_groups: list[
- ProjectGroupGet
- ] = await _groups_api.list_project_groups_by_user_and_project(
- request.app,
- user_id=req_ctx.user_id,
- project_id=path_params.project_id,
- product_name=req_ctx.product_name,
+ project_groups: list[ProjectGroupGet] = (
+ await _groups_service.list_project_groups_by_user_and_project(
+ request.app,
+ user_id=req_ctx.user_id,
+ project_id=path_params.project_id,
+ product_name=req_ctx.product_name,
+ )
)
return envelope_json_response(project_groups, web.HTTPOk)
@@ -114,13 +89,13 @@ async def list_project_groups(request: web.Request):
)
@login_required
@permission_required("project.access_rights.update")
-@_handle_projects_groups_exceptions
+@handle_plugin_requests_exceptions
async def replace_project_group(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request)
body_params = await parse_request_body_as(_ProjectsGroupsBodyParams, request)
- return await _groups_api.replace_project_group(
+ return await _groups_service.replace_project_group(
app=request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
@@ -138,12 +113,12 @@ async def replace_project_group(request: web.Request):
)
@login_required
@permission_required("project.access_rights.update")
-@_handle_projects_groups_exceptions
+@handle_plugin_requests_exceptions
async def delete_project_group(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectsGroupsPathParams, request)
- await _groups_api.delete_project_group(
+ await _groups_service.delete_project_group(
app=request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/metadata_rest.py
similarity index 60%
rename from services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/metadata_rest.py
index df139c6fd30..f03b711f56d 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/metadata_rest.py
@@ -10,7 +10,6 @@
- Get and Update methods only
"""
-import functools
import logging
from aiohttp import web
@@ -22,67 +21,33 @@
parse_request_body_as,
parse_request_path_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
from servicelib.logging_utils import log_catch
-from .._meta import api_version_prefix
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import _metadata_api
-from ._common.models import ProjectPathParams, RequestContext
-from .exceptions import (
- NodeNotFoundError,
- ParentNodeNotFoundError,
- ProjectInvalidRightsError,
- ProjectInvalidUsageError,
- ProjectNotFoundError,
-)
+from ..._meta import api_version_prefix
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _metadata_service
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
routes = web.RouteTableDef()
_logger = logging.getLogger(__name__)
-def _handle_project_exceptions(handler: Handler):
- """Transforms project errors -> http errors"""
-
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except (
- ProjectNotFoundError,
- NodeNotFoundError,
- ParentNodeNotFoundError,
- ) as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
- except ProjectInvalidRightsError as exc:
- raise web.HTTPUnauthorized(reason=f"{exc}") from exc
- except ProjectInvalidUsageError as exc:
- raise web.HTTPUnprocessableEntity(reason=f"{exc}") from exc
-
- return wrapper
-
-
-#
-# projects/*/custom-metadata
-#
-
-
@routes.get(
f"/{api_version_prefix}/projects/{{project_id}}/metadata",
name="get_project_metadata",
)
@login_required
@permission_required("project.read")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def get_project_metadata(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
- custom_metadata = await _metadata_api.get_project_custom_metadata(
+ custom_metadata = await _metadata_service.get_project_custom_metadata(
request.app, user_id=req_ctx.user_id, project_uuid=path_params.project_id
)
@@ -97,20 +62,20 @@ async def get_project_metadata(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.update")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def update_project_metadata(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
update = await parse_request_body_as(ProjectMetadataUpdate, request)
- custom_metadata = await _metadata_api.set_project_custom_metadata(
+ custom_metadata = await _metadata_service.set_project_custom_metadata(
request.app,
user_id=req_ctx.user_id,
project_uuid=path_params.project_id,
value=update.custom,
)
with log_catch(_logger, reraise=False):
- await _metadata_api.set_project_ancestors_from_custom_metadata(
+ await _metadata_service.set_project_ancestors_from_custom_metadata(
request.app,
user_id=req_ctx.user_id,
project_uuid=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_pricing_unit_rest.py
similarity index 73%
rename from services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/nodes_pricing_unit_rest.py
index 66b17383bba..6476389c4d7 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_pricing_unit_rest.py
@@ -1,8 +1,3 @@
-""" Handlers for CRUD operations on /projects/{*}/nodes/{*}/pricing-unit
-
-"""
-
-import functools
import logging
from aiohttp import web
@@ -13,45 +8,28 @@
from models_library.resource_tracker import PricingPlanId, PricingUnitId
from pydantic import BaseModel, ConfigDict
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
-from servicelib.aiohttp.typing_extension import Handler
-
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..resource_usage import service as rut_api
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import projects_service
-from ._common.models import RequestContext
-from ._nodes_handlers import NodePathParams
-from .db import ProjectDBAPI
-from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
+
+from ..._meta import API_VTAG
+from ...login.decorators import login_required
+from ...resource_usage import service as rut_api
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _projects_service
+from .._projects_repository_legacy import ProjectDBAPI
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import RequestContext
+from .nodes_rest import NodePathParams
_logger = logging.getLogger(__name__)
-class PricingUnitError(OsparcErrorMixin, ValueError):
- ...
+class PricingUnitError(OsparcErrorMixin, ValueError): ...
class PricingUnitNotFoundError(PricingUnitError):
msg_template = "Pricing unit not found"
-def _handle_projects_nodes_pricing_unit_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except (PricingUnitNotFoundError, ProjectInvalidRightsError) as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
-
-
routes = web.RouteTableDef()
@@ -61,14 +39,14 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
)
@login_required
@permission_required("project.wallet.*")
-@_handle_projects_nodes_pricing_unit_exceptions
+@handle_plugin_requests_exceptions
async def get_project_node_pricing_unit(request: web.Request):
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app)
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -108,7 +86,7 @@ class _ProjectNodePricingUnitPathParams(BaseModel):
)
@login_required
@permission_required("project.wallet.*")
-@_handle_projects_nodes_pricing_unit_exceptions
+@handle_plugin_requests_exceptions
async def connect_pricing_unit_to_project_node(request: web.Request):
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app)
req_ctx = RequestContext.model_validate(request)
@@ -117,7 +95,7 @@ async def connect_pricing_unit_to_project_node(request: web.Request):
)
# ensure the project exists
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -143,7 +121,7 @@ async def connect_pricing_unit_to_project_node(request: web.Request):
node_data = project["workbench"][NodeIDStr(f"{path_params.node_id}")]
- await projects_service.update_project_node_resources_from_hardware_info(
+ await _projects_service.update_project_node_resources_from_hardware_info(
request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py
similarity index 79%
rename from services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py
index 44ac05a12c7..9642ba581d3 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py
@@ -1,9 +1,4 @@
-""" Handlers for CRUD operations on /projects/{*}/nodes/{*}
-
-"""
-
import asyncio
-import functools
import logging
from aiohttp import web
@@ -11,6 +6,7 @@
from models_library.api_schemas_catalog.service_access_rights import (
ServiceAccessRightsGet,
)
+from models_library.api_schemas_catalog.services import MyServiceGet
from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet
from models_library.api_schemas_dynamic_scheduler.dynamic_services import (
DynamicServiceStop,
@@ -24,12 +20,15 @@
NodeOutputs,
NodePatch,
NodeRetrieve,
+ NodeServiceGet,
+ ProjectNodeServicesGet,
)
from models_library.groups import EVERYONE_GROUP_ID, Group, GroupID, GroupType
from models_library.projects import Project, ProjectID
from models_library.projects_nodes_io import NodeID, NodeIDStr
from models_library.services import ServiceKeyVersion
from models_library.services_resources import ServiceResourcesDict
+from models_library.services_types import ServiceKey, ServiceVersion
from models_library.utils.fastapi_encoders import jsonable_encoder
from pydantic import BaseModel, Field
from servicelib.aiohttp import status
@@ -42,17 +41,12 @@
parse_request_path_parameters_as,
parse_request_query_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
from servicelib.common_headers import (
UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
X_SIMCORE_USER_AGENT,
)
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.rabbitmq import RPCServerError
-from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
- CatalogForbiddenError,
- CatalogItemNotFoundError,
-)
from servicelib.rabbitmq.rpc_interfaces.dynamic_scheduler.errors import (
ServiceWaitingForManualInterventionError,
ServiceWasNotFoundError,
@@ -60,77 +54,30 @@
from servicelib.services_utils import get_status_as_dict
from simcore_postgres_database.models.users import UserRole
-from .._meta import API_VTAG as VTAG
-from ..catalog import client as catalog_client
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..groups.api import get_group_from_gid, list_all_user_groups_ids
-from ..groups.exceptions import GroupNotFoundError
-from ..login.decorators import login_required
-from ..projects.api import has_user_project_access_rights
-from ..resource_usage.errors import DefaultPricingPlanNotFoundError
-from ..security.decorators import permission_required
-from ..users.api import get_user_id_from_gid, get_user_role
-from ..users.exceptions import UserDefaultWalletNotFoundError
-from ..utils_aiohttp import envelope_json_response
-from ..wallets.errors import WalletAccessForbiddenError, WalletNotEnoughCreditsError
-from . import nodes_utils, projects_service
-from ._common.models import ProjectPathParams, RequestContext
-from ._nodes_api import NodeScreenshot, get_node_screenshots
-from .exceptions import (
- ClustersKeeperNotAvailableError,
- DefaultPricingUnitNotFoundError,
+from ..._meta import API_VTAG as VTAG
+from ...catalog import catalog_service
+from ...dynamic_scheduler import api as dynamic_scheduler_service
+from ...groups.api import get_group_from_gid, list_all_user_groups_ids
+from ...groups.exceptions import GroupNotFoundError
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...users.api import get_user_id_from_gid, get_user_role
+from ...utils_aiohttp import envelope_json_response
+from .. import _access_rights_service as access_rights_service
+from .. import _nodes_service, _projects_service, nodes_utils
+from .._nodes_service import NodeScreenshot, get_node_screenshots
+from ..api import has_user_project_access_rights
+from ..exceptions import (
NodeNotFoundError,
- ProjectInDebtCanNotChangeWalletError,
- ProjectInvalidRightsError,
- ProjectNodeRequiredInputsNotSetError,
ProjectNodeResourcesInsufficientRightsError,
ProjectNodeResourcesInvalidError,
- ProjectNotFoundError,
- ProjectStartsTooManyDynamicNodesError,
)
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
_logger = logging.getLogger(__name__)
-def _handle_project_nodes_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except (
- ProjectNotFoundError,
- NodeNotFoundError,
- UserDefaultWalletNotFoundError,
- DefaultPricingPlanNotFoundError,
- DefaultPricingUnitNotFoundError,
- GroupNotFoundError,
- CatalogItemNotFoundError,
- ) as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
- except (
- WalletNotEnoughCreditsError,
- ProjectInDebtCanNotChangeWalletError,
- ) as exc:
- raise web.HTTPPaymentRequired(reason=f"{exc}") from exc
- except ProjectInvalidRightsError as exc:
- raise web.HTTPUnauthorized(reason=f"{exc}") from exc
- except ProjectStartsTooManyDynamicNodesError as exc:
- raise web.HTTPConflict(reason=f"{exc}") from exc
- except ClustersKeeperNotAvailableError as exc:
- raise web.HTTPServiceUnavailable(reason=f"{exc}") from exc
- except ProjectNodeRequiredInputsNotSetError as exc:
- raise web.HTTPConflict(reason=f"{exc}") from exc
- except CatalogForbiddenError as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
- except WalletAccessForbiddenError as exc:
- raise web.HTTPForbidden(
- reason=f"Payment required, but the user lacks access to the project's linked wallet.: {exc}"
- ) from exc
-
- return wrapper
-
-
#
# projects/*/nodes COLLECTION -------------------------
#
@@ -145,13 +92,13 @@ class NodePathParams(ProjectPathParams):
@routes.post(f"/{VTAG}/projects/{{project_id}}/nodes", name="create_node")
@login_required
@permission_required("project.node.create")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def create_node(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
body = await parse_request_body_as(NodeCreate, request)
- if await projects_service.is_service_deprecated(
+ if await _projects_service.is_service_deprecated(
request.app,
req_ctx.user_id,
body.service_key,
@@ -163,13 +110,13 @@ async def create_node(request: web.Request) -> web.Response:
)
# ensure the project exists
- project_data = await projects_service.get_project_for_user(
+ project_data = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
)
data = {
- "node_id": await projects_service.add_project_node(
+ "node_id": await _projects_service.add_project_node(
request,
project_data,
req_ctx.user_id,
@@ -187,20 +134,20 @@ async def create_node(request: web.Request) -> web.Response:
@routes.get(f"/{VTAG}/projects/{{project_id}}/nodes/{{node_id}}", name="get_node")
@login_required
@permission_required("project.node.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
# NOTE: Careful, this endpoint is actually "get_node_state," and it doesn't return a Node resource.
async def get_node(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
# ensure the project exists
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
)
- if await projects_service.is_project_node_deprecated(
+ if await _projects_service.is_project_node_deprecated(
request.app,
req_ctx.user_id,
project,
@@ -213,7 +160,7 @@ async def get_node(request: web.Request) -> web.Response:
)
service_data: NodeGetIdle | NodeGetUnknown | DynamicServiceGet | NodeGet = (
- await dynamic_scheduler_api.get_dynamic_service(
+ await dynamic_scheduler_service.get_dynamic_service(
app=request.app, node_id=path_params.node_id
)
)
@@ -226,13 +173,13 @@ async def get_node(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.update")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def patch_project_node(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
node_patch = await parse_request_body_as(NodePatch, request)
- await projects_service.patch_project_node(
+ await _projects_service.patch_project_node(
request.app,
product_name=req_ctx.product_name,
user_id=req_ctx.user_id,
@@ -247,18 +194,18 @@ async def patch_project_node(request: web.Request) -> web.Response:
@routes.delete(f"/{VTAG}/projects/{{project_id}}/nodes/{{node_id}}", name="delete_node")
@login_required
@permission_required("project.node.delete")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def delete_node(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
)
- await projects_service.delete_project_node(
+ await _projects_service.delete_project_node(
request,
path_params.project_id,
req_ctx.user_id,
@@ -275,14 +222,14 @@ async def delete_node(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def retrieve_node(request: web.Request) -> web.Response:
"""Has only effect on nodes associated to dynamic services"""
path_params = parse_request_path_parameters_as(NodePathParams, request)
retrieve = await parse_request_body_as(NodeRetrieve, request)
return web.json_response(
- await dynamic_scheduler_api.retrieve_inputs(
+ await dynamic_scheduler_service.retrieve_inputs(
request.app, path_params.node_id, retrieve.port_keys
),
dumps=json_dumps,
@@ -295,7 +242,7 @@ async def retrieve_node(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.update")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def update_node_outputs(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
@@ -322,13 +269,13 @@ async def update_node_outputs(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.update")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def start_node(request: web.Request) -> web.Response:
"""Has only effect on nodes associated to dynamic services"""
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
- await projects_service.start_project_node(
+ await _projects_service.start_project_node(
request,
product_name=req_ctx.product_name,
user_id=req_ctx.user_id,
@@ -347,7 +294,7 @@ async def _stop_dynamic_service_task(
):
# NOTE: _handle_project_nodes_exceptions only decorate handlers
try:
- await dynamic_scheduler_api.stop_dynamic_service(
+ await dynamic_scheduler_service.stop_dynamic_service(
app, dynamic_service_stop=dynamic_service_stop
)
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -366,7 +313,7 @@ async def _stop_dynamic_service_task(
)
@login_required
@permission_required("project.update")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def stop_node(request: web.Request) -> web.Response:
"""Has only effect on nodes associated to dynamic services"""
req_ctx = RequestContext.model_validate(request)
@@ -408,13 +355,13 @@ async def stop_node(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def restart_node(request: web.Request) -> web.Response:
"""Has only effect on nodes associated to dynamic services"""
path_params = parse_request_path_parameters_as(NodePathParams, request)
- await dynamic_scheduler_api.restart_user_services(
+ await dynamic_scheduler_service.restart_user_services(
request.app, node_id=path_params.node_id
)
@@ -432,13 +379,13 @@ async def restart_node(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def get_node_resources(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
# ensure the project exists
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -448,13 +395,15 @@ async def get_node_resources(request: web.Request) -> web.Response:
node_id = f"{path_params.node_id}"
raise NodeNotFoundError(project_uuid=project_uuid, node_uuid=node_id)
- resources: ServiceResourcesDict = await projects_service.get_project_node_resources(
- request.app,
- user_id=req_ctx.user_id,
- project_id=path_params.project_id,
- node_id=path_params.node_id,
- service_key=project["workbench"][f"{path_params.node_id}"]["key"],
- service_version=project["workbench"][f"{path_params.node_id}"]["version"],
+ resources: ServiceResourcesDict = (
+ await _projects_service.get_project_node_resources(
+ request.app,
+ user_id=req_ctx.user_id,
+ project_id=path_params.project_id,
+ node_id=path_params.node_id,
+ service_key=project["workbench"][f"{path_params.node_id}"]["key"],
+ service_version=project["workbench"][f"{path_params.node_id}"]["version"],
+ )
)
return envelope_json_response(resources)
@@ -465,14 +414,14 @@ async def get_node_resources(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.node.update")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def replace_node_resources(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
body = await parse_request_body_as(ServiceResourcesDict, request)
# ensure the project exists
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -482,7 +431,7 @@ async def replace_node_resources(request: web.Request) -> web.Response:
project_uuid=f"{path_params.project_id}", node_uuid=f"{path_params.node_id}"
)
try:
- new_node_resources = await projects_service.update_project_node_resources(
+ new_node_resources = await _projects_service.update_project_node_resources(
request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
@@ -518,23 +467,64 @@ class _ProjectGroupAccess(BaseModel):
inaccessible_services: list[ServiceKeyVersion] | None = Field(default=None)
+@routes.get(
+ f"/{VTAG}/projects/{{project_id}}/nodes/-/services",
+ name="get_project_services",
+)
+@login_required
+@permission_required("project.read")
+@handle_plugin_requests_exceptions
+async def get_project_services(request: web.Request) -> web.Response:
+ req_ctx = RequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(ProjectPathParams, request)
+
+ await access_rights_service.check_user_project_permission(
+ request.app,
+ product_name=req_ctx.product_name,
+ user_id=req_ctx.user_id,
+ project_id=path_params.project_id,
+ permission="read",
+ )
+
+ services_in_project: list[tuple[ServiceKey, ServiceVersion]] = (
+ await _nodes_service.get_project_nodes_services(
+ request.app, project_uuid=path_params.project_id
+ )
+ )
+
+ services: list[MyServiceGet] = await catalog_service.batch_get_my_services(
+ request.app,
+ product_name=req_ctx.product_name,
+ user_id=req_ctx.user_id,
+ services_ids=services_in_project,
+ )
+
+ return envelope_json_response(
+ ProjectNodeServicesGet(
+ project_uuid=path_params.project_id,
+ services=[
+ NodeServiceGet.model_validate(sv, from_attributes=True)
+ for sv in services
+ ],
+ )
+ )
+
+
@routes.get(
f"/{VTAG}/projects/{{project_id}}/nodes/-/services:access",
name="get_project_services_access_for_gid",
)
@login_required
@permission_required("project.read")
-@_handle_project_nodes_exceptions
-async def get_project_services_access_for_gid(
- request: web.Request,
-) -> web.Response:
+@handle_plugin_requests_exceptions
+async def get_project_services_access_for_gid(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
query_params: _ServicesAccessQuery = parse_request_query_parameters_as(
_ServicesAccessQuery, request
)
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -547,7 +537,7 @@ async def get_project_services_access_for_gid(
project_services_access_rights: list[ServiceAccessRightsGet] = await asyncio.gather(
*[
- catalog_client.get_service_access_rights(
+ catalog_service.get_service_access_rights(
app=request.app,
user_id=req_ctx.user_id,
service_key=service.key,
@@ -644,14 +634,14 @@ class _ProjectNodePreview(BaseModel):
)
@login_required
@permission_required("project.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def list_project_nodes_previews(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
assert req_ctx # nosec
nodes_previews: list[_ProjectNodePreview] = []
- project_data = await projects_service.get_project_for_user(
+ project_data = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -684,13 +674,13 @@ async def list_project_nodes_previews(request: web.Request) -> web.Response:
)
@login_required
@permission_required("project.read")
-@_handle_project_nodes_exceptions
+@handle_plugin_requests_exceptions
async def get_project_node_preview(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(NodePathParams, request)
assert req_ctx # nosec
- project_data = await projects_service.get_project_for_user(
+ project_data = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/ports_rest.py
similarity index 71%
rename from services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/ports_rest.py
index b134929a8af..396ba2bbae7 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/ports_rest.py
@@ -1,15 +1,7 @@
-""" Handlers for some CRUD operations for
- - /projects/{*}/inputs
- - /projects/{*}/outputs
-"""
-
-import functools
import logging
-from collections.abc import Awaitable, Callable
from typing import Any, Literal
from aiohttp import web
-from common_library.json_serialization import json_dumps
from models_library.api_schemas_webserver.projects_ports import (
ProjectInputGet,
ProjectInputUpdate,
@@ -28,60 +20,24 @@
parse_request_path_parameters_as,
)
-from .._meta import API_VTAG as VTAG
-from ..login.decorators import login_required
-from ..projects._access_rights_api import check_user_project_permission
-from ..security.decorators import permission_required
-from . import _ports_api, projects_service
-from ._common.models import ProjectPathParams, RequestContext
-from .db import ProjectDBAPI
-from .exceptions import (
- NodeNotFoundError,
- ProjectInvalidRightsError,
- ProjectNotFoundError,
-)
-from .models import ProjectDict
+from ..._meta import API_VTAG as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _ports_service, _projects_service
+from .._access_rights_service import check_user_project_permission
+from .._projects_repository_legacy import ProjectDBAPI
+from ..models import ProjectDict
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
log = logging.getLogger(__name__)
-def _web_json_response_enveloped(data: Any) -> web.Response:
- return web.json_response(
- {
- "data": jsonable_encoder(data),
- },
- dumps=json_dumps,
- )
-
-
-def _handle_project_exceptions(
- handler: Callable[[web.Request], Awaitable[web.Response]]
-) -> Callable[[web.Request], Awaitable[web.Response]]:
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.Response:
- try:
- return await handler(request)
-
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(
- reason=f"Project '{exc.project_uuid}' not found"
- ) from exc
-
- except ProjectInvalidRightsError as exc:
- raise web.HTTPUnauthorized from exc
-
- except NodeNotFoundError as exc:
- raise web.HTTPNotFound(
- reason=f"Port '{exc.node_uuid}' not found in node '{exc.project_uuid}'"
- ) from exc
-
- return wrapper
-
-
async def _get_validated_workbench_model(
app: web.Application, project_id: ProjectID, user_id: UserID
) -> dict[NodeID, Node]:
- project: ProjectDict = await projects_service.get_project_for_user(
+ project: ProjectDict = await _projects_service.get_project_for_user(
app,
project_uuid=f"{project_id}",
user_id=user_id,
@@ -101,7 +57,7 @@ async def _get_validated_workbench_model(
@routes.get(f"/{VTAG}/projects/{{project_id}}/inputs", name="get_project_inputs")
@login_required
@permission_required("project.read")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def get_project_inputs(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
@@ -111,10 +67,10 @@ async def get_project_inputs(request: web.Request) -> web.Response:
workbench = await _get_validated_workbench_model(
app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id
)
- inputs: dict[NodeID, Any] = _ports_api.get_project_inputs(workbench)
+ inputs: dict[NodeID, Any] = _ports_service.get_project_inputs(workbench)
- return _web_json_response_enveloped(
- data={
+ return envelope_json_response(
+ {
node_id: ProjectInputGet(
key=node_id, label=workbench[node_id].label, value=value
)
@@ -126,7 +82,7 @@ async def get_project_inputs(request: web.Request) -> web.Response:
@routes.patch(f"/{VTAG}/projects/{{project_id}}/inputs", name="update_project_inputs")
@login_required
@permission_required("project.update")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def update_project_inputs(request: web.Request) -> web.Response:
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app)
req_ctx = RequestContext.model_validate(request)
@@ -138,7 +94,7 @@ async def update_project_inputs(request: web.Request) -> web.Response:
workbench = await _get_validated_workbench_model(
app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id
)
- current_inputs: dict[NodeID, Any] = _ports_api.get_project_inputs(workbench)
+ current_inputs: dict[NodeID, Any] = _ports_service.get_project_inputs(workbench)
# build workbench patch
partial_workbench_data = {}
@@ -172,10 +128,10 @@ async def update_project_inputs(request: web.Request) -> web.Response:
workbench = TypeAdapter(dict[NodeID, Node]).validate_python(
updated_project["workbench"]
)
- inputs: dict[NodeID, Any] = _ports_api.get_project_inputs(workbench)
+ inputs: dict[NodeID, Any] = _ports_service.get_project_inputs(workbench)
- return _web_json_response_enveloped(
- data={
+ return envelope_json_response(
+ {
node_id: ProjectInputGet(
key=node_id, label=workbench[node_id].label, value=value
)
@@ -192,7 +148,7 @@ async def update_project_inputs(request: web.Request) -> web.Response:
@routes.get(f"/{VTAG}/projects/{{project_id}}/outputs", name="get_project_outputs")
@login_required
@permission_required("project.read")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def get_project_outputs(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
@@ -202,12 +158,12 @@ async def get_project_outputs(request: web.Request) -> web.Response:
workbench = await _get_validated_workbench_model(
app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id
)
- outputs: dict[NodeID, Any] = await _ports_api.get_project_outputs(
+ outputs: dict[NodeID, Any] = await _ports_service.get_project_outputs(
request.app, project_id=path_params.project_id, workbench=workbench
)
- return _web_json_response_enveloped(
- data={
+ return envelope_json_response(
+ {
node_id: ProjectOutputGet(
key=node_id, label=workbench[node_id].label, value=value
)
@@ -239,7 +195,7 @@ class ProjectMetadataPortGet(BaseModel):
)
@login_required
@permission_required("project.read")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def list_project_metadata_ports(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
@@ -250,13 +206,13 @@ async def list_project_metadata_ports(request: web.Request) -> web.Response:
app=request.app, project_id=path_params.project_id, user_id=req_ctx.user_id
)
- return _web_json_response_enveloped(
- data=[
+ return envelope_json_response(
+ [
ProjectMetadataPortGet(
key=port.node_id,
kind=port.kind,
content_schema=port.get_schema(),
)
- for port in _ports_api.iter_project_ports(workbench)
+ for port in _ports_service.iter_project_ports(workbench)
]
)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py
similarity index 55%
rename from services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py
index aa2163bdb04..f20360d1cc0 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py
@@ -1,10 +1,3 @@
-"""Handlers for STANDARD methods on /projects colletions
-
-Standard methods or CRUD that states for Create+Read(Get&List)+Update+Delete
-
-"""
-
-import functools
import logging
from aiohttp import web
@@ -14,14 +7,11 @@
ProjectCopyOverride,
ProjectCreateNew,
ProjectGet,
- ProjectListItem,
ProjectPatch,
)
from models_library.generics import Envelope
from models_library.projects_state import ProjectLocked
from models_library.rest_ordering import OrderBy
-from models_library.rest_pagination import Page
-from models_library.rest_pagination_utils import paginate_data
from models_library.utils.fastapi_encoders import jsonable_encoder
from servicelib.aiohttp import status
from servicelib.aiohttp.long_running_tasks.server import start_long_running_task
@@ -31,44 +21,39 @@
parse_request_path_parameters_as,
parse_request_query_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
from servicelib.common_headers import (
UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
X_SIMCORE_USER_AGENT,
)
-from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.redis import get_project_locked_state
-from servicelib.rest_constants import RESPONSE_MODEL_POLICY
-
-from .._meta import API_VTAG as VTAG
-from ..catalog.client import get_services_for_user_in_product
-from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError
-from ..login.decorators import login_required
-from ..redis import get_redis_lock_manager_client_sdk
-from ..resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource
-from ..security.api import check_user_permission
-from ..security.decorators import permission_required
-from ..users.api import get_user_fullname
-from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError
-from . import _crud_api_create, _crud_api_read, projects_service
-from ._common.models import ProjectPathParams, RequestContext
-from ._crud_handlers_models import (
+
+from ..._meta import API_VTAG as VTAG
+from ...catalog import catalog_service
+from ...login.decorators import login_required
+from ...redis import get_redis_lock_manager_client_sdk
+from ...resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource
+from ...security.api import check_user_permission
+from ...security.decorators import permission_required
+from ...users.api import get_user_fullname
+from ...utils_aiohttp import envelope_json_response
+from .. import _crud_api_create, _crud_api_read, _projects_service
+from .._permalink_service import update_or_pop_permalink_in_project
+from ..models import ProjectDict
+from ..utils import get_project_unavailable_services, project_uses_available_services
+from . import _rest_utils
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import (
+ ProjectPathParams,
+ RequestContext,
+)
+from .projects_rest_schemas import (
ProjectActiveQueryParams,
ProjectCreateHeaders,
- ProjectCreateParams,
+ ProjectCreateQueryParams,
ProjectFilters,
ProjectsListQueryParams,
ProjectsSearchQueryParams,
)
-from ._permalink_api import update_or_pop_permalink_in_project
-from .exceptions import (
- ProjectDeleteError,
- ProjectInvalidRightsError,
- ProjectNotFoundError,
- ProjectOwnerNotFoundInTheProjectAccessRightsError,
- WrongTagIdsInQueryError,
-)
-from .utils import get_project_unavailable_services, project_uses_available_services
# When the user requests a project with a repo, the working copy might differ from
# the repo project. A middleware in the meta module (if active) will resolve
@@ -76,37 +61,9 @@
# response needs to refer to the uuid of the request and this is passed through this request key
RQ_REQUESTED_REPO_PROJECT_UUID_KEY = f"{__name__}.RQT_REQUESTED_REPO_PROJECT_UUID_KEY"
-
_logger = logging.getLogger(__name__)
-def _handle_projects_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def _wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except (
- ProjectNotFoundError,
- FolderNotFoundError,
- WorkspaceNotFoundError,
- ) as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
- except (
- ProjectOwnerNotFoundInTheProjectAccessRightsError,
- WrongTagIdsInQueryError,
- ) as exc:
- raise web.HTTPBadRequest(reason=f"{exc}") from exc
- except (
- ProjectInvalidRightsError,
- FolderAccessForbiddenError,
- WorkspaceAccessForbiddenError,
- ) as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return _wrapper
-
-
routes = web.RouteTableDef()
@@ -114,14 +71,14 @@ async def _wrapper(request: web.Request) -> web.StreamResponse:
@login_required
@permission_required("project.create")
@permission_required("services.pipeline.*") # due to update_pipeline_db
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def create_project(request: web.Request):
#
# - Create https://google.aip.dev/133
#
req_ctx = RequestContext.model_validate(request)
- query_params: ProjectCreateParams = parse_request_query_parameters_as(
- ProjectCreateParams, request
+ query_params: ProjectCreateQueryParams = parse_request_query_parameters_as(
+ ProjectCreateQueryParams, request
)
header_params = parse_request_headers_as(ProjectCreateHeaders, request)
if query_params.as_template: # create template from
@@ -131,26 +88,20 @@ async def create_project(request: web.Request):
# this entrypoint are in reality multiple entrypoints in one, namely
# :create, :copy (w/ and w/o override)
# NOTE: see clone_project
+ predefined_project: ProjectDict | None
if not request.can_read_body:
# request w/o body
predefined_project = None
else:
# request w/ body (I found cases in which body = {})
- project_create: (
- ProjectCreateNew | ProjectCopyOverride | EmptyModel
- ) = await parse_request_body_as(
- ProjectCreateNew | ProjectCopyOverride | EmptyModel, # type: ignore[arg-type] # from pydantic v2 --> https://github.com/pydantic/pydantic/discussions/4950
- request,
- )
- predefined_project = (
- project_create.model_dump(
- exclude_unset=True,
- by_alias=True,
- exclude_none=True,
+ project_create: ProjectCreateNew | ProjectCopyOverride | EmptyModel = (
+ await parse_request_body_as(
+ ProjectCreateNew | ProjectCopyOverride | EmptyModel, # type: ignore[arg-type] # from pydantic v2 --> https://github.com/pydantic/pydantic/discussions/4950
+ request,
)
- or None
)
+ predefined_project = project_create.to_domain_model() or None
return await start_long_running_task(
request,
@@ -172,31 +123,10 @@ async def create_project(request: web.Request):
)
-def _create_page_response(projects, request_url, total, limit, offset) -> web.Response:
- page = Page[ProjectListItem].model_validate(
- paginate_data(
- chunk=[
- ProjectListItem.from_domain_model(prj).model_dump(
- by_alias=True, exclude_unset=True
- )
- for prj in projects
- ],
- request_url=request_url,
- total=total,
- limit=limit,
- offset=offset,
- )
- )
- return web.Response(
- text=page.model_dump_json(**RESPONSE_MODEL_POLICY),
- content_type=MIMETYPE_APPLICATION_JSON,
- )
-
-
@routes.get(f"/{VTAG}/projects", name="list_projects")
@login_required
@permission_required("project.read")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def list_projects(request: web.Request):
#
# - List https://google.aip.dev/132
@@ -218,7 +148,7 @@ async def list_projects(request: web.Request):
assert query_params.filters # nosec
projects, total_number_of_projects = await _crud_api_read.list_projects(
- request,
+ request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
project_type=query_params.project_type,
@@ -233,7 +163,11 @@ async def list_projects(request: web.Request):
order_by=OrderBy.model_construct(**query_params.order_by.model_dump()),
)
- return _create_page_response(
+ projects = await _rest_utils.aggregate_data_to_projects_from_request(
+ request, projects
+ )
+
+ return _rest_utils.create_page_response(
projects=projects,
request_url=request.url,
total=total_number_of_projects,
@@ -245,7 +179,7 @@ async def list_projects(request: web.Request):
@routes.get(f"/{VTAG}/projects:search", name="list_projects_full_search")
@login_required
@permission_required("project.read")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def list_projects_full_search(request: web.Request):
req_ctx = RequestContext.model_validate(request)
query_params: ProjectsSearchQueryParams = parse_request_query_parameters_as(
@@ -257,7 +191,7 @@ async def list_projects_full_search(request: web.Request):
tag_ids_list = query_params.tag_ids_list()
projects, total_number_of_projects = await _crud_api_read.list_projects_full_depth(
- request,
+ request.app,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
trashed=query_params.filters.trashed,
@@ -269,7 +203,11 @@ async def list_projects_full_search(request: web.Request):
order_by=OrderBy.model_construct(**query_params.order_by.model_dump()),
)
- return _create_page_response(
+ projects = await _rest_utils.aggregate_data_to_projects_from_request(
+ request, projects
+ )
+
+ return _rest_utils.create_page_response(
projects=projects,
request_url=request.url,
total=total_number_of_projects,
@@ -281,7 +219,7 @@ async def list_projects_full_search(request: web.Request):
@routes.get(f"/{VTAG}/projects/active", name="get_active_project")
@login_required
@permission_required("project.read")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def get_active_project(request: web.Request) -> web.Response:
#
# - Get https://google.aip.dev/131
@@ -298,39 +236,35 @@ async def get_active_project(request: web.Request) -> web.Response:
ProjectActiveQueryParams, request
)
- try:
- user_active_projects = []
- with managed_resource(
- req_ctx.user_id, query_params.client_session_id, request.app
- ) as rt:
- # get user's projects
- user_active_projects = await rt.find(PROJECT_ID_KEY)
-
- data = None
- if user_active_projects:
- project = await projects_service.get_project_for_user(
- request.app,
- project_uuid=user_active_projects[0],
- user_id=req_ctx.user_id,
- include_state=True,
- include_trashed_by_primary_gid=True,
- )
+ user_active_projects = []
+ with managed_resource(
+ req_ctx.user_id, query_params.client_session_id, request.app
+ ) as rt:
+ # get user's projects
+ user_active_projects = await rt.find(PROJECT_ID_KEY)
- # updates project's permalink field
- await update_or_pop_permalink_in_project(request, project)
+ data = None
+ if user_active_projects:
+ project = await _projects_service.get_project_for_user(
+ request.app,
+ project_uuid=user_active_projects[0],
+ user_id=req_ctx.user_id,
+ include_state=True,
+ include_trashed_by_primary_gid=True,
+ )
- data = ProjectGet.from_domain_model(project).data(exclude_unset=True)
+ # updates project's permalink field
+ await update_or_pop_permalink_in_project(request, project)
- return web.json_response({"data": data}, dumps=json_dumps)
+ data = ProjectGet.from_domain_model(project).data(exclude_unset=True)
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(reason="Project not found") from exc
+ return envelope_json_response(data)
@routes.get(f"/{VTAG}/projects/{{project_id}}", name="get_project")
@login_required
@permission_required("project.read")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def get_project(request: web.Request):
"""
@@ -344,50 +278,42 @@ async def get_project(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
- user_available_services: list[dict] = await get_services_for_user_in_product(
- request.app, req_ctx.user_id, req_ctx.product_name, only_key_versions=True
+ user_available_services: list[dict] = (
+ await catalog_service.get_services_for_user_in_product(
+ request.app, req_ctx.user_id, req_ctx.product_name, only_key_versions=True
+ )
)
- try:
- project = await projects_service.get_project_for_user(
- request.app,
- project_uuid=f"{path_params.project_id}",
- user_id=req_ctx.user_id,
- include_state=True,
- include_trashed_by_primary_gid=True,
+ project = await _projects_service.get_project_for_user(
+ request.app,
+ project_uuid=f"{path_params.project_id}",
+ user_id=req_ctx.user_id,
+ include_state=True,
+ include_trashed_by_primary_gid=True,
+ )
+ if not await project_uses_available_services(project, user_available_services):
+ unavilable_services = get_project_unavailable_services(
+ project, user_available_services
)
- if not await project_uses_available_services(project, user_available_services):
- unavilable_services = get_project_unavailable_services(
- project, user_available_services
- )
- formatted_services = ", ".join(
- f"{service}:{version}" for service, version in unavilable_services
- )
- # TODO: lack of permissions should be notified with https://httpstatuses.com/403 web.HTTPForbidden
- raise web.HTTPNotFound(
- reason=(
- f"Project '{path_params.project_id}' uses unavailable services. Please ask "
- f"for permission for the following services {formatted_services}"
- )
+ formatted_services = ", ".join(
+ f"{service}:{version}" for service, version in unavilable_services
+ )
+ # TODO: lack of permissions should be notified with https://httpstatuses.com/403 web.HTTPForbidden
+ raise web.HTTPNotFound(
+ reason=(
+ f"Project '{path_params.project_id}' uses unavailable services. Please ask "
+ f"for permission for the following services {formatted_services}"
)
+ )
- if new_uuid := request.get(RQ_REQUESTED_REPO_PROJECT_UUID_KEY):
- project["uuid"] = new_uuid
-
- # Adds permalink
- await update_or_pop_permalink_in_project(request, project)
+ if new_uuid := request.get(RQ_REQUESTED_REPO_PROJECT_UUID_KEY):
+ project["uuid"] = new_uuid
- data = ProjectGet.from_domain_model(project).data(exclude_unset=True)
- return web.json_response({"data": data}, dumps=json_dumps)
+ # Adds permalink
+ await update_or_pop_permalink_in_project(request, project)
- except ProjectInvalidRightsError as exc:
- raise web.HTTPForbidden(
- reason=f"You do not have sufficient rights to read project {path_params.project_id}"
- ) from exc
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(
- reason=f"Project {path_params.project_id} not found"
- ) from exc
+ data = ProjectGet.from_domain_model(project).data(exclude_unset=True)
+ return envelope_json_response(data)
@routes.get(
@@ -395,11 +321,11 @@ async def get_project(request: web.Request):
)
@login_required
@permission_required("project.read")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def get_project_inactivity(request: web.Request):
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
- project_inactivity = await projects_service.get_project_inactivity(
+ project_inactivity = await _projects_service.get_project_inactivity(
app=request.app, project_id=path_params.project_id
)
return web.json_response(Envelope(data=project_inactivity), dumps=json_dumps)
@@ -409,7 +335,7 @@ async def get_project_inactivity(request: web.Request):
@login_required
@permission_required("project.update")
@permission_required("services.pipeline.*")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def patch_project(request: web.Request):
#
# Update https://google.aip.dev/134
@@ -418,7 +344,7 @@ async def patch_project(request: web.Request):
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
project_patch = await parse_request_body_as(ProjectPatch, request)
- await projects_service.patch_project(
+ await _projects_service.patch_project(
request.app,
user_id=req_ctx.user_id,
project_uuid=path_params.project_id,
@@ -432,7 +358,7 @@ async def patch_project(request: web.Request):
@routes.delete(f"/{VTAG}/projects/{{project_id}}", name="delete_project")
@login_required
@permission_required("project.delete")
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def delete_project(request: web.Request):
# Delete https://google.aip.dev/135
"""
@@ -450,64 +376,52 @@ async def delete_project(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
- try:
- await projects_service.get_project_for_user(
- request.app,
- project_uuid=f"{path_params.project_id}",
- user_id=req_ctx.user_id,
- )
- project_users: set[int] = set()
- with managed_resource(req_ctx.user_id, None, request.app) as user_session:
- project_users = {
- s.user_id
- for s in await user_session.find_users_of_resource(
- request.app, PROJECT_ID_KEY, f"{path_params.project_id}"
- )
- }
- # that project is still in use
- if req_ctx.user_id in project_users:
- raise web.HTTPForbidden(
- reason="Project is still open in another tab/browser."
- "It cannot be deleted until it is closed."
- )
- if project_users:
- other_user_names = {
- f"{await get_user_fullname(request.app, user_id=uid)}"
- for uid in project_users
- }
- raise web.HTTPForbidden(
- reason=f"Project is open by {other_user_names}. "
- "It cannot be deleted until the project is closed."
- )
-
- project_locked_state: ProjectLocked | None
- if project_locked_state := await get_project_locked_state(
- get_redis_lock_manager_client_sdk(request.app),
- project_uuid=path_params.project_id,
- ):
- raise web.HTTPConflict(
- reason=f"Project {path_params.project_id} is locked: {project_locked_state=}"
+ await _projects_service.get_project_for_user(
+ request.app,
+ project_uuid=f"{path_params.project_id}",
+ user_id=req_ctx.user_id,
+ )
+ project_users: set[int] = set()
+ with managed_resource(req_ctx.user_id, None, request.app) as user_session:
+ project_users = {
+ s.user_id
+ for s in await user_session.find_users_of_resource(
+ request.app, PROJECT_ID_KEY, f"{path_params.project_id}"
)
+ }
+ # that project is still in use
+ if req_ctx.user_id in project_users:
+ raise web.HTTPForbidden(
+ reason="Project is still open in another tab/browser."
+ "It cannot be deleted until it is closed."
+ )
+ if project_users:
+ other_user_names = {
+ f"{await get_user_fullname(request.app, user_id=uid)}"
+ for uid in project_users
+ }
+ raise web.HTTPForbidden(
+ reason=f"Project is open by {other_user_names}. "
+ "It cannot be deleted until the project is closed."
+ )
- await projects_service.submit_delete_project_task(
- request.app,
- path_params.project_id,
- req_ctx.user_id,
- request.headers.get(
- X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
- ),
+ project_locked_state: ProjectLocked | None
+ if project_locked_state := await get_project_locked_state(
+ get_redis_lock_manager_client_sdk(request.app),
+ project_uuid=path_params.project_id,
+ ):
+ raise web.HTTPConflict(
+ reason=f"Project {path_params.project_id} is locked: {project_locked_state=}"
)
- except ProjectInvalidRightsError as err:
- raise web.HTTPForbidden(
- reason="You do not have sufficient rights to delete this project"
- ) from err
- except ProjectNotFoundError as err:
- raise web.HTTPNotFound(
- reason=f"Project {path_params.project_id} not found"
- ) from err
- except ProjectDeleteError as err:
- raise web.HTTPConflict(reason=f"{err}") from err
+ await _projects_service.submit_delete_project_task(
+ request.app,
+ project_uuid=path_params.project_id,
+ user_id=req_ctx.user_id,
+ simcore_user_agent=request.headers.get(
+ X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
+ ),
+ )
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -523,7 +437,7 @@ async def delete_project(request: web.Request):
@login_required
@permission_required("project.create")
@permission_required("services.pipeline.*") # due to update_pipeline_db
-@_handle_projects_exceptions
+@handle_plugin_requests_exceptions
async def clone_project(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py
similarity index 95%
rename from services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py
index 188b3cc960b..55834b7b658 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py
@@ -1,9 +1,3 @@
-""" Handlers for STANDARD methods on /projects colletions
-
-Standard methods or CRUD that states for Create+Read(Get&List)+Update+Delete
-
-"""
-
from typing import Annotated, Self
from models_library.basic_types import IDStr
@@ -38,8 +32,8 @@
X_SIMCORE_USER_AGENT,
)
-from .exceptions import WrongTagIdsInQueryError
-from .models import ProjectTypeAPI
+from ..exceptions import WrongTagIdsInQueryError
+from ..models import ProjectTypeAPI
class ProjectCreateHeaders(BaseModel):
@@ -73,7 +67,7 @@ def check_parent_valid(self) -> Self:
model_config = ConfigDict(populate_by_name=False)
-class ProjectCreateParams(BaseModel):
+class ProjectCreateQueryParams(BaseModel):
from_study: ProjectID | None = Field(
None,
description="Option to create a project from existing template or study: from_study={study_uuid}",
@@ -159,8 +153,7 @@ class ProjectsListQueryParams(
ProjectsListOrderParams, # type: ignore[misc, valid-type]
FiltersQueryParameters[ProjectFilters],
ProjectsListExtraQueryParams,
-):
- ...
+): ...
class ProjectActiveQueryParams(BaseModel):
diff --git a/services/web/server/src/simcore_service_webserver/projects/_observer.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py
similarity index 85%
rename from services/web/server/src/simcore_service_webserver/projects/_observer.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py
index f830ae40f6f..8537fbc6616 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_observer.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_slot.py
@@ -1,6 +1,4 @@
-""" Handlers to events registered in servicelib.observer.event_registry
-
-"""
+"""Handlers to events registered in servicelib.observer.event_registry"""
import logging
@@ -15,9 +13,9 @@
from servicelib.logging_utils import log_context
from servicelib.utils import logged_gather
-from ..notifications import project_logs
-from ..resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource
-from .projects_service import retrieve_and_notify_project_locked_state
+from ...notifications import project_logs
+from ...resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource
+from .._projects_service import retrieve_and_notify_project_locked_state
_logger = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py
similarity index 67%
rename from services/web/server/src/simcore_service_webserver/projects/_states_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py
index 956226d7f32..252708921fe 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_states_rest.py
@@ -1,7 +1,4 @@
-"""handlers for project states"""
-
import contextlib
-import functools
import json
import logging
@@ -14,7 +11,6 @@
parse_request_path_parameters_as,
parse_request_query_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
from servicelib.aiohttp.web_exceptions_extension import HTTPLockedError
from servicelib.common_headers import (
UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
@@ -23,29 +19,20 @@
from simcore_postgres_database.models.users import UserRole
from simcore_postgres_database.webserver_models import ProjectType
-from .._meta import API_VTAG as VTAG
-from ..director_v2.exceptions import DirectorServiceError
-from ..login.decorators import login_required
-from ..notifications import project_logs
-from ..products.api import Product, get_current_product
-from ..resource_usage.errors import DefaultPricingPlanNotFoundError
-from ..security.decorators import permission_required
-from ..users import api
-from ..users.exceptions import UserDefaultWalletNotFoundError
-from ..utils_aiohttp import envelope_json_response
-from ..wallets.errors import WalletNotEnoughCreditsError
-from . import api as projects_api
-from . import projects_service
-from ._common.models import ProjectPathParams, RequestContext
-from .exceptions import (
- DefaultPricingUnitNotFoundError,
- ProjectInDebtCanNotChangeWalletError,
- ProjectInDebtCanNotOpenError,
- ProjectInvalidRightsError,
- ProjectNotFoundError,
- ProjectStartsTooManyDynamicNodesError,
- ProjectTooManyProjectOpenedError,
-)
+from ..._meta import API_VTAG as VTAG
+from ...director_v2.exceptions import DirectorServiceError
+from ...login.decorators import login_required
+from ...notifications import project_logs
+from ...products import products_web
+from ...products.models import Product
+from ...security.decorators import permission_required
+from ...users import api
+from ...utils_aiohttp import envelope_json_response
+from .. import _projects_service
+from .. import api as projects_api
+from ..exceptions import ProjectStartsTooManyDynamicNodesError
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
_logger = logging.getLogger(__name__)
@@ -53,38 +40,6 @@
routes = web.RouteTableDef()
-def _handle_project_exceptions(handler: Handler):
- """Transforms common project errors -> http errors"""
-
- @functools.wraps(handler)
- async def _wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except (
- ProjectNotFoundError,
- UserDefaultWalletNotFoundError,
- DefaultPricingPlanNotFoundError,
- DefaultPricingUnitNotFoundError,
- ) as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except ProjectInvalidRightsError as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- except ProjectTooManyProjectOpenedError as exc:
- raise web.HTTPConflict(reason=f"{exc}") from exc
-
- except (
- WalletNotEnoughCreditsError,
- ProjectInDebtCanNotChangeWalletError,
- ProjectInDebtCanNotOpenError,
- ) as exc:
- raise web.HTTPPaymentRequired(reason=f"{exc}") from exc
-
- return _wrapper
-
-
#
# open project: custom methods https://google.aip.dev/136
#
@@ -97,7 +52,7 @@ class _OpenProjectQuery(BaseModel):
@routes.post(f"/{VTAG}/projects/{{project_id}}:open", name="open_project")
@login_required
@permission_required("project.open")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def open_project(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
@@ -112,7 +67,7 @@ async def open_project(request: web.Request) -> web.Response:
raise web.HTTPBadRequest(reason="Invalid request body") from exc
try:
- project_type: ProjectType = await projects_service.get_project_type(
+ project_type: ProjectType = await _projects_service.get_project_type(
request.app, path_params.project_id
)
user_role: UserRole = await api.get_user_role(
@@ -122,7 +77,7 @@ async def open_project(request: web.Request) -> web.Response:
# only USERS/TESTERS can do that
raise web.HTTPForbidden(reason="Wrong user role to open/edit a template")
- project = await projects_service.get_project_for_user(
+ project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -138,9 +93,9 @@ async def open_project(request: web.Request) -> web.Response:
product_name=req_ctx.product_name,
)
- product: Product = get_current_product(request)
+ product: Product = products_web.get_current_product(request)
- if not await projects_service.try_open_project_for_user(
+ if not await _projects_service.try_open_project_for_user(
req_ctx.user_id,
project_uuid=path_params.project_id,
client_session_id=client_session_id,
@@ -150,7 +105,7 @@ async def open_project(request: web.Request) -> web.Response:
raise HTTPLockedError(reason="Project is locked, try later")
# the project can be opened, let's update its product links
- await projects_service.update_project_linked_product(
+ await _projects_service.update_project_linked_product(
request.app, path_params.project_id, req_ctx.product_name
)
@@ -163,30 +118,30 @@ async def open_project(request: web.Request) -> web.Response:
# NOTE: this method raises that exception when the number of dynamic
# services in the project is highter than the maximum allowed per project
# the project shall still open though.
- await projects_service.run_project_dynamic_services(
+ await _projects_service.run_project_dynamic_services(
request, project, req_ctx.user_id, req_ctx.product_name
)
# and let's update the project last change timestamp
- await projects_service.update_project_last_change_timestamp(
+ await _projects_service.update_project_last_change_timestamp(
request.app, path_params.project_id
)
# notify users that project is now opened
- project = await projects_service.add_project_states_for_user(
+ project = await _projects_service.add_project_states_for_user(
user_id=req_ctx.user_id,
project=project,
is_template=False,
app=request.app,
)
- await projects_service.notify_project_state_update(request.app, project)
+ await _projects_service.notify_project_state_update(request.app, project)
return envelope_json_response(ProjectGet.from_domain_model(project))
except DirectorServiceError as exc:
# there was an issue while accessing the director-v2/director-v0
# ensure the project is closed again
- await projects_service.try_close_project_for_user(
+ await _projects_service.try_close_project_for_user(
user_id=req_ctx.user_id,
project_uuid=f"{path_params.project_id}",
client_session_id=client_session_id,
@@ -208,7 +163,7 @@ async def open_project(request: web.Request) -> web.Response:
@routes.post(f"/{VTAG}/projects/{{project_id}}:close", name="close_project")
@login_required
@permission_required("project.close")
-@_handle_project_exceptions
+@handle_plugin_requests_exceptions
async def close_project(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
@@ -220,13 +175,13 @@ async def close_project(request: web.Request) -> web.Response:
raise web.HTTPBadRequest(reason="Invalid request body") from exc
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
include_state=False,
)
- await projects_service.try_close_project_for_user(
+ await _projects_service.try_close_project_for_user(
req_ctx.user_id,
f"{path_params.project_id}",
client_session_id,
@@ -247,12 +202,13 @@ async def close_project(request: web.Request) -> web.Response:
@routes.get(f"/{VTAG}/projects/{{project_id}}/state", name="get_project_state")
@login_required
@permission_required("project.read")
+@handle_plugin_requests_exceptions
async def get_project_state(request: web.Request) -> web.Response:
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
# check that project exists and queries state
- validated_project = await projects_service.get_project_for_user(
+ validated_project = await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/tags_rest.py
similarity index 80%
rename from services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/tags_rest.py
index 1eff696a177..9fce75cda63 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/tags_rest.py
@@ -1,18 +1,15 @@
-""" Handlers for CRUD operations on /projects/{*}/tags/{*}
-
-"""
-
import logging
from aiohttp import web
from models_library.projects import ProjectID
from servicelib.request_keys import RQT_USERID_KEY
-from simcore_service_webserver.utils_aiohttp import envelope_json_response
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from . import _tags_api as tags_api
+from ..._meta import API_VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _tags_service as tags_api
+from ._rest_exceptions import handle_plugin_requests_exceptions
_logger = logging.getLogger(__name__)
@@ -25,6 +22,7 @@
)
@login_required
@permission_required("project.tag.*")
+@handle_plugin_requests_exceptions
async def add_project_tag(request: web.Request):
user_id: int = request[RQT_USERID_KEY]
@@ -51,6 +49,7 @@ async def add_project_tag(request: web.Request):
)
@login_required
@permission_required("project.tag.*")
+@handle_plugin_requests_exceptions
async def remove_project_tag(request: web.Request):
user_id: int = request[RQT_USERID_KEY]
diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/trash_rest.py
similarity index 70%
rename from services/web/server/src/simcore_service_webserver/projects/_trash_rest.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/trash_rest.py
index 22368285efc..f1cae188a7d 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/trash_rest.py
@@ -7,28 +7,25 @@
parse_request_query_parameters_as,
)
-from .._meta import API_VTAG as VTAG
-from ..exception_handling import (
+from ..._meta import API_VTAG as VTAG
+from ...exception_handling import (
ExceptionToHttpErrorMap,
HttpErrorInfo,
exception_handling_decorator,
to_exceptions_handlers_map,
)
-from ..login.decorators import get_user_id, login_required
-from ..products.api import get_product_name
-from ..security.decorators import permission_required
-from . import _trash_service
-from ._common.models import ProjectPathParams, RemoveQueryParams
-from .exceptions import ProjectRunningConflictError, ProjectStoppingError
+from ...login.decorators import get_user_id, login_required
+from ...products import products_web
+from ...security.decorators import permission_required
+from .. import _trash_service
+from ..exceptions import ProjectRunningConflictError, ProjectStoppingError
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RemoveQueryParams
_logger = logging.getLogger(__name__)
-#
-# EXCEPTIONS HANDLING
-#
-
-_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+_TRASH_ERRORS: ExceptionToHttpErrorMap = {
ProjectRunningConflictError: HttpErrorInfo(
status.HTTP_409_CONFLICT,
"Current study is in use and cannot be trashed [project_id={project_uuid}]. Please stop all services first and try again",
@@ -39,26 +36,22 @@
),
}
-
-_handle_exceptions = exception_handling_decorator(
- to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+_handle_local_request_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TRASH_ERRORS)
)
-#
-# ROUTES
-#
-
routes = web.RouteTableDef()
@routes.post(f"/{VTAG}/projects/{{project_id}}:trash", name="trash_project")
@login_required
@permission_required("project.delete")
-@_handle_exceptions
+@handle_plugin_requests_exceptions
+@_handle_local_request_exceptions
async def trash_project(request: web.Request):
user_id = get_user_id(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
query_params: RemoveQueryParams = parse_request_query_parameters_as(
RemoveQueryParams, request
@@ -79,10 +72,11 @@ async def trash_project(request: web.Request):
@routes.post(f"/{VTAG}/projects/{{project_id}}:untrash", name="untrash_project")
@login_required
@permission_required("project.delete")
-@_handle_exceptions
+@handle_plugin_requests_exceptions
+@_handle_local_request_exceptions
async def untrash_project(request: web.Request):
user_id = get_user_id(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
await _trash_service.untrash_project(
diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/wallets_rest.py
similarity index 67%
rename from services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/wallets_rest.py
index 346958c3487..a2b734a20cb 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/wallets_rest.py
@@ -1,8 +1,3 @@
-""" Handlers for CRUD operations on /projects/{*}/wallet
-
-"""
-
-import functools
import logging
from decimal import Decimal
from typing import Annotated
@@ -17,49 +12,16 @@
parse_request_body_as,
parse_request_path_parameters_as,
)
-from servicelib.aiohttp.typing_extension import Handler
-from simcore_service_webserver.utils_aiohttp import envelope_json_response
-
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..wallets.errors import WalletAccessForbiddenError, WalletNotFoundError
-from . import _wallets_api as wallets_api
-from . import projects_service
-from ._common.models import ProjectPathParams, RequestContext
-from .exceptions import (
- ProjectInDebtCanNotChangeWalletError,
- ProjectInvalidRightsError,
- ProjectNotFoundError,
- ProjectWalletPendingTransactionError,
-)
-
-_logger = logging.getLogger(__name__)
-
-def _handle_project_wallet_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
+from ..._meta import API_VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from ...utils_aiohttp import envelope_json_response
+from .. import _projects_service, _wallets_service
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import ProjectPathParams, RequestContext
- except ProjectNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except WalletNotFoundError as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except ProjectInDebtCanNotChangeWalletError as exc:
- raise web.HTTPPaymentRequired(reason=f"{exc}") from exc
-
- except (
- WalletAccessForbiddenError,
- ProjectInvalidRightsError,
- ProjectWalletPendingTransactionError,
- ) as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
+_logger = logging.getLogger(__name__)
routes = web.RouteTableDef()
@@ -68,19 +30,19 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
@routes.get(f"/{API_VTAG}/projects/{{project_id}}/wallet", name="get_project_wallet")
@login_required
@permission_required("project.wallet.*")
-@_handle_project_wallet_exceptions
+@handle_plugin_requests_exceptions
async def get_project_wallet(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
include_state=False,
)
- wallet: WalletGet | None = await wallets_api.get_project_wallet(
+ wallet: WalletGet | None = await _wallets_service.get_project_wallet(
request.app, path_params.project_id
)
@@ -99,20 +61,20 @@ class _ProjectWalletPathParams(BaseModel):
)
@login_required
@permission_required("project.wallet.*")
-@_handle_project_wallet_exceptions
+@handle_plugin_requests_exceptions
async def connect_wallet_to_project(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectWalletPathParams, request)
# ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
include_state=False,
)
- wallet: WalletGet = await wallets_api.connect_wallet_to_project(
+ wallet: WalletGet = await _wallets_service.connect_wallet_to_project(
request.app,
product_name=req_ctx.product_name,
project_id=path_params.project_id,
@@ -134,14 +96,14 @@ class _PayProjectDebtBody(BaseModel):
)
@login_required
@permission_required("project.wallet.*")
-@_handle_project_wallet_exceptions
+@handle_plugin_requests_exceptions
async def pay_project_debt(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(_ProjectWalletPathParams, request)
body_params = await parse_request_body_as(_PayProjectDebtBody, request)
# Ensure the project exists
- await projects_service.get_project_for_user(
+ await _projects_service.get_project_for_user(
request.app,
project_uuid=f"{path_params.project_id}",
user_id=req_ctx.user_id,
@@ -149,7 +111,7 @@ async def pay_project_debt(request: web.Request):
)
# Get curently associated wallet with the project
- current_wallet: WalletGet | None = await wallets_api.get_project_wallet(
+ current_wallet: WalletGet | None = await _wallets_service.get_project_wallet(
request.app, path_params.project_id
)
if not current_wallet:
@@ -172,7 +134,7 @@ async def pay_project_debt(request: web.Request):
# Steps:
# 1. Transfer the required credits from the specified wallet to the connected wallet.
# 2. Mark the project transactions as billed
- await wallets_api.pay_debt_with_different_wallet(
+ await _wallets_service.pay_debt_with_different_wallet(
app=request.app,
product_name=req_ctx.product_name,
project_id=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_controller/workspaces_rest.py
similarity index 52%
rename from services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py
rename to services/web/server/src/simcore_service_webserver/projects/_controller/workspaces_rest.py
index b5a6082cb50..14b085c66b1 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_controller/workspaces_rest.py
@@ -1,4 +1,3 @@
-import functools
import logging
from typing import Annotated
@@ -9,43 +8,17 @@
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
from servicelib.aiohttp import status
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
-from servicelib.aiohttp.typing_extension import Handler
-from .._meta import api_version_prefix as VTAG
-from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError
-from . import _workspaces_api
-from ._common.models import RequestContext
-from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
+from ..._meta import api_version_prefix as VTAG
+from ...login.decorators import login_required
+from ...security.decorators import permission_required
+from .. import _workspaces_service
+from ._rest_exceptions import handle_plugin_requests_exceptions
+from ._rest_schemas import RequestContext
_logger = logging.getLogger(__name__)
-def _handle_projects_workspaces_exceptions(handler: Handler):
- @functools.wraps(handler)
- async def wrapper(request: web.Request) -> web.StreamResponse:
- try:
- return await handler(request)
-
- except (
- ProjectNotFoundError,
- FolderNotFoundError,
- WorkspaceNotFoundError,
- ) as exc:
- raise web.HTTPNotFound(reason=f"{exc}") from exc
-
- except (
- ProjectInvalidRightsError,
- FolderAccessForbiddenError,
- WorkspaceAccessForbiddenError,
- ) as exc:
- raise web.HTTPForbidden(reason=f"{exc}") from exc
-
- return wrapper
-
-
routes = web.RouteTableDef()
@@ -64,14 +37,14 @@ class _ProjectWorkspacesPathParams(BaseModel):
)
@login_required
@permission_required("project.workspaces.*")
-@_handle_projects_workspaces_exceptions
+@handle_plugin_requests_exceptions
async def move_project_to_workspace(request: web.Request):
req_ctx = RequestContext.model_validate(request)
path_params = parse_request_path_parameters_as(
_ProjectWorkspacesPathParams, request
)
- await _workspaces_api.move_project_into_workspace(
+ await _workspaces_service.move_project_into_workspace(
app=request.app,
user_id=req_ctx.user_id,
project_id=path_params.project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py
index feebc745fe6..4bb6f9b938a 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py
@@ -27,10 +27,10 @@
from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB
from ..application_settings import get_application_settings
-from ..catalog import client as catalog_client
-from ..director_v2 import api as director_v2_api
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..folders import _folders_repository as _folders_repository
+from ..catalog import catalog_service
+from ..director_v2 import director_v2_service
+from ..dynamic_scheduler import api as dynamic_scheduler_service
+from ..folders import _folders_repository as folders_folders_repository
from ..redis import get_redis_lock_manager_client_sdk
from ..storage.api import (
copy_data_folders_from_project,
@@ -39,11 +39,10 @@
from ..users.api import get_user_fullname
from ..workspaces.api import check_user_workspace_access, get_user_workspace
from ..workspaces.errors import WorkspaceAccessForbiddenError
-from . import _folders_db as project_to_folders_db
-from . import projects_service
-from ._metadata_api import set_project_ancestors
-from ._permalink_api import update_or_pop_permalink_in_project
-from .db import ProjectDBAPI
+from . import _folders_repository, _projects_service
+from ._metadata_service import set_project_ancestors
+from ._permalink_service import update_or_pop_permalink_in_project
+from ._projects_repository_legacy import ProjectDBAPI
from .exceptions import (
ParentNodeNotFoundError,
ParentProjectNotFoundError,
@@ -77,7 +76,7 @@ async def _prepare_project_copy(
deep_copy: bool,
task_progress: TaskProgress,
) -> tuple[ProjectDict, CopyProjectNodesCoro | None, CopyFileCoro | None]:
- source_project = await projects_service.get_project_for_user(
+ source_project = await _projects_service.get_project_for_user(
app,
project_uuid=f"{src_project_uuid}",
user_id=user_id,
@@ -192,7 +191,7 @@ async def _copy() -> None:
owner=Owner(
user_id=user_id, **await get_user_fullname(app, user_id=user_id)
),
- notification_cb=projects_service.create_user_notification_cb(
+ notification_cb=_projects_service.create_user_notification_cb(
user_id, ProjectID(f"{source_project['uuid']}"), app
),
)(_copy)()
@@ -219,7 +218,7 @@ async def _compose_project_data(
NodeID(node_id): ProjectNodeCreate(
node_id=NodeID(node_id),
required_resources=jsonable_encoder(
- await catalog_client.get_service_resources(
+ await catalog_service.get_service_resources(
app, user_id, node_data["key"], node_data["version"]
)
),
@@ -293,7 +292,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
)
if folder_id := predefined_project.get("folderId", None):
# Check user has access to folder
- await _folders_repository.get_for_user_or_workspace(
+ await folders_folders_repository.get_for_user_or_workspace(
request.app,
folder_id=folder_id,
product_name=product_name,
@@ -322,7 +321,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
# 1.2 does project belong to some folder?
workspace_id = new_project["workspaceId"]
- prj_to_folder_db = await project_to_folders_db.get_project_to_folder(
+ prj_to_folder_db = await _folders_repository.get_project_to_folder(
request.app,
project_id=from_study,
private_workspace_user_id_or_none=(
@@ -369,7 +368,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
# 3.2 move project to proper folder
if folder_id:
- await project_to_folders_db.insert_project_to_folder(
+ await _folders_repository.insert_project_to_folder(
request.app,
project_id=new_project["uuid"],
folder_id=folder_id,
@@ -390,13 +389,13 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
)
# update the network information in director-v2
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=ProjectID(new_project["uuid"])
)
task_progress.update()
# This is a new project and every new graph needs to be reflected in the pipeline tables
- await director_v2_api.create_or_update_pipeline(
+ await director_v2_service.create_or_update_pipeline(
request.app, user_id, new_project["uuid"], product_name
)
# get the latest state of the project (lastChangeDate for instance)
@@ -404,7 +403,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
project_uuid=new_project["uuid"]
)
# Appends state
- new_project = await projects_service.add_project_states_for_user(
+ new_project = await _projects_service.add_project_states_for_user(
user_id=user_id,
project=new_project,
is_template=as_template,
@@ -419,9 +418,9 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
user_specific_project_data_db = (
await _projects_repository.get_user_specific_project_data_db(
project_uuid=new_project["uuid"],
- private_workspace_user_id_or_none=user_id
- if workspace_id is None
- else None,
+ private_workspace_user_id_or_none=(
+ user_id if workspace_id is None else None
+ ),
)
)
new_project["folderId"] = user_specific_project_data_db.folder_id
@@ -460,7 +459,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
except (ParentProjectNotFoundError, ParentNodeNotFoundError) as exc:
if project_uuid := new_project.get("uuid"):
- await projects_service.submit_delete_project_task(
+ await _projects_service.submit_delete_project_task(
app=request.app,
project_uuid=project_uuid,
user_id=user_id,
@@ -474,7 +473,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
f"{user_id=}",
)
if project_uuid := new_project.get("uuid"):
- await projects_service.submit_delete_project_task(
+ await _projects_service.submit_delete_project_task(
app=request.app,
project_uuid=project_uuid,
user_id=user_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py
index 8fa770ddae9..866609110f3 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_delete.py
@@ -1,4 +1,4 @@
-""" Implements logic to delete a project (and all associated services, data, etc)
+"""Implements logic to delete a project (and all associated services, data, etc)
NOTE: this entire module is protected within the `projects` package
@@ -13,12 +13,12 @@
from models_library.projects import ProjectID
from models_library.users import UserID
-from ..director_v2 import api
+from ..director_v2 import director_v2_service
from ..storage.api import delete_data_folders_of_project
from ..users.api import FullNameDict
from ..users.exceptions import UserNotFoundError
-from ._access_rights_api import check_user_project_permission
-from .db import ProjectDBAPI
+from ._access_rights_service import check_user_project_permission
+from ._projects_repository_legacy import ProjectDBAPI
from .exceptions import (
ProjectDeleteError,
ProjectInvalidRightsError,
@@ -44,8 +44,7 @@ async def __call__(
*,
notify_users: bool = True,
user_name: FullNameDict | None = None,
- ) -> None:
- ...
+ ) -> None: ...
async def mark_project_as_deleted(
@@ -78,7 +77,7 @@ async def delete_project(
app: web.Application,
project_uuid: ProjectID,
user_id: UserID,
- simcore_user_agent,
+ simcore_user_agent: str,
remove_project_dynamic_services: RemoveProjectServicesCallable,
) -> None:
"""Stops dynamic services, deletes data and finally deletes project
@@ -111,7 +110,7 @@ async def delete_project(
# stops computational services
# - raises DirectorServiceError
- await api.delete_pipeline(app, user_id, project_uuid)
+ await director_v2_service.delete_pipeline(app, user_id, project_uuid)
# rm data from storage
await delete_data_folders_of_project(app, project_uuid, user_id)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py
index 43ba76b6673..45c0837ec03 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py
@@ -1,10 +1,13 @@
-""" Utils to implement READ operations (from cRud) on the project resource
+"""Utils to implement READ operations (from cRud) on the project resource
Read operations are list, get
"""
+from collections.abc import Coroutine
+from typing import Any
+
from aiohttp import web
from models_library.folders import FolderID, FolderQuery, FolderScope
from models_library.projects import ProjectID
@@ -15,59 +18,71 @@
from servicelib.utils import logged_gather
from simcore_postgres_database.models.projects import ProjectType
from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB
-from simcore_service_webserver.projects._projects_db import (
- batch_get_trashed_by_primary_gid,
-)
-from ..catalog.client import get_services_for_user_in_product
-from ..folders import _folders_repository as _folders_repository
+from ..catalog import catalog_service
+from ..folders import _folders_repository
from ..workspaces._workspaces_service import check_user_workspace_access
-from . import projects_service
-from ._permalink_api import update_or_pop_permalink_in_project
-from .db import ProjectDBAPI
+from . import _projects_service
+from ._projects_repository import batch_get_trashed_by_primary_gid
+from ._projects_repository_legacy import ProjectDBAPI
from .models import ProjectDict, ProjectTypeAPI
-async def _update_project_dict(
- request: web.Request,
- *,
- user_id: UserID,
- project: ProjectDict,
- is_template: bool,
-) -> ProjectDict:
- # state
- await projects_service.add_project_states_for_user(
- user_id=user_id,
- project=project,
- is_template=is_template,
- app=request.app,
- )
+def _batch_update(
+ key: str,
+ value_per_object: list[Any],
+ objects: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ for obj, value in zip(objects, value_per_object, strict=True):
+ obj[key] = value
+ return objects
- # permalink
- await update_or_pop_permalink_in_project(request, project)
- return project
+async def _paralell_update(*update_per_object: Coroutine) -> list[Any]:
+ return await logged_gather(
+ *update_per_object,
+ reraise=True,
+ max_concurrency=100,
+ )
-async def _batch_update_list_of_project_dict(
- app: web.Application, list_of_project_dict: list[ProjectDict]
+async def _aggregate_data_to_projects_from_other_sources(
+ app: web.Application,
+ *,
+ db_projects: list[ProjectDict],
+ db_project_types: list[ProjectTypeDB],
+ user_id: UserID,
) -> list[ProjectDict]:
-
- # updating `trashed_by_primary_gid`
+ """
+ Aggregates data to each project from other sources, first as a batch-update and then as a parallel-update.
+ """
+ # updating `project.trashed_by_primary_gid`
trashed_by_primary_gid_values = await batch_get_trashed_by_primary_gid(
- app, projects_uuids=[ProjectID(p["uuid"]) for p in list_of_project_dict]
+ app, projects_uuids=[ProjectID(p["uuid"]) for p in db_projects]
)
- for project_dict, value in zip(
- list_of_project_dict, trashed_by_primary_gid_values, strict=True
- ):
- project_dict.update(trashed_by_primary_gid=value)
+ _batch_update("trashed_by_primary_gid", trashed_by_primary_gid_values, db_projects)
+
+ # udpating `project.state`
+ update_state_per_project = [
+ _projects_service.add_project_states_for_user(
+ user_id=user_id,
+ project=prj,
+ is_template=prj_type == ProjectTypeDB.TEMPLATE,
+ app=app,
+ )
+ for prj, prj_type in zip(db_projects, db_project_types, strict=False)
+ ]
+
+ updated_projects: list[ProjectDict] = await _paralell_update(
+ *update_state_per_project,
+ )
- return list_of_project_dict
+ return updated_projects
async def list_projects( # pylint: disable=too-many-arguments
- request: web.Request,
+ app: web.Application,
user_id: UserID,
product_name: str,
*,
@@ -87,11 +102,12 @@ async def list_projects( # pylint: disable=too-many-arguments
# ordering
order_by: OrderBy,
) -> tuple[list[ProjectDict], int]:
- app = request.app
db = ProjectDBAPI.get_from_app_context(app)
- user_available_services: list[dict] = await get_services_for_user_in_product(
- app, user_id, product_name, only_key_versions=True
+ user_available_services: list[dict] = (
+ await catalog_service.get_services_for_user_in_product(
+ app, user_id, product_name, only_key_versions=True
+ )
)
workspace_is_private = True
@@ -145,27 +161,15 @@ async def list_projects( # pylint: disable=too-many-arguments
order_by=order_by,
)
- db_projects = await _batch_update_list_of_project_dict(app, db_projects)
-
- projects: list[ProjectDict] = await logged_gather(
- *(
- _update_project_dict(
- request,
- user_id=user_id,
- project=prj,
- is_template=prj_type == ProjectTypeDB.TEMPLATE,
- )
- for prj, prj_type in zip(db_projects, db_project_types, strict=False)
- ),
- reraise=True,
- max_concurrency=100,
+ projects = await _aggregate_data_to_projects_from_other_sources(
+ app, db_projects=db_projects, db_project_types=db_project_types, user_id=user_id
)
return projects, total_number_projects
async def list_projects_full_depth(
- request,
+ app: web.Application,
*,
user_id: UserID,
product_name: str,
@@ -180,10 +184,12 @@ async def list_projects_full_depth(
search_by_multi_columns: str | None,
search_by_project_name: str | None,
) -> tuple[list[ProjectDict], int]:
- db = ProjectDBAPI.get_from_app_context(request.app)
+ db = ProjectDBAPI.get_from_app_context(app)
- user_available_services: list[dict] = await get_services_for_user_in_product(
- request.app, user_id, product_name, only_key_versions=True
+ user_available_services: list[dict] = (
+ await catalog_service.get_services_for_user_in_product(
+ app, user_id, product_name, only_key_versions=True
+ )
)
db_projects, db_project_types, total_number_projects = await db.list_projects_dicts(
@@ -202,30 +208,8 @@ async def list_projects_full_depth(
order_by=order_by,
)
- db_projects = await _batch_update_list_of_project_dict(request.app, db_projects)
-
- projects: list[ProjectDict] = await logged_gather(
- *(
- _update_project_dict(
- request,
- user_id=user_id,
- project=prj,
- is_template=prj_type == ProjectTypeDB.TEMPLATE,
- )
- for prj, prj_type in zip(db_projects, db_project_types, strict=False)
- ),
- reraise=True,
- max_concurrency=100,
+ projects = await _aggregate_data_to_projects_from_other_sources(
+ app, db_projects=db_projects, db_project_types=db_project_types, user_id=user_id
)
return projects, total_number_projects
-
-
-async def get_project(
- request: web.Request,
- user_id: UserID,
- product_name: str,
- project_uuid: ProjectID,
- project_type: ProjectTypeAPI,
-):
- raise NotImplementedError
diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py b/services/web/server/src/simcore_service_webserver/projects/_folders_repository.py
similarity index 98%
rename from services/web/server/src/simcore_service_webserver/projects/_folders_db.py
rename to services/web/server/src/simcore_service_webserver/projects/_folders_repository.py
index d4fde1f5ce9..f9c4e689eac 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_folders_repository.py
@@ -1,9 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
-
import logging
from datetime import datetime
diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py b/services/web/server/src/simcore_service_webserver/projects/_folders_service.py
similarity index 66%
rename from services/web/server/src/simcore_service_webserver/projects/_folders_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_folders_service.py
index 7595f31d94d..88659d68ac5 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_folders_service.py
@@ -6,10 +6,10 @@
from models_library.projects import ProjectID
from models_library.users import UserID
-from ..folders import _folders_repository as _folders_repository
-from ..projects._access_rights_api import get_user_project_access_rights
-from . import _folders_db as project_to_folders_db
-from .db import APP_PROJECT_DBAPI, ProjectDBAPI
+from ..folders import _folders_repository as folders_folders_repository
+from . import _folders_repository
+from ._access_rights_service import get_user_project_access_rights
+from ._projects_repository_legacy import APP_PROJECT_DBAPI, ProjectDBAPI
from .exceptions import ProjectInvalidRightsError
_logger = logging.getLogger(__name__)
@@ -43,46 +43,48 @@ async def move_project_into_folder(
)
workspace_is_private = False
+ private_workspace_user_id_or_none: UserID | None = (
+ user_id if workspace_is_private else None
+ )
+
if folder_id:
# Check user has access to folder
- await _folders_repository.get_for_user_or_workspace(
+ await folders_folders_repository.get_for_user_or_workspace(
app,
folder_id=folder_id,
product_name=product_name,
- user_id=user_id if workspace_is_private else None,
+ user_id=private_workspace_user_id_or_none,
workspace_id=project_db.workspace_id,
)
# Move project to folder
- prj_to_folder_db = await project_to_folders_db.get_project_to_folder(
+ prj_to_folder_db = await _folders_repository.get_project_to_folder(
app,
project_id=project_id,
- private_workspace_user_id_or_none=user_id if workspace_is_private else None,
+ private_workspace_user_id_or_none=private_workspace_user_id_or_none,
)
if prj_to_folder_db is None:
if folder_id is None:
return
- await project_to_folders_db.insert_project_to_folder(
+ await _folders_repository.insert_project_to_folder(
app,
project_id=project_id,
folder_id=folder_id,
- private_workspace_user_id_or_none=user_id if workspace_is_private else None,
+ private_workspace_user_id_or_none=private_workspace_user_id_or_none,
)
else:
# Delete old
- await project_to_folders_db.delete_project_to_folder(
+ await _folders_repository.delete_project_to_folder(
app,
project_id=project_id,
folder_id=prj_to_folder_db.folder_id,
- private_workspace_user_id_or_none=user_id if workspace_is_private else None,
+ private_workspace_user_id_or_none=private_workspace_user_id_or_none,
)
# Create new
if folder_id is not None:
- await project_to_folders_db.insert_project_to_folder(
+ await _folders_repository.insert_project_to_folder(
app,
project_id=project_id,
folder_id=folder_id,
- private_workspace_user_id_or_none=(
- user_id if workspace_is_private else None
- ),
+ private_workspace_user_id_or_none=private_workspace_user_id_or_none,
)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_models.py b/services/web/server/src/simcore_service_webserver/projects/_groups_models.py
new file mode 100644
index 00000000000..a9d0828ed5b
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_groups_models.py
@@ -0,0 +1,18 @@
+import logging
+from datetime import datetime
+
+from models_library.groups import GroupID
+from pydantic import BaseModel, ConfigDict
+
+_logger = logging.getLogger(__name__)
+
+
+class ProjectGroupGetDB(BaseModel):
+ gid: GroupID
+ read: bool
+ write: bool
+ delete: bool
+ created: datetime
+ modified: datetime
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_db.py b/services/web/server/src/simcore_service_webserver/projects/_groups_repository.py
similarity index 93%
rename from services/web/server/src/simcore_service_webserver/projects/_groups_db.py
rename to services/web/server/src/simcore_service_webserver/projects/_groups_repository.py
index 86d9c83d781..00f7d467054 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_groups_db.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_groups_repository.py
@@ -1,16 +1,9 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
-
import logging
-from datetime import datetime
from aiohttp import web
from models_library.groups import GroupID
from models_library.projects import ProjectID
-from pydantic import BaseModel, ConfigDict, TypeAdapter
+from pydantic import TypeAdapter
from simcore_postgres_database.models.project_to_groups import project_to_groups
from simcore_postgres_database.utils_repos import transaction_context
from sqlalchemy import func, literal_column
@@ -19,26 +12,11 @@
from sqlalchemy.sql import select
from ..db.plugin import get_asyncpg_engine
+from ._groups_models import ProjectGroupGetDB
from .exceptions import ProjectGroupNotFoundError
_logger = logging.getLogger(__name__)
-### Models
-
-
-class ProjectGroupGetDB(BaseModel):
- gid: GroupID
- read: bool
- write: bool
- delete: bool
- created: datetime
- modified: datetime
-
- model_config = ConfigDict(from_attributes=True)
-
-
-## DB API
-
async def create_project_group(
app: web.Application,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py
similarity index 82%
rename from services/web/server/src/simcore_service_webserver/projects/_groups_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_groups_service.py
index 355b25481f6..af2d9161b10 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py
@@ -8,11 +8,11 @@
from models_library.users import UserID
from pydantic import BaseModel
-from ..users import api as users_api
-from . import _groups_db as projects_groups_db
-from ._access_rights_api import check_user_project_permission
-from ._groups_db import ProjectGroupGetDB
-from .db import APP_PROJECT_DBAPI, ProjectDBAPI
+from ..users import api as users_service
+from . import _groups_repository
+from ._access_rights_service import check_user_project_permission
+from ._groups_models import ProjectGroupGetDB
+from ._projects_repository_legacy import APP_PROJECT_DBAPI, ProjectDBAPI
from .exceptions import ProjectInvalidRightsError
_logger = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ async def create_project_group(
permission="write",
)
- project_group_db: ProjectGroupGetDB = await projects_groups_db.create_project_group(
+ project_group_db: ProjectGroupGetDB = await _groups_repository.create_project_group(
app=app,
project_id=project_id,
group_id=group_id,
@@ -76,9 +76,9 @@ async def list_project_groups_by_user_and_project(
permission="read",
)
- project_groups_db: list[
- ProjectGroupGetDB
- ] = await projects_groups_db.list_project_groups(app=app, project_id=project_id)
+ project_groups_db: list[ProjectGroupGetDB] = (
+ await _groups_repository.list_project_groups(app=app, project_id=project_id)
+ )
project_groups_api: list[ProjectGroupGet] = [
ProjectGroupGet.model_validate(group.model_dump())
@@ -109,9 +109,9 @@ async def replace_project_group(
project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI]
project = await project_db.get_project_db(project_id)
- project_owner_user: dict = await users_api.get_user(app, project.prj_owner)
+ project_owner_user: dict = await users_service.get_user(app, project.prj_owner)
if project_owner_user["primary_gid"] == group_id:
- user: dict = await users_api.get_user(app, user_id)
+ user: dict = await users_service.get_user(app, user_id)
if user["primary_gid"] != project_owner_user["primary_gid"]:
# Only the owner of the project can modify the owner group
raise ProjectInvalidRightsError(
@@ -121,7 +121,7 @@ async def replace_project_group(
)
project_group_db: ProjectGroupGetDB = (
- await projects_groups_db.replace_project_group(
+ await _groups_repository.replace_project_group(
app=app,
project_id=project_id,
group_id=group_id,
@@ -143,7 +143,7 @@ async def delete_project_group(
group_id: GroupID,
product_name: ProductName,
) -> None:
- user: dict = await users_api.get_user(app, user_id=user_id)
+ user: dict = await users_service.get_user(app, user_id=user_id)
if user["primary_gid"] != group_id:
await check_user_project_permission(
app,
@@ -155,7 +155,7 @@ async def delete_project_group(
project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI]
project = await project_db.get_project_db(project_id)
- project_owner_user: dict = await users_api.get_user(app, project.prj_owner)
+ project_owner_user: dict = await users_service.get_user(app, project.prj_owner)
if project_owner_user["primary_gid"] == group_id:
if user["primary_gid"] != project_owner_user["primary_gid"]:
# Only the owner of the project can delete the owner group
@@ -165,7 +165,7 @@ async def delete_project_group(
reason=f"User does not have access to modify owner project group in project {project_id}",
)
- await projects_groups_db.delete_project_group(
+ await _groups_repository.delete_project_group(
app=app, project_id=project_id, group_id=group_id
)
@@ -179,7 +179,7 @@ async def delete_project_group_without_checking_permissions(
project_id: ProjectID,
group_id: GroupID,
) -> None:
- await projects_groups_db.delete_project_group(
+ await _groups_repository.delete_project_group(
app=app, project_id=project_id, group_id=group_id
)
@@ -193,7 +193,7 @@ async def create_project_group_without_checking_permissions(
write: bool,
delete: bool,
) -> None:
- await projects_groups_db.update_or_insert_project_group(
+ await _groups_repository.update_or_insert_project_group(
app=app,
project_id=project_id,
group_id=group_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_db.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_repository.py
similarity index 100%
rename from services/web/server/src/simcore_service_webserver/projects/_metadata_db.py
rename to services/web/server/src/simcore_service_webserver/projects/_metadata_repository.py
diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_api.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_service.py
similarity index 85%
rename from services/web/server/src/simcore_service_webserver/projects/_metadata_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_metadata_service.py
index f17c7941a1d..6eb8662c841 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_metadata_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_metadata_service.py
@@ -9,8 +9,8 @@
from pydantic import TypeAdapter
from ..db.plugin import get_database_engine
-from . import _metadata_db
-from ._access_rights_api import validate_project_ownership
+from . import _metadata_repository
+from ._access_rights_service import validate_project_ownership
_logger = logging.getLogger(__name__)
@@ -20,7 +20,7 @@ async def get_project_custom_metadata(
) -> MetadataDict:
await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid)
- return await _metadata_db.get_project_custom_metadata(
+ return await _metadata_repository.get_project_custom_metadata(
engine=get_database_engine(app), project_uuid=project_uuid
)
@@ -33,7 +33,7 @@ async def set_project_custom_metadata(
) -> MetadataDict:
await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid)
- return await _metadata_db.set_project_custom_metadata(
+ return await _metadata_repository.set_project_custom_metadata(
engine=get_database_engine(app),
project_uuid=project_uuid,
custom_metadata=value,
@@ -48,7 +48,7 @@ async def _project_has_ancestors(
) -> bool:
await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid)
- return await _metadata_db.project_has_ancestors(
+ return await _metadata_repository.project_has_ancestors(
engine=get_database_engine(app), project_uuid=project_uuid
)
@@ -73,11 +73,11 @@ async def set_project_ancestors_from_custom_metadata(
return
# let's try to get the parent project UUID
- parent_project_uuid = await _metadata_db.get_project_id_from_node_id(
+ parent_project_uuid = await _metadata_repository.get_project_id_from_node_id(
get_database_engine(app), node_id=parent_node_id
)
- await _metadata_db.set_project_ancestors(
+ await _metadata_repository.set_project_ancestors(
get_database_engine(app),
project_uuid=project_uuid,
parent_project_uuid=parent_project_uuid,
@@ -94,7 +94,7 @@ async def set_project_ancestors(
) -> None:
await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid)
- await _metadata_db.set_project_ancestors(
+ await _metadata_repository.set_project_ancestors(
get_database_engine(app),
project_uuid=project_uuid,
parent_project_uuid=parent_project_uuid,
diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_repository.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_repository.py
new file mode 100644
index 00000000000..e5060360265
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_repository.py
@@ -0,0 +1,18 @@
+from aiohttp import web
+from models_library.projects import ProjectID
+from models_library.services_types import ServiceKey, ServiceVersion
+from simcore_postgres_database.utils_projects_nodes import ProjectNodesRepo
+
+from ..db.plugin import get_database_engine
+
+
+async def get_project_nodes_services(
+ app: web.Application, *, project_uuid: ProjectID
+) -> list[tuple[ServiceKey, ServiceVersion]]:
+ repo = ProjectNodesRepo(project_uuid=project_uuid)
+
+ async with get_database_engine(app).acquire() as conn:
+ nodes = await repo.list(conn)
+
+ # removes duplicates by preserving order
+ return list(dict.fromkeys((node.key, node.version) for node in nodes))
diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_service.py
similarity index 95%
rename from services/web/server/src/simcore_service_webserver/projects/_nodes_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_nodes_service.py
index 4815ae19d03..0206e1315cc 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_service.py
@@ -7,11 +7,12 @@
from aiohttp import web
from aiohttp.client import ClientError
-from models_library.api_schemas_storage import FileMetaDataGet
+from models_library.api_schemas_storage.storage_schemas import FileMetaDataGet
from models_library.basic_types import KeyIDStr
from models_library.projects import ProjectID
from models_library.projects_nodes import Node
from models_library.projects_nodes_io import NodeID, SimCoreFileLink
+from models_library.services_types import ServiceKey, ServiceVersion
from models_library.users import UserID
from pydantic import (
BaseModel,
@@ -26,6 +27,7 @@
from ..application_settings import get_application_settings
from ..storage.api import get_download_link, get_files_in_node_folder
+from . import _nodes_repository
from .exceptions import ProjectStartsTooManyDynamicNodesError
_logger = logging.getLogger(__name__)
@@ -71,6 +73,14 @@ def get_total_project_dynamic_nodes_creation_interval(
return max_nodes * _NODE_START_INTERVAL_S.total_seconds()
+async def get_project_nodes_services(
+ app: web.Application, *, project_uuid: ProjectID
+) -> list[tuple[ServiceKey, ServiceVersion]]:
+ return await _nodes_repository.get_project_nodes_services(
+ app, project_uuid=project_uuid
+ )
+
+
#
# PREVIEWS
#
diff --git a/services/web/server/src/simcore_service_webserver/projects/_permalink_api.py b/services/web/server/src/simcore_service_webserver/projects/_permalink_service.py
similarity index 58%
rename from services/web/server/src/simcore_service_webserver/projects/_permalink_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_permalink_service.py
index 241376ae76a..e6fa6e61a8b 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_permalink_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_permalink_service.py
@@ -1,7 +1,6 @@
import asyncio
import logging
-from collections.abc import Callable, Coroutine
-from typing import Any, cast
+from typing import Protocol, cast
from aiohttp import web
from models_library.api_schemas_webserver.permalinks import ProjectPermalink
@@ -13,21 +12,24 @@
_PROJECT_PERMALINK = f"{__name__}"
_logger = logging.getLogger(__name__)
-_CreateLinkCallable = Callable[
- [web.Request, ProjectID], Coroutine[Any, Any, ProjectPermalink]
-]
+class CreateLinkCoroutine(Protocol):
+ async def __call__(
+ self, request: web.Request, project_uuid: ProjectID
+ ) -> ProjectPermalink:
+ ...
-def register_factory(app: web.Application, factory_coro: _CreateLinkCallable):
+
+def register_factory(app: web.Application, factory_coro: CreateLinkCoroutine):
if _create := app.get(_PROJECT_PERMALINK):
msg = f"Permalink factory can only be set once: registered {_create}"
raise PermalinkFactoryError(msg)
app[_PROJECT_PERMALINK] = factory_coro
-def _get_factory(app: web.Application) -> _CreateLinkCallable:
+def _get_factory(app: web.Application) -> CreateLinkCoroutine:
if _create := app.get(_PROJECT_PERMALINK):
- return cast(_CreateLinkCallable, _create)
+ return cast(CreateLinkCoroutine, _create)
msg = "Undefined permalink factory. Check plugin initialization."
raise PermalinkFactoryError(msg)
@@ -37,17 +39,18 @@ def _get_factory(app: web.Application) -> _CreateLinkCallable:
async def _create_permalink(
- request: web.Request, project_id: ProjectID
+ request: web.Request, project_uuid: ProjectID
) -> ProjectPermalink:
- create = _get_factory(request.app)
+ create_coro: CreateLinkCoroutine = _get_factory(request.app)
try:
permalink: ProjectPermalink = await asyncio.wait_for(
- create(request, project_id), timeout=_PERMALINK_CREATE_TIMEOUT_S
+ create_coro(request=request, project_uuid=project_uuid),
+ timeout=_PERMALINK_CREATE_TIMEOUT_S,
)
return permalink
- except asyncio.TimeoutError as err:
- msg = f"Permalink factory callback '{create}' timed out after {_PERMALINK_CREATE_TIMEOUT_S} secs"
+ except TimeoutError as err:
+ msg = f"Permalink factory callback '{create_coro}' timed out after {_PERMALINK_CREATE_TIMEOUT_S} secs"
raise PermalinkFactoryError(msg) from err
@@ -61,7 +64,7 @@ async def update_or_pop_permalink_in_project(
If fails, it pops it from project (so it is not set in the pydantic model. SEE ProjectGet.permalink)
"""
try:
- permalink = await _create_permalink(request, project_id=project["uuid"])
+ permalink = await _create_permalink(request, project_uuid=project["uuid"])
assert permalink # nosec
project["permalink"] = permalink
@@ -74,4 +77,14 @@ async def update_or_pop_permalink_in_project(
return None
+async def aggregate_permalink_in_project(
+ request: web.Request, project: ProjectDict
+) -> ProjectDict:
+ """
+ Adapter to use in parallel aggregation of fields in a project dataset
+ """
+ await update_or_pop_permalink_in_project(request, project)
+ return project
+
+
__all__: tuple[str, ...] = ("ProjectPermalink",)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports_api.py b/services/web/server/src/simcore_service_webserver/projects/_ports_service.py
similarity index 99%
rename from services/web/server/src/simcore_service_webserver/projects/_ports_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_ports_service.py
index 9ae42c397c8..e00998744ba 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_ports_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_ports_service.py
@@ -26,7 +26,7 @@
from models_library.utils.services_io import JsonSchemaDict, get_service_io_json_schema
from pydantic import ConfigDict, ValidationError
-from ..director_v2.api import get_batch_tasks_outputs
+from ..director_v2.director_v2_service import get_batch_tasks_outputs
from .exceptions import InvalidInputValue
diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py
deleted file mode 100644
index 95af2e41284..00000000000
--- a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import logging
-
-import sqlalchemy as sa
-from aiohttp import web
-from models_library.groups import GroupID
-from models_library.projects import ProjectID
-from simcore_postgres_database.models.projects import projects
-from simcore_postgres_database.models.users import users
-from simcore_postgres_database.utils_repos import (
- get_columns_from_db_model,
- pass_or_acquire_connection,
- transaction_context,
-)
-from sqlalchemy import sql
-from sqlalchemy.ext.asyncio import AsyncConnection
-
-from ..db.plugin import get_asyncpg_engine
-from .exceptions import ProjectNotFoundError
-from .models import ProjectDB
-
-_logger = logging.getLogger(__name__)
-
-
-PROJECT_DB_COLS = get_columns_from_db_model( # noqa: RUF012
- # NOTE: MD: I intentionally didn't include the workbench. There is a special interface
- # for the workbench, and at some point, this column should be removed from the table.
- # The same holds true for access_rights/ui/classifiers/quality, but we have decided to proceed step by step.
- projects,
- ProjectDB,
-)
-
-
-async def patch_project(
- app: web.Application,
- connection: AsyncConnection | None = None,
- *,
- project_uuid: ProjectID,
- new_partial_project_data: dict,
-) -> ProjectDB:
-
- async with transaction_context(get_asyncpg_engine(app), connection) as conn:
- result = await conn.stream(
- projects.update()
- .values(last_change_date=sa.func.now(), **new_partial_project_data)
- .where(projects.c.uuid == f"{project_uuid}")
- .returning(*PROJECT_DB_COLS)
- )
- row = await result.first()
- if row is None:
- raise ProjectNotFoundError(project_uuid=project_uuid)
- return ProjectDB.model_validate(row)
-
-
-def _select_trashed_by_primary_gid_query() -> sql.Select:
- return sa.select(
- users.c.primary_gid.label("trashed_by_primary_gid"),
- ).select_from(projects.outerjoin(users, projects.c.trashed_by == users.c.id))
-
-
-async def get_trashed_by_primary_gid(
- app: web.Application,
- connection: AsyncConnection | None = None,
- *,
- projects_uuid: ProjectID,
-) -> GroupID | None:
- query = _select_trashed_by_primary_gid_query().where(
- projects.c.uuid == projects_uuid
- )
-
- async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
- result = await conn.execute(query)
- row = result.first()
- return row.trashed_by_primary_gid if row else None
-
-
-async def batch_get_trashed_by_primary_gid(
- app: web.Application,
- connection: AsyncConnection | None = None,
- *,
- projects_uuids: list[ProjectID],
-) -> list[GroupID | None]:
- """Batch version of get_trashed_by_primary_gid
-
- Returns:
- values of trashed_by_primary_gid in the SAME ORDER as projects_uuids
- """
- if not projects_uuids:
- return []
-
- projects_uuids_str = [f"{uuid}" for uuid in projects_uuids]
-
- query = (
- _select_trashed_by_primary_gid_query().where(
- projects.c.uuid.in_(projects_uuids_str)
- )
- ).order_by(
- # Preserves the order of folders_ids
- # SEE https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.case
- sa.case(
- {
- project_uuid: index
- for index, project_uuid in enumerate(projects_uuids_str)
- },
- value=projects.c.uuid,
- )
- )
- async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
- result = await conn.stream(query)
- return [row.trashed_by_primary_gid async for row in result]
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
new file mode 100644
index 00000000000..d3312bdc1eb
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py
@@ -0,0 +1,211 @@
+import logging
+from collections.abc import Callable
+from datetime import datetime
+from typing import cast
+
+import sqlalchemy as sa
+from aiohttp import web
+from common_library.exclude import UnSet, is_set
+from models_library.basic_types import IDStr
+from models_library.groups import GroupID
+from models_library.projects import ProjectID
+from models_library.rest_ordering import OrderBy, OrderDirection
+from models_library.rest_pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
+from pydantic import NonNegativeInt, PositiveInt
+from simcore_postgres_database.models.projects import projects
+from simcore_postgres_database.models.users import users
+from simcore_postgres_database.utils_repos import (
+ get_columns_from_db_model,
+ pass_or_acquire_connection,
+ transaction_context,
+)
+from sqlalchemy import sql
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from ..db.plugin import get_asyncpg_engine
+from .exceptions import ProjectNotFoundError
+from .models import ProjectDBGet
+
+_logger = logging.getLogger(__name__)
+
+
+PROJECT_DB_COLS = get_columns_from_db_model(
+ # NOTE: MD: I intentionally didn't include the workbench. There is a special interface
+ # for the workbench, and at some point, this column should be removed from the table.
+ # The same holds true for access_rights/ui/classifiers/quality, but we have decided to proceed step by step.
+ projects,
+ ProjectDBGet,
+)
+
+OLDEST_TRASHED_FIRST = OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC)
+
+
+def _to_sql_expression(table: sa.Table, order_by: OrderBy):
+ direction_func: Callable = {
+ OrderDirection.ASC: sql.asc,
+ OrderDirection.DESC: sql.desc,
+ }[order_by.direction]
+ return direction_func(table.columns[order_by.field])
+
+
+async def list_trashed_projects(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ # filter
+ trashed_explicitly: bool | UnSet = UnSet.VALUE,
+ trashed_before: datetime | UnSet = UnSet.VALUE,
+ # pagination
+ offset: NonNegativeInt = 0,
+ limit: PositiveInt = MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE,
+ # order
+ order_by: OrderBy = OLDEST_TRASHED_FIRST,
+) -> tuple[int, list[ProjectDBGet]]:
+
+ base_query = sql.select(*PROJECT_DB_COLS).where(projects.c.trashed.is_not(None))
+
+ if is_set(trashed_explicitly):
+ assert isinstance(trashed_explicitly, bool) # nosec
+ base_query = base_query.where(
+ projects.c.trashed_explicitly.is_(trashed_explicitly)
+ )
+
+ if is_set(trashed_before):
+ assert isinstance(trashed_before, datetime) # nosec
+ base_query = base_query.where(projects.c.trashed < trashed_before)
+
+ # Select total count from base_query
+ count_query = sql.select(sql.func.count()).select_from(base_query.subquery())
+
+ # Ordering and pagination
+ list_query = (
+ base_query.order_by(_to_sql_expression(projects, order_by))
+ .offset(offset)
+ .limit(limit)
+ )
+
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ total_count = await conn.scalar(count_query)
+
+ result = await conn.stream(list_query)
+ projects_list: list[ProjectDBGet] = [
+ ProjectDBGet.model_validate(row) async for row in result
+ ]
+ return cast(int, total_count), projects_list
+
+
+async def get_project(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ project_uuid: ProjectID,
+) -> ProjectDBGet:
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ query = sql.select(*PROJECT_DB_COLS).where(projects.c.uuid == f"{project_uuid}")
+ result = await conn.execute(query)
+ row = result.one_or_none()
+ if row is None:
+ raise ProjectNotFoundError(project_uuid=project_uuid)
+ return ProjectDBGet.model_validate(row)
+
+
+def _select_trashed_by_primary_gid_query() -> sql.Select:
+ return sql.select(
+ projects.c.uuid,
+ users.c.primary_gid.label("trashed_by_primary_gid"),
+ ).select_from(projects.outerjoin(users, projects.c.trashed_by == users.c.id))
+
+
+async def get_trashed_by_primary_gid(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ projects_uuid: ProjectID,
+) -> GroupID | None:
+ query = _select_trashed_by_primary_gid_query().where(
+ projects.c.uuid == f"{projects_uuid}"
+ )
+
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.execute(query)
+ row = result.one_or_none()
+ return row.trashed_by_primary_gid if row else None
+
+
+async def batch_get_trashed_by_primary_gid(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ projects_uuids: list[ProjectID],
+) -> list[GroupID | None]:
+ """Batch version of get_trashed_by_primary_gid
+
+ Returns:
+ values of trashed_by_primary_gid in the SAME ORDER as projects_uuids
+ """
+ if not projects_uuids:
+ return []
+
+ projects_uuids_str = [f"{uuid}" for uuid in projects_uuids]
+
+ query = (
+ _select_trashed_by_primary_gid_query().where(
+ projects.c.uuid.in_(projects_uuids_str)
+ )
+ ).order_by(
+ # Preserves the order of folders_ids
+ # SEE https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.case
+ sql.case(
+ {
+ project_uuid: index
+ for index, project_uuid in enumerate(projects_uuids_str)
+ },
+ value=projects.c.uuid,
+ )
+ )
+ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(query)
+ rows = {row.uuid: row.trashed_by_primary_gid async for row in result}
+
+ return [rows.get(project_uuid) for project_uuid in projects_uuids_str]
+
+
+async def patch_project(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ project_uuid: ProjectID,
+ new_partial_project_data: dict,
+) -> ProjectDBGet:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(
+ projects.update()
+ .values(
+ **new_partial_project_data,
+ last_change_date=sql.func.now(),
+ )
+ .where(projects.c.uuid == f"{project_uuid}")
+ .returning(*PROJECT_DB_COLS)
+ )
+ row = await result.one_or_none()
+ if row is None:
+ raise ProjectNotFoundError(project_uuid=project_uuid)
+ return ProjectDBGet.model_validate(row)
+
+
+async def delete_project(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ project_uuid: ProjectID,
+) -> ProjectDBGet:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ result = await conn.stream(
+ projects.delete()
+ .where(projects.c.uuid == f"{project_uuid}")
+ .returning(*PROJECT_DB_COLS)
+ )
+ row = await result.one_or_none()
+ if row is None:
+ raise ProjectNotFoundError(project_uuid=project_uuid)
+ return ProjectDBGet.model_validate(row)
diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py
similarity index 98%
rename from services/web/server/src/simcore_service_webserver/projects/db.py
rename to services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py
index 66f285fcfa8..e322c947a1a 100644
--- a/services/web/server/src/simcore_service_webserver/projects/db.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py
@@ -1,7 +1,7 @@
-""" Database API
+"""Database API
- - Adds a layer to the postgres API with a focus on the projects data
- - Shall be used as entry point for all the queries to the database regarding projects
+- Adds a layer to the postgres API with a focus on the projects data
+- Shall be used as entry point for all the queries to the database regarding projects
"""
@@ -36,7 +36,7 @@
from pydantic.types import PositiveInt
from servicelib.aiohttp.application_keys import APP_AIOPG_ENGINE_KEY
from servicelib.logging_utils import get_log_record_extra, log_context
-from simcore_postgres_database.errors import UniqueViolation
+from simcore_postgres_database.aiopg_errors import UniqueViolation
from simcore_postgres_database.models.groups import user_to_groups
from simcore_postgres_database.models.project_to_groups import project_to_groups
from simcore_postgres_database.models.projects_nodes import projects_nodes
@@ -66,7 +66,7 @@
from tenacity.retry import retry_if_exception_type
from ..utils import now_str
-from ._comments_db import (
+from ._comments_repository import (
create_project_comment,
delete_project_comment,
get_project_comment,
@@ -74,7 +74,8 @@
total_project_comments,
update_project_comment,
)
-from ._db_utils import (
+from ._projects_repository import PROJECT_DB_COLS
+from ._projects_repository_legacy_utils import (
ANY_USER_ID_SENTINEL,
BaseProjectDB,
ProjectAccessRights,
@@ -85,7 +86,6 @@
patch_workbench,
update_workbench,
)
-from ._projects_db import PROJECT_DB_COLS
from .exceptions import (
ProjectDeleteError,
ProjectInvalidRightsError,
@@ -93,10 +93,10 @@
ProjectNotFoundError,
)
from .models import (
- ProjectDB,
+ ProjectDBGet,
ProjectDict,
UserProjectAccessRightsDB,
- UserSpecificProjectDataDB,
+ UserSpecificProjectDataDBGet,
)
_logger = logging.getLogger(__name__)
@@ -307,7 +307,6 @@ async def insert_project(
# All non-default in projects table
insert_values.setdefault("name", "New Study")
insert_values.setdefault("workbench", {})
-
insert_values.setdefault("workspace_id", None)
# must be valid uuid
@@ -765,7 +764,7 @@ async def get_project_dict_and_type(
project_type,
)
- async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB:
+ async def get_project_db(self, project_uuid: ProjectID) -> ProjectDBGet:
async with self.engine.acquire() as conn:
result = await conn.execute(
sa.select(
@@ -776,11 +775,11 @@ async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB:
row = await result.fetchone()
if row is None:
raise ProjectNotFoundError(project_uuid=project_uuid)
- return ProjectDB.model_validate(row)
+ return ProjectDBGet.model_validate(row)
async def get_user_specific_project_data_db(
self, project_uuid: ProjectID, private_workspace_user_id_or_none: UserID | None
- ) -> UserSpecificProjectDataDB:
+ ) -> UserSpecificProjectDataDBGet:
async with self.engine.acquire() as conn:
result = await conn.execute(
sa.select(
@@ -805,7 +804,7 @@ async def get_user_specific_project_data_db(
row = await result.fetchone()
if row is None:
raise ProjectNotFoundError(project_uuid=project_uuid)
- return UserSpecificProjectDataDB.model_validate(row)
+ return UserSpecificProjectDataDBGet.model_validate(row)
async def get_pure_project_access_rights_without_workspace(
self, user_id: UserID, project_uuid: ProjectID
diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy_utils.py
similarity index 99%
rename from services/web/server/src/simcore_service_webserver/projects/_db_utils.py
rename to services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy_utils.py
index ecc88a5a59e..c92c5910d06 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy_utils.py
@@ -24,7 +24,7 @@
from ..db.models import GroupType, groups, projects_tags, user_to_groups, users
from ..users.exceptions import UserNotFoundError
from ..utils import format_datetime
-from ._projects_db import PROJECT_DB_COLS
+from ._projects_repository import PROJECT_DB_COLS
from .exceptions import (
NodeNotFoundError,
ProjectInvalidRightsError,
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
similarity index 93%
rename from services/web/server/src/simcore_service_webserver/projects/projects_service.py
rename to services/web/server/src/simcore_service_webserver/projects/_projects_service.py
index 112655b6d03..f079b8fb5f6 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
@@ -94,11 +94,10 @@
from simcore_postgres_database.webserver_models import ProjectType
from ..application_settings import get_application_settings
-from ..catalog import client as catalog_client
-from ..director_v2 import api as director_v2_api
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..products import api as products_api
-from ..products.api import get_product_name
+from ..catalog import catalog_service
+from ..director_v2 import director_v2_service
+from ..dynamic_scheduler import api as dynamic_scheduler_service
+from ..products import products_web
from ..rabbitmq import get_rabbitmq_rpc_client
from ..redis import get_redis_lock_manager_client_sdk
from ..resource_manager.user_sessions import (
@@ -113,7 +112,7 @@
send_message_to_standard_group,
send_message_to_user,
)
-from ..storage import api as storage_api
+from ..storage import api as storage_service
from ..users.api import FullNameDict, get_user, get_user_fullname, get_user_role
from ..users.exceptions import UserNotFoundError
from ..users.preferences_api import (
@@ -121,23 +120,23 @@
UserDefaultWalletNotFoundError,
get_frontend_user_preference,
)
-from ..wallets import api as wallets_api
+from ..wallets import api as wallets_service
from ..wallets.errors import WalletNotEnoughCreditsError
-from ..workspaces import _workspaces_repository as workspaces_db
+from ..workspaces import _workspaces_repository as workspaces_workspaces_repository
from . import (
_crud_api_delete,
- _nodes_api,
- _projects_db,
+ _nodes_service,
_projects_nodes_repository,
- _wallets_api,
+ _projects_repository,
+ _wallets_service,
)
-from ._access_rights_api import (
+from ._access_rights_service import (
check_user_project_permission,
has_user_project_access_rights,
)
-from ._db_utils import PermissionStr
from ._nodes_utils import set_reservation_same_as_limit, validate_new_service_resources
-from .db import APP_PROJECT_DBAPI, ProjectDBAPI
+from ._projects_repository_legacy import APP_PROJECT_DBAPI, ProjectDBAPI
+from ._projects_repository_legacy_utils import PermissionStr
from .exceptions import (
ClustersKeeperNotAvailableError,
DefaultPricingUnitNotFoundError,
@@ -211,7 +210,10 @@ async def get_project_for_user(
# adds state if it is not a template
if include_state:
project = await add_project_states_for_user(
- user_id, project, project_type is ProjectType.TEMPLATE, app
+ user_id=user_id,
+ project=project,
+ is_template=project_type is ProjectType.TEMPLATE,
+ app=app,
)
# adds `trashed_by_primary_gid`
@@ -220,14 +222,14 @@ async def get_project_for_user(
and project.get("trashed_by", project.get("trashedBy")) is not None
):
project.update(
- trashedByPrimaryGid=await _projects_db.get_trashed_by_primary_gid(
+ trashedByPrimaryGid=await _projects_repository.get_trashed_by_primary_gid(
app, projects_uuid=project["uuid"]
)
)
if project["workspaceId"] is not None:
workspace: UserWorkspaceWithAccessRights = (
- await workspaces_db.get_workspace_for_user(
+ await workspaces_workspaces_repository.get_workspace_for_user(
app=app,
user_id=user_id,
workspace_id=project["workspaceId"],
@@ -306,7 +308,7 @@ async def patch_project(
raise ProjectOwnerNotFoundInTheProjectAccessRightsError
# 4. Patch the project
- await _projects_db.patch_project(
+ await _projects_repository.patch_project(
app=app,
project_uuid=project_uuid,
new_partial_project_data=patch_project_data,
@@ -318,6 +320,33 @@ async def patch_project(
#
+async def delete_project_by_user(
+ app: web.Application,
+ *,
+ project_uuid: ProjectID,
+ user_id: UserID,
+ simcore_user_agent: str = UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
+ wait_until_completed: bool = True,
+) -> None:
+ task = await submit_delete_project_task(
+ app,
+ project_uuid=project_uuid,
+ user_id=user_id,
+ simcore_user_agent=simcore_user_agent,
+ )
+ if wait_until_completed:
+ await task
+
+
+def get_delete_project_task(
+ project_uuid: ProjectID, user_id: UserID
+) -> asyncio.Task | None:
+ if tasks := _crud_api_delete.get_scheduled_tasks(project_uuid, user_id):
+ assert len(tasks) == 1, f"{tasks=}" # nosec
+ return tasks[0]
+ return None
+
+
async def submit_delete_project_task(
app: web.Application,
project_uuid: ProjectID,
@@ -353,15 +382,6 @@ async def submit_delete_project_task(
return task
-def get_delete_project_task(
- project_uuid: ProjectID, user_id: UserID
-) -> asyncio.Task | None:
- if tasks := _crud_api_delete.get_scheduled_tasks(project_uuid, user_id):
- assert len(tasks) == 1, f"{tasks=}" # nosec
- return tasks[0]
- return None
-
-
#
# PROJECT NODES -----------------------------------------------------
#
@@ -395,9 +415,9 @@ async def _get_default_pricing_and_hardware_info(
)
-_MACHINE_TOTAL_RAM_SAFE_MARGIN_RATIO: Final[
- float
-] = 0.1 # NOTE: machines always have less available RAM than advertised
+_MACHINE_TOTAL_RAM_SAFE_MARGIN_RATIO: Final[float] = (
+ 0.1 # NOTE: machines always have less available RAM than advertised
+)
_SIDECARS_OPS_SAFE_RAM_MARGIN: Final[ByteSize] = TypeAdapter(ByteSize).validate_python(
"1GiB"
)
@@ -420,11 +440,11 @@ async def update_project_node_resources_from_hardware_info(
return
try:
rabbitmq_rpc_client = get_rabbitmq_rpc_client(app)
- unordered_list_ec2_instance_types: list[
- EC2InstanceTypeGet
- ] = await get_instance_type_details(
- rabbitmq_rpc_client,
- instance_type_names=set(hardware_info.aws_ec2_instances),
+ unordered_list_ec2_instance_types: list[EC2InstanceTypeGet] = (
+ await get_instance_type_details(
+ rabbitmq_rpc_client,
+ instance_type_names=set(hardware_info.aws_ec2_instances),
+ )
)
assert unordered_list_ec2_instance_types # nosec
@@ -612,19 +632,19 @@ async def _start_dynamic_service( # noqa: C901
@exclusive(
get_redis_lock_manager_client_sdk(request.app),
- lock_key=_nodes_api.get_service_start_lock_key(user_id, project_uuid),
+ lock_key=_nodes_service.get_service_start_lock_key(user_id, project_uuid),
blocking=True,
blocking_timeout=datetime.timedelta(
- seconds=_nodes_api.get_total_project_dynamic_nodes_creation_interval(
+ seconds=_nodes_service.get_total_project_dynamic_nodes_creation_interval(
get_plugin_settings(request.app).PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES
)
),
)
async def _() -> None:
- project_running_nodes = await dynamic_scheduler_api.list_dynamic_services(
+ project_running_nodes = await dynamic_scheduler_service.list_dynamic_services(
request.app, user_id=user_id, project_id=project_uuid
)
- _nodes_api.check_num_service_per_projects_limit(
+ _nodes_service.check_num_service_per_projects_limit(
app=request.app,
number_of_services=len(project_running_nodes),
user_id=user_id,
@@ -633,14 +653,14 @@ async def _() -> None:
# Get wallet/pricing/hardware information
wallet_info, pricing_info, hardware_info = None, None, None
- product = products_api.get_current_product(request)
+ product = products_web.get_current_product(request)
app_settings = get_application_settings(request.app)
if (
product.is_payment_enabled
and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED
):
# Deal with Wallet
- project_wallet = await _wallets_api.get_project_wallet(
+ project_wallet = await _wallets_service.get_project_wallet(
request.app, project_id=project_uuid
)
if project_wallet is None:
@@ -655,7 +675,7 @@ async def _() -> None:
project_wallet_id = TypeAdapter(WalletID).validate_python(
user_default_wallet_preference.value
)
- await _wallets_api.connect_wallet_to_project(
+ await _wallets_service.connect_wallet_to_project(
request.app,
product_name=product_name,
project_id=project_uuid,
@@ -665,13 +685,11 @@ async def _() -> None:
else:
project_wallet_id = project_wallet.wallet_id
# Check whether user has access to the wallet
- wallet = (
- await wallets_api.get_wallet_with_available_credits_by_user_and_wallet(
- request.app,
- user_id=user_id,
- wallet_id=project_wallet_id,
- product_name=product_name,
- )
+ wallet = await wallets_service.get_wallet_with_available_credits_by_user_and_wallet(
+ request.app,
+ user_id=user_id,
+ wallet_id=project_wallet_id,
+ product_name=product_name,
)
wallet_info = WalletInfo(
wallet_id=project_wallet_id,
@@ -746,7 +764,7 @@ async def _() -> None:
service_key=service_key,
service_version=service_version,
)
- await dynamic_scheduler_api.run_dynamic_service(
+ await dynamic_scheduler_service.run_dynamic_service(
app=request.app,
dynamic_service_start=DynamicServiceStart(
product_name=product_name,
@@ -800,7 +818,7 @@ async def add_project_node(
)
node_uuid = NodeID(service_id if service_id else f"{uuid4()}")
- default_resources = await catalog_client.get_service_resources(
+ default_resources = await catalog_service.get_service_resources(
request.app, user_id, service_key, service_version
)
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(request.app)
@@ -827,10 +845,10 @@ async def add_project_node(
# also ensure the project is updated by director-v2 since services
# are due to access comp_tasks at some point see [https://github.com/ITISFoundation/osparc-simcore/issues/3216]
- await director_v2_api.create_or_update_pipeline(
+ await director_v2_service.create_or_update_pipeline(
request.app, user_id, project["uuid"], product_name
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=ProjectID(project["uuid"])
)
@@ -885,7 +903,7 @@ async def _remove_service_and_its_data_folders(
) -> None:
if stop_service:
# no need to save the state of the node when deleting it
- await dynamic_scheduler_api.stop_dynamic_service(
+ await dynamic_scheduler_service.stop_dynamic_service(
app,
dynamic_service_stop=DynamicServiceStop(
user_id=user_id,
@@ -897,7 +915,7 @@ async def _remove_service_and_its_data_folders(
)
# remove the node's data if any
- await storage_api.delete_data_folders_of_project_node(
+ await storage_service.delete_data_folders_of_project_node(
app, f"{project_uuid}", node_uuid, user_id
)
@@ -921,10 +939,12 @@ async def delete_project_node(
permission="write",
)
- list_running_dynamic_services = await dynamic_scheduler_api.list_dynamic_services(
- request.app,
- user_id=user_id,
- project_id=project_uuid,
+ list_running_dynamic_services = (
+ await dynamic_scheduler_service.list_dynamic_services(
+ request.app,
+ user_id=user_id,
+ project_id=project_uuid,
+ )
)
fire_and_forget_task(
@@ -949,11 +969,11 @@ async def delete_project_node(
assert db # nosec
await db.remove_project_node(user_id, project_uuid, NodeID(node_uuid))
# also ensure the project is updated by director-v2 since services
- product_name = get_product_name(request)
- await director_v2_api.create_or_update_pipeline(
+ product_name = products_web.get_product_name(request)
+ await director_v2_service.create_or_update_pipeline(
request.app, user_id, project_uuid, product_name
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=project_uuid
)
@@ -1080,11 +1100,13 @@ async def patch_project_node(
)
# 4. Make calls to director-v2 to keep data in sync (ex. comp_tasks DB table)
- await director_v2_api.create_or_update_pipeline(
+ await director_v2_service.create_or_update_pipeline(
app, user_id, project_id, product_name=product_name
)
if _node_patch_exclude_unset.get("label"):
- await dynamic_scheduler_api.update_projects_networks(app, project_id=project_id)
+ await dynamic_scheduler_service.update_projects_networks(
+ app, project_id=project_id
+ )
# 5. Updates project states for user, if inputs/outputs have been changed
if {"inputs", "outputs"} & _node_patch_exclude_unset.keys():
@@ -1188,7 +1210,7 @@ async def _safe_retrieve(
app: web.Application, node_id: NodeID, port_keys: list[str]
) -> None:
try:
- await dynamic_scheduler_api.retrieve_inputs(app, node_id, port_keys)
+ await dynamic_scheduler_service.retrieve_inputs(app, node_id, port_keys)
except RPCServerError as exc:
log.warning(
"Unable to call :retrieve endpoint on service %s, keys: [%s]: error: [%s]",
@@ -1338,7 +1360,7 @@ async def _open_project() -> bool:
for uuid in await user_session.find_all_resources_of_user(
PROJECT_ID_KEY
)
- if uuid != project_uuid
+ if uuid != f"{project_uuid}"
}
)
>= max_number_of_studies_per_user
@@ -1349,10 +1371,10 @@ async def _open_project() -> bool:
# Assign project_id to current_session
current_session: UserSessionID = user_session.get_id()
- sessions_with_project: list[
- UserSessionID
- ] = await user_session.find_users_of_resource(
- app, PROJECT_ID_KEY, f"{project_uuid}"
+ sessions_with_project: list[UserSessionID] = (
+ await user_session.find_users_of_resource(
+ app, PROJECT_ID_KEY, f"{project_uuid}"
+ )
)
if not sessions_with_project:
# no one has the project so we assign it
@@ -1401,10 +1423,10 @@ async def try_close_project_for_user(
):
with managed_resource(user_id, client_session_id, app) as user_session:
current_session: UserSessionID = user_session.get_id()
- all_sessions_with_project: list[
- UserSessionID
- ] = await user_session.find_users_of_resource(
- app, key=PROJECT_ID_KEY, value=project_uuid
+ all_sessions_with_project: list[UserSessionID] = (
+ await user_session.find_users_of_resource(
+ app, key=PROJECT_ID_KEY, value=project_uuid
+ )
)
# first check whether other sessions registered this project
@@ -1531,11 +1553,10 @@ async def get_project_states_for_user(
user_id: int, project_uuid: str, app: web.Application
) -> ProjectState:
# for templates: the project is never locked and never opened. also the running state is always unknown
- lock_state = ProjectLocked(value=False, status=ProjectStatus.CLOSED)
running_state = RunningState.UNKNOWN
lock_state, computation_task = await logged_gather(
_get_project_lock_state(user_id, project_uuid, app),
- director_v2_api.get_computation_task(app, user_id, UUID(project_uuid)),
+ director_v2_service.get_computation_task(app, user_id, UUID(project_uuid)),
)
if computation_task:
# get the running state
@@ -1547,6 +1568,7 @@ async def get_project_states_for_user(
async def add_project_states_for_user(
+ *,
user_id: int,
project: ProjectDict,
is_template: bool,
@@ -1562,7 +1584,7 @@ async def add_project_states_for_user(
running_state = RunningState.UNKNOWN
if not is_template and (
- computation_task := await director_v2_api.get_computation_task(
+ computation_task := await director_v2_service.get_computation_task(
app, user_id, project["uuid"]
)
):
@@ -1599,7 +1621,7 @@ async def is_service_deprecated(
service_version: str,
product_name: str,
) -> bool:
- service = await catalog_client.get_service(
+ service = await catalog_service.get_service(
app, user_id, service_key, service_version, product_name
)
if deprecation_date := service.get("deprecated"):
@@ -1648,7 +1670,7 @@ async def get_project_node_resources(
)
if not node_resources:
# get default resources
- node_resources = await catalog_client.get_service_resources(
+ node_resources = await catalog_service.get_service_resources(
app, user_id, service_key, service_version
)
return node_resources
@@ -1679,7 +1701,7 @@ async def update_project_node_resources(
if not current_resources:
# NOTE: this can happen after the migration
# get default resources
- current_resources = await catalog_client.get_service_resources(
+ current_resources = await catalog_service.get_service_resources(
app, user_id, service_key, service_version
)
@@ -1718,7 +1740,7 @@ async def run_project_dynamic_services(
project_settings: ProjectsSettings = get_plugin_settings(request.app)
running_services_uuids: list[NodeIDStr] = [
f"{d.node_uuid}"
- for d in await dynamic_scheduler_api.list_dynamic_services(
+ for d in await dynamic_scheduler_service.list_dynamic_services(
request.app, user_id=user_id, project_id=ProjectID(project["uuid"])
)
]
@@ -1834,7 +1856,7 @@ async def _locked_stop_dynamic_serivces_in_project() -> None:
ServiceWasNotFoundError,
):
# here RPC exceptions are suppressed. in case the service is not found to preserve old behavior
- await dynamic_scheduler_api.stop_dynamic_services_in_project(
+ await dynamic_scheduler_service.stop_dynamic_services_in_project(
app=app,
user_id=user_id,
project_id=project_uuid,
@@ -1925,7 +1947,7 @@ async def get_project_inactivity(
app: web.Application, project_id: ProjectID
) -> GetProjectInactivityResponse:
project_settings: ProjectsSettings = get_plugin_settings(app)
- return await dynamic_scheduler_api.get_project_inactivity(
+ return await dynamic_scheduler_service.get_project_inactivity(
app,
project_id=project_id,
# NOTE: project is considered inactive if all services exposing an /inactivity
diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service_delete.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service_delete.py
new file mode 100644
index 00000000000..bd8d57886e4
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service_delete.py
@@ -0,0 +1,106 @@
+import asyncio
+import logging
+import time
+from contextlib import contextmanager
+from typing import Any, Protocol
+
+from aiohttp import web
+from models_library.projects import ProjectID
+from models_library.users import UserID
+from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
+from servicelib.redis._errors import ProjectLockError
+
+from ..director_v2 import director_v2_service
+from . import _projects_repository, _projects_service
+from .exceptions import ProjectDeleteError, ProjectNotFoundError
+
+_logger = logging.getLogger(__name__)
+
+
+@contextmanager
+def _monitor_step(steps: dict[str, Any], *, name: str, elapsed: bool = False):
+ # util
+ start_time = time.perf_counter()
+ steps[name] = {"status": "starting"}
+ try:
+ yield
+ except Exception as exc:
+ steps[name]["status"] = "raised"
+ steps[name]["exception"] = f"{exc.__class__.__name__}:{exc}"
+ raise
+ else:
+ steps[name]["status"] = "success"
+ finally:
+ if elapsed:
+ steps[name]["elapsed"] = time.perf_counter() - start_time
+
+
+class StopServicesCallback(Protocol):
+ async def __call__(self, app: web.Application, project_uuid: ProjectID) -> None: ...
+
+
+async def batch_stop_services_in_project(
+ app: web.Application, *, user_id: UserID, project_uuid: ProjectID
+) -> None:
+ await asyncio.gather(
+ director_v2_service.stop_pipeline(
+ app, user_id=user_id, project_id=project_uuid
+ ),
+ _projects_service.remove_project_dynamic_services(
+ user_id=user_id,
+ project_uuid=f"{project_uuid}",
+ app=app,
+ simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
+ notify_users=False,
+ ),
+ )
+
+
+async def delete_project_as_admin(
+ app: web.Application,
+ *,
+ project_uuid: ProjectID,
+):
+
+ state: dict[str, Any] = {}
+
+ try:
+ # 1. hide
+ with _monitor_step(state, name="hide"):
+ project = await _projects_repository.patch_project(
+ app,
+ project_uuid=project_uuid,
+ new_partial_project_data={"hidden": True},
+ )
+
+ # 2. stop
+ with _monitor_step(state, name="stop", elapsed=True):
+ # NOTE: this callback could take long or raise whatever!
+ await batch_stop_services_in_project(
+ app, user_id=project.prj_owner, project_uuid=project_uuid
+ )
+
+ # 3. delete
+ with _monitor_step(state, name="delete"):
+ await _projects_repository.delete_project(app, project_uuid=project_uuid)
+
+ except ProjectNotFoundError as err:
+ _logger.debug(
+ "Project %s being deleted is already gone. IGNORING error. Details: %s",
+ project_uuid,
+ err,
+ )
+
+ except ProjectLockError as err:
+ raise ProjectDeleteError(
+ project_uuid=project_uuid,
+ reason=f"Cannot delete project {project_uuid} because it is currently in use. Details: {err}",
+ state=state,
+ ) from err
+
+ except Exception as err:
+ raise ProjectDeleteError(
+ project_uuid=project_uuid,
+ reason=f"Unexpected error. Deletion sequence: {state=}",
+ state=state,
+ ) from err
diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_access.py b/services/web/server/src/simcore_service_webserver/projects/_security_service.py
similarity index 78%
rename from services/web/server/src/simcore_service_webserver/projects/_projects_access.py
rename to services/web/server/src/simcore_service_webserver/projects/_security_service.py
index c0054dc5c3f..f3c176b4de8 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_projects_access.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_security_service.py
@@ -2,12 +2,12 @@
from aiohttp import web
from simcore_postgres_database.models.users import UserRole
-from ..projects.api import check_user_project_permission
from ..security.api import get_access_model
-from .db import ProjectDBAPI
+from ._projects_repository_legacy import ProjectDBAPI
+from .api import check_user_project_permission
-async def can_update_node_inputs(context):
+async def _can_update_node_inputs(context):
"""Check function associated to "project.workbench.node.inputs.update" permission label
Returns True if user has permission to update inputs
@@ -34,17 +34,13 @@ async def can_update_node_inputs(context):
diffs = jsondiff.diff(current_project, updated_project)
- # TODO: depends on schema. Shall change if schema changes!?
if "workbench" in diffs:
try:
for node in diffs["workbench"]:
# can ONLY modify `inputs` fields set as ReadAndWrite
access = current_project["workbench"][node]["inputAccess"]
inputs = diffs["workbench"][node]["inputs"]
- for key in inputs:
- if access.get(key) != "ReadAndWrite":
- return False
- return True
+ return all(access.get(key) == "ReadAndWrite" for key in inputs)
except KeyError:
pass
return False
@@ -58,7 +54,6 @@ def setup_projects_access(app: web.Application):
"""
hrba = get_access_model(app)
- # TODO: add here also named permissions, i.e. all project.* operations
hrba.roles[UserRole.GUEST].check[
"project.workbench.node.inputs.update"
- ] = can_update_node_inputs
+ ] = _can_update_node_inputs
diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_api.py b/services/web/server/src/simcore_service_webserver/projects/_tags_service.py
similarity index 84%
rename from services/web/server/src/simcore_service_webserver/projects/_tags_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_tags_service.py
index 93bf232706b..d7f1af590a2 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_tags_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_tags_service.py
@@ -1,6 +1,4 @@
-""" Handlers for CRUD operations on /projects/{*}/tags/{*}
-
-"""
+"""Handlers for CRUD operations on /projects/{*}/tags/{*}"""
import logging
@@ -9,9 +7,9 @@
from models_library.users import UserID
from models_library.workspaces import UserWorkspaceWithAccessRights
-from ..workspaces import _workspaces_repository as workspaces_db
-from ._access_rights_api import check_user_project_permission
-from .db import ProjectDBAPI
+from ..workspaces import _workspaces_repository as workspaces_workspaces_repository
+from ._access_rights_service import check_user_project_permission
+from ._projects_repository_legacy import ProjectDBAPI
from .models import ProjectDict
_logger = logging.getLogger(__name__)
@@ -37,7 +35,7 @@ async def add_tag(
if project["workspaceId"] is not None:
workspace: UserWorkspaceWithAccessRights = (
- await workspaces_db.get_workspace_for_user(
+ await workspaces_workspaces_repository.get_workspace_for_user(
app=app,
user_id=user_id,
workspace_id=project["workspaceId"],
@@ -71,7 +69,7 @@ async def remove_tag(
if project["workspaceId"] is not None:
workspace: UserWorkspaceWithAccessRights = (
- await workspaces_db.get_workspace_for_user(
+ await workspaces_workspaces_repository.get_workspace_for_user(
app=app,
user_id=user_id,
workspace_id=project["workspaceId"],
diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py
index cc6be0de495..a70a52937bb 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py
@@ -1,21 +1,31 @@
-import asyncio
import logging
+from datetime import datetime
import arrow
from aiohttp import web
+from common_library.pagination_tools import iter_pagination_params
+from models_library.basic_types import IDStr
from models_library.products import ProductName
from models_library.projects import ProjectID
+from models_library.rest_ordering import OrderBy, OrderDirection
+from models_library.rest_pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
from models_library.users import UserID
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
-from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
from servicelib.utils import fire_and_forget_task
-from ..director_v2 import api as director_v2_api
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from . import projects_service
-from ._access_rights_api import check_user_project_permission
-from .exceptions import ProjectRunningConflictError
-from .models import ProjectPatchInternalExtended
+from ..director_v2 import director_v2_service
+from ..dynamic_scheduler import api as dynamic_scheduler_service
+from . import _crud_api_read
+from . import _projects_repository as _projects_repository
+from . import _projects_service, _projects_service_delete
+from ._access_rights_service import check_user_project_permission
+from .exceptions import (
+ ProjectNotFoundError,
+ ProjectNotTrashedError,
+ ProjectRunningConflictError,
+ ProjectsBatchDeleteError,
+)
+from .models import ProjectDict, ProjectPatchInternalExtended
_logger = logging.getLogger(__name__)
@@ -27,11 +37,11 @@ async def _is_project_running(
project_id: ProjectID,
) -> bool:
return bool(
- await director_v2_api.is_pipeline_running(
+ await director_v2_service.is_pipeline_running(
app, user_id=user_id, project_id=project_id
)
) or bool(
- await dynamic_scheduler_api.list_dynamic_services(
+ await dynamic_scheduler_service.list_dynamic_services(
app, user_id=user_id, project_id=project_id
)
)
@@ -45,7 +55,7 @@ async def trash_project(
project_id: ProjectID,
force_stop_first: bool,
explicit: bool,
-):
+) -> None:
"""
Raises:
@@ -62,22 +72,10 @@ async def trash_project(
if force_stop_first:
- async def _schedule():
- await asyncio.gather(
- director_v2_api.stop_pipeline(
- app, user_id=user_id, project_id=project_id
- ),
- projects_service.remove_project_dynamic_services(
- user_id=user_id,
- project_uuid=f"{project_id}",
- app=app,
- simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
- notify_users=False,
- ),
- )
-
fire_and_forget_task(
- _schedule(),
+ _projects_service_delete.batch_stop_services_in_project(
+ app, user_id=user_id, project_uuid=project_id
+ ),
task_suffix_name=f"trash_project_force_stop_first_{user_id=}_{project_id=}",
fire_and_forget_tasks_collection=app[APP_FIRE_AND_FORGET_TASKS_KEY],
)
@@ -89,7 +87,7 @@ async def _schedule():
product_name=product_name,
)
- await projects_service.patch_project(
+ await _projects_service.patch_project(
app,
user_id=user_id,
product_name=product_name,
@@ -108,9 +106,9 @@ async def untrash_project(
product_name: ProductName,
user_id: UserID,
project_id: ProjectID,
-):
+) -> None:
# NOTE: check_user_project_permission is inside projects_api.patch_project
- await projects_service.patch_project(
+ await _projects_service.patch_project(
app,
user_id=user_id,
product_name=product_name,
@@ -119,3 +117,160 @@ async def untrash_project(
trashed_at=None, trashed_explicitly=False, trashed_by=None
),
)
+
+
+def _can_delete(
+ project: ProjectDict,
+ user_id: UserID,
+ until_equal_datetime: datetime | None,
+) -> bool:
+ """
+ This is the current policy to delete trashed project
+
+ """
+ trashed_at = project.get("trashed")
+ trashed_by = project.get("trashedBy")
+ trashed_explicitly = project.get("trashedExplicitly")
+
+ assert trashed_at is not None # nosec
+ assert trashed_by is not None # nosec
+
+ is_shared = len(project["accessRights"]) > 1
+
+ return bool(
+ trashed_at
+ and (until_equal_datetime is None or trashed_at < until_equal_datetime)
+ # NOTE: current policy is more restricted until
+ # logic is adapted to deal with the other cases
+ and trashed_by == user_id
+ and not is_shared
+ and trashed_explicitly
+ )
+
+
+async def list_explicitly_trashed_projects(
+ app: web.Application,
+ *,
+ product_name: ProductName,
+ user_id: UserID,
+ until_equal_datetime: datetime | None = None,
+) -> list[ProjectID]:
+ """
+ Lists all projects that were trashed until a specific datetime (if !=None).
+ """
+ trashed_projects: list[ProjectID] = []
+
+ for page_params in iter_pagination_params(limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE):
+ (
+ projects,
+ page_params.total_number_of_items,
+ ) = await _crud_api_read.list_projects_full_depth(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ trashed=True,
+ tag_ids_list=[],
+ offset=page_params.offset,
+ limit=page_params.limit,
+ order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
+ search_by_multi_columns=None,
+ search_by_project_name=None,
+ )
+
+ # NOTE: Applying POST-FILTERING because we do not want to modify the interface of
+ # _crud_api_read.list_projects_full_depth at this time.
+ # This filtering couldn't be handled at the database level when `projects_repo`
+ # was refactored, as defining a custom trash_filter was needed to allow more
+ # flexibility in filtering options.
+ trashed_projects.extend(
+ [
+ project["uuid"]
+ for project in projects
+ if _can_delete(project, user_id, until_equal_datetime)
+ ]
+ )
+ return trashed_projects
+
+
+async def delete_explicitly_trashed_project(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ project_id: ProjectID,
+ until_equal_datetime: datetime | None = None,
+) -> None:
+ """
+ Deletes a project that was explicitly trashed by the user from a specific datetime (if provided, otherwise all).
+
+ Raises:
+ ProjectNotFoundError: If the project is not found.
+ ProjectNotTrashedError: If the project was not trashed explicitly by the user from the specified datetime.
+ """
+ project = await _projects_service.get_project_for_user(
+ app, project_uuid=f"{project_id}", user_id=user_id
+ )
+
+ if not project:
+ raise ProjectNotFoundError(project_uuid=project_id, user_id=user_id)
+
+ if not _can_delete(project, user_id, until_equal_datetime):
+ # safety check
+ raise ProjectNotTrashedError(
+ project_uuid=project_id,
+ user_id=user_id,
+ reason="Cannot delete trashed project since it does not fit current criteria",
+ )
+
+ await _projects_service.delete_project_by_user(
+ app,
+ user_id=user_id,
+ project_uuid=project_id,
+ )
+
+
+async def batch_delete_trashed_projects_as_admin(
+ app: web.Application,
+ *,
+ trashed_before: datetime,
+ fail_fast: bool,
+) -> list[ProjectID]:
+
+ deleted_project_ids: list[ProjectID] = []
+ errors: list[tuple[ProjectID, Exception]] = []
+
+ for page_params in iter_pagination_params(limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE):
+ (
+ page_params.total_number_of_items,
+ expired_trashed_projects,
+ ) = await _projects_repository.list_trashed_projects(
+ app,
+ # both implicit and explicitly trashed
+ trashed_before=trashed_before,
+ offset=page_params.offset,
+ limit=page_params.limit,
+ order_by=_projects_repository.OLDEST_TRASHED_FIRST,
+ )
+ # BATCH delete
+ for project in expired_trashed_projects:
+
+ assert project.trashed # nosec
+
+ try:
+ await _projects_service_delete.delete_project_as_admin(
+ app,
+ project_uuid=project.uuid,
+ )
+ deleted_project_ids.append(project.uuid)
+ except Exception as err: # pylint: disable=broad-exception-caught
+ if fail_fast:
+ raise
+ errors.append((project.uuid, err))
+
+ if errors:
+ raise ProjectsBatchDeleteError(
+ errors=errors,
+ trashed_before=trashed_before,
+ deleted_project_ids=deleted_project_ids,
+ )
+
+ return deleted_project_ids
diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py
similarity index 94%
rename from services/web/server/src/simcore_service_webserver/projects/_wallets_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_wallets_service.py
index 1610cb4c363..e671b7eac6e 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_service.py
@@ -16,9 +16,9 @@
)
from ..rabbitmq import get_rabbitmq_rpc_client
-from ..users import api as users_api
-from ..wallets import _api as wallets_api
-from .db import ProjectDBAPI
+from ..users import api as users_service
+from ..wallets import _api as wallets_service
+from ._projects_repository_legacy import ProjectDBAPI
from .exceptions import (
ProjectInDebtCanNotChangeWalletError,
ProjectInDebtCanNotOpenError,
@@ -73,7 +73,7 @@ async def connect_wallet_to_project(
db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app)
# ensure the wallet can be used by the user
- wallet: WalletGet = await wallets_api.get_wallet_by_user(
+ wallet: WalletGet = await wallets_service.get_wallet_by_user(
app,
user_id=user_id,
wallet_id=wallet_id,
@@ -153,20 +153,20 @@ async def pay_debt_with_different_wallet(
assert current_wallet_id != new_wallet_id # nosec
# ensure the wallets can be used by the user
- new_wallet: WalletGet = await wallets_api.get_wallet_by_user(
+ new_wallet: WalletGet = await wallets_service.get_wallet_by_user(
app,
user_id=user_id,
wallet_id=new_wallet_id,
product_name=product_name,
)
- current_wallet: WalletGet = await wallets_api.get_wallet_by_user(
+ current_wallet: WalletGet = await wallets_service.get_wallet_by_user(
app,
user_id=user_id,
wallet_id=current_wallet_id,
product_name=product_name,
)
- user = await users_api.get_user(app, user_id=user_id)
+ user = await users_service.get_user(app, user_id=user_id)
# Transfer credits from the source wallet to the connected wallet
rpc_client = get_rabbitmq_rpc_client(app)
diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py
similarity index 83%
rename from services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py
rename to services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py
index 1462168fa52..fdf40f27371 100644
--- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/_workspaces_service.py
@@ -8,12 +8,10 @@
from simcore_postgres_database.utils_repos import transaction_context
from ..db.plugin import get_asyncpg_engine
-from ..projects._access_rights_api import get_user_project_access_rights
from ..users.api import get_user
from ..workspaces.api import check_user_workspace_access
-from . import _folders_db as project_to_folders_db
-from . import _groups_db as project_groups_db
-from . import _projects_db
+from . import _folders_repository, _groups_repository, _projects_repository
+from ._access_rights_service import get_user_project_access_rights
from .exceptions import ProjectInvalidRightsError
_logger = logging.getLogger(__name__)
@@ -46,14 +44,14 @@ async def move_project_into_workspace(
async with transaction_context(get_asyncpg_engine(app)) as conn:
# 3. Delete project to folders (for everybody)
- await project_to_folders_db.delete_all_project_to_folder_by_project_id(
+ await _folders_repository.delete_all_project_to_folder_by_project_id(
app,
connection=conn,
project_id=project_id,
)
# 4. Update workspace ID on the project resource
- await _projects_db.patch_project(
+ await _projects_repository.patch_project(
app=app,
connection=conn,
project_uuid=project_id,
@@ -62,10 +60,10 @@ async def move_project_into_workspace(
# 5. Remove all project permissions, leave only the user who moved the project
user = await get_user(app, user_id=user_id)
- await project_groups_db.delete_all_project_groups(
+ await _groups_repository.delete_all_project_groups(
app, connection=conn, project_id=project_id
)
- await project_groups_db.update_or_insert_project_group(
+ await _groups_repository.update_or_insert_project_group(
app,
connection=conn,
project_id=project_id,
diff --git a/services/web/server/src/simcore_service_webserver/projects/api.py b/services/web/server/src/simcore_service_webserver/projects/api.py
index ba5f5ae14fb..bde3efa68d9 100644
--- a/services/web/server/src/simcore_service_webserver/projects/api.py
+++ b/services/web/server/src/simcore_service_webserver/projects/api.py
@@ -1,32 +1,28 @@
# NOTE: we will slowly move heere projects_api.py
-from ._access_rights_api import (
+from ._access_rights_service import (
check_user_project_permission,
has_user_project_access_rights,
)
-from ._groups_api import (
+from ._groups_service import (
create_project_group_without_checking_permissions,
delete_project_group_without_checking_permissions,
)
-from ._permalink_api import ProjectPermalink
-from ._permalink_api import register_factory as register_permalink_factory
-from ._wallets_api import (
+from ._wallets_service import (
check_project_financial_status,
connect_wallet_to_project,
get_project_wallet,
)
__all__: tuple[str, ...] = (
+ "check_project_financial_status",
"check_user_project_permission",
"connect_wallet_to_project",
"create_project_group_without_checking_permissions",
"delete_project_group_without_checking_permissions",
"get_project_wallet",
"has_user_project_access_rights",
- "ProjectPermalink",
- "register_permalink_factory",
- "check_project_financial_status",
)
diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py
index cc45dabdfb2..8c270f99df5 100644
--- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py
+++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py
@@ -79,6 +79,10 @@ def __init__(self, *, project_uuid, reason, **ctx):
self.reason = reason
+class ProjectsBatchDeleteError(BaseProjectError):
+ msg_template = "One or more projects could not be deleted in the batch: {errors}"
+
+
class ProjectTrashError(BaseProjectError):
...
@@ -93,6 +97,12 @@ class ProjectRunningConflictError(ProjectTrashError):
)
+class ProjectNotTrashedError(ProjectTrashError):
+ msg_template = (
+ "Cannot delete project {project_uuid} since it was not trashed first: {reason}"
+ )
+
+
class NodeNotFoundError(BaseProjectError):
msg_template = "Node '{node_uuid}' not found in project '{project_uuid}'"
diff --git a/services/web/server/src/simcore_service_webserver/projects/models.py b/services/web/server/src/simcore_service_webserver/projects/models.py
index d9974b436b0..16ff35651d6 100644
--- a/services/web/server/src/simcore_service_webserver/projects/models.py
+++ b/services/web/server/src/simcore_service_webserver/projects/models.py
@@ -5,10 +5,10 @@
from aiopg.sa.result import RowProxy
from common_library.dict_tools import remap_keys
from models_library.api_schemas_webserver.projects import ProjectPatch
+from models_library.api_schemas_webserver.projects_ui import StudyUI
from models_library.folders import FolderID
from models_library.groups import GroupID
from models_library.projects import ClassifierID, ProjectID
-from models_library.projects_ui import StudyUI
from models_library.users import UserID
from models_library.utils.common_validators import (
empty_str_to_none_pre_validator,
@@ -36,7 +36,7 @@ def to_project_type_db(cls, api_type: "ProjectTypeAPI") -> ProjectType | None:
}[api_type]
-class ProjectDB(BaseModel):
+class ProjectDBGet(BaseModel):
# NOTE: model intented to read one-to-one columns of the `projects` table
id: int
type: ProjectType
@@ -71,12 +71,12 @@ class ProjectDB(BaseModel):
)
-class ProjectWithTrashExtra(ProjectDB):
+class ProjectWithTrashExtra(ProjectDBGet):
# This field is not part of the tables
trashed_by_primary_gid: GroupID | None = None
-class UserSpecificProjectDataDB(ProjectDB):
+class UserSpecificProjectDataDBGet(ProjectDBGet):
folder_id: FolderID | None
model_config = ConfigDict(from_attributes=True)
diff --git a/services/web/server/src/simcore_service_webserver/projects/nodes_utils.py b/services/web/server/src/simcore_service_webserver/projects/nodes_utils.py
index 32531914163..4cf8a690aee 100644
--- a/services/web/server/src/simcore_service_webserver/projects/nodes_utils.py
+++ b/services/web/server/src/simcore_service_webserver/projects/nodes_utils.py
@@ -12,7 +12,7 @@
from servicelib.logging_utils import log_decorator
from servicelib.utils import fire_and_forget_task, logged_gather
-from . import projects_service
+from . import _projects_service
from .utils import get_frontend_node_outputs_changes
log = logging.getLogger(__name__)
@@ -46,7 +46,7 @@ async def update_node_outputs(
ui_changed_keys: set[str] | None,
) -> None:
# the new outputs might be {}, or {key_name: payload}
- project, keys_changed = await projects_service.update_project_node_outputs(
+ project, keys_changed = await _projects_service.update_project_node_outputs(
app,
user_id,
project_uuid,
@@ -55,14 +55,14 @@ async def update_node_outputs(
new_run_hash=run_hash,
)
- await projects_service.notify_project_node_update(
+ await _projects_service.notify_project_node_update(
app, project, node_uuid, errors=node_errors
)
# get depending node and notify for these ones as well
depending_node_uuids = await project_get_depending_nodes(project, node_uuid)
await logged_gather(
*[
- projects_service.notify_project_node_update(app, project, nid, errors=None)
+ _projects_service.notify_project_node_update(app, project, nid, errors=None)
for nid in depending_node_uuids
]
)
@@ -86,7 +86,7 @@ async def update_node_outputs(
)
# fire&forget to notify connected nodes to retrieve its inputs **if necessary**
- await projects_service.post_trigger_connected_service_retrieve(
+ await _projects_service.post_trigger_connected_service_retrieve(
app=app, project=project, updated_node_uuid=f"{node_uuid}", changed_keys=keys
)
diff --git a/services/web/server/src/simcore_service_webserver/projects/plugin.py b/services/web/server/src/simcore_service_webserver/projects/plugin.py
index 5cba65b8a2b..f968908c797 100644
--- a/services/web/server/src/simcore_service_webserver/projects/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/projects/plugin.py
@@ -1,32 +1,33 @@
-""" projects management subsystem
+"""projects management subsystem
- A project is a document defining a osparc study
- It contains metadata about the study (e.g. name, description, owner, etc) and a workbench section that describes the study pipeline
+A project is a document defining a osparc study
+It contains metadata about the study (e.g. name, description, owner, etc) and a workbench section that describes the study pipeline
"""
+
import logging
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import APP_SETTINGS_KEY
-from . import (
- _comments_handlers,
- _crud_handlers,
- _folders_handlers,
- _groups_handlers,
- _metadata_handlers,
- _nodes_handlers,
- _ports_handlers,
- _projects_nodes_pricing_unit_handlers,
- _states_handlers,
- _tags_handlers,
- _trash_rest,
- _wallets_handlers,
- _workspaces_handlers,
+from ..constants import APP_SETTINGS_KEY
+from ._controller import (
+ comments_rest,
+ folders_rest,
+ groups_rest,
+ metadata_rest,
+ nodes_pricing_unit_rest,
+ nodes_rest,
+ ports_rest,
+ projects_rest,
+ projects_states_rest,
+ tags_rest,
+ trash_rest,
+ wallets_rest,
+ workspaces_rest,
)
-from ._observer import setup_project_observer_events
-from ._projects_access import setup_projects_access
-from .db import setup_projects_db
+from ._controller.projects_slot import setup_project_observer_events
+from ._projects_repository_legacy import setup_projects_db
+from ._security_service import setup_projects_access
logger = logging.getLogger(__name__)
@@ -50,18 +51,18 @@ def setup_projects(app: web.Application) -> bool:
# registers event handlers (e.g. on_user_disconnect)
setup_project_observer_events(app)
- app.router.add_routes(_states_handlers.routes)
- app.router.add_routes(_crud_handlers.routes)
- app.router.add_routes(_comments_handlers.routes)
- app.router.add_routes(_groups_handlers.routes)
- app.router.add_routes(_metadata_handlers.routes)
- app.router.add_routes(_ports_handlers.routes)
- app.router.add_routes(_nodes_handlers.routes)
- app.router.add_routes(_tags_handlers.routes)
- app.router.add_routes(_wallets_handlers.routes)
- app.router.add_routes(_folders_handlers.routes)
- app.router.add_routes(_projects_nodes_pricing_unit_handlers.routes)
- app.router.add_routes(_workspaces_handlers.routes)
- app.router.add_routes(_trash_rest.routes)
+ app.router.add_routes(projects_states_rest.routes)
+ app.router.add_routes(projects_rest.routes)
+ app.router.add_routes(comments_rest.routes)
+ app.router.add_routes(groups_rest.routes)
+ app.router.add_routes(metadata_rest.routes)
+ app.router.add_routes(ports_rest.routes)
+ app.router.add_routes(nodes_rest.routes)
+ app.router.add_routes(tags_rest.routes)
+ app.router.add_routes(wallets_rest.routes)
+ app.router.add_routes(folders_rest.routes)
+ app.router.add_routes(nodes_pricing_unit_rest.routes)
+ app.router.add_routes(workspaces_rest.routes)
+ app.router.add_routes(trash_rest.routes)
return True
diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_permalink_service.py b/services/web/server/src/simcore_service_webserver/projects/projects_permalink_service.py
new file mode 100644
index 00000000000..ed242f8c1e3
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/projects_permalink_service.py
@@ -0,0 +1,9 @@
+from ._permalink_service import ProjectPermalink
+from ._permalink_service import register_factory as register_permalink_factory
+
+__all__: tuple[str, ...] = (
+ "ProjectPermalink",
+ "register_permalink_factory",
+)
+
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_trash_service.py b/services/web/server/src/simcore_service_webserver/projects/projects_trash_service.py
new file mode 100644
index 00000000000..2270ca66e6c
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/projects/projects_trash_service.py
@@ -0,0 +1,13 @@
+from ._trash_service import (
+ batch_delete_trashed_projects_as_admin,
+ delete_explicitly_trashed_project,
+ list_explicitly_trashed_projects,
+)
+
+__all__: tuple[str, ...] = (
+ "batch_delete_trashed_projects_as_admin",
+ "delete_explicitly_trashed_project",
+ "list_explicitly_trashed_projects",
+)
+
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/projects/settings.py b/services/web/server/src/simcore_service_webserver/projects/settings.py
index ace29385602..198afae90a9 100644
--- a/services/web/server/src/simcore_service_webserver/projects/settings.py
+++ b/services/web/server/src/simcore_service_webserver/projects/settings.py
@@ -4,7 +4,7 @@
from pydantic import ByteSize, Field, NonNegativeInt, TypeAdapter
from settings_library.base import BaseCustomSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class ProjectsSettings(BaseCustomSettings):
diff --git a/services/web/server/src/simcore_service_webserver/publications/_handlers.py b/services/web/server/src/simcore_service_webserver/publications/_rest.py
similarity index 95%
rename from services/web/server/src/simcore_service_webserver/publications/_handlers.py
rename to services/web/server/src/simcore_service_webserver/publications/_rest.py
index 2653bba1390..35ccbac61a5 100644
--- a/services/web/server/src/simcore_service_webserver/publications/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/publications/_rest.py
@@ -2,7 +2,6 @@
from aiohttp import MultipartReader, hdrs, web
from common_library.json_serialization import json_dumps
-from json2html import json2html # type: ignore[import-untyped]
from servicelib.aiohttp import status
from servicelib.mimetype_constants import (
MIMETYPE_APPLICATION_JSON,
@@ -14,7 +13,8 @@
from ..login.decorators import login_required
from ..login.storage import AsyncpgStorage, get_plugin_storage
from ..login.utils_email import AttachmentTuple, send_email_from_template, themed
-from ..products.api import get_current_product
+from ..products import products_web
+from ._utils import json2html
_logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@
@routes.post(f"/{VTAG}/publications/service-submission", name="service_submission")
@login_required
async def service_submission(request: web.Request):
- product = get_current_product(request)
+ product = products_web.get_current_product(request)
reader = MultipartReader.from_response(request) # type: ignore[arg-type] # PC, IP Whoever is in charge of this. please have a look. this looks very weird
data = None
filename = None
diff --git a/services/web/server/src/simcore_service_webserver/publications/_utils.py b/services/web/server/src/simcore_service_webserver/publications/_utils.py
new file mode 100644
index 00000000000..0e1ab899db1
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/publications/_utils.py
@@ -0,0 +1,240 @@
+"""
+This module provides functionality to convert JSON data into an HTML table format.
+It is a snapshot of the `json2html` library to avoid compatibility issues with
+specific versions of `setuptools`.
+
+Classes:
+- Json2Html: A class that provides methods to convert JSON data into HTML tables
+ or lists, with options for customization.
+
+Functions:
+----------
+- Json2Html.convert: Converts JSON data into an HTML table or list format.
+- Json2Html.column_headers_from_list_of_dicts: Determines column headers for a list of dictionaries.
+- Json2Html.convert_json_node: Dispatches JSON input based on its type and processes it into HTML.
+- Json2Html.convert_list: Converts a JSON list into an HTML table or list.
+- Json2Html.convert_object: Converts a JSON object into an HTML table.
+
+Attributes:
+-----------
+- json2html: An instance of the Json2Html class for direct use.
+
+Notes:
+------
+- This module supports Python 2.7+ and Python 3.x.
+- It uses `OrderedDict` to preserve the order of keys in JSON objects.
+- The `html_escape` function is used to escape HTML characters in text.
+
+License:
+MIT License
+
+Source:
+-------
+Snapshot of https://github.com/softvar/json2html/blob/0a223c7b3e5dce286811fb12bbab681e7212ebfe/json2html/jsonconv.py
+JSON 2 HTML Converter
+=====================
+
+(c) Varun Malhotra 2013
+Source Code: https://github.com/softvar/json2html
+
+
+Contributors:
+-------------
+1. Michel Müller (@muellermichel), https://github.com/softvar/json2html/pull/2
+2. Daniel Lekic (@lekic), https://github.com/softvar/json2html/pull/17
+
+LICENSE: MIT
+--------
+"""
+
+# pylint: skip-file
+#
+# NOTE: Snapshot of https://github.com/softvar/json2html/blob/0a223c7b3e5dce286811fb12bbab681e7212ebfe/json2html/jsonconv.py
+# to avoid failure to install this module with `setuptools 78.0.1` due to
+# deprecated feature that this library still uses
+#
+
+
+import sys
+
+if sys.version_info[:2] < (2, 7):
+ import simplejson as json_parser
+ from ordereddict import OrderedDict
+else:
+ import json as json_parser
+ from collections import OrderedDict
+
+if sys.version_info[:2] < (3, 0):
+ from cgi import escape as html_escape
+
+ text = unicode
+ text_types = (unicode, str)
+else:
+ from html import escape as html_escape
+
+ text = str
+ text_types = (str,)
+
+
+class Json2Html:
+ def convert(
+ self,
+ json="",
+ table_attributes='border="1"',
+ clubbing=True,
+ encode=False,
+ escape=True,
+ ):
+ """
+ Convert JSON to HTML Table format
+ """
+ # table attributes such as class, id, data-attr-*, etc.
+ # eg: table_attributes = 'class = "table table-bordered sortable"'
+ self.table_init_markup = "" % table_attributes
+ self.clubbing = clubbing
+ self.escape = escape
+ json_input = None
+ if not json:
+ json_input = {}
+ elif type(json) in text_types:
+ try:
+ json_input = json_parser.loads(json, object_pairs_hook=OrderedDict)
+ except ValueError as e:
+ # so the string passed here is actually not a json string
+ # - let's analyze whether we want to pass on the error or use the string as-is as a text node
+ if "Expecting property name" in text(e):
+ # if this specific json loads error is raised, then the user probably actually wanted to pass json, but made a mistake
+ raise e
+ json_input = json
+ else:
+ json_input = json
+ converted = self.convert_json_node(json_input)
+ if encode:
+ return converted.encode("ascii", "xmlcharrefreplace")
+ return converted
+
+ def column_headers_from_list_of_dicts(self, json_input):
+ """
+ This method is required to implement clubbing.
+ It tries to come up with column headers for your input
+ """
+ if (
+ not json_input
+ or not hasattr(json_input, "__getitem__")
+ or not hasattr(json_input[0], "keys")
+ ):
+ return None
+ column_headers = json_input[0].keys()
+ for entry in json_input:
+ if (
+ not hasattr(entry, "keys")
+ or not hasattr(entry, "__iter__")
+ or len(entry.keys()) != len(column_headers)
+ ):
+ return None
+ for header in column_headers:
+ if header not in entry:
+ return None
+ return column_headers
+
+ def convert_json_node(self, json_input):
+ """
+ Dispatch JSON input according to the outermost type and process it
+ to generate the super awesome HTML format.
+ We try to adhere to duck typing such that users can just pass all kinds
+ of funky objects to json2html that *behave* like dicts and lists and other
+ basic JSON types.
+ """
+ if type(json_input) in text_types:
+ if self.escape:
+ return html_escape(text(json_input))
+ else:
+ return text(json_input)
+ if hasattr(json_input, "items"):
+ return self.convert_object(json_input)
+ if hasattr(json_input, "__iter__") and hasattr(json_input, "__getitem__"):
+ return self.convert_list(json_input)
+ return text(json_input)
+
+ def convert_list(self, list_input):
+ """
+ Iterate over the JSON list and process it
+ to generate either an HTML table or a HTML list, depending on what's inside.
+ If suppose some key has array of objects and all the keys are same,
+ instead of creating a new row for each such entry,
+ club such values, thus it makes more sense and more readable table.
+
+ @example:
+ jsonObject = {
+ "sampleData": [
+ {"a":1, "b":2, "c":3},
+ {"a":5, "b":6, "c":7}
+ ]
+ }
+ OUTPUT:
+ _____________________________
+ | | | | |
+ | | a | c | b |
+ | sampleData |---|---|---|
+ | | 1 | 3 | 2 |
+ | | 5 | 7 | 6 |
+ -----------------------------
+
+ @contributed by: @muellermichel
+ """
+ if not list_input:
+ return ""
+ converted_output = ""
+ column_headers = None
+ if self.clubbing:
+ column_headers = self.column_headers_from_list_of_dicts(list_input)
+ if column_headers is not None:
+ converted_output += self.table_init_markup
+ converted_output += ""
+ converted_output += (
+ "| " + " | ".join(column_headers) + " |
"
+ )
+ converted_output += ""
+ converted_output += ""
+ for list_entry in list_input:
+ converted_output += "| "
+ converted_output += " | ".join(
+ [
+ self.convert_json_node(list_entry[column_header])
+ for column_header in column_headers
+ ]
+ )
+ converted_output += " |
"
+ converted_output += ""
+ converted_output += "
"
+ return converted_output
+
+ # so you don't want or need clubbing eh? This makes @muellermichel very sad... ;(
+ # alright, let's fall back to a basic list here...
+ converted_output = "- "
+ converted_output += "
- ".join(
+ [self.convert_json_node(child) for child in list_input]
+ )
+ converted_output += "
"
+ return converted_output
+
+ def convert_object(self, json_input):
+ """
+ Iterate over the JSON object and process it
+ to generate the super awesome HTML Table format
+ """
+ if not json_input:
+ return "" # avoid empty tables
+ converted_output = self.table_init_markup + ""
+ converted_output += "
".join(
+ [
+ "| %s | %s | "
+ % (self.convert_json_node(k), self.convert_json_node(v))
+ for k, v in json_input.items()
+ ]
+ )
+ converted_output += "
"
+ return converted_output
+
+
+json2html = Json2Html()
diff --git a/services/web/server/src/simcore_service_webserver/publications/plugin.py b/services/web/server/src/simcore_service_webserver/publications/plugin.py
index e1460d653dc..a85b83cf3b8 100644
--- a/services/web/server/src/simcore_service_webserver/publications/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/publications/plugin.py
@@ -1,6 +1,5 @@
-""" publications management subsystem
+"""publications management subsystem"""
-"""
import logging
from aiohttp import web
@@ -9,9 +8,9 @@
from ..email.plugin import setup_email
from ..products.plugin import setup_products
-from . import _handlers
+from . import _rest
-logger = logging.getLogger(__name__)
+_logger = logging.getLogger(__name__)
@app_module_setup(
@@ -19,7 +18,7 @@
ModuleCategory.ADDON,
depends=["simcore_service_webserver.rest"],
settings_name="WEBSERVER_PUBLICATIONS",
- logger=logger,
+ logger=_logger,
)
def setup_publications(app: web.Application):
assert app[APP_SETTINGS_KEY].WEBSERVER_PUBLICATIONS # nosec
@@ -27,4 +26,4 @@ def setup_publications(app: web.Application):
setup_email(app)
setup_products(app)
- app.router.add_routes(_handlers.routes)
+ app.router.add_routes(_rest.routes)
diff --git a/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py b/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py
index a05929f1c1b..79a85b69d5c 100644
--- a/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py
+++ b/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py
@@ -8,7 +8,7 @@
from aiohttp.web import Application
from settings_library.rabbit import RabbitSettings
-from ._constants import APP_SETTINGS_KEY
+from .constants import APP_SETTINGS_KEY
def get_plugin_settings(app: Application) -> RabbitSettings:
diff --git a/services/web/server/src/simcore_service_webserver/redis.py b/services/web/server/src/simcore_service_webserver/redis.py
index 5caebe02c53..cd66a4e004d 100644
--- a/services/web/server/src/simcore_service_webserver/redis.py
+++ b/services/web/server/src/simcore_service_webserver/redis.py
@@ -6,8 +6,8 @@
from servicelib.redis import RedisClientSDK, RedisClientsManager, RedisManagerDBConfig
from settings_library.redis import RedisDatabase, RedisSettings
-from ._constants import APP_SETTINGS_KEY
from ._meta import APP_NAME
+from .constants import APP_SETTINGS_KEY
_logger = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_client.py b/services/web/server/src/simcore_service_webserver/resource_usage/_client.py
index 63d5187a7d5..d6f5c9598b5 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_client.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_client.py
@@ -16,8 +16,8 @@
WalletTotalCredits,
)
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingUnitGet,
)
from models_library.resource_tracker import PricingPlanId, PricingUnitId
from models_library.users import UserID
@@ -86,7 +86,7 @@ async def list_service_runs_by_user_and_product_and_wallet(
async def get_default_service_pricing_plan(
app: web.Application, product_name: str, service_key: str, service_version: str
-) -> PricingPlanGet:
+) -> RutPricingPlanGet:
settings: ResourceUsageTrackerSettings = get_plugin_settings(app)
url = URL(
f"{settings.api_base_url}/services/{urllib.parse.quote_plus(service_key)}/{service_version}/pricing-plan",
@@ -101,7 +101,7 @@ async def get_default_service_pricing_plan(
async with session.get(url) as response:
response.raise_for_status()
body: dict = await response.json()
- return PricingPlanGet.model_validate(body)
+ return RutPricingPlanGet.model_validate(body)
except ClientResponseError as e:
if e.status == status.HTTP_404_NOT_FOUND:
raise DefaultPricingPlanNotFoundError from e
@@ -113,7 +113,7 @@ async def get_pricing_plan_unit(
product_name: str,
pricing_plan_id: PricingPlanId,
pricing_unit_id: PricingUnitId,
-) -> PricingUnitGet:
+) -> RutPricingUnitGet:
settings: ResourceUsageTrackerSettings = get_plugin_settings(app)
url = (
URL(settings.api_base_url)
@@ -130,7 +130,7 @@ async def get_pricing_plan_unit(
async with session.get(url) as response:
response.raise_for_status()
body: dict = await response.json()
- return PricingUnitGet.model_validate(body)
+ return RutPricingUnitGet.model_validate(body)
async def sum_total_available_credits_in_the_wallet(
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_observer.py b/services/web/server/src/simcore_service_webserver/resource_usage/_observer.py
index 4362247f3ec..114bc1df298 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_observer.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_observer.py
@@ -1,7 +1,4 @@
-""" Handlers to events registered in servicelib.observer.event_registry
-
-"""
-
+"""Handlers to events registered in servicelib.observer.event_registry"""
import logging
@@ -15,7 +12,7 @@
from servicelib.utils import logged_gather
from ..notifications import wallet_osparc_credits
-from ..wallets import api as wallets_api
+from ..wallets import api as wallets_service
_logger = logging.getLogger(__name__)
@@ -30,7 +27,7 @@ async def _on_user_disconnected(
assert client_session_id # nosec
# Get all user wallets and unsubscribe
- user_wallet = await wallets_api.list_wallets_for_user(
+ user_wallet = await wallets_service.list_wallets_for_user(
app, user_id=user_id, product_name=product_name
)
disconnect_tasks = [
@@ -44,7 +41,7 @@ async def _on_user_connected(
user_id: int, app: web.Application, product_name: str
) -> None:
# Get all user wallets and subscribe
- user_wallet = await wallets_api.list_wallets_for_user(
+ user_wallet = await wallets_service.list_wallets_for_user(
app, user_id=user_id, product_name=product_name
)
_logger.debug("Connecting user %s to wallets %s", f"{user_id}", f"{user_wallet}")
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py
index 93386ff05a8..338eed936a5 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py
@@ -2,7 +2,7 @@
from aiohttp import web
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
+ RutPricingPlanGet,
)
from models_library.api_schemas_webserver.resource_usage import (
ConnectServiceToPricingPlanBodyParams,
@@ -33,6 +33,9 @@
from servicelib.aiohttp.typing_extension import Handler
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
from servicelib.rabbitmq._errors import RPCServerError
+from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import (
+ PricingUnitDuplicationError,
+)
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
from .._meta import API_VTAG as VTAG
@@ -54,6 +57,9 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
try:
return await handler(request)
+ except (ValueError, PricingUnitDuplicationError) as exc:
+ raise web.HTTPBadRequest(reason=f"{exc}") from exc
+
except RPCServerError as exc:
# NOTE: This will be improved; we will add a mapping between
# RPC errors and user-friendly frontend errors to pass to the frontend.
@@ -85,12 +91,14 @@ async def list_pricing_plans_for_admin_user(request: web.Request):
PageQueryParameters, request
)
- pricing_plan_page = await pricing_plans_admin_service.list_pricing_plans(
- app=request.app,
- product_name=req_ctx.product_name,
- exclude_inactive=False,
- offset=query_params.offset,
- limit=query_params.limit,
+ pricing_plan_page = (
+ await pricing_plans_admin_service.list_pricing_plans_without_pricing_units(
+ app=request.app,
+ product_name=req_ctx.product_name,
+ exclude_inactive=False,
+ offset=query_params.offset,
+ limit=query_params.limit,
+ )
)
webserver_pricing_plans = [
PricingPlanAdminGet(
@@ -121,7 +129,9 @@ async def list_pricing_plans_for_admin_user(request: web.Request):
)
-def pricing_plan_get_to_admin(pricing_plan_get: PricingPlanGet) -> PricingPlanAdminGet:
+def pricing_plan_get_to_admin(
+ pricing_plan_get: RutPricingPlanGet,
+) -> PricingPlanAdminGet:
"""
Convert a PricingPlanGet object into a PricingPlanAdminGet object.
"""
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py
index 7f574b69d5d..d74bdee870a 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py
@@ -1,18 +1,21 @@
from aiohttp import web
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
- PricingPlanPage,
PricingPlanToServiceGet,
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingPlanPage,
+ RutPricingUnitGet,
)
from models_library.products import ProductName
from models_library.resource_tracker import (
+ PricingPlanClassification,
PricingPlanCreate,
PricingPlanId,
PricingPlanUpdate,
PricingUnitId,
PricingUnitWithCostCreate,
PricingUnitWithCostUpdate,
+ UnitExtraInfoLicense,
+ UnitExtraInfoTier,
)
from models_library.services import ServiceKey, ServiceVersion
from models_library.users import UserID
@@ -21,27 +24,29 @@
pricing_units,
)
-from ..catalog import client as catalog_client
+from ..catalog import catalog_service
from ..rabbitmq import get_rabbitmq_rpc_client
## Pricing Plans
-async def list_pricing_plans(
+async def list_pricing_plans_without_pricing_units(
app: web.Application,
*,
product_name: ProductName,
exclude_inactive: bool,
offset: int,
limit: int,
-) -> PricingPlanPage:
+) -> RutPricingPlanPage:
rpc_client = get_rabbitmq_rpc_client(app)
- output: PricingPlanPage = await pricing_plans.list_pricing_plans(
- rpc_client,
- product_name=product_name,
- exclude_inactive=exclude_inactive,
- offset=offset,
- limit=limit,
+ output: RutPricingPlanPage = (
+ await pricing_plans.list_pricing_plans_without_pricing_units(
+ rpc_client,
+ product_name=product_name,
+ exclude_inactive=exclude_inactive,
+ offset=offset,
+ limit=limit,
+ )
)
return output
@@ -50,7 +55,7 @@ async def get_pricing_plan(
app: web.Application,
product_name: ProductName,
pricing_plan_id: PricingPlanId,
-) -> PricingPlanGet:
+) -> RutPricingPlanGet:
rpc_client = get_rabbitmq_rpc_client(app)
return await pricing_plans.get_pricing_plan(
rpc_client,
@@ -62,14 +67,14 @@ async def get_pricing_plan(
async def create_pricing_plan(
app: web.Application,
data: PricingPlanCreate,
-) -> PricingPlanGet:
+) -> RutPricingPlanGet:
rpc_client = get_rabbitmq_rpc_client(app)
return await pricing_plans.create_pricing_plan(rpc_client, data=data)
async def update_pricing_plan(
app: web.Application, product_name: ProductName, data: PricingPlanUpdate
-) -> PricingPlanGet:
+) -> RutPricingPlanGet:
rpc_client = get_rabbitmq_rpc_client(app)
return await pricing_plans.update_pricing_plan(
rpc_client, product_name=product_name, data=data
@@ -84,7 +89,7 @@ async def get_pricing_unit(
product_name: ProductName,
pricing_plan_id: PricingPlanId,
pricing_unit_id: PricingUnitId,
-) -> PricingUnitGet:
+) -> RutPricingUnitGet:
rpc_client = get_rabbitmq_rpc_client(app)
return await pricing_units.get_pricing_unit(
rpc_client,
@@ -96,8 +101,12 @@ async def get_pricing_unit(
async def create_pricing_unit(
app: web.Application, product_name: ProductName, data: PricingUnitWithCostCreate
-) -> PricingUnitGet:
+) -> RutPricingUnitGet:
rpc_client = get_rabbitmq_rpc_client(app)
+ pricing_plan = await pricing_plans.get_pricing_plan(
+ rpc_client, product_name=product_name, pricing_plan_id=data.pricing_plan_id
+ )
+ _validate_pricing_unit(pricing_plan.classification, data.unit_extra_info)
return await pricing_units.create_pricing_unit(
rpc_client, product_name=product_name, data=data
)
@@ -105,13 +114,31 @@ async def create_pricing_unit(
async def update_pricing_unit(
app: web.Application, product_name: ProductName, data: PricingUnitWithCostUpdate
-) -> PricingUnitGet:
+) -> RutPricingUnitGet:
rpc_client = get_rabbitmq_rpc_client(app)
+ pricing_plan = await pricing_plans.get_pricing_plan(
+ rpc_client, product_name=product_name, pricing_plan_id=data.pricing_plan_id
+ )
+ _validate_pricing_unit(pricing_plan.classification, data.unit_extra_info)
return await pricing_units.update_pricing_unit(
rpc_client, product_name=product_name, data=data
)
+def _validate_pricing_unit(classification: PricingPlanClassification, unit_extra_info):
+ if classification == PricingPlanClassification.LICENSE:
+ if not isinstance(unit_extra_info, UnitExtraInfoLicense):
+ msg = "Expected UnitExtraInfoLicense (num_of_seats) for LICENSE classification"
+ raise ValueError(msg)
+ elif classification == PricingPlanClassification.TIER:
+ if not isinstance(unit_extra_info, UnitExtraInfoTier):
+ msg = "Expected UnitExtraInfoTier (CPU, RAM, VRAM) for TIER classification"
+ raise ValueError(msg)
+ else:
+ msg = "Not known pricing plan classification"
+ raise ValueError(msg)
+
+
## Pricing Plans to Service
@@ -119,10 +146,10 @@ async def list_connected_services_to_pricing_plan(
app: web.Application, product_name: ProductName, pricing_plan_id: PricingPlanId
) -> list[PricingPlanToServiceGet]:
rpc_client = get_rabbitmq_rpc_client(app)
- output: list[
- PricingPlanToServiceGet
- ] = await pricing_plans.list_connected_services_to_pricing_plan_by_pricing_plan(
- rpc_client, product_name=product_name, pricing_plan_id=pricing_plan_id
+ output: list[PricingPlanToServiceGet] = (
+ await pricing_plans.list_connected_services_to_pricing_plan_by_pricing_plan(
+ rpc_client, product_name=product_name, pricing_plan_id=pricing_plan_id
+ )
)
return output
@@ -136,7 +163,7 @@ async def connect_service_to_pricing_plan(
service_version: ServiceVersion,
) -> PricingPlanToServiceGet:
# Check whether service key and version exists
- await catalog_client.get_service(
+ await catalog_service.get_service(
app, user_id, service_key, service_version, product_name
)
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py
index e97446c2d88..5bd826a24a5 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py
@@ -96,12 +96,14 @@ async def list_pricing_plans(request: web.Request):
PageQueryParameters, request
)
- pricing_plan_page = await pricing_plans_admin_service.list_pricing_plans(
- app=request.app,
- product_name=req_ctx.product_name,
- exclude_inactive=True,
- offset=query_params.offset,
- limit=query_params.limit,
+ pricing_plan_page = (
+ await pricing_plans_admin_service.list_pricing_plans_without_pricing_units(
+ app=request.app,
+ product_name=req_ctx.product_name,
+ exclude_inactive=True,
+ offset=query_params.offset,
+ limit=query_params.limit,
+ )
)
webserver_pricing_plans = [
PricingPlanGet(
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py
index a29ffa32632..6c5b07714a8 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py
@@ -1,7 +1,7 @@
from aiohttp import web
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingUnitGet,
)
from models_library.products import ProductName
from models_library.resource_tracker import PricingPlanId, PricingUnitId
@@ -15,8 +15,8 @@ async def get_default_service_pricing_plan(
product_name: ProductName,
service_key: ServiceKey,
service_version: ServiceVersion,
-) -> PricingPlanGet:
- data: PricingPlanGet = (
+) -> RutPricingPlanGet:
+ data: RutPricingPlanGet = (
await resource_tracker_client.get_default_service_pricing_plan(
app=app,
product_name=product_name,
@@ -33,8 +33,8 @@ async def get_pricing_plan_unit(
product_name: ProductName,
pricing_plan_id: PricingPlanId,
pricing_unit_id: PricingUnitId,
-) -> PricingUnitGet:
- data: PricingUnitGet = await resource_tracker_client.get_pricing_plan_unit(
+) -> RutPricingUnitGet:
+ data: RutPricingUnitGet = await resource_tracker_client.get_pricing_plan_unit(
app=app,
product_name=product_name,
pricing_plan_id=pricing_plan_id,
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/service.py b/services/web/server/src/simcore_service_webserver/resource_usage/service.py
index 05992fe36e4..39ddc24a2ef 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/service.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/service.py
@@ -11,6 +11,7 @@
from models_library.wallets import WalletID
from . import _client
+from ._pricing_plans_admin_service import get_pricing_plan
from ._pricing_plans_service import (
get_default_service_pricing_plan,
get_pricing_plan_unit,
@@ -56,4 +57,5 @@ async def add_credits_to_wallet(
__all__ = (
"get_default_service_pricing_plan",
"get_pricing_plan_unit",
+ "get_pricing_plan",
)
diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/settings.py b/services/web/server/src/simcore_service_webserver/resource_usage/settings.py
index 70687177fcb..db1df4b8bca 100644
--- a/services/web/server/src/simcore_service_webserver/resource_usage/settings.py
+++ b/services/web/server/src/simcore_service_webserver/resource_usage/settings.py
@@ -7,7 +7,7 @@
from aiohttp import web
from settings_library.resource_usage_tracker import ResourceUsageTrackerSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
def get_plugin_settings(app: web.Application) -> ResourceUsageTrackerSettings:
diff --git a/services/web/server/src/simcore_service_webserver/rest/_handlers.py b/services/web/server/src/simcore_service_webserver/rest/_handlers.py
index b874d441db0..5425d7341e4 100644
--- a/services/web/server/src/simcore_service_webserver/rest/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/rest/_handlers.py
@@ -11,10 +11,10 @@
from pydantic import BaseModel
from servicelib.aiohttp import status
-from .._constants import APP_PUBLIC_CONFIG_PER_PRODUCT, APP_SETTINGS_KEY
from .._meta import API_VTAG
+from ..constants import APP_PUBLIC_CONFIG_PER_PRODUCT, APP_SETTINGS_KEY
from ..login.decorators import login_required
-from ..products.api import get_product_name
+from ..products import products_web
from ..redis import get_redis_scheduled_maintenance_client
from ..utils_aiohttp import envelope_json_response
from .healthcheck import HealthCheck, HealthCheckError
@@ -76,7 +76,7 @@ async def get_config(request: web.Request):
"""
app_public_config: dict[str, Any] = request.app[APP_SETTINGS_KEY].public_dict()
- product_name = get_product_name(request=request)
+ product_name = products_web.get_product_name(request=request)
product_public_config = request.app.get(APP_PUBLIC_CONFIG_PER_PRODUCT, {}).get(
product_name, {}
)
diff --git a/services/web/server/src/simcore_service_webserver/rest/healthcheck.py b/services/web/server/src/simcore_service_webserver/rest/healthcheck.py
index fd4b5045215..dc31678c3ef 100644
--- a/services/web/server/src/simcore_service_webserver/rest/healthcheck.py
+++ b/services/web/server/src/simcore_service_webserver/rest/healthcheck.py
@@ -55,7 +55,7 @@
TypedDict,
)
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
_HealthCheckSlot = Callable[[web.Application], Awaitable[None]]
diff --git a/services/web/server/src/simcore_service_webserver/rest/settings.py b/services/web/server/src/simcore_service_webserver/rest/settings.py
index d061af13d5c..3f3047d7fb0 100644
--- a/services/web/server/src/simcore_service_webserver/rest/settings.py
+++ b/services/web/server/src/simcore_service_webserver/rest/settings.py
@@ -1,7 +1,7 @@
from aiohttp import web
from settings_library.base import BaseCustomSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class RestSettings(BaseCustomSettings):
diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py
index 0bf88e69b05..e265f4b5323 100644
--- a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py
+++ b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py
@@ -2,7 +2,7 @@
from pydantic import Field, HttpUrl, SecretStr, TypeAdapter
from settings_library.base import BaseCustomSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
# TODO: read https://www.force11.org/group/resource-identification-initiative
SCICRUNCH_DEFAULT_URL = "https://scicrunch.org"
diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py
index 0bd7e6a75eb..e0cc216e22b 100644
--- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py
+++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py
@@ -61,6 +61,7 @@ class PermissionDict(TypedDict, total=False):
"groups.*",
"catalog/licensed-items.*",
"product.price.read",
+ "product.ui.read",
"project.folders.*",
"project.access_rights.update",
"project.classifier.*",
@@ -111,8 +112,8 @@ class PermissionDict(TypedDict, total=False):
UserRole.ADMIN: PermissionDict(
can=[
"admin.*",
- "storage.files.sync",
"resource-usage.write",
+ "storage.files.sync",
],
inherits=[UserRole.TESTER],
),
diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_policy.py b/services/web/server/src/simcore_service_webserver/security/_authz_policy.py
index 612c1e64975..3bd5408f4d3 100644
--- a/services/web/server/src/simcore_service_webserver/security/_authz_policy.py
+++ b/services/web/server/src/simcore_service_webserver/security/_authz_policy.py
@@ -1,6 +1,4 @@
-""" AUTHoriZation (auth) policy
-
-"""
+"""AUTHoriZation (auth) policy"""
import contextlib
import logging
@@ -14,7 +12,7 @@
)
from models_library.products import ProductName
from models_library.users import UserID
-from simcore_postgres_database.errors import DatabaseError
+from simcore_postgres_database.aiopg_errors import DatabaseError
from ..db.plugin import get_database_engine
from ._authz_access_model import (
diff --git a/services/web/server/src/simcore_service_webserver/session/settings.py b/services/web/server/src/simcore_service_webserver/session/settings.py
index 74a7f18f2e9..4e1c99dac68 100644
--- a/services/web/server/src/simcore_service_webserver/session/settings.py
+++ b/services/web/server/src/simcore_service_webserver/session/settings.py
@@ -7,7 +7,7 @@
from settings_library.base import BaseCustomSettings
from settings_library.utils_session import MixinSessionSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
_MINUTE: Final[int] = 60 # secs
diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
index 078c22e8cf7..85a618f15d1 100644
--- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py
@@ -19,7 +19,8 @@
from ..groups.api import list_user_groups_ids_with_read_access
from ..login.decorators import login_required
-from ..products.api import Product, get_current_product
+from ..products import products_web
+from ..products.models import Product
from ..resource_manager.user_sessions import managed_resource
from ._utils import EnvironDict, SocketID, get_socket_server, register_socketio_handler
from .messages import SOCKET_IO_HEARTBEAT_EVENT, send_message_to_user
@@ -51,7 +52,7 @@ async def _handler(request: web.Request) -> tuple[UserID, ProductName]:
app = request.app
user_id = UserID(request.get(RQT_USERID_KEY, _ANONYMOUS_USER_ID))
client_session_id = request.query.get("client_session_id", None)
- product: Product = get_current_product(request)
+ product: Product = products_web.get_current_product(request)
_logger.debug(
"client %s,%s authenticated", f"{user_id=}", f"{client_session_id=}"
diff --git a/services/web/server/src/simcore_service_webserver/socketio/plugin.py b/services/web/server/src/simcore_service_webserver/socketio/plugin.py
index 20ceef31053..86d19aceeac 100644
--- a/services/web/server/src/simcore_service_webserver/socketio/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/socketio/plugin.py
@@ -9,7 +9,7 @@
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
from ..rabbitmq import setup_rabbitmq
from ._observer import setup_socketio_observer_events
from .server import setup_socketio_server
diff --git a/services/web/server/src/simcore_service_webserver/statics/_constants.py b/services/web/server/src/simcore_service_webserver/statics/_constants.py
index ec85d8114a5..72f4b298276 100644
--- a/services/web/server/src/simcore_service_webserver/statics/_constants.py
+++ b/services/web/server/src/simcore_service_webserver/statics/_constants.py
@@ -1,22 +1,4 @@
-# these are the apps built right now by static-webserver/client
-
-FRONTEND_APPS_AVAILABLE = frozenset(
- {
- "osparc",
- "tis",
- "tiplite",
- "s4l",
- "s4llite",
- "s4lacad",
- "s4lengine",
- "s4ldesktop",
- "s4ldesktopacad",
- }
-)
-FRONTEND_APP_DEFAULT = "osparc"
-
-assert FRONTEND_APP_DEFAULT in FRONTEND_APPS_AVAILABLE # nosec
-
+from ..constants import FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE
STATIC_DIRNAMES = FRONTEND_APPS_AVAILABLE | {"resource", "transpiled"}
@@ -24,3 +6,11 @@
APP_FRONTEND_CACHED_STATICS_JSON_KEY = f"{__name__}.cached_statics_json"
APP_CLIENTAPPS_SETTINGS_KEY = f"{__file__}.client_apps_settings"
+
+
+__all__: tuple[str, ...] = (
+ "FRONTEND_APPS_AVAILABLE",
+ "FRONTEND_APP_DEFAULT",
+)
+
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/statics/_events.py b/services/web/server/src/simcore_service_webserver/statics/_events.py
index b34f7e8948a..1d1e1912004 100644
--- a/services/web/server/src/simcore_service_webserver/statics/_events.py
+++ b/services/web/server/src/simcore_service_webserver/statics/_events.py
@@ -7,6 +7,7 @@
from aiohttp.client import ClientSession
from aiohttp.client_exceptions import ClientConnectionError, ClientError
from common_library.json_serialization import json_dumps
+from packaging.version import Version
from servicelib.aiohttp.client_session import get_client_session
from tenacity.asyncio import AsyncRetrying
from tenacity.before import before_log
@@ -15,9 +16,9 @@
from tenacity.wait import wait_fixed
from yarl import URL
-from .._constants import APP_PRODUCTS_KEY
from ..application_settings import ApplicationSettings, get_application_settings
-from ..products.api import Product
+from ..constants import APP_PRODUCTS_KEY
+from ..products.models import Product
from ._constants import (
APP_FRONTEND_CACHED_INDEXES_KEY,
APP_FRONTEND_CACHED_STATICS_JSON_KEY,
@@ -93,6 +94,11 @@ async def create_cached_indexes(app: web.Application) -> None:
app[APP_FRONTEND_CACHED_INDEXES_KEY] = cached_indexes
+def _get_release_notes_vtag(vtag: str) -> str:
+ version = Version(vtag)
+ return f"v{version.major}.{version.minor}.0"
+
+
async def create_and_cache_statics_json(app: web.Application) -> None:
# NOTE: in devel model, the folder might be under construction
# (qx-compile takes time), therefore we create statics.json
@@ -132,7 +138,8 @@ async def create_and_cache_statics_json(app: web.Application) -> None:
):
# template URL should be somethign like:
# https://github.com/ITISFoundation/osparc-issues/blob/master/release-notes/osparc/{vtag}.md
- data["vcsReleaseUrl"] = template_url.format(vtag=vtag)
+ release_vtag = _get_release_notes_vtag(vtag)
+ data["vcsReleaseUrl"] = template_url.format(vtag=release_vtag)
data_json = json_dumps(data)
_logger.debug("Front-end statics.json: %s", data_json)
diff --git a/services/web/server/src/simcore_service_webserver/statics/_handlers.py b/services/web/server/src/simcore_service_webserver/statics/_handlers.py
index ecda8a0a83e..0f37438e69c 100644
--- a/services/web/server/src/simcore_service_webserver/statics/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/statics/_handlers.py
@@ -3,7 +3,7 @@
from aiohttp import web
from servicelib.mimetype_constants import MIMETYPE_TEXT_HTML
-from ..products.api import get_product_name
+from ..products import products_web
from ._constants import (
APP_FRONTEND_CACHED_INDEXES_KEY,
APP_FRONTEND_CACHED_STATICS_JSON_KEY,
@@ -14,7 +14,7 @@
async def get_cached_frontend_index(request: web.Request):
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
assert ( # nosec
product_name in FRONTEND_APPS_AVAILABLE
@@ -38,7 +38,7 @@ async def get_cached_frontend_index(request: web.Request):
async def get_statics_json(request: web.Request):
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
return web.Response(
body=request.app[APP_FRONTEND_CACHED_STATICS_JSON_KEY].get(product_name, None),
diff --git a/services/web/server/src/simcore_service_webserver/statics/plugin.py b/services/web/server/src/simcore_service_webserver/statics/plugin.py
index 4178325851f..07c30033fe8 100644
--- a/services/web/server/src/simcore_service_webserver/statics/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/statics/plugin.py
@@ -11,7 +11,7 @@
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import INDEX_RESOURCE_NAME
+from ..constants import INDEX_RESOURCE_NAME
from ..products.plugin import setup_products
from ._events import create_and_cache_statics_json, create_cached_indexes
from ._handlers import get_cached_frontend_index, get_statics_json
diff --git a/services/web/server/src/simcore_service_webserver/statics/settings.py b/services/web/server/src/simcore_service_webserver/statics/settings.py
index 32c3b740220..3915c59a156 100644
--- a/services/web/server/src/simcore_service_webserver/statics/settings.py
+++ b/services/web/server/src/simcore_service_webserver/statics/settings.py
@@ -13,7 +13,7 @@
TypedDict,
)
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class ThirdPartyInfoDict(TypedDict):
diff --git a/services/web/server/src/simcore_service_webserver/storage/_handlers.py b/services/web/server/src/simcore_service_webserver/storage/_handlers.py
deleted file mode 100644
index 83372296dd2..00000000000
--- a/services/web/server/src/simcore_service_webserver/storage/_handlers.py
+++ /dev/null
@@ -1,346 +0,0 @@
-""" Handlers exposed by storage subsystem
-
- Mostly resolves and redirect to storage API
-"""
-import logging
-from typing import Any, Final, NamedTuple
-
-from aiohttp import ClientTimeout, web
-from models_library.api_schemas_storage import (
- FileUploadCompleteResponse,
- FileUploadCompletionBody,
- FileUploadSchema,
- LinkType,
-)
-from models_library.projects_nodes_io import LocationID
-from models_library.utils.fastapi_encoders import jsonable_encoder
-from pydantic import AnyUrl, BaseModel, ByteSize, TypeAdapter
-from servicelib.aiohttp.client_session import get_client_session
-from servicelib.aiohttp.requests_validation import (
- parse_request_body_as,
- parse_request_path_parameters_as,
- parse_request_query_parameters_as,
-)
-from servicelib.aiohttp.rest_responses import create_data_response, unwrap_envelope
-from servicelib.common_headers import X_FORWARDED_PROTO
-from servicelib.request_keys import RQT_USERID_KEY
-from yarl import URL
-
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from .schemas import StorageFileIDStr
-from .settings import StorageSettings, get_plugin_settings
-
-log = logging.getLogger(__name__)
-
-
-def _get_base_storage_url(app: web.Application) -> URL:
- settings: StorageSettings = get_plugin_settings(app)
- return URL(settings.base_url, encoded=True)
-
-
-def _get_storage_vtag(app: web.Application) -> str:
- settings: StorageSettings = get_plugin_settings(app)
- storage_prefix: str = settings.STORAGE_VTAG
- return storage_prefix
-
-
-def _to_storage_url(request: web.Request) -> URL:
- """Converts web-api url to storage-api url"""
- userid = request[RQT_USERID_KEY]
-
- # storage service API endpoint
- url = _get_base_storage_url(request.app)
-
- basepath_index = 3
- # strip basepath from webserver API path (i.e. webserver api version)
- # >>> URL('http://storage:1234/v5/storage/asdf/').raw_parts[3:]
- suffix = "/".join(request.url.raw_parts[basepath_index:])
-
- return (
- url.joinpath(suffix, encoded=True)
- .with_query(request.query)
- .update_query(user_id=userid)
- )
-
-
-def _from_storage_url(request: web.Request, storage_url: AnyUrl) -> AnyUrl:
- """Converts storage-api url to web-api url"""
- assert storage_url.path # nosec
-
- prefix = f"/{_get_storage_vtag(request.app)}"
- converted_url = request.url.with_path(
- f"/v0/storage{storage_url.path.removeprefix(prefix)}", encoded=True
- ).with_scheme(request.headers.get(X_FORWARDED_PROTO, request.url.scheme))
-
- webserver_url: AnyUrl = TypeAdapter(AnyUrl).validate_python(f"{converted_url}")
- return webserver_url
-
-
-class _ResponseTuple(NamedTuple):
- payload: Any
- status_code: int
-
-
-async def _forward_request_to_storage(
- request: web.Request, method: str, body: dict[str, Any] | None = None, **kwargs
-) -> _ResponseTuple:
- url = _to_storage_url(request)
- session = get_client_session(request.app)
-
- async with session.request(
- method.upper(), url, ssl=False, json=body, **kwargs
- ) as resp:
- payload = await resp.json()
- return _ResponseTuple(payload=payload, status_code=resp.status)
-
-
-# ---------------------------------------------------------------------
-
-routes = web.RouteTableDef()
-_path_prefix = f"/{API_VTAG}/storage/locations"
-
-
-@routes.get(_path_prefix, name="get_storage_locations")
-@login_required
-@permission_required("storage.files.*")
-async def get_storage_locations(request: web.Request) -> web.Response:
- payload, status = await _forward_request_to_storage(request, "GET", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.get(_path_prefix + "/{location_id}/datasets", name="get_datasets_metadata")
-@login_required
-@permission_required("storage.files.*")
-async def get_datasets_metadata(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
-
- parse_request_path_parameters_as(_PathParams, request)
-
- payload, status = await _forward_request_to_storage(request, "GET", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.get(
- _path_prefix + "/{location_id}/files/metadata",
- name="get_files_metadata",
-)
-@login_required
-@permission_required("storage.files.*")
-async def get_files_metadata(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
-
- parse_request_path_parameters_as(_PathParams, request)
-
- class _QueryParams(BaseModel):
- uuid_filter: str = ""
- expand_dirs: bool = True
-
- parse_request_query_parameters_as(_QueryParams, request)
-
- payload, status = await _forward_request_to_storage(request, "GET", body=None)
- return create_data_response(payload, status=status)
-
-
-_LIST_ALL_DATASETS_TIMEOUT_S: Final[int] = 60
-
-
-@routes.get(
- _path_prefix + "/{location_id}/datasets/{dataset_id}/metadata",
- name="get_files_metadata_dataset",
-)
-@login_required
-@permission_required("storage.files.*")
-async def get_files_metadata_dataset(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- dataset_id: str
-
- parse_request_path_parameters_as(_PathParams, request)
-
- class _QueryParams(BaseModel):
- uuid_filter: str = ""
- expand_dirs: bool = True
-
- parse_request_query_parameters_as(_QueryParams, request)
-
- payload, status = await _forward_request_to_storage(
- request,
- "GET",
- body=None,
- timeout=ClientTimeout(total=_LIST_ALL_DATASETS_TIMEOUT_S),
- )
- return create_data_response(payload, status=status)
-
-
-@routes.get(
- _path_prefix + "/{location_id}/files/{file_id}/metadata",
- name="get_file_metadata",
-)
-@login_required
-@permission_required("storage.files.*")
-async def get_file_metadata(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
-
- payload, status = await _forward_request_to_storage(request, "GET")
- return create_data_response(payload, status=status)
-
-
-@routes.get(
- _path_prefix + "/{location_id}/files/{file_id}",
- name="download_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def download_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
-
- class _QueryParams(BaseModel):
- link_type: LinkType = LinkType.PRESIGNED
-
- parse_request_query_parameters_as(_QueryParams, request)
-
- payload, status = await _forward_request_to_storage(request, "GET", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.put(
- _path_prefix + "/{location_id}/files/{file_id}",
- name="upload_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def upload_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
-
- class _QueryParams(BaseModel):
- file_size: ByteSize | None = None
- link_type: LinkType = LinkType.PRESIGNED
- is_directory: bool = False
-
- parse_request_query_parameters_as(_QueryParams, request)
-
- payload, status = await _forward_request_to_storage(request, "PUT", body=None)
- data, _ = unwrap_envelope(payload)
- file_upload_schema = FileUploadSchema.model_validate(data)
- file_upload_schema.links.complete_upload = _from_storage_url(
- request, file_upload_schema.links.complete_upload
- )
- file_upload_schema.links.abort_upload = _from_storage_url(
- request, file_upload_schema.links.abort_upload
- )
- return create_data_response(jsonable_encoder(file_upload_schema), status=status)
-
-
-@routes.post(
- _path_prefix + "/{location_id}/files/{file_id}:complete",
- name="complete_upload_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def complete_upload_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
- body_item = await parse_request_body_as(FileUploadCompletionBody, request)
-
- payload, status = await _forward_request_to_storage(
- request, "POST", body=body_item.model_dump()
- )
- data, _ = unwrap_envelope(payload)
- file_upload_complete = FileUploadCompleteResponse.model_validate(data)
- file_upload_complete.links.state = _from_storage_url(
- request, file_upload_complete.links.state
- )
- return create_data_response(jsonable_encoder(file_upload_complete), status=status)
-
-
-@routes.post(
- _path_prefix + "/{location_id}/files/{file_id}:abort",
- name="abort_upload_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def abort_upload_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
-
- payload, status = await _forward_request_to_storage(request, "POST", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.post(
- _path_prefix + "/{location_id}/files/{file_id}:complete/futures/{future_id}",
- name="is_completed_upload_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def is_completed_upload_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
- future_id: str
-
- parse_request_path_parameters_as(_PathParams, request)
-
- payload, status = await _forward_request_to_storage(request, "POST", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.delete(
- _path_prefix + "/{location_id}/files/{file_id}",
- name="delete_file",
-)
-@login_required
-@permission_required("storage.files.*")
-async def delete_file(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
- file_id: StorageFileIDStr
-
- parse_request_path_parameters_as(_PathParams, request)
-
- payload, status = await _forward_request_to_storage(request, "DELETE", body=None)
- return create_data_response(payload, status=status)
-
-
-@routes.post(
- _path_prefix + "/{location_id}:sync",
- name="synchronise_meta_data_table",
-)
-@login_required
-@permission_required("storage.files.sync")
-async def synchronise_meta_data_table(request: web.Request) -> web.Response:
- class _PathParams(BaseModel):
- location_id: LocationID
-
- parse_request_path_parameters_as(_PathParams, request)
-
- class _QueryParams(BaseModel):
- dry_run: bool = False
- fire_and_forget: bool = False
-
- parse_request_query_parameters_as(_QueryParams, request)
-
- payload, status = await _forward_request_to_storage(request, "POST", body=None)
- return create_data_response(payload, status=status)
diff --git a/services/web/server/src/simcore_service_webserver/storage/_rest.py b/services/web/server/src/simcore_service_webserver/storage/_rest.py
new file mode 100644
index 00000000000..fbc419d9015
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/storage/_rest.py
@@ -0,0 +1,475 @@
+"""Handlers exposed by storage subsystem
+
+Mostly resolves and redirect to storage API
+"""
+
+import logging
+import urllib.parse
+from typing import Any, Final, NamedTuple
+from urllib.parse import quote, unquote
+
+from aiohttp import ClientTimeout, web
+from models_library.api_schemas_long_running_tasks.tasks import (
+ TaskGet,
+)
+from models_library.api_schemas_rpc_async_jobs.async_jobs import (
+ AsyncJobNameData,
+)
+from models_library.api_schemas_storage.storage_schemas import (
+ FileUploadCompleteResponse,
+ FileUploadCompletionBody,
+ FileUploadSchema,
+ LinkType,
+)
+from models_library.api_schemas_webserver.storage import (
+ DataExportPost,
+ StoragePathComputeSizeParams,
+)
+from models_library.projects_nodes_io import LocationID
+from models_library.utils.change_case import camel_to_snake
+from models_library.utils.fastapi_encoders import jsonable_encoder
+from pydantic import AnyUrl, BaseModel, ByteSize, TypeAdapter
+from servicelib.aiohttp import status
+from servicelib.aiohttp.client_session import get_client_session
+from servicelib.aiohttp.requests_validation import (
+ parse_request_body_as,
+ parse_request_path_parameters_as,
+ parse_request_query_parameters_as,
+)
+from servicelib.aiohttp.rest_responses import create_data_response
+from servicelib.common_headers import X_FORWARDED_PROTO
+from servicelib.rabbitmq.rpc_interfaces.storage.data_export import start_data_export
+from servicelib.rabbitmq.rpc_interfaces.storage.paths import (
+ compute_path_size as remote_compute_path_size,
+)
+from servicelib.request_keys import RQT_USERID_KEY
+from servicelib.rest_responses import unwrap_envelope
+from yarl import URL
+
+from .._meta import API_VTAG
+from ..login.decorators import login_required
+from ..models import RequestContext
+from ..rabbitmq import get_rabbitmq_rpc_client
+from ..security.decorators import permission_required
+from ..tasks._exception_handlers import handle_data_export_exceptions
+from .schemas import StorageFileIDStr
+from .settings import StorageSettings, get_plugin_settings
+
+log = logging.getLogger(__name__)
+
+
+def _get_base_storage_url(app: web.Application) -> URL:
+ settings: StorageSettings = get_plugin_settings(app)
+ return URL(settings.base_url, encoded=True)
+
+
+def _get_storage_vtag(app: web.Application) -> str:
+ settings: StorageSettings = get_plugin_settings(app)
+ storage_prefix: str = settings.STORAGE_VTAG
+ return storage_prefix
+
+
+def _to_storage_url(request: web.Request) -> URL:
+ """Converts web-api url to storage-api url"""
+ userid = request[RQT_USERID_KEY]
+
+ # storage service API endpoint
+ url = _get_base_storage_url(request.app)
+
+ basepath_index = 3
+ # strip basepath from webserver API path (i.e. webserver api version)
+ # >>> URL('http://storage:1234/v5/storage/asdf/').raw_parts[3:]
+ suffix = "/".join(request.url.parts[basepath_index:])
+ # we need to quote anything before the column, but not the column
+ if (column_index := suffix.find(":")) > 0:
+ fastapi_encoded_suffix = (
+ urllib.parse.quote(suffix[:column_index], safe="/") + suffix[column_index:]
+ )
+ else:
+ fastapi_encoded_suffix = urllib.parse.quote(suffix, safe="/")
+
+ return (
+ url.joinpath(fastapi_encoded_suffix, encoded=True)
+ .with_query({camel_to_snake(k): v for k, v in request.query.items()})
+ .update_query(user_id=userid)
+ )
+
+
+def _from_storage_url(
+ request: web.Request, storage_url: AnyUrl, url_encode: str | None
+) -> AnyUrl:
+ """Converts storage-api url to web-api url"""
+ assert storage_url.path # nosec
+
+ prefix = f"/{_get_storage_vtag(request.app)}"
+ converted_url = str(
+ request.url.with_path(
+ f"/v0/storage{storage_url.path.removeprefix(prefix)}", encoded=True
+ ).with_scheme(request.headers.get(X_FORWARDED_PROTO, request.url.scheme))
+ )
+ if url_encode:
+ converted_url = converted_url.replace(
+ url_encode, quote(unquote(url_encode), safe="")
+ )
+
+ webserver_url: AnyUrl = TypeAdapter(AnyUrl).validate_python(f"{converted_url}")
+ return webserver_url
+
+
+class _ResponseTuple(NamedTuple):
+ payload: Any
+ status_code: int
+
+
+async def _forward_request_to_storage(
+ request: web.Request,
+ method: str,
+ body: dict[str, Any] | None = None,
+ **kwargs,
+) -> _ResponseTuple:
+ url = _to_storage_url(request)
+ session = get_client_session(request.app)
+
+ async with session.request(
+ method.upper(), url, ssl=False, json=body, **kwargs
+ ) as resp:
+ match resp.status:
+ case status.HTTP_422_UNPROCESSABLE_ENTITY:
+ raise web.HTTPUnprocessableEntity(
+ reason=await resp.text(), content_type=resp.content_type
+ )
+ case status.HTTP_404_NOT_FOUND:
+ raise web.HTTPNotFound(reason=await resp.text())
+ case _ if resp.status >= status.HTTP_400_BAD_REQUEST:
+ raise web.HTTPError(reason=await resp.text())
+ case _:
+ payload = await resp.json()
+ return _ResponseTuple(payload=payload, status_code=resp.status)
+
+
+# ---------------------------------------------------------------------
+
+routes = web.RouteTableDef()
+_storage_prefix = f"/{API_VTAG}/storage"
+_storage_locations_prefix = f"{_storage_prefix}/locations"
+
+
+@routes.get(_storage_locations_prefix, name="list_storage_locations")
+@login_required
+@permission_required("storage.files.*")
+async def list_storage_locations(request: web.Request) -> web.Response:
+ payload, resp_status = await _forward_request_to_storage(request, "GET", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.get(
+ f"{_storage_locations_prefix}/{{location_id}}/paths", name="list_storage_paths"
+)
+@login_required
+@permission_required("storage.files.*")
+async def list_paths(request: web.Request) -> web.Response:
+ payload, resp_status = await _forward_request_to_storage(request, "GET", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.post(
+ f"{_storage_locations_prefix}/{{location_id}}/paths/{{path}}:size",
+ name="compute_path_size",
+)
+@login_required
+@permission_required("storage.files.*")
+async def compute_path_size(request: web.Request) -> web.Response:
+ req_ctx = RequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(
+ StoragePathComputeSizeParams, request
+ )
+
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+ async_job, _ = await remote_compute_path_size(
+ rabbitmq_rpc_client,
+ user_id=req_ctx.user_id,
+ product_name=req_ctx.product_name,
+ location_id=path_params.location_id,
+ path=path_params.path,
+ )
+
+ _job_id = f"{async_job.job_id}"
+ return create_data_response(
+ TaskGet(
+ task_id=_job_id,
+ task_name=_job_id,
+ status_href=f"{request.url.with_path(str(request.app.router['get_async_job_status'].url_for(task_id=_job_id)))}",
+ abort_href=f"{request.url.with_path(str(request.app.router['abort_async_job'].url_for(task_id=_job_id)))}",
+ result_href=f"{request.url.with_path(str(request.app.router['get_async_job_result'].url_for(task_id=_job_id)))}",
+ ),
+ status=status.HTTP_202_ACCEPTED,
+ )
+
+
+@routes.get(
+ _storage_locations_prefix + "/{location_id}/datasets", name="list_datasets_metadata"
+)
+@login_required
+@permission_required("storage.files.*")
+async def list_datasets_metadata(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "GET", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.get(
+ _storage_locations_prefix + "/{location_id}/files/metadata",
+ name="get_files_metadata",
+)
+@login_required
+@permission_required("storage.files.*")
+async def get_files_metadata(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ class _QueryParams(BaseModel):
+ uuid_filter: str = ""
+ expand_dirs: bool = True
+
+ parse_request_query_parameters_as(_QueryParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "GET", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+_LIST_ALL_DATASETS_TIMEOUT_S: Final[int] = 60
+
+
+@routes.get(
+ _storage_locations_prefix + "/{location_id}/datasets/{dataset_id}/metadata",
+ name="list_dataset_files_metadata",
+)
+@login_required
+@permission_required("storage.files.*")
+async def list_dataset_files_metadata(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ dataset_id: str
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ class _QueryParams(BaseModel):
+ uuid_filter: str = ""
+ expand_dirs: bool = True
+
+ parse_request_query_parameters_as(_QueryParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(
+ request,
+ "GET",
+ body=None,
+ timeout=ClientTimeout(total=_LIST_ALL_DATASETS_TIMEOUT_S),
+ )
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.get(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}/metadata",
+ name="get_file_metadata",
+)
+@login_required
+@permission_required("storage.files.*")
+async def get_file_metadata(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "GET")
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.get(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}",
+ name="download_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def download_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ class _QueryParams(BaseModel):
+ link_type: LinkType = LinkType.PRESIGNED
+
+ parse_request_query_parameters_as(_QueryParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "GET", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.put(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}",
+ name="upload_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def upload_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ path_params = parse_request_path_parameters_as(_PathParams, request)
+
+ class _QueryParams(BaseModel):
+ file_size: ByteSize | None = None
+ link_type: LinkType = LinkType.PRESIGNED
+ is_directory: bool = False
+
+ parse_request_query_parameters_as(_QueryParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "PUT", body=None)
+ data, _ = unwrap_envelope(payload)
+ file_upload_schema = FileUploadSchema.model_validate(data)
+ # NOTE: since storage is fastapi-based it returns file_id not url encoded and aiohttp does not like it
+ # /v0/locations/{location_id}/files/{file_id:non-encoded-containing-slashes}:complete --> /v0/storage/locations/{location_id}/files/{file_id:non-encode}:complete
+ storage_encoded_file_id = quote(path_params.file_id, safe="/")
+ file_upload_schema.links.complete_upload = _from_storage_url(
+ request,
+ file_upload_schema.links.complete_upload,
+ url_encode=storage_encoded_file_id,
+ )
+ file_upload_schema.links.abort_upload = _from_storage_url(
+ request,
+ file_upload_schema.links.abort_upload,
+ url_encode=storage_encoded_file_id,
+ )
+ return create_data_response(
+ jsonable_encoder(file_upload_schema), status=resp_status
+ )
+
+
+@routes.post(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}:complete",
+ name="complete_upload_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def complete_upload_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ path_params = parse_request_path_parameters_as(_PathParams, request)
+ body_item = await parse_request_body_as(FileUploadCompletionBody, request)
+
+ payload, resp_status = await _forward_request_to_storage(
+ request, "POST", body=body_item.model_dump()
+ )
+ data, _ = unwrap_envelope(payload)
+ storage_encoded_file_id = quote(path_params.file_id, safe="/")
+ file_upload_complete = FileUploadCompleteResponse.model_validate(data)
+ file_upload_complete.links.state = _from_storage_url(
+ request, file_upload_complete.links.state, url_encode=storage_encoded_file_id
+ )
+ return create_data_response(
+ jsonable_encoder(file_upload_complete), status=resp_status
+ )
+
+
+@routes.post(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}:abort",
+ name="abort_upload_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def abort_upload_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "POST", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.post(
+ _storage_locations_prefix
+ + "/{location_id}/files/{file_id}:complete/futures/{future_id}",
+ name="is_completed_upload_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def is_completed_upload_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+ future_id: str
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(request, "POST", body=None)
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.delete(
+ _storage_locations_prefix + "/{location_id}/files/{file_id}",
+ name="delete_file",
+)
+@login_required
+@permission_required("storage.files.*")
+async def delete_file(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+ file_id: StorageFileIDStr
+
+ parse_request_path_parameters_as(_PathParams, request)
+
+ payload, resp_status = await _forward_request_to_storage(
+ request, "DELETE", body=None
+ )
+ return create_data_response(payload, status=resp_status)
+
+
+@routes.post(
+ _storage_locations_prefix + "/{location_id}/export-data", name="export_data"
+)
+@login_required
+@permission_required("storage.files.*")
+@handle_data_export_exceptions
+async def export_data(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ location_id: LocationID
+
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+ _req_ctx = RequestContext.model_validate(request)
+ _path_params = parse_request_path_parameters_as(_PathParams, request)
+ data_export_post = await parse_request_body_as(
+ model_schema_cls=DataExportPost, request=request
+ )
+ async_job_rpc_get = await start_data_export(
+ rabbitmq_rpc_client=rabbitmq_rpc_client,
+ job_id_data=AsyncJobNameData(
+ user_id=_req_ctx.user_id, product_name=_req_ctx.product_name
+ ),
+ data_export_start=data_export_post.to_rpc_schema(
+ location_id=_path_params.location_id,
+ ),
+ )
+ _job_id = f"{async_job_rpc_get.job_id}"
+ return create_data_response(
+ TaskGet(
+ task_id=_job_id,
+ task_name=_job_id,
+ status_href=f"{request.url.with_path(str(request.app.router['get_async_job_status'].url_for(task_id=_job_id)))}",
+ abort_href=f"{request.url.with_path(str(request.app.router['abort_async_job'].url_for(task_id=_job_id)))}",
+ result_href=f"{request.url.with_path(str(request.app.router['get_async_job_result'].url_for(task_id=_job_id)))}",
+ ),
+ status=status.HTTP_202_ACCEPTED,
+ )
diff --git a/services/web/server/src/simcore_service_webserver/storage/api.py b/services/web/server/src/simcore_service_webserver/storage/api.py
index 8e1ad334beb..9d65ac3faf3 100644
--- a/services/web/server/src/simcore_service_webserver/storage/api.py
+++ b/services/web/server/src/simcore_service_webserver/storage/api.py
@@ -1,6 +1,4 @@
-""" Storage subsystem's API: responsible of communication with storage service
-
-"""
+"""Storage subsystem's API: responsible of communication with storage service"""
import asyncio
import logging
@@ -9,7 +7,7 @@
from typing import Any, Final
from aiohttp import ClientError, ClientSession, ClientTimeout, web
-from models_library.api_schemas_storage import (
+from models_library.api_schemas_storage.storage_schemas import (
FileLocation,
FileLocationArray,
FileMetaDataGet,
@@ -48,7 +46,7 @@ def _get_storage_client(app: web.Application) -> tuple[ClientSession, URL]:
return session, endpoint
-async def get_storage_locations(
+async def list_storage_locations(
app: web.Application, user_id: UserID
) -> FileLocationArray:
_logger.debug("getting %s accessible locations...", f"{user_id=}")
diff --git a/services/web/server/src/simcore_service_webserver/storage/plugin.py b/services/web/server/src/simcore_service_webserver/storage/plugin.py
index 104a9c37319..e0c17eb8b0f 100644
--- a/services/web/server/src/simcore_service_webserver/storage/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/storage/plugin.py
@@ -7,9 +7,9 @@
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
from ..rest.plugin import setup_rest
-from . import _handlers
+from . import _rest
_logger = logging.getLogger(__name__)
@@ -21,4 +21,4 @@ def setup_storage(app: web.Application):
assert app[APP_SETTINGS_KEY].WEBSERVER_STORAGE # nosec
setup_rest(app)
- app.router.add_routes(_handlers.routes)
+ app.router.add_routes(_rest.routes)
diff --git a/services/web/server/src/simcore_service_webserver/storage/schemas.py b/services/web/server/src/simcore_service_webserver/storage/schemas.py
index 26381218c0e..9840e89e225 100644
--- a/services/web/server/src/simcore_service_webserver/storage/schemas.py
+++ b/services/web/server/src/simcore_service_webserver/storage/schemas.py
@@ -1,7 +1,6 @@
from enum import Enum
from typing import Any, TypeAlias
-from models_library.api_schemas_storage import TableSynchronisation
from pydantic import BaseModel, ConfigDict, Field, RootModel
# NOTE: storage generates URLs that contain double encoded
@@ -79,11 +78,6 @@ class FileLocationEnveloped(BaseModel):
error: Any | None = None
-class TableSynchronisationEnveloped(BaseModel):
- data: TableSynchronisation
- error: Any
-
-
class FileUploadEnveloped(BaseModel):
data: FileUploadSchema
error: Any
diff --git a/services/web/server/src/simcore_service_webserver/storage/settings.py b/services/web/server/src/simcore_service_webserver/storage/settings.py
index 04ac00f61c3..38d9befd914 100644
--- a/services/web/server/src/simcore_service_webserver/storage/settings.py
+++ b/services/web/server/src/simcore_service_webserver/storage/settings.py
@@ -6,7 +6,7 @@
from settings_library.utils_service import DEFAULT_AIOHTTP_PORT, MixinServiceSettings
from yarl import URL
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class StorageSettings(BaseCustomSettings, MixinServiceSettings):
diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py
index 788ca886593..a4adbf8e576 100644
--- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py
+++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py
@@ -1,8 +1,8 @@
-""" Projects management
+"""Projects management
- Keeps functionality that couples with the following app modules
- - projects
- - TMP: add_new_project includes to projects and director_v2 app modules!
+Keeps functionality that couples with the following app modules
+ - projects
+ - TMP: add_new_project includes to projects and director_v2 app modules!
"""
@@ -12,18 +12,18 @@
from typing import NamedTuple
from aiohttp import web
+from models_library.api_schemas_webserver.projects_ui import StudyUI
from models_library.projects import DateTimeStr, Project, ProjectID
from models_library.projects_access import AccessRights, GroupIDStr
from models_library.projects_nodes import Node
from models_library.projects_nodes_io import DownloadLink, NodeID, PortLink
-from models_library.projects_ui import StudyUI
from models_library.services import ServiceKey, ServiceVersion
from pydantic import AnyUrl, HttpUrl, TypeAdapter
from servicelib.logging_utils import log_decorator
-from ..projects.db import ProjectDBAPI
+from ..projects._projects_repository_legacy import ProjectDBAPI
+from ..projects._projects_service import get_project_for_user
from ..projects.exceptions import ProjectInvalidRightsError, ProjectNotFoundError
-from ..projects.projects_service import get_project_for_user
from ..utils import now_str
from ._core import compose_uuid_from
from ._models import FileParams, ServiceInfo, ViewerInfo
@@ -97,12 +97,16 @@ def _create_project(
name=name,
description=description,
thumbnail=thumbnail,
- prjOwner=owner.email,
- accessRights={GroupIDStr(owner.primary_gid): access_rights},
- creationDate=DateTimeStr(now_str()),
- lastChangeDate=DateTimeStr(now_str()),
+ prj_owner=owner.email,
+ access_rights={GroupIDStr(owner.primary_gid): access_rights},
+ creation_date=DateTimeStr(now_str()),
+ last_change_date=DateTimeStr(now_str()),
workbench=workbench,
- ui=StudyUI(workbench=workbench_ui), # type: ignore[arg-type]
+ ui=StudyUI.model_validate(
+ {
+ "workbench": workbench_ui,
+ }
+ ).model_dump(mode="json", exclude_unset=True),
)
@@ -184,8 +188,8 @@ async def _add_new_project(
# TODO: move this to projects_api
# TODO: this piece was taken from the end of projects.projects_handlers.create_projects
- from ..director_v2.api import create_or_update_pipeline
- from ..projects.db import APP_PROJECT_DBAPI
+ from ..director_v2.director_v2_service import create_or_update_pipeline
+ from ..projects._projects_repository_legacy import APP_PROJECT_DBAPI
db: ProjectDBAPI = app[APP_PROJECT_DBAPI]
diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects_permalinks.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects_permalinks.py
index 406982190ec..362bb7509b8 100644
--- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects_permalinks.py
+++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects_permalinks.py
@@ -11,8 +11,11 @@
)
from ..db.plugin import get_database_engine
-from ..projects.api import ProjectPermalink, register_permalink_factory
from ..projects.exceptions import PermalinkNotAllowedError, ProjectNotFoundError
+from ..projects.projects_permalink_service import (
+ ProjectPermalink,
+ register_permalink_factory,
+)
from ..utils_aiohttp import create_url_for_function
from .settings import StudiesDispatcherSettings
diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py
index 060cea75a4d..0a0d37ef17b 100644
--- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py
@@ -1,6 +1,4 @@
-""" Handles request to the viewers redirection entrypoints
-
-"""
+"""Handles request to the viewers redirection entrypoints"""
import functools
import logging
@@ -18,8 +16,8 @@
from servicelib.aiohttp.typing_extension import Handler
from servicelib.logging_errors import create_troubleshotting_log_kwargs
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..products.api import get_product_name
+from ..dynamic_scheduler import api as dynamic_scheduler_service
+from ..products import products_web
from ..utils import compose_support_error_msg
from ..utils_aiohttp import create_redirect_to_page_response
from ._catalog import ValidService, validate_requested_service
@@ -169,8 +167,7 @@ def ensure_extension_upper_and_dotless(cls, v):
return v
-class ServiceAndFileParams(FileQueryParams, ServiceParams):
- ...
+class ServiceAndFileParams(FileQueryParams, ServiceParams): ...
class ViewerQueryParams(BaseModel):
@@ -205,6 +202,7 @@ def ensure_extension_upper_and_dotless(cls, v):
| ServiceQueryParams
)
+
#
# API HANDLERS
#
@@ -250,9 +248,9 @@ async def get_redirection_to_viewer(request: web.Request):
user,
viewer,
file_params.download_link,
- product_name=get_product_name(request),
+ product_name=products_web.get_product_name(request),
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=project_id
)
@@ -281,9 +279,9 @@ async def get_redirection_to_viewer(request: web.Request):
request.app,
user,
service_info=_create_service_info_from(valid_service),
- product_name=get_product_name(request),
+ product_name=products_web.get_product_name(request),
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=project_id
)
@@ -319,9 +317,9 @@ async def get_redirection_to_viewer(request: web.Request):
project_thumbnail=get_plugin_settings(
app=request.app
).STUDIES_DEFAULT_FILE_THUMBNAIL,
- product_name=get_product_name(request),
+ product_name=products_web.get_product_name(request),
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=project_id
)
diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py
index b003ad55963..943893972fe 100644
--- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py
@@ -19,7 +19,7 @@
from pydantic.networks import HttpUrl
from .._meta import API_VTAG
-from ..products.api import get_product_name
+from ..products import products_web
from ..utils_aiohttp import envelope_json_response
from ._catalog import ServiceMetaData, iter_latest_product_services
from ._core import list_viewers_info
@@ -163,7 +163,7 @@ def remove_dot_prefix_from_extension(cls, v):
@routes.get(f"/{API_VTAG}/services", name="list_latest_services")
async def list_latest_services(request: Request):
"""Returns a list latest version of services"""
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
services = []
async for service_data in iter_latest_product_services(
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 1dec4c84956..691f6c4df69 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
@@ -1,4 +1,4 @@
-""" handles access to *public* studies
+"""handles access to *public* studies
Handles a request to share a given sharable study via '/study/{id}'
@@ -26,13 +26,13 @@
from servicelib.aiohttp.typing_extension import Handler
from servicelib.logging_errors import create_troubleshotting_log_kwargs
-from .._constants import INDEX_RESOURCE_NAME
+from ..constants import INDEX_RESOURCE_NAME
from ..director_v2._core_computations import create_or_update_pipeline
-from ..dynamic_scheduler import api as dynamic_scheduler_api
-from ..products.api import get_current_product, get_product_name
-from ..projects._groups_db import get_project_group
+from ..dynamic_scheduler import api as dynamic_scheduler_service
+from ..products import products_web
+from ..projects._groups_repository import get_project_group
+from ..projects._projects_repository_legacy import ProjectDBAPI
from ..projects.api import check_user_project_permission
-from ..projects.db import ProjectDBAPI
from ..projects.exceptions import (
ProjectGroupNotFoundError,
ProjectInvalidRightsError,
@@ -117,7 +117,7 @@ async def _get_published_template_project(
err.debug_message(),
)
- support_email = get_current_product(request).support_email
+ support_email = products_web.get_current_product(request).support_email
if only_public_projects:
raise RedirectToFrontEndPageError(
MSG_PUBLIC_PROJECT_NOT_PUBLISHED.format(support_email=support_email),
@@ -141,7 +141,7 @@ async def copy_study_to_account(
- Replaces template parameters by values passed in query
- Avoids multiple copies of the same template on each account
"""
- from ..projects.db import APP_PROJECT_DBAPI
+ from ..projects._projects_repository_legacy import APP_PROJECT_DBAPI
from ..projects.utils import clone_project_document, substitute_parameterized_inputs
db: ProjectDBAPI = request.config_dict[APP_PROJECT_DBAPI]
@@ -185,7 +185,7 @@ async def copy_study_to_account(
substitute_parameterized_inputs(project, template_parameters) or project
)
# add project model + copy data TODO: guarantee order and atomicity
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
await db.insert_project(
project,
user["id"],
@@ -212,7 +212,7 @@ async def copy_study_to_account(
await create_or_update_pipeline(
request.app, user["id"], project["uuid"], product_name
)
- await dynamic_scheduler_api.update_projects_networks(
+ await dynamic_scheduler_service.update_projects_networks(
request.app, project_id=ProjectID(project["uuid"])
)
diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py
index 531759b062f..ea7b8fecf6c 100644
--- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py
+++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py
@@ -29,7 +29,7 @@
from ..groups.api import auto_add_user_to_product_group
from ..login.storage import AsyncpgStorage, get_plugin_storage
from ..login.utils import ACTIVE, GUEST
-from ..products.api import get_product_name
+from ..products import products_web
from ..redis import get_redis_lock_manager_client
from ..security.api import (
check_user_authorized,
@@ -103,7 +103,7 @@ async def create_temporary_guest_user(request: web.Request):
db: AsyncpgStorage = get_plugin_storage(request.app)
redis_locks_client: aioredis.Redis = get_redis_lock_manager_client(request.app)
settings: StudiesDispatcherSettings = get_plugin_settings(app=request.app)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
random_user_name = "".join(
secrets.choice(string.ascii_lowercase) for _ in range(10)
diff --git a/services/web/server/src/simcore_service_webserver/tags/_rest.py b/services/web/server/src/simcore_service_webserver/tags/_rest.py
index 7550c8343ed..ea39edd6c2a 100644
--- a/services/web/server/src/simcore_service_webserver/tags/_rest.py
+++ b/services/web/server/src/simcore_service_webserver/tags/_rest.py
@@ -8,11 +8,6 @@
TagNotFoundError,
TagOperationNotAllowedError,
)
-from simcore_service_webserver.tags.errors import (
- InsufficientTagShareAccessError,
- ShareTagWithEveryoneNotAllowedError,
- ShareTagWithProductGroupNotAllowedError,
-)
from .._meta import API_VTAG as VTAG
from ..exception_handling import (
@@ -25,6 +20,11 @@
from ..security.decorators import permission_required
from ..utils_aiohttp import envelope_json_response
from . import _service
+from .errors import (
+ InsufficientTagShareAccessError,
+ ShareTagWithEveryoneNotAllowedError,
+ ShareTagWithProductGroupNotAllowedError,
+)
from .schemas import (
TagCreate,
TagGroupCreate,
diff --git a/services/web/server/src/simcore_service_webserver/tags/_service.py b/services/web/server/src/simcore_service_webserver/tags/_service.py
index be73441f224..0c28c2a462f 100644
--- a/services/web/server/src/simcore_service_webserver/tags/_service.py
+++ b/services/web/server/src/simcore_service_webserver/tags/_service.py
@@ -11,7 +11,7 @@
from simcore_postgres_database.utils_tags import TagAccessRightsDict, TagsRepo
from sqlalchemy.ext.asyncio import AsyncEngine
-from ..products.api import list_products
+from ..products import products_service
from ..users.api import get_user_role
from .errors import (
InsufficientTagShareAccessError,
@@ -70,7 +70,7 @@ async def delete_tag(app: web.Application, user_id: UserID, tag_id: IdInt):
def _is_product_group(app: web.Application, group_id: GroupID):
- products = list_products(app)
+ products = products_service.list_products(app)
return any(group_id == p.group_id for p in products)
diff --git a/services/web/server/src/simcore_service_webserver/tags/errors.py b/services/web/server/src/simcore_service_webserver/tags/errors.py
index 95fa3185972..579ed5ef125 100644
--- a/services/web/server/src/simcore_service_webserver/tags/errors.py
+++ b/services/web/server/src/simcore_service_webserver/tags/errors.py
@@ -1,8 +1,9 @@
+# pylint: disable=too-many-ancestors
+
from ..errors import WebServerBaseError
-class TagsPermissionError(WebServerBaseError, PermissionError):
- ...
+class TagsPermissionError(WebServerBaseError, PermissionError): ...
class ShareTagWithEveryoneNotAllowedError(TagsPermissionError):
diff --git a/services/web/server/src/simcore_service_webserver/tasks/__init__.py b/services/web/server/src/simcore_service_webserver/tasks/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/services/web/server/src/simcore_service_webserver/tasks/_exception_handlers.py b/services/web/server/src/simcore_service_webserver/tasks/_exception_handlers.py
new file mode 100644
index 00000000000..8e4f467cf47
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/tasks/_exception_handlers.py
@@ -0,0 +1,60 @@
+from models_library.api_schemas_rpc_async_jobs.exceptions import (
+ JobAbortedError,
+ JobError,
+ JobMissingError,
+ JobNotDoneError,
+ JobSchedulerError,
+ JobStatusError,
+)
+from models_library.api_schemas_storage.data_export_async_jobs import (
+ AccessRightError,
+ InvalidFileIdentifierError,
+)
+from servicelib.aiohttp import status
+
+from ..exception_handling import (
+ ExceptionToHttpErrorMap,
+ HttpErrorInfo,
+ exception_handling_decorator,
+ to_exceptions_handlers_map,
+)
+
+_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+ InvalidFileIdentifierError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "Could not find file {file_id}",
+ ),
+ AccessRightError: HttpErrorInfo(
+ status.HTTP_403_FORBIDDEN,
+ "Accessright error: user {user_id} does not have access to file {file_id} with location {location_id}",
+ ),
+ JobAbortedError: HttpErrorInfo(
+ status.HTTP_410_GONE,
+ "Task {job_id} is aborted",
+ ),
+ JobError: HttpErrorInfo(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ "Task {job_id} failed with exception type {exc_type} and message {exc_msg}",
+ ),
+ JobNotDoneError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "task {job_id} is not done yet",
+ ),
+ JobMissingError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "No task with id: {job_id}",
+ ),
+ JobSchedulerError: HttpErrorInfo(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ "Encountered an error with the task scheduling system",
+ ),
+ JobStatusError: HttpErrorInfo(
+ status.HTTP_500_INTERNAL_SERVER_ERROR,
+ "Encountered an error while getting the status of task {job_id}",
+ ),
+}
+
+
+handle_data_export_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+)
diff --git a/services/web/server/src/simcore_service_webserver/tasks/_rest.py b/services/web/server/src/simcore_service_webserver/tasks/_rest.py
new file mode 100644
index 00000000000..d1c74ce6da7
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/tasks/_rest.py
@@ -0,0 +1,188 @@
+"""Handlers exposed by storage subsystem
+
+Mostly resolves and redirect to storage API
+"""
+
+import logging
+from typing import Final
+from uuid import UUID
+
+from aiohttp import web
+from models_library.api_schemas_long_running_tasks.base import TaskProgress
+from models_library.api_schemas_long_running_tasks.tasks import (
+ TaskGet,
+ TaskResult,
+ TaskStatus,
+)
+from models_library.api_schemas_rpc_async_jobs.async_jobs import (
+ AsyncJobId,
+ AsyncJobNameData,
+)
+from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE
+from models_library.generics import Envelope
+from pydantic import BaseModel
+from servicelib.aiohttp import status
+from servicelib.aiohttp.client_session import get_client_session
+from servicelib.aiohttp.requests_validation import (
+ parse_request_path_parameters_as,
+)
+from servicelib.aiohttp.rest_responses import create_data_response
+from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs
+
+from .._meta import API_VTAG
+from ..login.decorators import login_required
+from ..models import RequestContext
+from ..rabbitmq import get_rabbitmq_rpc_client
+from ..security.decorators import permission_required
+from ._exception_handlers import handle_data_export_exceptions
+
+log = logging.getLogger(__name__)
+
+
+routes = web.RouteTableDef()
+
+_task_prefix: Final[str] = f"/{API_VTAG}/tasks"
+
+
+@routes.get(
+ _task_prefix,
+ name="get_async_jobs",
+)
+@login_required
+@permission_required("storage.files.*")
+@handle_data_export_exceptions
+async def get_async_jobs(request: web.Request) -> web.Response:
+ session = get_client_session(request.app)
+ async with session.request(
+ "GET",
+ request.url.with_path(str(request.app.router["list_tasks"].url_for())),
+ cookies=request.cookies,
+ ) as resp:
+ if resp.status != status.HTTP_200_OK:
+ return web.Response(
+ status=resp.status,
+ body=await resp.read(),
+ content_type=resp.content_type,
+ )
+ inprocess_tasks = (
+ Envelope[list[TaskGet]].model_validate_json(await resp.text()).data
+ )
+ assert inprocess_tasks is not None # nosec
+
+ _req_ctx = RequestContext.model_validate(request)
+
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+
+ user_async_jobs = await async_jobs.list_jobs(
+ rabbitmq_rpc_client=rabbitmq_rpc_client,
+ rpc_namespace=STORAGE_RPC_NAMESPACE,
+ job_id_data=AsyncJobNameData(
+ user_id=_req_ctx.user_id, product_name=_req_ctx.product_name
+ ),
+ filter_="",
+ )
+ return create_data_response(
+ [
+ TaskGet(
+ task_id=f"{job.job_id}",
+ task_name=f"{job.job_id}",
+ status_href=f"{request.url.with_path(str(request.app.router['get_async_job_status'].url_for(task_id=str(job.job_id))))}",
+ abort_href=f"{request.url.with_path(str(request.app.router['abort_async_job'].url_for(task_id=str(job.job_id))))}",
+ result_href=f"{request.url.with_path(str(request.app.router['get_async_job_result'].url_for(task_id=str(job.job_id))))}",
+ )
+ for job in user_async_jobs
+ ]
+ + inprocess_tasks,
+ status=status.HTTP_200_OK,
+ )
+
+
+class _StorageAsyncJobId(BaseModel):
+ task_id: AsyncJobId
+
+
+@routes.get(
+ _task_prefix + "/{task_id}",
+ name="get_async_job_status",
+)
+@login_required
+@handle_data_export_exceptions
+async def get_async_job_status(request: web.Request) -> web.Response:
+
+ _req_ctx = RequestContext.model_validate(request)
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+
+ async_job_get = parse_request_path_parameters_as(_StorageAsyncJobId, request)
+ async_job_rpc_status = await async_jobs.status(
+ rabbitmq_rpc_client=rabbitmq_rpc_client,
+ rpc_namespace=STORAGE_RPC_NAMESPACE,
+ job_id=async_job_get.task_id,
+ job_id_data=AsyncJobNameData(
+ user_id=_req_ctx.user_id, product_name=_req_ctx.product_name
+ ),
+ )
+ _task_id = f"{async_job_rpc_status.job_id}"
+ return create_data_response(
+ TaskStatus(
+ task_progress=TaskProgress(
+ task_id=_task_id, percent=async_job_rpc_status.progress.percent_value
+ ),
+ done=async_job_rpc_status.done,
+ started=None,
+ ),
+ status=status.HTTP_200_OK,
+ )
+
+
+@routes.delete(
+ _task_prefix + "/{task_id}",
+ name="abort_async_job",
+)
+@login_required
+@permission_required("storage.files.*")
+@handle_data_export_exceptions
+async def abort_async_job(request: web.Request) -> web.Response:
+
+ _req_ctx = RequestContext.model_validate(request)
+
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+ async_job_get = parse_request_path_parameters_as(_StorageAsyncJobId, request)
+ await async_jobs.cancel(
+ rabbitmq_rpc_client=rabbitmq_rpc_client,
+ rpc_namespace=STORAGE_RPC_NAMESPACE,
+ job_id=async_job_get.task_id,
+ job_id_data=AsyncJobNameData(
+ user_id=_req_ctx.user_id, product_name=_req_ctx.product_name
+ ),
+ )
+ return web.Response(status=status.HTTP_204_NO_CONTENT)
+
+
+@routes.get(
+ _task_prefix + "/{task_id}/result",
+ name="get_async_job_result",
+)
+@login_required
+@permission_required("storage.files.*")
+@handle_data_export_exceptions
+async def get_async_job_result(request: web.Request) -> web.Response:
+ class _PathParams(BaseModel):
+ task_id: UUID
+
+ _req_ctx = RequestContext.model_validate(request)
+
+ rabbitmq_rpc_client = get_rabbitmq_rpc_client(request.app)
+ async_job_get = parse_request_path_parameters_as(_PathParams, request)
+ async_job_rpc_result = await async_jobs.result(
+ rabbitmq_rpc_client=rabbitmq_rpc_client,
+ rpc_namespace=STORAGE_RPC_NAMESPACE,
+ job_id=async_job_get.task_id,
+ job_id_data=AsyncJobNameData(
+ user_id=_req_ctx.user_id, product_name=_req_ctx.product_name
+ ),
+ )
+
+ return create_data_response(
+ TaskResult(result=async_job_rpc_result.result, error=None),
+ status=status.HTTP_200_OK,
+ )
diff --git a/services/web/server/src/simcore_service_webserver/tasks/plugin.py b/services/web/server/src/simcore_service_webserver/tasks/plugin.py
new file mode 100644
index 00000000000..e9bfdeea222
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/tasks/plugin.py
@@ -0,0 +1,9 @@
+from aiohttp import web
+
+from ..rest.plugin import setup_rest
+from . import _rest
+
+
+def setup_tasks(app: web.Application):
+ setup_rest(app)
+ app.router.add_routes(_rest.routes)
diff --git a/services/web/server/src/simcore_service_webserver/templates/common/reset_password_email_failed.jinja2 b/services/web/server/src/simcore_service_webserver/templates/common/reset_password_email_failed.jinja2
deleted file mode 100644
index 86a984dff35..00000000000
--- a/services/web/server/src/simcore_service_webserver/templates/common/reset_password_email_failed.jinja2
+++ /dev/null
@@ -1,14 +0,0 @@
-Reset Password on {{ host }}
-
-Dear {{ name }},
-A request to reset your {{ host }} password has been made.
-It could not be completed due to the following reason:
- {{ reason }}
-If you did not request this, please contact us immediatly at {{ product.support_email }} for security reasons.
-Best Regards,
-The {{ product.display_name }} Team
diff --git a/services/web/server/src/simcore_service_webserver/tracing.py b/services/web/server/src/simcore_service_webserver/tracing.py
index 23041d95238..d07757106e8 100644
--- a/services/web/server/src/simcore_service_webserver/tracing.py
+++ b/services/web/server/src/simcore_service_webserver/tracing.py
@@ -5,8 +5,8 @@
from servicelib.aiohttp.tracing import setup_tracing
from settings_library.tracing import TracingSettings
-from ._constants import APP_SETTINGS_KEY
from ._meta import APP_NAME
+from .constants import APP_SETTINGS_KEY
log = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/trash/_rest.py b/services/web/server/src/simcore_service_webserver/trash/_rest.py
index f5912b042fe..d6971984086 100644
--- a/services/web/server/src/simcore_service_webserver/trash/_rest.py
+++ b/services/web/server/src/simcore_service_webserver/trash/_rest.py
@@ -1,7 +1,10 @@
+import asyncio
import logging
from aiohttp import web
from servicelib.aiohttp import status
+from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
+from servicelib.utils import fire_and_forget_task
from .._meta import API_VTAG as VTAG
from ..exception_handling import (
@@ -11,17 +14,13 @@
to_exceptions_handlers_map,
)
from ..login.decorators import get_user_id, login_required
-from ..products.api import get_product_name
+from ..products import products_web
from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError
from ..security.decorators import permission_required
from . import _service
_logger = logging.getLogger(__name__)
-#
-# EXCEPTIONS HANDLING
-#
-
_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
ProjectRunningConflictError: HttpErrorInfo(
@@ -40,21 +39,34 @@
)
-#
-# ROUTES
-#
-
routes = web.RouteTableDef()
-@routes.delete(f"/{VTAG}/trash", name="empty_trash")
+@routes.post(f"/{VTAG}/trash:empty", name="empty_trash")
@login_required
@permission_required("project.delete")
@_handle_exceptions
async def empty_trash(request: web.Request):
user_id = get_user_id(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
+
+ explicitly_trashed_project_deleted = asyncio.Event()
+
+ fire_and_forget_task(
+ _service.safe_empty_trash(
+ request.app,
+ product_name=product_name,
+ user_id=user_id,
+ on_explicitly_trashed_projects_deleted=explicitly_trashed_project_deleted,
+ ),
+ task_suffix_name="rest.empty_trash",
+ fire_and_forget_tasks_collection=request.app[APP_FIRE_AND_FORGET_TASKS_KEY],
+ )
- await _service.empty_trash(request.app, product_name=product_name, user_id=user_id)
+ # NOTE: Ensures `fire_and_forget_task` is triggered and deletes explicit projects;
+ # otherwise, when the front-end requests the trash item list,
+ # it may still display items, misleading the user into
+ # thinking the `empty trash` operation failed.
+ await explicitly_trashed_project_deleted.wait()
return web.json_response(status=status.HTTP_204_NO_CONTENT)
diff --git a/services/web/server/src/simcore_service_webserver/trash/_service.py b/services/web/server/src/simcore_service_webserver/trash/_service.py
index cc94d680d64..1f38043faf5 100644
--- a/services/web/server/src/simcore_service_webserver/trash/_service.py
+++ b/services/web/server/src/simcore_service_webserver/trash/_service.py
@@ -1,36 +1,179 @@
import asyncio
import logging
from datetime import timedelta
+from typing import Final
+import arrow
from aiohttp import web
from models_library.products import ProductName
from models_library.users import UserID
+from servicelib.logging_errors import create_troubleshotting_log_kwargs
+from servicelib.logging_utils import log_context
+from ..folders import folders_trash_service
+from ..products import products_service
+from ..projects import projects_trash_service
from .settings import get_plugin_settings
_logger = logging.getLogger(__name__)
+_TIP: Final[str] = (
+ "`empty_trash_safe` is set `fail_fast=False`."
+ "\nErrors while deletion are ignored."
+ "\nNew runs might resolve them"
+)
-async def empty_trash(app: web.Application, product_name: ProductName, user_id: UserID):
- assert app # nosec
- # filter trashed=True and set them to False
- _logger.debug(
- "CODE PLACEHOLDER: all projects marked as trashed of %s in %s are deleted",
- f"{user_id=}",
- f"{product_name=}",
+
+async def _empty_explicitly_trashed_projects(
+ app: web.Application, product_name: ProductName, user_id: UserID
+):
+ trashed_projects_ids = (
+ await projects_trash_service.list_explicitly_trashed_projects(
+ app=app, product_name=product_name, user_id=user_id
+ )
)
- raise NotImplementedError
+ with log_context(
+ _logger,
+ logging.DEBUG,
+ "Deleting %s explicitly trashed projects",
+ len(trashed_projects_ids),
+ ):
+ for project_id in trashed_projects_ids:
+ try:
+
+ await projects_trash_service.delete_explicitly_trashed_project(
+ app,
+ user_id=user_id,
+ project_id=project_id,
+ )
+
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ _logger.warning(
+ **create_troubleshotting_log_kwargs(
+ "Error deleting a trashed project while emptying trash.",
+ error=exc,
+ error_context={
+ "project_id": project_id,
+ "product_name": product_name,
+ "user_id": user_id,
+ },
+ tip=_TIP,
+ )
+ )
+
+
+async def _empty_explicitly_trashed_folders_and_content(
+ app: web.Application, product_name: ProductName, user_id: UserID
+):
+ trashed_folders_ids = await folders_trash_service.list_explicitly_trashed_folders(
+ app=app, product_name=product_name, user_id=user_id
+ )
+
+ with log_context(
+ _logger,
+ logging.DEBUG,
+ "Deleting %s trashed folders (and all its content)",
+ len(trashed_folders_ids),
+ ):
+ for folder_id in trashed_folders_ids:
+ try:
+ await folders_trash_service.delete_trashed_folder(
+ app,
+ product_name=product_name,
+ user_id=user_id,
+ folder_id=folder_id,
+ )
+
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ _logger.warning(
+ **create_troubleshotting_log_kwargs(
+ "Error deleting a trashed folders (and content) while emptying trash.",
+ error=exc,
+ error_context={
+ "folder_id": folder_id,
+ "product_name": product_name,
+ "user_id": user_id,
+ },
+ tip=_TIP,
+ )
+ )
+
+
+async def safe_empty_trash(
+ app: web.Application,
+ *,
+ product_name: ProductName,
+ user_id: UserID,
+ on_explicitly_trashed_projects_deleted: asyncio.Event | None = None
+):
+ # Delete explicitly trashed projects & notify
+ await _empty_explicitly_trashed_projects(app, product_name, user_id)
+ if on_explicitly_trashed_projects_deleted:
+ on_explicitly_trashed_projects_deleted.set()
+
+ # Delete explicitly trashed folders (and all implicitly trashed sub-folders and projects)
+ await _empty_explicitly_trashed_folders_and_content(app, product_name, user_id)
-async def prune_trash(app: web.Application) -> list[str]:
- """Deletes expired items in the trash"""
+
+async def safe_delete_expired_trash_as_admin(app: web.Application) -> None:
settings = get_plugin_settings(app)
retention = timedelta(days=settings.TRASH_RETENTION_DAYS)
+ delete_until = arrow.now().datetime - retention
- _logger.debug(
- "CODE PLACEHOLDER: **ALL** projects marked as trashed during %s days are deleted",
- retention,
- )
- await asyncio.sleep(5)
+ app_products_names = await products_service.list_products_names(app)
+
+ for product_name in app_products_names:
+
+ ctx = {
+ "delete_until": delete_until,
+ "retention": retention,
+ "product_name": product_name,
+ }
+
+ with log_context(
+ _logger,
+ logging.DEBUG,
+ "Deleting items marked as trashed before %s in %s [trashed_at < %s will be deleted]",
+ retention,
+ product_name,
+ delete_until,
+ ):
+ try:
+
+ await folders_trash_service.batch_delete_trashed_folders_as_admin(
+ app,
+ trashed_before=delete_until,
+ product_name=product_name,
+ fail_fast=False,
+ )
+
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ _logger.warning(
+ **create_troubleshotting_log_kwargs(
+ "Error batch deleting expired trashed folders as admin.",
+ error=exc,
+ error_context=ctx,
+ )
+ )
+
+ try:
+
+ deleted_project_ids = (
+ await projects_trash_service.batch_delete_trashed_projects_as_admin(
+ app,
+ trashed_before=delete_until,
+ fail_fast=False,
+ )
+ )
+
+ _logger.info("Deleted %d trashed projects", len(deleted_project_ids))
- return []
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ _logger.warning(
+ **create_troubleshotting_log_kwargs(
+ "Error batch deleting expired projects as admin.",
+ error=exc,
+ error_context=ctx,
+ )
+ )
diff --git a/services/web/server/src/simcore_service_webserver/trash/plugin.py b/services/web/server/src/simcore_service_webserver/trash/plugin.py
index a4cde641596..977a1c74884 100644
--- a/services/web/server/src/simcore_service_webserver/trash/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/trash/plugin.py
@@ -8,7 +8,7 @@
from aiohttp import web
from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
from ..folders.plugin import setup_folders
from ..projects.plugin import setup_projects
from ..workspaces.plugin import setup_workspaces
diff --git a/services/web/server/src/simcore_service_webserver/trash/settings.py b/services/web/server/src/simcore_service_webserver/trash/settings.py
index 38d4f91fdcb..f51832b9aa7 100644
--- a/services/web/server/src/simcore_service_webserver/trash/settings.py
+++ b/services/web/server/src/simcore_service_webserver/trash/settings.py
@@ -2,7 +2,7 @@
from pydantic import Field, NonNegativeInt
from settings_library.base import BaseCustomSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class TrashSettings(BaseCustomSettings):
diff --git a/services/web/server/src/simcore_service_webserver/trash/trash_service.py b/services/web/server/src/simcore_service_webserver/trash/trash_service.py
new file mode 100644
index 00000000000..3cd438f4e1c
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/trash/trash_service.py
@@ -0,0 +1,4 @@
+from ._service import safe_delete_expired_trash_as_admin
+
+__all__: tuple[str, ...] = ("safe_delete_expired_trash_as_admin",)
+# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/users/_common/models.py b/services/web/server/src/simcore_service_webserver/users/_common/models.py
index 513d8bed102..967f010d0b0 100644
--- a/services/web/server/src/simcore_service_webserver/users/_common/models.py
+++ b/services/web/server/src/simcore_service_webserver/users/_common/models.py
@@ -55,6 +55,7 @@ class ToUserUpdateDB(BaseModel):
first_name: str | None = None
last_name: str | None = None
+ privacy_hide_username: bool | None = None
privacy_hide_fullname: bool | None = None
privacy_hide_email: bool | None = None
diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py
index 04946e21fcc..a76326182ae 100644
--- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py
+++ b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py
@@ -18,7 +18,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from servicelib.request_keys import RQT_USERID_KEY
-from ..._constants import RQ_PRODUCT_KEY
+from ...constants import RQ_PRODUCT_KEY
class UsersRequestContext(BaseModel):
diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py
index 2e243d4da90..65c427bf7b0 100644
--- a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py
+++ b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py
@@ -15,7 +15,7 @@
from .._meta import API_VTAG
from ..login.decorators import login_required
-from ..products.api import get_product_name
+from ..products import products_web
from ..redis import get_redis_user_notifications_client
from ..security.decorators import permission_required
from ..utils_aiohttp import envelope_json_response
@@ -62,7 +62,7 @@ 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.model_validate(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
notifications = await _get_user_notifications(
redis_client, req_ctx.user_id, product_name
)
diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py
index 16730437394..8f13169e147 100644
--- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py
+++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py
@@ -5,6 +5,7 @@
from aiohttp import web
from common_library.users_enums import UserRole
from models_library.groups import GroupID
+from models_library.products import ProductName
from models_library.users import (
MyProfile,
UserBillingDetails,
@@ -58,8 +59,7 @@ def _public_user_cols(caller_id: int):
return (
# Fits PublicUser model
users.c.id.label("user_id"),
- users.c.name.label("user_name"),
- *visible_user_profile_cols(caller_id),
+ *visible_user_profile_cols(caller_id, username_label="user_name"),
users.c.primary_gid.label("group_id"),
)
@@ -102,7 +102,10 @@ async def search_public_user(
query = (
sa.select(*_public_user_cols(caller_id=caller_id))
.where(
- users.c.name.ilike(_pattern)
+ (
+ is_public(users.c.privacy_hide_username, caller_id)
+ & users.c.name.ilike(_pattern)
+ )
| (
is_public(users.c.privacy_hide_email, caller_id)
& users.c.email.ilike(_pattern)
@@ -151,7 +154,10 @@ async def get_user_or_raise(
async def get_user_primary_group_id(
- engine: AsyncEngine, connection: AsyncConnection | None = None, *, user_id: UserID
+ engine: AsyncEngine,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
) -> GroupID:
async with pass_or_acquire_connection(engine, connection) as conn:
primary_gid: GroupID | None = await conn.scalar(
@@ -179,7 +185,7 @@ async def get_users_ids_in_group(
return {row.uid async for row in result}
-async def get_user_id_from_pgid(app: web.Application, primary_gid: int) -> UserID:
+async def get_user_id_from_pgid(app: web.Application, *, primary_gid: int) -> UserID:
async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn:
user_id: UserID = await conn.scalar(
sa.select(
@@ -386,13 +392,9 @@ async def get_user_products(
.where(products.c.group_id == groups.c.gid)
.label("product_name")
)
- products_gis_subq = (
- sa.select(
- products.c.group_id,
- )
- .distinct()
- .subquery()
- )
+ products_group_ids_subq = sa.select(
+ products.c.group_id,
+ ).distinct()
query = (
sa.select(
groups.c.gid,
@@ -402,7 +404,7 @@ async def get_user_products(
users.join(user_to_groups, user_to_groups.c.uid == users.c.id).join(
groups,
(groups.c.gid == user_to_groups.c.gid)
- & groups.c.gid.in_(products_gis_subq),
+ & groups.c.gid.in_(products_group_ids_subq),
)
)
.where(users.c.id == user_id)
@@ -461,6 +463,32 @@ async def delete_user_by_id(
return bool(deleted_user)
+async def is_user_in_product_name(
+ engine: AsyncEngine,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+) -> bool:
+ query = (
+ sa.select(users.c.id)
+ .select_from(
+ users.join(
+ user_to_groups,
+ user_to_groups.c.uid == users.c.id,
+ ).join(
+ products,
+ products.c.group_id == user_to_groups.c.gid,
+ )
+ )
+ .where((users.c.id == user_id) & (products.c.name == product_name))
+ )
+ async with pass_or_acquire_connection(engine, connection) as conn:
+ value = await conn.scalar(query)
+ assert value is None or value == user_id # nosec
+ return value is not None
+
+
#
# USER PROFILE
#
@@ -480,6 +508,8 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile:
users.c.email,
users.c.role,
sa.func.json_build_object(
+ "hide_username",
+ users.c.privacy_hide_username,
"hide_fullname",
users.c.privacy_hide_fullname,
"hide_email",
@@ -530,11 +560,12 @@ async def update_user_profile(
)
except IntegrityError as err:
- user_name = updated_values.get("name")
-
- raise UserNameDuplicateError(
- user_name=user_name,
- alternative_user_name=generate_alternative_username(user_name),
- user_id=user_id,
- updated_values=updated_values,
- ) from err
+ if user_name := updated_values.get("name"):
+ raise UserNameDuplicateError(
+ user_name=user_name,
+ alternative_user_name=generate_alternative_username(user_name),
+ user_id=user_id,
+ updated_values=updated_values,
+ ) from err
+
+ raise # not due to name duplication
diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py
index d2ece688514..e89814e5e2d 100644
--- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py
+++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py
@@ -15,8 +15,6 @@
parse_request_query_parameters_as,
)
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
-from simcore_service_webserver.products._api import get_current_product
-from simcore_service_webserver.products._model import Product
from .._meta import API_VTAG
from ..exception_handling import (
@@ -28,6 +26,8 @@
from ..groups import api as groups_api
from ..groups.exceptions import GroupNotFoundError
from ..login.decorators import login_required
+from ..products import products_web
+from ..products.models import Product
from ..security.decorators import permission_required
from ..utils_aiohttp import envelope_json_response
from . import _users_service
@@ -59,8 +59,7 @@
MissingGroupExtraPropertiesForProductError: HttpErrorInfo(
status.HTTP_503_SERVICE_UNAVAILABLE,
"The product is not ready for use until the configuration is fully completed. "
- "Please wait and try again. "
- "If this issue persists, contact support indicating this support code: {error_code}.",
+ "Please wait and try again. ",
),
}
@@ -81,7 +80,7 @@
@login_required
@_handle_users_exceptions
async def get_my_profile(request: web.Request) -> web.Response:
- product: Product = get_current_product(request)
+ product: Product = products_web.get_current_product(request)
req_ctx = UsersRequestContext.model_validate(request)
groups_by_type = await groups_api.list_user_groups_with_read_access(
@@ -114,9 +113,6 @@ async def get_my_profile(request: web.Request) -> web.Response:
@routes.patch(f"/{API_VTAG}/me", name="update_my_profile")
-@routes.put(
- f"/{API_VTAG}/me", name="replace_my_profile" # deprecated. Use patch instead
-)
@login_required
@permission_required("user.profile.update")
@_handle_users_exceptions
@@ -181,7 +177,10 @@ async def search_users_for_admin(request: web.Request) -> web.Response:
)
return envelope_json_response(
- [_.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY) for _ in found]
+ [
+ user_for_admin.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY)
+ for user_for_admin in found
+ ]
)
diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py
index 2bb52b85d57..5d71423646d 100644
--- a/services/web/server/src/simcore_service_webserver/users/_users_service.py
+++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py
@@ -122,7 +122,7 @@ async def get_user_primary_group_id(app: web.Application, user_id: UserID) -> Gr
async def get_user_id_from_gid(app: web.Application, primary_gid: GroupID) -> UserID:
- return await _users_repository.get_user_id_from_pgid(app, primary_gid)
+ return await _users_repository.get_user_id_from_pgid(app, primary_gid=primary_gid)
async def search_users(
@@ -180,6 +180,14 @@ async def get_users_in_group(app: web.Application, *, gid: GroupID) -> set[UserI
get_guest_user_ids_and_names = _users_repository.get_guest_user_ids_and_names
+async def is_user_in_product(
+ app: web.Application, *, user_id: UserID, product_name: ProductName
+) -> bool:
+ return await _users_repository.is_user_in_product_name(
+ get_asyncpg_engine(app), user_id=user_id, product_name=product_name
+ )
+
+
#
# GET USER PROPERTIES
#
diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py
index 09ca7b757e6..0d00834c02d 100644
--- a/services/web/server/src/simcore_service_webserver/users/api.py
+++ b/services/web/server/src/simcore_service_webserver/users/api.py
@@ -14,13 +14,17 @@
get_user_primary_group_id,
get_user_role,
get_users_in_group,
+ is_user_in_product,
set_user_as_deleted,
update_expired_users,
)
__all__: tuple[str, ...] = (
+ "FullNameDict",
+ "UserDisplayAndIdNamesTuple",
"delete_user_without_projects",
"get_guest_user_ids_and_names",
+ "get_user",
"get_user_credentials",
"get_user_display_and_id_names",
"get_user_fullname",
@@ -29,11 +33,9 @@
"get_user_name_and_email",
"get_user_primary_group_id",
"get_user_role",
- "get_user",
"get_users_in_group",
+ "is_user_in_product",
"set_user_as_deleted",
"update_expired_users",
- "FullNameDict",
- "UserDisplayAndIdNamesTuple",
)
# nopycln: file
diff --git a/services/web/server/src/simcore_service_webserver/users/settings.py b/services/web/server/src/simcore_service_webserver/users/settings.py
index 2b6b9f101ac..3800f55d635 100644
--- a/services/web/server/src/simcore_service_webserver/users/settings.py
+++ b/services/web/server/src/simcore_service_webserver/users/settings.py
@@ -3,7 +3,7 @@
from settings_library.base import BaseCustomSettings
from settings_library.utils_service import MixinServiceSettings
-from .._constants import APP_SETTINGS_KEY
+from ..constants import APP_SETTINGS_KEY
class UsersSettings(BaseCustomSettings, MixinServiceSettings):
diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py
index bb60b8a1b8f..5a13e108201 100644
--- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py
+++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py
@@ -14,7 +14,7 @@
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
from yarl import URL
-from ._constants import INDEX_RESOURCE_NAME
+from .constants import INDEX_RESOURCE_NAME
_logger = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/utils_rate_limiting.py b/services/web/server/src/simcore_service_webserver/utils_rate_limiting.py
index 8c1117300ca..2266170c5ac 100644
--- a/services/web/server/src/simcore_service_webserver/utils_rate_limiting.py
+++ b/services/web/server/src/simcore_service_webserver/utils_rate_limiting.py
@@ -1,11 +1,13 @@
+from collections.abc import Callable
from dataclasses import dataclass
-from datetime import datetime, timedelta
+from datetime import UTC, datetime, timedelta
from functools import wraps
from math import ceil
-from typing import Callable, NamedTuple
+from typing import Final, NamedTuple
from aiohttp.web_exceptions import HTTPTooManyRequests
-from common_library.json_serialization import json_dumps
+from models_library.rest_error import EnvelopedError, ErrorGet
+from servicelib.aiohttp import status
class RateLimitSetup(NamedTuple):
@@ -13,7 +15,16 @@ class RateLimitSetup(NamedTuple):
interval_seconds: float
-def global_rate_limit_route(number_of_requests: int, interval_seconds: float):
+MSG_TOO_MANY_REQUESTS: Final[str] = (
+ "Requests are being made too frequently. Please wait a moment before trying again."
+)
+
+
+def global_rate_limit_route(
+ number_of_requests: int,
+ interval_seconds: float,
+ error_msg: str = MSG_TOO_MANY_REQUESTS,
+):
"""
Limits the requests per given interval to this endpoint
from all incoming sources.
@@ -41,7 +52,7 @@ class _Context:
@wraps(decorated_function)
async def _wrapper(*args, **kwargs):
- utc_now = datetime.utcnow()
+ utc_now = datetime.now(UTC)
utc_now_timestamp = datetime.timestamp(utc_now)
# reset counter & first time initialization
@@ -61,16 +72,18 @@ async def _wrapper(*args, **kwargs):
"Content-Type": "application/json",
"Retry-After": f"{retry_after_sec}",
},
- text=json_dumps(
- {
- "error": {
- "logs": [{"message": "API rate limit exceeded."}],
- "status": HTTPTooManyRequests.status_code,
- }
- }
- ),
+ text=EnvelopedError(
+ error=ErrorGet(
+ message=error_msg,
+ status=status.HTTP_429_TOO_MANY_REQUESTS,
+ )
+ ).model_dump_json(),
)
+ assert ( # nosec
+ HTTPTooManyRequests.status_code == status.HTTP_429_TOO_MANY_REQUESTS
+ )
+
# increase counter and return original function call
context.remaining -= 1
return await decorated_function(*args, **kwargs)
diff --git a/services/web/server/src/simcore_service_webserver/version_control/_core.py b/services/web/server/src/simcore_service_webserver/version_control/_core.py
deleted file mode 100644
index 860d124ce48..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/_core.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""
- A checkpoint is equivalent to a commit and can be tagged at the same time (*)
-
- Working copy
-
- HEAD revision
-
- (*) This is a concept introduced for the front-end to avoid using
- more fine grained concepts as tags and commits directly
-"""
-import logging
-from uuid import UUID
-
-from aiopg.sa.result import RowProxy
-from pydantic import NonNegativeInt, PositiveInt, validate_call
-
-from .db import VersionControlRepository
-from .errors import CleanRequiredError
-from .models import Checkpoint, CommitLog, RefID, WorkbenchView
-
-_logger = logging.getLogger(__name__)
-
-
-async def list_repos(
- vc_repo: VersionControlRepository,
- *,
- offset: NonNegativeInt = 0,
- limit: PositiveInt | None = None,
-) -> tuple[list[RowProxy], PositiveInt]:
- # NOTE: this layer does NOT add much .. why not use vc_repo directly?
- repos_rows, total_number_of_repos = await vc_repo.list_repos(offset, limit)
-
- assert len(repos_rows) <= total_number_of_repos # nosec
- return repos_rows, total_number_of_repos
-
-
-async def list_checkpoints(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- *,
- offset: NonNegativeInt = 0,
- limit: PositiveInt | None = None,
-) -> tuple[list[Checkpoint], PositiveInt]:
- repo_id = await vc_repo.get_repo_id(project_uuid)
- if not repo_id:
- return [], 0
-
- logs: list[CommitLog]
- logs, total_number_of_commits = await vc_repo.log(
- repo_id, offset=offset, limit=limit
- )
-
- checkpoints = [Checkpoint.from_commit_log(commit, tags) for commit, tags in logs]
- assert len(checkpoints) <= limit if limit else True # nosec
- assert total_number_of_commits > 0 # nosec
-
- return checkpoints, total_number_of_commits
-
-
-async def create_checkpoint(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- *,
- tag: str,
- message: str | None = None,
-) -> Checkpoint:
- repo_id = await vc_repo.get_repo_id(project_uuid)
- if repo_id is None:
- repo_id = await vc_repo.init_repo(project_uuid)
-
- commit_id = await vc_repo.commit(repo_id, tag=tag, message=message)
- commit, tags = await vc_repo.get_commit_log(commit_id)
- assert commit # nosec
-
- return Checkpoint.from_commit_log(commit, tags)
-
-
-async def get_checkpoint(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- ref_id: RefID,
-) -> Checkpoint:
- repo_id, commit_id = await vc_repo.as_repo_and_commit_ids(project_uuid, ref_id)
- assert repo_id # nosec
-
- commit, tags = await vc_repo.get_commit_log(commit_id)
- return Checkpoint.from_commit_log(commit, tags)
-
-
-async def update_checkpoint(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- ref_id: RefID,
- *,
- message: str | None = None,
- tag: str | None = None,
-) -> Checkpoint:
- repo_id, commit_id = await vc_repo.as_repo_and_commit_ids(project_uuid, ref_id)
-
- if message is None and tag is None:
- _logger.warning(
- "Nothing to update. Skipping updating ref %s of %s", ref_id, project_uuid
- )
- else:
- await vc_repo.update_annotations(repo_id, commit_id, message, tag)
-
- commit, tags = await vc_repo.get_commit_log(commit_id)
- return Checkpoint.from_commit_log(commit, tags)
-
-
-async def checkout_checkpoint(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- ref_id: RefID,
-) -> Checkpoint:
- repo_id, commit_id = await vc_repo.as_repo_and_commit_ids(project_uuid, ref_id)
-
- # check if working copy has changes, if so, auto commit it
- try:
- commit_id = await vc_repo.checkout(repo_id, commit_id)
- except CleanRequiredError:
- _logger.info("Local changes found. Auto-commiting project %s", project_uuid)
- await vc_repo.commit(repo_id, message="auto commit")
- commit_id = await vc_repo.checkout(repo_id, commit_id)
-
- commit, tags = await vc_repo.get_commit_log(commit_id)
- return Checkpoint.from_commit_log(commit, tags)
-
-
-async def get_workbench(
- vc_repo: VersionControlRepository,
- project_uuid: UUID,
- ref_id: RefID,
-) -> WorkbenchView:
- repo_id, commit_id = await vc_repo.as_repo_and_commit_ids(project_uuid, ref_id)
-
- # prefer actual project to snapshot
- content = await vc_repo.get_workbench_view(repo_id, commit_id)
- return WorkbenchView.model_validate(content)
-
-
-#
-# All above with validated arguments
-#
-
-_CONFIG = {"arbitrary_types_allowed": True}
-
-
-list_repos_safe = validate_call(list_repos, config=_CONFIG) # type: ignore
-list_checkpoints_safe = validate_call(list_checkpoints, config=_CONFIG) # type: ignore
-create_checkpoint_safe = validate_call(create_checkpoint, config=_CONFIG) # type: ignore
-get_checkpoint_safe = validate_call(get_checkpoint, config=_CONFIG) # type: ignore
-update_checkpoint_safe = validate_call(update_checkpoint, config=_CONFIG) # type: ignore
-checkout_checkpoint_safe = validate_call(checkout_checkpoint, config=_CONFIG) # type: ignore
-get_workbench_safe = validate_call(get_workbench, config=_CONFIG) # type: ignore
diff --git a/services/web/server/src/simcore_service_webserver/version_control/_handlers.py b/services/web/server/src/simcore_service_webserver/version_control/_handlers.py
deleted file mode 100644
index a0847ea34ea..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/_handlers.py
+++ /dev/null
@@ -1,337 +0,0 @@
-import logging
-
-from aiohttp import web
-from models_library.projects import ProjectID
-from models_library.rest_pagination import Page, PageQueryParameters
-from models_library.rest_pagination_utils import paginate_data
-from pydantic import BaseModel, field_validator
-from servicelib.aiohttp.requests_validation import (
- parse_request_body_as,
- parse_request_path_parameters_as,
- parse_request_query_parameters_as,
-)
-from servicelib.rest_constants import RESPONSE_MODEL_POLICY
-
-from .._meta import API_VTAG as VTAG
-from ..login.decorators import login_required
-from ..security.decorators import permission_required
-from ..utils_aiohttp import create_url_for_function, envelope_json_response
-from ._core import (
- checkout_checkpoint,
- create_checkpoint,
- get_checkpoint,
- get_workbench,
- list_checkpoints,
- list_repos,
- update_checkpoint,
-)
-from ._handlers_base import handle_request_errors
-from .db import VersionControlRepository
-from .models import (
- HEAD,
- Checkpoint,
- CheckpointAnnotations,
- CheckpointApiModel,
- CheckpointNew,
- RefID,
- RepoApiModel,
- WorkbenchView,
- WorkbenchViewApiModel,
-)
-
-_logger = logging.getLogger(__name__)
-
-
-class _CheckpointsPathParam(BaseModel):
- project_uuid: ProjectID
- ref_id: RefID
-
- @field_validator("ref_id", mode="before")
- @classmethod
- def _normalize_refid(cls, v):
- if v and v == "HEAD":
- return HEAD
- return v
-
-
-class _ProjectPathParam(BaseModel):
- project_uuid: ProjectID
-
-
-routes = web.RouteTableDef()
-
-
-@routes.get(f"/{VTAG}/repos/projects", name="list_repos")
-@login_required
-@permission_required("project.read")
-@handle_request_errors
-async def _list_repos_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- query_params: PageQueryParameters = parse_request_query_parameters_as(
- PageQueryParameters, request
- )
-
- repos_rows, total_number_of_repos = await list_repos(
- vc_repo, offset=query_params.offset, limit=query_params.limit
- )
-
- assert len(repos_rows) <= query_params.limit # nosec
-
- # parse and validate
- repos_list = [
- RepoApiModel.model_validate(
- {
- "url": url_for("list_repos"),
- **dict(row.items()),
- }
- )
- for row in repos_rows
- ]
-
- page = Page[RepoApiModel].model_validate(
- paginate_data(
- chunk=repos_list,
- request_url=request.url,
- total=total_number_of_repos,
- limit=query_params.limit,
- offset=query_params.offset,
- )
- )
- return web.Response(
- text=page.model_dump_json(**RESPONSE_MODEL_POLICY),
- content_type="application/json",
- )
-
-
-@routes.post(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints", name="create_checkpoint"
-)
-@login_required
-@permission_required("project.create")
-@handle_request_errors
-async def _create_checkpoint_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_ProjectPathParam, request)
- _body = CheckpointNew.model_validate(await request.json())
-
- checkpoint: Checkpoint = await create_checkpoint(
- vc_repo,
- project_uuid=path_params.project_uuid,
- **_body.model_dump(include={"tag", "message"}),
- )
-
- data = CheckpointApiModel.model_validate(
- {
- "url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **checkpoint.model_dump(),
- }
- )
- return envelope_json_response(data, status_cls=web.HTTPCreated)
-
-
-@routes.get(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints", name="list_checkpoints"
-)
-@login_required
-@permission_required("project.read")
-@handle_request_errors
-async def _list_checkpoints_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_ProjectPathParam, request)
- query_params: PageQueryParameters = parse_request_query_parameters_as(
- PageQueryParameters, request
- )
-
- checkpoints: list[Checkpoint]
-
- checkpoints, total = await list_checkpoints(
- vc_repo,
- project_uuid=path_params.project_uuid,
- offset=query_params.offset,
- limit=query_params.limit,
- )
-
- # parse and validate
- checkpoints_list = [
- CheckpointApiModel.model_validate(
- {
- "url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **checkpoint.model_dump(),
- }
- )
- for checkpoint in checkpoints
- ]
-
- page = Page[CheckpointApiModel].model_validate(
- paginate_data(
- chunk=checkpoints_list,
- request_url=request.url,
- total=total,
- limit=query_params.limit,
- offset=query_params.offset,
- )
- )
- return web.Response(
- text=page.model_dump_json(**RESPONSE_MODEL_POLICY),
- content_type="application/json",
- )
-
-
-# includes repos/projects/{project_uuid}/checkpoints/HEAD
-@routes.get(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints/{{ref_id}}",
- name="get_checkpoint",
-)
-@login_required
-@permission_required("project.read")
-@handle_request_errors
-async def _get_checkpoint_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_CheckpointsPathParam, request)
-
- checkpoint: Checkpoint = await get_checkpoint(
- vc_repo,
- project_uuid=path_params.project_uuid,
- ref_id=path_params.ref_id,
- )
-
- data = CheckpointApiModel.model_validate(
- {
- "url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **checkpoint.model_dump(**RESPONSE_MODEL_POLICY),
- }
- )
- return envelope_json_response(data)
-
-
-@routes.patch(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints/{{ref_id}}",
- name="update_checkpoint",
-)
-@login_required
-@permission_required("project.update")
-@handle_request_errors
-async def _update_checkpoint_annotations_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_CheckpointsPathParam, request)
- update = await parse_request_body_as(CheckpointAnnotations, request)
-
- assert isinstance(path_params.ref_id, int)
-
- checkpoint: Checkpoint = await update_checkpoint(
- vc_repo,
- project_uuid=path_params.project_uuid,
- ref_id=path_params.ref_id,
- **update.model_dump(include={"tag", "message"}, exclude_none=True),
- )
-
- data = CheckpointApiModel.model_validate(
- {
- "url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **checkpoint.model_dump(**RESPONSE_MODEL_POLICY),
- }
- )
- return envelope_json_response(data)
-
-
-@routes.post(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints/{{ref_id}}:checkout",
- name="checkout",
-)
-@login_required
-@permission_required("project.create")
-@handle_request_errors
-async def _checkout_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_CheckpointsPathParam, request)
-
- checkpoint: Checkpoint = await checkout_checkpoint(
- vc_repo,
- project_uuid=path_params.project_uuid,
- ref_id=path_params.ref_id,
- )
-
- data = CheckpointApiModel.model_validate(
- {
- "url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **checkpoint.model_dump(**RESPONSE_MODEL_POLICY),
- }
- )
- return envelope_json_response(data)
-
-
-@routes.get(
- f"/{VTAG}/repos/projects/{{project_uuid}}/checkpoints/{{ref_id}}/workbench/view",
- name="view_project_workbench",
-)
-@login_required
-@permission_required("project.read")
-@handle_request_errors
-async def _view_project_workbench_handler(request: web.Request):
- url_for = create_url_for_function(request)
- vc_repo = VersionControlRepository.create_from_request(request)
-
- path_params = parse_request_path_parameters_as(_CheckpointsPathParam, request)
-
- checkpoint: Checkpoint = await get_checkpoint(
- vc_repo,
- project_uuid=path_params.project_uuid,
- ref_id=path_params.ref_id,
- )
-
- view: WorkbenchView = await get_workbench(
- vc_repo,
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- )
-
- data = WorkbenchViewApiModel.model_validate(
- {
- # = request.url??
- "url": url_for(
- "view_project_workbench",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- "checkpoint_url": url_for(
- "get_checkpoint",
- project_uuid=path_params.project_uuid,
- ref_id=checkpoint.id,
- ),
- **view.model_dump(**RESPONSE_MODEL_POLICY),
- }
- )
-
- return envelope_json_response(data)
diff --git a/services/web/server/src/simcore_service_webserver/version_control/_handlers_base.py b/services/web/server/src/simcore_service_webserver/version_control/_handlers_base.py
deleted file mode 100644
index 3424788fafa..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/_handlers_base.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import logging
-from functools import wraps
-from typing import Any
-
-from aiohttp import web
-from common_library.json_serialization import json_dumps
-from pydantic import ValidationError
-from servicelib.aiohttp.typing_extension import Handler
-
-from ..projects.exceptions import ProjectNotFoundError
-from .errors import InvalidParameterError, NoCommitError, NotFoundError
-
-_logger = logging.getLogger(__name__)
-
-
-def handle_request_errors(handler: Handler) -> Handler:
- """
- - required and type validation of path and query parameters
- """
-
- @wraps(handler)
- async def wrapped(request: web.Request):
- try:
- response: Any = await handler(request)
- return response
-
- except KeyError as err:
- # NOTE: handles required request.match_info[*] or request.query[*]
- _logger.debug(err, exc_info=True)
- raise web.HTTPBadRequest(reason=f"Expected parameter {err}") from err
-
- except ValidationError as err:
- # NOTE: pydantic.validate_arguments parses and validates -> ValidationError
- _logger.debug(err, exc_info=True)
- raise web.HTTPUnprocessableEntity(
- text=json_dumps({"error": err.errors()}),
- content_type="application/json",
- ) from err
-
- except (InvalidParameterError, NoCommitError) as err:
- raise web.HTTPUnprocessableEntity(reason=str(err)) from err
-
- except NotFoundError as err:
- raise web.HTTPNotFound(reason=str(err)) from err
-
- except ProjectNotFoundError as err:
- _logger.debug(err, exc_info=True)
- raise web.HTTPNotFound(
- reason=f"Project not found {err.project_uuid} or not accessible. Skipping snapshot"
- ) from err
-
- return wrapped
diff --git a/services/web/server/src/simcore_service_webserver/version_control/db.py b/services/web/server/src/simcore_service_webserver/version_control/db.py
deleted file mode 100644
index ee884df6e9c..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/db.py
+++ /dev/null
@@ -1,551 +0,0 @@
-import json
-import logging
-from types import SimpleNamespace
-from typing import Any, cast
-from uuid import UUID
-
-import sqlalchemy as sa
-from aiopg.sa import SAConnection
-from aiopg.sa.result import RowProxy
-from common_library.json_serialization import json_dumps
-from models_library.basic_types import SHA1Str
-from models_library.projects import ProjectIDStr
-from pydantic.types import NonNegativeInt, PositiveInt
-from simcore_postgres_database.models.projects import projects
-from simcore_postgres_database.models.projects_version_control import (
- projects_vc_branches,
- projects_vc_commits,
- projects_vc_heads,
- projects_vc_repos,
- projects_vc_snapshots,
- projects_vc_tags,
-)
-from simcore_postgres_database.utils_aiopg_orm import BaseOrm
-from sqlalchemy.dialects.postgresql import insert as pg_insert
-
-from ..db.base_repository import BaseRepository
-from ..projects.models import ProjectProxy
-from .errors import (
- CleanRequiredError,
- InvalidParameterError,
- NoCommitError,
- NotFoundError,
-)
-from .models import HEAD, CommitID, CommitLog, CommitProxy, RefID, RepoProxy, TagProxy
-from .vc_changes import compute_workbench_checksum
-from .vc_tags import parse_workcopy_project_tag_name
-
-_logger = logging.getLogger(__name__)
-
-
-class VersionControlRepository(BaseRepository):
- """
- db layer to access multiple tables within projects_version_control
- """
-
- class ReposOrm(BaseOrm[int]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_repos,
- connection,
- readonly={"id", "created", "modified"},
- )
-
- class BranchesOrm(BaseOrm[int]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_branches,
- connection,
- readonly={"id", "created", "modified"},
- )
-
- class CommitsOrm(BaseOrm[int]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_commits,
- connection,
- readonly={"id", "created", "modified"},
- # pylint: disable=no-member
- writeonce={
- c for c in projects_vc_commits.columns.keys() if c != "message"
- },
- )
-
- class TagsOrm(BaseOrm[int]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_tags,
- connection,
- readonly={"id", "created", "modified"},
- )
-
- class ProjectsOrm(BaseOrm[str]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects,
- connection,
- readonly={"id", "creation_date", "last_change_date"},
- writeonce={"uuid"},
- )
-
- class SnapshotsOrm(BaseOrm[str]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_snapshots,
- connection,
- writeonce={"checksum"},
- )
-
- class HeadsOrm(BaseOrm[int]):
- def __init__(self, connection: SAConnection):
- super().__init__(
- projects_vc_heads,
- connection,
- writeonce={"repo_id"},
- )
-
- # ------------
-
- async def _get_head_branch(
- self, repo_id: int, conn: SAConnection
- ) -> RowProxy | None:
- if h := await self.HeadsOrm(conn).fetch("head_branch_id", rowid=repo_id):
- branch = (
- await self.BranchesOrm(conn)
- .set_filter(id=h.head_branch_id)
- .fetch("id name head_commit_id")
- )
- return branch
- return None
-
- async def _get_HEAD_commit(
- self, repo_id: int, conn: SAConnection
- ) -> CommitProxy | None:
- if branch := await self._get_head_branch(repo_id, conn):
- commit = (
- await self.CommitsOrm(conn).set_filter(id=branch.head_commit_id).fetch()
- )
- return commit
- return None
-
- async def _fetch_workcopy_project_id(
- self, repo_id: int, commit_id: int, conn: SAConnection
- ) -> ProjectIDStr:
- # commit has a workcopy associated?
- found = (
- await self.TagsOrm(conn).set_filter(commit_id=commit_id).fetch_all("name")
- )
- for tag in found:
- if workcopy_project_id := parse_workcopy_project_tag_name(tag.name):
- return ProjectIDStr(workcopy_project_id)
-
- repo = await self.ReposOrm(conn).set_filter(id=repo_id).fetch("project_uuid")
- assert repo # nosec
- return cast(ProjectIDStr, repo.project_uuid)
-
- async def _update_state(
- self, repo_id: int, conn: SAConnection
- ) -> tuple[RepoProxy, CommitProxy | None, ProjectProxy]:
- head_commit: CommitProxy | None = await self._get_HEAD_commit(repo_id, conn)
-
- # current repo
- repo_orm = self.ReposOrm(conn).set_filter(id=repo_id)
- returning_cols = "id project_uuid project_checksum modified"
- repo = await repo_orm.fetch(returning_cols)
- assert repo # nosec
-
- # fetch working copy
- workcopy_project_id = await self._fetch_workcopy_project_id(
- repo_id, head_commit.id if head_commit else -1, conn
- )
- workcopy_project = (
- await self.ProjectsOrm(conn)
- .set_filter(uuid=workcopy_project_id)
- .fetch("last_change_date workbench ui uuid")
- )
- assert workcopy_project # nosec
-
- # uses checksum cached in repo table to avoid re-computing checksum
- checksum: SHA1Str | None = repo.project_checksum
- if not checksum or (
- checksum and repo.modified < workcopy_project.last_change_date
- ):
- checksum = compute_workbench_checksum(workcopy_project.workbench)
-
- repo = await repo_orm.update(returning_cols, project_checksum=checksum)
- assert repo
- return repo, head_commit, workcopy_project
-
- @staticmethod
- async def _upsert_snapshot(
- project_checksum: str,
- project: RowProxy | SimpleNamespace,
- conn: SAConnection,
- ):
- # has changes wrt previous commit
- assert project_checksum # nosec
- insert_stmt = pg_insert(projects_vc_snapshots).values(
- checksum=project_checksum,
- content={
- "workbench": json.loads(json_dumps(project.workbench)),
- "ui": json.loads(json_dumps(project.ui)),
- },
- )
- upsert_snapshot = insert_stmt.on_conflict_do_update(
- constraint=projects_vc_snapshots.primary_key,
- set_=dict(content=insert_stmt.excluded.content),
- )
- await conn.execute(upsert_snapshot)
-
- # PUBLIC
-
- async def list_repos(
- self,
- offset: NonNegativeInt = 0,
- limit: PositiveInt | None = None,
- ) -> tuple[list[RowProxy], NonNegativeInt]:
- async with self.engine.acquire() as conn:
- repo_orm = self.ReposOrm(conn)
-
- rows: list[RowProxy]
- rows, total_count = await repo_orm.fetch_page(
- "project_uuid", offset=offset, limit=limit
- )
-
- return rows, total_count
-
- async def get_repo_id(self, project_uuid: UUID) -> int | None:
- async with self.engine.acquire() as conn:
- repo_orm = self.ReposOrm(conn).set_filter(project_uuid=str(project_uuid))
- repo = await repo_orm.fetch("id")
- return int(repo.id) if repo else None
-
- async def init_repo(self, project_uuid: UUID) -> int:
- async with self.engine.acquire() as conn:
- async with conn.begin():
- # create repo
- repo_orm = self.ReposOrm(conn)
- repo_id = await repo_orm.insert(project_uuid=str(project_uuid))
- assert repo_id is not None # nosec
- assert isinstance(repo_id, int) # nosec
-
- repo = await repo_orm.fetch(rowid=repo_id)
- assert repo # nosec
-
- # create main branch
- branches_orm = self.BranchesOrm(conn)
- branch_id = await branches_orm.insert(repo_id=repo.id)
- assert branch_id is not None
- assert isinstance(branch_id, int) # nosec
-
- main_branch: RowProxy | None = await branches_orm.fetch(rowid=branch_id)
- assert main_branch # nosec
- assert main_branch.name == "main" # nosec
-
- # assign head branch
- heads_orm = self.HeadsOrm(conn)
- await heads_orm.insert(repo_id=repo.id, head_branch_id=branch_id)
-
- return repo_id
-
- async def commit(
- self, repo_id: int, tag: str | None = None, message: str | None = None
- ) -> int:
- """add changes, commits and tags (if tag is not None)
-
- Message is added to tag if set otherwise to commit
- """
- if tag in ["HEAD", HEAD]:
- raise InvalidParameterError(name="tag", reason="is a reserved word")
-
- async with self.engine.acquire() as conn:
- # get head branch
- branch = await self._get_head_branch(repo_id, conn)
- if not branch:
- raise NotImplementedError("Detached heads still not implemented")
-
- _logger.info("On branch %s", branch.name)
-
- # get head commit
- repo, head_commit, workcopy_project = await self._update_state(
- repo_id, conn
- )
-
- if head_commit is None:
- previous_checksum = None
- commit_id = None
- else:
- previous_checksum = head_commit.snapshot_checksum
- commit_id = head_commit.id
-
- async with conn.begin():
- # take a snapshot if changes
- if repo.project_checksum != previous_checksum:
- await self._upsert_snapshot(
- repo.project_checksum, workcopy_project, conn
- )
-
- # commit new snapshot in history
- commit_id = await self.CommitsOrm(conn).insert(
- repo_id=repo_id,
- parent_commit_id=commit_id,
- message=message,
- snapshot_checksum=repo.project_checksum,
- )
- assert commit_id # nosec
-
- # updates head/branch to this commit
- await self.BranchesOrm(conn).set_filter(id=branch.id).update(
- head_commit_id=commit_id
- )
-
- # tag it (again)
- if tag:
- insert_stmt = pg_insert(projects_vc_tags).values(
- repo_id=repo_id,
- commit_id=commit_id,
- name=tag,
- message=message,
- hidden=False,
- )
- upsert_tag = insert_stmt.on_conflict_do_update(
- constraint="repo_tag_uniqueness",
- set_=dict(name=insert_stmt.excluded.name),
- )
- await conn.execute(upsert_tag)
- else:
- _logger.info("Nothing to commit, working tree clean")
-
- assert isinstance(commit_id, int) # nosec
- return commit_id
-
- async def get_commit_log(self, commit_id: int) -> CommitLog:
- async with self.engine.acquire() as conn:
- commit = await self.CommitsOrm(conn).fetch(rowid=commit_id)
- if commit:
- assert isinstance(commit, RowProxy) # nosec
-
- tags: list[TagProxy] = (
- await self.TagsOrm(conn)
- .set_filter(commit_id=commit.id, hidden=False)
- .fetch_all("name message")
- )
- return commit, tags
- raise NotFoundError(name="commit", value=commit_id)
-
- async def log(
- self,
- repo_id: int,
- offset: NonNegativeInt = 0,
- limit: PositiveInt | None = None,
- ) -> tuple[list[CommitLog], NonNegativeInt]:
- async with self.engine.acquire() as conn:
- commits_orm = self.CommitsOrm(conn).set_filter(repo_id=repo_id)
- tags_orm = self.TagsOrm(conn)
-
- commits: list[CommitProxy]
- commits, total_count = await commits_orm.fetch_page(
- offset=offset,
- limit=limit,
- sort_by=sa.desc(commits_orm.columns["created"]),
- )
-
- logs = []
- for commit in commits:
- tags: list[TagProxy]
- tags = await tags_orm.set_filter(commit_id=commit.id).fetch_all()
- logs.append((commit, tags))
-
- return logs, total_count
-
- async def update_annotations(
- self,
- repo_id: int,
- commit_id: CommitID,
- message: str | None = None,
- tag_name: str | None = None,
- ):
- async with self.engine.acquire() as conn:
- async with conn.begin():
- if message:
- await self.CommitsOrm(conn).set_filter(id=commit_id).update(
- message=message
- )
-
- if tag_name:
- tag = (
- await self.TagsOrm(conn)
- .set_filter(repo_id=repo_id, commit_id=commit_id, hidden=False)
- .fetch("id")
- )
-
- if tag:
- await self.TagsOrm(conn).set_filter(rowid=tag.id).update(
- name=tag_name
- )
-
- async def as_repo_and_commit_ids(
- self, project_uuid: UUID, ref_id: RefID
- ) -> tuple[int, CommitID]:
- """Translates (project-uuid, ref-id) to (repo-id, commit-id)
-
- :return: tuple with repo and commit identifiers
- """
- async with self.engine.acquire() as conn:
- repo = (
- await self.ReposOrm(conn)
- .set_filter(project_uuid=str(project_uuid))
- .fetch("id")
- )
- commit_id = None
- if repo:
- if ref_id == HEAD:
- commit = await self._get_HEAD_commit(repo.id, conn)
- if commit:
- commit_id = commit.id
- elif isinstance(ref_id, CommitID):
- commit_id = ref_id
- else:
- assert isinstance(ref_id, str) # nosec
- # head branch or tag
- raise NotImplementedError(
- f"WIP: Tag or head branches as ref_id={ref_id}"
- )
-
- if not commit_id or not repo:
- raise NotFoundError(
- name="project {project_uuid} reference", value=ref_id
- )
-
- return repo.id, commit_id
-
- async def checkout(self, repo_id: int, commit_id: int) -> int:
- """checks out working copy of project_uuid to commit ref_id
-
- :raises RuntimeError: if local copy has changes (i.e. dirty)
- :return: commit id
- :rtype: int
- """
- async with self.engine.acquire() as conn:
- repo, head_commit, workcopy_project = await self._update_state(
- repo_id, conn
- )
-
- if head_commit is None:
- raise NoCommitError(
- details="Cannot checkout without commit changes first"
- )
-
- # check if working copy has changes, if so, fail
- if repo.project_checksum != head_commit.snapshot_checksum:
- raise CleanRequiredError(
- details="Your local changes would be overwritten by checkout. "
- "Cannot checkout without commit changes first."
- )
-
- # already in head commit
- if head_commit.id == commit_id:
- return commit_id
-
- async with conn.begin():
- commit = (
- await self.CommitsOrm(conn)
- .set_filter(id=commit_id)
- .fetch("snapshot_checksum")
- )
- assert commit # nosec
-
- # restores project snapshot ONLY if main workcopy project
- if workcopy_project.uuid == repo.project_uuid:
- snapshot = (
- await self.SnapshotsOrm(conn)
- .set_filter(commit.snapshot_checksum)
- .fetch("content")
- )
- assert snapshot # nosec
-
- await self.ProjectsOrm(conn).set_filter(
- uuid=repo.project_uuid
- ).update(**snapshot.content)
-
- # create detached branch that points to (repo_id, commit_id)
- # upsert "detached" branch
- insert_stmt = (
- pg_insert(projects_vc_branches)
- .values(
- repo_id=repo_id,
- head_commit_id=commit_id,
- name=f"{commit_id}-DETACHED",
- )
- .returning(projects_vc_branches.c.id)
- )
- upsert_tag = insert_stmt.on_conflict_do_update(
- constraint="repo_branch_uniqueness",
- set_=dict(head_commit_id=insert_stmt.excluded.head_commit_id),
- )
- branch_id = await conn.scalar(upsert_tag)
-
- # updates head
- await self.HeadsOrm(conn).set_filter(repo_id=repo_id).update(
- head_branch_id=branch_id
- )
-
- return commit_id
-
- async def get_snapshot_content(
- self, repo_id: int, commit_id: int
- ) -> dict[str, Any]:
- async with self.engine.acquire() as conn:
- if (
- commit := await self.CommitsOrm(conn)
- .set_filter(repo_id=repo_id, id=commit_id)
- .fetch("snapshot_checksum")
- ):
- if (
- snapshot := await self.SnapshotsOrm(conn)
- .set_filter(checksum=commit.snapshot_checksum)
- .fetch("content")
- ):
- content: dict[str, Any] = snapshot.content
- return content
-
- raise NotFoundError(name="snapshot for commit", value=(repo_id, commit_id))
-
- async def get_workbench_view(self, repo_id: int, commit_id: int) -> dict[str, Any]:
- async with self.engine.acquire() as conn:
- if (
- commit := await self.CommitsOrm(conn)
- .set_filter(repo_id=repo_id, id=commit_id)
- .fetch("snapshot_checksum")
- ):
- repo = (
- await self.ReposOrm(conn)
- .set_filter(id=repo_id)
- .fetch("project_uuid")
- )
- assert repo # nosec
-
- # if snapshot differs from workcopy, then show working copy
- workcopy_project_id = await self._fetch_workcopy_project_id(
- repo_id, commit_id, conn
- )
-
- # NOTE: For the moment, all wcopies except for the repo's main workcopy
- # (i.e. repo.project_uuid) are READ-ONLY
- if workcopy_project_id != repo.project_uuid:
- if project := (
- await self.ProjectsOrm(conn)
- .set_filter(uuid=workcopy_project_id)
- .fetch("workbench ui")
- ):
- return dict(project.items())
- else:
- if (
- snapshot := await self.SnapshotsOrm(conn)
- .set_filter(checksum=commit.snapshot_checksum)
- .fetch("content")
- ):
- assert isinstance(snapshot.content, dict) # nosec
- return snapshot.content
-
- raise NotFoundError(name="snapshot for commit", value=(repo_id, commit_id))
diff --git a/services/web/server/src/simcore_service_webserver/version_control/errors.py b/services/web/server/src/simcore_service_webserver/version_control/errors.py
deleted file mode 100644
index e0b3dd3ba63..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/errors.py
+++ /dev/null
@@ -1,29 +0,0 @@
-from ..errors import WebServerBaseError
-
-
-class VersionControlValueError(WebServerBaseError, ValueError):
- pass
-
-
-class VersionControlRuntimeError(WebServerBaseError, RuntimeError):
- pass
-
-
-class NotFoundError(VersionControlValueError):
- msg_template = "Could not find {name} '{value}'"
-
-
-class InvalidParameterError(VersionControlValueError):
- msg_template = "Invalid {name}: {reason}"
-
-
-class NoCommitError(VersionControlRuntimeError):
- msg_template = "No commit found: {details}"
-
-
-class CleanRequiredError(VersionControlRuntimeError):
- msg_template = "Working copy w/o changes (clean) is required: {details}"
-
-
-class UserUndefinedError(VersionControlRuntimeError):
- msg_template = "User required but undefined"
diff --git a/services/web/server/src/simcore_service_webserver/version_control/models.py b/services/web/server/src/simcore_service_webserver/version_control/models.py
deleted file mode 100644
index 505758d53d2..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/models.py
+++ /dev/null
@@ -1,110 +0,0 @@
-from datetime import datetime
-from typing import Annotated, Any, TypeAlias, Union
-
-from aiopg.sa.result import RowProxy
-from models_library.basic_types import SHA1Str
-from models_library.projects import ProjectID
-from models_library.projects_nodes import Node
-from pydantic import (
- BaseModel,
- ConfigDict,
- Field,
- PositiveInt,
- StrictBool,
- StrictFloat,
- StrictInt,
-)
-from pydantic.networks import HttpUrl
-
-BuiltinTypes: TypeAlias = Union[StrictBool, StrictInt, StrictFloat, str]
-
-# alias for readability
-# SEE https://pydantic-docs.helpmanual.io/usage/models/#orm-mode-aka-arbitrary-class-instances
-
-BranchProxy: TypeAlias = RowProxy
-CommitProxy: TypeAlias = RowProxy
-RepoProxy: TypeAlias = RowProxy
-TagProxy: TypeAlias = RowProxy
-CommitLog: TypeAlias = tuple[CommitProxy, list[TagProxy]]
-
-
-HEAD = f"{__file__}/ref/HEAD"
-
-CommitID: TypeAlias = int
-BranchID: TypeAlias = int
-RefID: TypeAlias = Annotated[CommitID | str, Field(union_mode="left_to_right")]
-
-CheckpointID: TypeAlias = PositiveInt
-
-
-class Checkpoint(BaseModel):
- id: CheckpointID
- checksum: SHA1Str
- created_at: datetime
- tags: tuple[str, ...]
- message: str | None = None
- parents_ids: tuple[PositiveInt, ...] | None = Field(default=None)
-
- @classmethod
- def from_commit_log(cls, commit: RowProxy, tags: list[RowProxy]) -> "Checkpoint":
- return cls(
- id=commit.id,
- checksum=commit.snapshot_checksum,
- tags=tuple(tag.name for tag in tags),
- message=commit.message,
- parents_ids=(commit.parent_commit_id,) if commit.parent_commit_id else None,
- created_at=commit.created,
- )
-
-
-class WorkbenchView(BaseModel):
- """A view (i.e. read-only and visual) of the project's workbench"""
-
- model_config = ConfigDict(from_attributes=True)
-
- # NOTE: Tmp replacing UUIDS by str due to a problem serializing to json UUID keys
- # in the response https://github.com/samuelcolvin/pydantic/issues/2096#issuecomment-814860206
- workbench: dict[str, Node]
- ui: dict[str, Any] = {}
-
-
-# API models ---------------
-
-
-class RepoApiModel(BaseModel):
- project_uuid: ProjectID
- url: HttpUrl
-
-
-class CheckpointApiModel(Checkpoint):
- url: HttpUrl
-
-
-class CheckpointNew(BaseModel):
- tag: str
- message: str | None = None
- # new_branch: Optional[str] = None
-
-
-class CheckpointAnnotations(BaseModel):
- tag: str | None = None
- message: str | None = None
-
-
-class WorkbenchViewApiModel(WorkbenchView):
- url: HttpUrl
- checkpoint_url: HttpUrl
-
-
-__all__: tuple[str, ...] = (
- "BranchID",
- "BranchProxy",
- "CheckpointID",
- "CommitID",
- "CommitLog",
- "CommitProxy",
- "HEAD",
- "RefID",
- "RepoProxy",
- "TagProxy",
-)
diff --git a/services/web/server/src/simcore_service_webserver/version_control/plugin.py b/services/web/server/src/simcore_service_webserver/version_control/plugin.py
deleted file mode 100644
index a1e31611f43..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/plugin.py
+++ /dev/null
@@ -1,29 +0,0 @@
-""" An add-on on projects module
-
- Adds version control to projects
-
-"""
-import logging
-
-from aiohttp import web
-from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup
-
-from .._constants import APP_SETTINGS_KEY
-from . import _handlers
-
-_logger = logging.getLogger(__name__)
-
-
-@app_module_setup(
- __name__,
- ModuleCategory.ADDON,
- settings_name="WEBSERVER_VERSION_CONTROL",
- depends=[
- "simcore_service_webserver.projects",
- ],
- logger=_logger,
-)
-def setup_version_control(app: web.Application):
- assert app[APP_SETTINGS_KEY].WEBSERVER_VERSION_CONTROL # nosec
-
- app.add_routes(_handlers.routes)
diff --git a/services/web/server/src/simcore_service_webserver/version_control/vc_changes.py b/services/web/server/src/simcore_service_webserver/version_control/vc_changes.py
deleted file mode 100644
index cc3559c118b..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/vc_changes.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-
-- How to detect that a particular feature/characteristic in an entity has changed over time?
-
-Feature/charateristics of an entity at a given moment can be "snapshot" and given a hash value
-- If the same feature at another moment results in a different hash value, it means that this feature
-has changed
-
-
-"""
-
-from typing import Any
-from uuid import UUID, uuid3
-
-from models_library.basic_types import SHA1Str
-from models_library.projects import ProjectID, ProjectIDStr
-from models_library.projects_nodes import Node
-
-from ..projects.models import ProjectProxy
-from ..utils import compute_sha1_on_small_dataset
-
-
-def compute_workbench_checksum(workbench: dict[str, Any]) -> SHA1Str:
- #
- # NOTE that UI is NOT accounted in the checksum
- #
- normalized = {
- str(k): (Node(**v) if not isinstance(v, Node) else v)
- for k, v in workbench.items()
- }
-
- checksum = compute_sha1_on_small_dataset(
- {
- k: node.model_dump(
- exclude_unset=True,
- exclude_defaults=True,
- exclude_none=True,
- include={
- "key",
- "version",
- "inputs",
- "input_nodes",
- "outputs",
- "output_nodes",
- },
- )
- for k, node in normalized.items()
- }
- )
- return checksum
-
-
-def _eval_checksum(repo, project: ProjectProxy) -> SHA1Str:
- # cached checksum of project workcopy
- checksum: SHA1Str | None = repo.project_checksum
- is_invalid = not checksum or (checksum and repo.modified < project.last_change_date)
- if is_invalid:
- # invalid -> recompute
- checksum = compute_workbench_checksum(project.workbench)
- assert checksum # nosec
- return checksum
-
-
-def eval_workcopy_project_id(
- repo_project_uuid: ProjectID | ProjectIDStr, snapshot_checksum: SHA1Str
-) -> ProjectID:
- """
- A working copy is a real project associated to a snapshot so it can be operated
- as a project resource (e.g. run, save, etc).
-
- The uuid of the workcopy is a composition of the repo-project uuid and the snapshot-checksum
- i.e. all identical snapshots (e.g. different iterations commits) map to the same project workcopy
- can avoid re-run
-
- If a snapshot is identical but associated to two different repos, then it will still be
- treated as a separate project to avoid colision between e.g. two users having coincidentaly the same
- worbench blueprint. Nonetheless, this could be refined in the future since we could use this
- knowledge to reuse results.
- """
- if isinstance(repo_project_uuid, str):
- repo_project_uuid = UUID(repo_project_uuid)
-
- return uuid3(repo_project_uuid, snapshot_checksum)
diff --git a/services/web/server/src/simcore_service_webserver/version_control/vc_tags.py b/services/web/server/src/simcore_service_webserver/version_control/vc_tags.py
deleted file mode 100644
index 5b9d86df791..00000000000
--- a/services/web/server/src/simcore_service_webserver/version_control/vc_tags.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import re
-
-from models_library.basic_regex import UUID_RE_BASE
-from models_library.projects import ProjectID
-
-
-def compose_workcopy_project_tag_name(workcopy_project_id: ProjectID) -> str:
- return f"project:{workcopy_project_id}"
-
-
-def parse_workcopy_project_tag_name(name: str) -> ProjectID | None:
- if m := re.match(rf"^project:(?P{UUID_RE_BASE})$", name):
- data = m.groupdict()
- return ProjectID(data["workcopy_project_id"])
- return None
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_api.py b/services/web/server/src/simcore_service_webserver/wallets/_api.py
index a9721f5dfe0..dd092e2a39f 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_api.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_api.py
@@ -16,7 +16,7 @@
from pydantic import TypeAdapter
from ..resource_usage.service import get_wallet_total_available_credits
-from ..users import api as users_api
+from ..users import api as users_service
from ..users import preferences_api as user_preferences_api
from ..users.exceptions import UserDefaultWalletNotFoundError
from . import _db as db
@@ -33,7 +33,7 @@ async def create_wallet(
thumbnail: str | None,
product_name: ProductName,
) -> WalletGet:
- user: dict = await users_api.get_user(app, user_id)
+ user: dict = await users_service.get_user(app, user_id)
wallet_db: WalletDB = await db.create_wallet(
app=app,
owner=user["primary_gid"],
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_db.py b/services/web/server/src/simcore_service_webserver/wallets/_db.py
index 98ec51a658c..4d17c742925 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_db.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_db.py
@@ -1,8 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
import logging
from aiohttp import web
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_events.py b/services/web/server/src/simcore_service_webserver/wallets/_events.py
index 5e881ebdae5..3aea74cdb83 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_events.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_events.py
@@ -7,7 +7,7 @@
from pydantic import PositiveInt
from servicelib.aiohttp.observer import register_observer, setup_observer_registry
-from ..products.api import get_product
+from ..products import products_service
from ..resource_usage.service import add_credits_to_wallet
from ..users import preferences_api
from ..users.api import get_user_display_and_id_names
@@ -27,7 +27,7 @@ async def _auto_add_default_wallet(
app, user_id=user_id, product_name=product_name
):
user = await get_user_display_and_id_names(app, user_id=user_id)
- product = get_product(app, product_name)
+ product = products_service.get_product(app, product_name)
wallet = await create_wallet(
app,
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py b/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py
index 5a3dcc0a339..05b6625ae5e 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_groups_api.py
@@ -8,7 +8,7 @@
from models_library.wallets import UserWalletDB, WalletID
from pydantic import BaseModel, ConfigDict
-from ..users import api as users_api
+from ..users import api as users_service
from . import _db as wallets_db
from . import _groups_db as wallets_groups_db
from ._groups_db import WalletGroupGetDB
@@ -87,9 +87,9 @@ async def list_wallet_groups_by_user_and_wallet(
),
)
- wallet_groups_db: list[
- WalletGroupGetDB
- ] = await wallets_groups_db.list_wallet_groups(app=app, wallet_id=wallet_id)
+ wallet_groups_db: list[WalletGroupGetDB] = (
+ await wallets_groups_db.list_wallet_groups(app=app, wallet_id=wallet_id)
+ )
wallet_groups_api: list[WalletGroupGet] = [
WalletGroupGet.model_validate(group) for group in wallet_groups_db
@@ -103,9 +103,9 @@ async def list_wallet_groups_with_read_access_by_wallet(
*,
wallet_id: WalletID,
) -> list[WalletGroupGet]:
- wallet_groups_db: list[
- WalletGroupGetDB
- ] = await wallets_groups_db.list_wallet_groups(app=app, wallet_id=wallet_id)
+ wallet_groups_db: list[WalletGroupGetDB] = (
+ await wallets_groups_db.list_wallet_groups(app=app, wallet_id=wallet_id)
+ )
wallet_groups_api: list[WalletGroupGet] = [
WalletGroupGet.model_validate(group)
@@ -135,7 +135,7 @@ async def update_wallet_group(
reason=f"User does not have write access to wallet {wallet_id}"
)
if wallet.owner == group_id:
- user: dict = await users_api.get_user(app, user_id)
+ user: dict = await users_service.get_user(app, user_id)
if user["primary_gid"] != wallet.owner:
# Only the owner of the wallet can modify the owner group
raise WalletAccessForbiddenError(
@@ -177,7 +177,7 @@ async def delete_wallet_group(
reason=f"User does not have delete access to wallet {wallet_id}"
)
if wallet.owner == group_id:
- user: dict = await users_api.get_user(app, user_id)
+ user: dict = await users_service.get_user(app, user_id)
if user["primary_gid"] != wallet.owner:
# Only the owner of the wallet can delete the owner group
raise WalletAccessForbiddenError(
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_groups_db.py b/services/web/server/src/simcore_service_webserver/wallets/_groups_db.py
index 8c2148e05ce..c7e24fff4b8 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_groups_db.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_groups_db.py
@@ -1,8 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
import logging
from datetime import datetime
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py
index 9afcdb7c437..22d085b90f1 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py
@@ -21,9 +21,9 @@
from servicelib.logging_errors import create_troubleshotting_log_kwargs
from servicelib.request_keys import RQT_USERID_KEY
-from .._constants import RQ_PRODUCT_KEY
from .._meta import API_VTAG as VTAG
from ..application_settings_utils import requires_dev_feature_enabled
+from ..constants import RQ_PRODUCT_KEY
from ..login.decorators import login_required
from ..payments.errors import (
InvalidPaymentMethodError,
@@ -97,7 +97,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
except BillingDetailsNotFoundError as exc:
error_code = create_error_code(exc)
- user_error_msg = f"{MSG_BILLING_DETAILS_NOT_DEFINED_ERROR} [{error_code}]"
+ user_error_msg = MSG_BILLING_DETAILS_NOT_DEFINED_ERROR
_logger.exception(
**create_troubleshotting_log_kwargs(
@@ -155,10 +155,10 @@ async def create_wallet(request: web.Request):
async def list_wallets(request: web.Request):
req_ctx = WalletsRequestContext.model_validate(request)
- wallets: list[
- WalletGetWithAvailableCredits
- ] = await _api.list_wallets_with_available_credits_for_user(
- app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name
+ wallets: list[WalletGetWithAvailableCredits] = (
+ await _api.list_wallets_with_available_credits_for_user(
+ app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name
+ )
)
return envelope_json_response(wallets)
diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py
index 66c73b5a293..2751abc457e 100644
--- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py
+++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py
@@ -12,7 +12,6 @@
ReplaceWalletAutoRecharge,
WalletPaymentInitiated,
)
-from models_library.products import CreditResultGet
from models_library.rest_pagination import Page, PageQueryParameters
from models_library.rest_pagination_utils import paginate_data
from servicelib.aiohttp import status
@@ -24,6 +23,7 @@
)
from servicelib.logging_utils import get_log_record_extra, log_context
from servicelib.utils import fire_and_forget_task
+from simcore_service_webserver.products._models import CreditResult
from .._meta import API_VTAG as VTAG
from ..login.decorators import login_required
@@ -42,7 +42,7 @@
pay_with_payment_method,
replace_wallet_payment_autorecharge,
)
-from ..products.api import get_credit_amount
+from ..products import products_service
from ..security.decorators import permission_required
from ..utils_aiohttp import envelope_json_response
from ._handlers import (
@@ -79,7 +79,7 @@ async def _create_payment(request: web.Request):
log_duration=True,
extra=get_log_record_extra(user_id=req_ctx.user_id),
):
- credit_result: CreditResultGet = await get_credit_amount(
+ credit_result: CreditResult = await products_service.get_credit_amount(
request.app,
dollar_amount=body_params.price_dollars,
product_name=req_ctx.product_name,
@@ -351,7 +351,7 @@ async def _pay_with_payment_method(request: web.Request):
log_duration=True,
extra=get_log_record_extra(user_id=req_ctx.user_id),
):
- credit_result: CreditResultGet = await get_credit_amount(
+ credit_result: CreditResult = await products_service.get_credit_amount(
request.app,
dollar_amount=body_params.price_dollars,
product_name=req_ctx.product_name,
@@ -420,7 +420,7 @@ async def _get_wallet_autorecharge(request: web.Request):
)
# NOTE: just to check that top_up is under limit. Guaranteed by _validate_prices_in_product_settings
- assert await get_credit_amount( # nosec
+ assert await products_service.get_credit_amount( # nosec
request.app,
dollar_amount=auto_recharge.top_up_amount_in_usd,
product_name=req_ctx.product_name,
@@ -441,7 +441,7 @@ async def _replace_wallet_autorecharge(request: web.Request):
path_params = parse_request_path_parameters_as(WalletsPathParams, request)
body_params = await parse_request_body_as(ReplaceWalletAutoRecharge, request)
- await get_credit_amount(
+ await products_service.get_credit_amount(
request.app,
dollar_amount=body_params.top_up_amount_in_usd,
product_name=req_ctx.product_name,
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py
index a94ec063f15..05d962a30d5 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py
@@ -18,7 +18,7 @@
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
from servicelib.request_keys import RQT_USERID_KEY
-from ..._constants import RQ_PRODUCT_KEY
+from ...constants import RQ_PRODUCT_KEY
_logger = logging.getLogger(__name__)
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py
index d14127d5b37..5f3ab1963ba 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py
@@ -1,9 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
-
import logging
from datetime import datetime
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py
index 37737e73590..37c84b3682a 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py
@@ -8,9 +8,9 @@
from models_library.workspaces import UserWorkspaceWithAccessRights, WorkspaceID
from pydantic import BaseModel, ConfigDict
-from ..users import api as users_api
+from ..users import api as users_service
from . import _groups_repository as workspaces_groups_db
-from . import _workspaces_repository as workspaces_db
+from . import _workspaces_repository as workspaces_workspaces_repository
from ._groups_repository import WorkspaceGroupGetDB
from ._workspaces_service import check_user_workspace_access
from .errors import WorkspaceAccessForbiddenError
@@ -80,10 +80,10 @@ async def list_workspace_groups_by_user_and_workspace(
permission="read",
)
- workspace_groups_db: list[
- WorkspaceGroupGetDB
- ] = await workspaces_groups_db.list_workspace_groups(
- app=app, workspace_id=workspace_id
+ workspace_groups_db: list[WorkspaceGroupGetDB] = (
+ await workspaces_groups_db.list_workspace_groups(
+ app=app, workspace_id=workspace_id
+ )
)
workspace_groups_api: list[WorkspaceGroupGet] = [
@@ -98,10 +98,10 @@ async def list_workspace_groups_with_read_access_by_workspace(
*,
workspace_id: WorkspaceID,
) -> list[WorkspaceGroupGet]:
- workspace_groups_db: list[
- WorkspaceGroupGetDB
- ] = await workspaces_groups_db.list_workspace_groups(
- app=app, workspace_id=workspace_id
+ workspace_groups_db: list[WorkspaceGroupGetDB] = (
+ await workspaces_groups_db.list_workspace_groups(
+ app=app, workspace_id=workspace_id
+ )
)
workspace_groups_api: list[WorkspaceGroupGet] = [
@@ -125,7 +125,7 @@ async def update_workspace_group(
product_name: ProductName,
) -> WorkspaceGroupGet:
workspace: UserWorkspaceWithAccessRights = (
- await workspaces_db.get_workspace_for_user(
+ await workspaces_workspaces_repository.get_workspace_for_user(
app=app,
user_id=user_id,
workspace_id=workspace_id,
@@ -137,7 +137,7 @@ async def update_workspace_group(
reason=f"User does not have write access to workspace {workspace_id}"
)
if workspace.owner_primary_gid == group_id:
- user: dict = await users_api.get_user(app, user_id)
+ user: dict = await users_service.get_user(app, user_id)
if user["primary_gid"] != workspace.owner_primary_gid:
# Only the owner of the workspace can modify the owner group
raise WorkspaceAccessForbiddenError(
@@ -169,9 +169,9 @@ async def delete_workspace_group(
group_id: GroupID,
product_name: ProductName,
) -> None:
- user: dict = await users_api.get_user(app, user_id=user_id)
+ user: dict = await users_service.get_user(app, user_id=user_id)
workspace: UserWorkspaceWithAccessRights = (
- await workspaces_db.get_workspace_for_user(
+ await workspaces_workspaces_repository.get_workspace_for_user(
app=app,
user_id=user_id,
workspace_id=workspace_id,
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py
index fd7b708c1dd..41776bc57a6 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py
@@ -9,7 +9,7 @@
from .._meta import API_VTAG as VTAG
from ..login.decorators import get_user_id, login_required
-from ..products.api import get_product_name
+from ..products import products_web
from ..security.decorators import permission_required
from . import _trash_services
from ._common.exceptions_handlers import handle_plugin_requests_exceptions
@@ -27,7 +27,7 @@
@handle_plugin_requests_exceptions
async def trash_workspace(request: web.Request):
user_id = get_user_id(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
path_params = parse_request_path_parameters_as(WorkspacesPathParams, request)
query_params: WorkspaceTrashQueryParams = parse_request_query_parameters_as(
WorkspaceTrashQueryParams, request
@@ -50,7 +50,7 @@ async def trash_workspace(request: web.Request):
@handle_plugin_requests_exceptions
async def untrash_workspace(request: web.Request):
user_id = get_user_id(request)
- product_name = get_product_name(request)
+ product_name = products_web.get_product_name(request)
path_params = parse_request_path_parameters_as(WorkspacesPathParams, request)
await _trash_services.untrash_workspace(
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py
index e62efae0a10..63f014bb85d 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py
@@ -1,9 +1,3 @@
-""" Database API
-
- - Adds a layer to the postgres API with a focus on the projects comments
-
-"""
-
import logging
from typing import cast
diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py
index e87dc72d054..e96e7937eb4 100644
--- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py
+++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py
@@ -13,7 +13,7 @@
)
from pydantic import NonNegativeInt
-from ..projects._db_utils import PermissionStr
+from ..projects._projects_repository_legacy_utils import PermissionStr
from ..users.api import get_user
from . import _workspaces_repository as db
from .errors import WorkspaceAccessForbiddenError
diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py
index 2ff0c5c2a0a..a833d401b4d 100644
--- a/services/web/server/tests/conftest.py
+++ b/services/web/server/tests/conftest.py
@@ -41,7 +41,9 @@
from simcore_service_webserver.projects._crud_api_create import (
OVERRIDABLE_DOCUMENT_KEYS,
)
-from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
+from simcore_service_webserver.projects._groups_repository import (
+ update_or_insert_project_group,
+)
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.utils import to_datetime
from tenacity.asyncio import AsyncRetrying
diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py
index 62075ff6ba0..43b89e073b8 100644
--- a/services/web/server/tests/integration/01/test_garbage_collection.py
+++ b/services/web/server/tests/integration/01/test_garbage_collection.py
@@ -41,7 +41,9 @@
from simcore_service_webserver.groups.api import add_user_in_group
from simcore_service_webserver.login.plugin import setup_login
from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks
-from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
+from simcore_service_webserver.projects._groups_repository import (
+ update_or_insert_project_group,
+)
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.projects.plugin import setup_projects
from simcore_service_webserver.resource_manager.plugin import setup_resource_manager
@@ -198,7 +200,7 @@ async def _fake_background_task(app: web.Application):
await asyncio.sleep(0.1)
return mocker.patch(
- "simcore_service_webserver.garbage_collector.plugin.run_background_task",
+ "simcore_service_webserver.garbage_collector.plugin._tasks_core.run_background_task",
side_effect=_fake_background_task,
)
diff --git a/services/web/server/tests/integration/conftest.py b/services/web/server/tests/integration/conftest.py
index c6575d80e21..a66e1e4bec6 100644
--- a/services/web/server/tests/integration/conftest.py
+++ b/services/web/server/tests/integration/conftest.py
@@ -62,7 +62,7 @@ def webserver_environ(
# version tha loads only the subsystems under test. For that reason,
# the test webserver is built-up in webserver_service fixture that runs
# on the host.
- EXCLUDED_SERVICES = ["dask-scheduler", "director"]
+ EXCLUDED_SERVICES = ["dask-scheduler", "director", "sto-worker"]
services_with_published_ports = [
name
for name in core_services
diff --git a/services/web/server/tests/unit/conftest.py b/services/web/server/tests/unit/conftest.py
index b322655c20c..4c6dd952f46 100644
--- a/services/web/server/tests/unit/conftest.py
+++ b/services/web/server/tests/unit/conftest.py
@@ -10,10 +10,10 @@
from collections.abc import Callable, Iterable
from pathlib import Path
from typing import Any
-from unittest.mock import MagicMock
import pytest
import yaml
+from pytest_mock import MockFixture, MockType
from pytest_simcore.helpers.webserver_projects import empty_project_data
from simcore_service_webserver.application_settings_utils import AppConfigDict
@@ -62,7 +62,7 @@ def activity_data(fake_data_dir: Path) -> Iterable[dict[str, Any]]:
@pytest.fixture
-def mock_orphaned_services(mocker) -> MagicMock:
+def mock_orphaned_services(mocker: MockFixture) -> MockType:
return mocker.patch(
"simcore_service_webserver.garbage_collector._core.remove_orphaned_services",
return_value="",
@@ -70,9 +70,19 @@ def mock_orphaned_services(mocker) -> MagicMock:
@pytest.fixture
-def disable_gc_manual_guest_users(mocker):
+def disable_gc_manual_guest_users(mocker: MockFixture) -> None:
"""Disable to avoid an almost instant cleanup of GUEST users with their projects"""
mocker.patch(
"simcore_service_webserver.garbage_collector._core.remove_users_manually_marked_as_guests",
return_value=None,
)
+
+
+@pytest.fixture
+def disabled_setup_garbage_collector(mocker: MockFixture) -> MockType:
+ # WARNING: add it BEFORE `client` to have effect
+ return mocker.patch(
+ "simcore_service_webserver.application.setup_garbage_collector",
+ autospec=True,
+ return_value=False,
+ )
diff --git a/services/web/server/tests/unit/isolated/conftest.py b/services/web/server/tests/unit/isolated/conftest.py
index 77a4b7ca567..eccad058e53 100644
--- a/services/web/server/tests/unit/isolated/conftest.py
+++ b/services/web/server/tests/unit/isolated/conftest.py
@@ -102,6 +102,8 @@ def mock_env_devel_environment(
monkeypatch,
envs={
"WEBSERVER_DEV_FEATURES_ENABLED": "1",
+ "TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT": "null",
+ "TRACING_OPENTELEMETRY_COLLECTOR_PORT": "null",
},
)
@@ -251,7 +253,7 @@ def mocked_login_required(mocker: MockerFixture):
)
mocker.patch(
- "simcore_service_webserver.login.decorators.get_product_name",
+ "simcore_service_webserver.login.decorators.products_web.get_product_name",
spec=True,
return_value="osparc",
)
diff --git a/services/web/server/tests/unit/isolated/notifications/test_rabbitmq_consumers.py b/services/web/server/tests/unit/isolated/notifications/test_rabbitmq_consumers.py
index 4a9bc655df2..20d47155339 100644
--- a/services/web/server/tests/unit/isolated/notifications/test_rabbitmq_consumers.py
+++ b/services/web/server/tests/unit/isolated/notifications/test_rabbitmq_consumers.py
@@ -42,6 +42,7 @@
"progress_type": ProgressType.SERVICE_OUTPUTS_PULLING.value,
"progress_report": {
"actual_value": 0.4,
+ "attempt": 0,
"total": 1.0,
"unit": None,
"message": None,
@@ -65,6 +66,7 @@
"progress_type": ProgressType.PROJECT_CLOSING.value,
"progress_report": {
"actual_value": 0.4,
+ "attempt": 0,
"total": 1.0,
"unit": None,
"message": None,
diff --git a/services/web/server/tests/unit/isolated/products/conftest.py b/services/web/server/tests/unit/isolated/products/conftest.py
new file mode 100644
index 00000000000..8fe754e9307
--- /dev/null
+++ b/services/web/server/tests/unit/isolated/products/conftest.py
@@ -0,0 +1,48 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+
+import json
+import re
+from typing import Any
+
+import pytest
+from faker import Faker
+from models_library.products import ProductName
+from pytest_simcore.helpers.faker_factories import random_product
+from simcore_postgres_database.models.products import products as products_table
+from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT
+from sqlalchemy import String
+from sqlalchemy.dialects import postgresql
+
+
+@pytest.fixture(scope="session")
+def product_name() -> ProductName:
+ return ProductName(FRONTEND_APP_DEFAULT)
+
+
+@pytest.fixture
+def product_db_server_defaults() -> dict[str, Any]:
+ server_defaults = {}
+ for c in products_table.columns:
+ if c.server_default is not None:
+ if isinstance(c.type, String):
+ server_defaults[c.name] = c.server_default.arg
+ elif isinstance(c.type, postgresql.JSONB):
+ m = re.match(r"^'(.+)'::jsonb$", c.server_default.arg.text)
+ if m:
+ server_defaults[c.name] = json.loads(m.group(1))
+ return server_defaults
+
+
+@pytest.fixture
+def fake_product_from_db(
+ faker: Faker, product_name: ProductName, product_db_server_defaults: dict[str, Any]
+) -> dict[str, Any]:
+ return random_product(
+ name=product_name,
+ fake=faker,
+ **product_db_server_defaults,
+ )
diff --git a/services/web/server/tests/unit/isolated/test_products_middlewares.py b/services/web/server/tests/unit/isolated/products/test_products_middlewares.py
similarity index 74%
rename from services/web/server/tests/unit/isolated/test_products_middlewares.py
rename to services/web/server/tests/unit/isolated/products/test_products_middlewares.py
index 8dbf517492d..08dc4f6e013 100644
--- a/services/web/server/tests/unit/isolated/test_products_middlewares.py
+++ b/services/web/server/tests/unit/isolated/products/test_products_middlewares.py
@@ -8,43 +8,44 @@
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
+from faker import Faker
+from pytest_simcore.helpers.faker_factories import random_product
from servicelib.aiohttp import status
from servicelib.rest_constants import X_PRODUCT_NAME_HEADER
-from simcore_postgres_database.models.products import LOGIN_SETTINGS_DEFAULT
-from simcore_postgres_database.webserver_models import products
-from simcore_service_webserver.products._events import _set_app_state
-from simcore_service_webserver.products._middlewares import discover_product_middleware
-from simcore_service_webserver.products._model import Product
-from simcore_service_webserver.products.api import get_product_name
+from simcore_service_webserver.products import products_web
+from simcore_service_webserver.products._web_events import _set_app_state
+from simcore_service_webserver.products._web_middlewares import (
+ discover_product_middleware,
+)
+from simcore_service_webserver.products.models import Product
from simcore_service_webserver.statics._constants import FRONTEND_APP_DEFAULT
from yarl import URL
-@pytest.fixture()
-def mock_postgres_product_table():
- # NOTE: try here your product's host_regex before adding them in the database!
- column_defaults: dict[str, Any] = {
- c.name: f"{c.server_default.arg}" for c in products.columns if c.server_default
- }
-
- column_defaults["login_settings"] = LOGIN_SETTINGS_DEFAULT
+@pytest.fixture
+def mock_product_db_get_data(
+ faker: Faker, product_db_server_defaults: dict[str, Any]
+) -> list[dict[str, Any]]:
_SUBDOMAIN_PREFIX = r"[\w-]+\."
return [
- dict(
+ random_product(
name="osparc",
host_regex=rf"^({_SUBDOMAIN_PREFIX})*osparc[\.-]",
- **column_defaults,
+ fake=faker,
+ **product_db_server_defaults,
),
- dict(
+ random_product(
name="s4l",
host_regex=rf"^({_SUBDOMAIN_PREFIX})*(s4l|sim4life)[\.-]",
- **column_defaults,
+ fake=faker,
+ **product_db_server_defaults,
),
- dict(
+ random_product(
name="tis",
host_regex=rf"^({_SUBDOMAIN_PREFIX})*(tis|^ti-solutions)[\.-]",
+ fake=faker,
vendor={
"name": "ACME",
"address": "sesame street",
@@ -52,18 +53,20 @@ def mock_postgres_product_table():
"url": "https://acme.com",
"forum_url": "https://forum.acme.com",
},
- **column_defaults,
+ **product_db_server_defaults,
),
]
@pytest.fixture
-def mock_app(mock_postgres_product_table: dict[str, Any]) -> web.Application:
+def mock_app(mock_product_db_get_data: list[dict[str, Any]]) -> web.Application:
app = web.Application()
app_products: dict[str, Product] = {
- entry["name"]: Product(**entry) for entry in mock_postgres_product_table
+ product_db_get["name"]: Product.model_validate(product_db_get)
+ for product_db_get in mock_product_db_get_data
}
+
default_product_name = next(iter(app_products.keys()))
_set_app_state(app, app_products, default_product_name)
@@ -124,5 +127,5 @@ async def _mock_handler(_request: web.Request):
response = await discover_product_middleware(mock_request, _mock_handler)
# checks
- assert get_product_name(mock_request) == expected_product
+ assert products_web.get_product_name(mock_request) == expected_product
assert response.status == status.HTTP_200_OK
diff --git a/services/web/server/tests/unit/isolated/products/test_products_model.py b/services/web/server/tests/unit/isolated/products/test_products_model.py
new file mode 100644
index 00000000000..291383be932
--- /dev/null
+++ b/services/web/server/tests/unit/isolated/products/test_products_model.py
@@ -0,0 +1,187 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+
+import re
+from typing import Any
+
+import pytest
+import simcore_service_webserver.products
+import sqlalchemy as sa
+from faker import Faker
+from models_library.basic_regex import TWILIO_ALPHANUMERIC_SENDER_ID_RE
+from models_library.products import ProductName
+from pydantic import BaseModel, ValidationError
+from pytest_simcore.helpers.faker_factories import random_product
+from pytest_simcore.pydantic_models import (
+ assert_validation_model,
+ walk_model_examples_in_package,
+)
+from simcore_postgres_database.models.products import products as products_table
+from simcore_service_webserver.products.models import Product
+
+
+@pytest.mark.parametrize(
+ "model_cls, example_name, example_data",
+ walk_model_examples_in_package(simcore_service_webserver.products),
+)
+def test_all_products_models_examples(
+ model_cls: type[BaseModel], example_name: str, example_data: Any
+):
+ model_instance = assert_validation_model(
+ model_cls, example_name=example_name, example_data=example_data
+ )
+
+ # Some extra checks for Products
+ if isinstance(model_instance, Product):
+ assert model_instance.to_statics()
+ if "registration_email_template" in example_data:
+ assert model_instance.get_template_name_for("registration_email.jinja2")
+
+
+def test_product_to_static():
+
+ product = Product.model_validate(Product.model_json_schema()["examples"][0])
+ assert product.to_statics() == {
+ "displayName": "o²S²PARC",
+ "supportEmail": "support@osparc.io",
+ }
+
+ product = Product.model_validate(Product.model_json_schema()["examples"][2])
+
+ assert product.to_statics() == {
+ "displayName": "o²S²PARC FOO",
+ "supportEmail": "foo@osparcf.io",
+ "vendor": {
+ "copyright": "© ACME correcaminos",
+ "name": "ACME",
+ "url": "https://acme.com",
+ "license_url": "https://acme.com/license",
+ "invitation_form": True,
+ },
+ "issues": [
+ {
+ "label": "github",
+ "login_url": "https://github.com/ITISFoundation/osparc-simcore",
+ "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
+ },
+ {
+ "label": "fogbugz",
+ "login_url": "https://fogbugz.com/login",
+ "new_url": "https://fogbugz.com/new?project=123",
+ },
+ ],
+ "manuals": [
+ {"label": "main", "url": "doc.acme.com"},
+ {"label": "z43", "url": "yet-another-manual.acme.com"},
+ ],
+ "support": [
+ {"kind": "forum", "label": "forum", "url": "forum.acme.com"},
+ {"kind": "email", "label": "email", "email": "more-support@acme.com"},
+ {"kind": "web", "label": "web-form", "url": "support.acme.com"},
+ ],
+ "isPaymentEnabled": False,
+ }
+
+
+def test_product_host_regex_with_spaces():
+ data = Product.model_json_schema()["examples"][2]
+
+ # with leading and trailing spaces and uppercase (tests anystr_strip_whitespace )
+ data["support_email"] = " fOO@BaR.COM "
+
+ # with leading trailing spaces (tests validator("host_regex", pre=True))
+ expected = r"([\.-]{0,1}osparc[\.-])".strip()
+ data["host_regex"] = expected + " "
+
+ # parsing should strip all whitespaces and normalize email
+ product = Product.model_validate(data)
+
+ assert product.host_regex.pattern == expected
+ assert product.host_regex.search("osparc.bar.com")
+
+ assert product.support_email == "foo@bar.com"
+
+
+def test_safe_load_empty_blanks_on_string_cols_from_db(
+ fake_product_from_db: dict[str, Any]
+):
+ nullable_strings_column_names = [
+ c.name
+ for c in products_table.columns
+ if isinstance(c.type, sa.String) and c.nullable
+ ]
+
+ fake_product_from_db.update(
+ {name: " " * len(name) for name in nullable_strings_column_names}
+ )
+
+ product = Product.model_validate(fake_product_from_db)
+
+ assert product.model_dump(include=set(nullable_strings_column_names)) == {
+ name: None for name in nullable_strings_column_names
+ }
+
+
+@pytest.mark.parametrize(
+ "expected_product_name",
+ [
+ "osparc",
+ "s4l",
+ "s4lacad",
+ "s4ldesktop",
+ "s4ldesktopacad",
+ "s4lengine",
+ "s4llite",
+ "tiplite",
+ "tis",
+ ],
+)
+def test_product_name_needs_front_end(
+ faker: Faker,
+ expected_product_name: ProductName,
+ product_db_server_defaults: dict[str, Any],
+):
+ product_from_db = random_product(
+ name=expected_product_name,
+ fake=faker,
+ **product_db_server_defaults,
+ )
+ product = Product.model_validate(product_from_db)
+ assert product.name == expected_product_name
+
+
+def test_product_name_invalid(fake_product_from_db: dict[str, Any]):
+ # Test with an invalid name
+ fake_product_from_db.update(name="invalid name")
+ with pytest.raises(ValidationError):
+ Product.model_validate(fake_product_from_db)
+
+
+def test_twilio_sender_id_is_truncated(fake_product_from_db: dict[str, Any]):
+ fake_product_from_db.update(short_name=None, display_name="very long name" * 12)
+ product = Product.model_validate(fake_product_from_db)
+
+ assert re.match(
+ TWILIO_ALPHANUMERIC_SENDER_ID_RE, product.twilio_alpha_numeric_sender_id
+ )
+
+
+def test_template_names_from_file(fake_product_from_db: dict[str, Any]):
+ fake_product_from_db.update(registration_email_template="some_template_name_id")
+ product = Product.model_validate(fake_product_from_db)
+
+ assert (
+ product.get_template_name_for(filename="registration_email.jinja2")
+ == "some_template_name_id"
+ )
+ assert product.get_template_name_for(filename="other_template.jinja2") is None
+
+ fake_product_from_db.update(registration_email_template=None)
+ product = Product.model_validate(fake_product_from_db)
+ assert (
+ product.get_template_name_for(filename="registration_email_template.jinja2")
+ is None
+ )
diff --git a/services/web/server/tests/unit/isolated/test_application_settings.py b/services/web/server/tests/unit/isolated/test_application_settings.py
index 5aae772d2d9..7ead40a3c27 100644
--- a/services/web/server/tests/unit/isolated/test_application_settings.py
+++ b/services/web/server/tests/unit/isolated/test_application_settings.py
@@ -3,13 +3,16 @@
# pylint:disable=no-name-in-module
import json
+from typing import Annotated
import pytest
from aiohttp import web
from common_library.json_serialization import json_dumps
-from pydantic import HttpUrl, TypeAdapter
+from pydantic import Field, HttpUrl, TypeAdapter
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
from simcore_service_webserver.application_settings import (
+ _X_DEV_FEATURE_FLAG,
APP_SETTINGS_KEY,
ApplicationSettings,
setup_settings,
@@ -61,19 +64,24 @@ def test_settings_to_client_statics(app_settings: ApplicationSettings):
# special alias
assert statics["stackName"] == "master-simcore"
- assert statics["pluginsDisabled"] == []
+ assert statics["pluginsDisabled"] == [
+ "WEBSERVER_META_MODELING",
+ "WEBSERVER_VERSION_CONTROL",
+ ]
def test_settings_to_client_statics_plugins(
mock_webserver_service_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
):
- disable_plugins = {"WEBSERVER_EXPORTER", "WEBSERVER_SCICRUNCH"}
+ disable_plugins = {
+ "WEBSERVER_EXPORTER",
+ "WEBSERVER_SCICRUNCH",
+ "WEBSERVER_META_MODELING",
+ "WEBSERVER_VERSION_CONTROL",
+ }
for name in disable_plugins:
monkeypatch.setenv(name, "null")
- monkeypatch.setenv("WEBSERVER_VERSION_CONTROL", "0")
- disable_plugins.add("WEBSERVER_VERSION_CONTROL")
-
monkeypatch.setenv("WEBSERVER_FOLDERS", "0")
disable_plugins.add("WEBSERVER_FOLDERS")
@@ -84,7 +92,7 @@ def test_settings_to_client_statics_plugins(
assert settings.WEBSERVER_LOGIN
- assert statics["webserverLicenses"] == settings.WEBSERVER_LICENSES
+ assert "webserverLicenses" not in statics
assert (
statics["webserverLogin"]["LOGIN_ACCOUNT_DELETION_RETENTION_DAYS"]
@@ -106,29 +114,34 @@ def test_settings_to_client_statics_plugins(
@pytest.mark.parametrize("is_dev_feature_enabled", [True, False])
-@pytest.mark.parametrize(
- "plugin_name",
- ["WEBSERVER_META_MODELING", "WEBSERVER_VERSION_CONTROL"],
- # NOTE: this is the list in _enable_only_if_dev_features_allowed
-)
def test_disabled_plugins_settings_to_client_statics(
is_dev_feature_enabled: bool,
mock_webserver_service_environment: EnvVarsDict,
monkeypatch: pytest.MonkeyPatch,
- plugin_name: str,
):
- monkeypatch.setenv(
- "WEBSERVER_DEV_FEATURES_ENABLED", f"{is_dev_feature_enabled}".lower()
+ setenvs_from_dict(
+ monkeypatch,
+ {
+ "WEBSERVER_DEV_FEATURES_ENABLED": f"{is_dev_feature_enabled}".lower(),
+ "TEST_FOO": "1",
+ "TEST_BAR": "42",
+ },
)
- settings = ApplicationSettings.create_from_envs()
- statics = settings.to_client_statics()
+ class DevSettings(ApplicationSettings):
+ TEST_FOO: Annotated[bool, Field(json_schema_extra={_X_DEV_FEATURE_FLAG: True})]
+ TEST_BAR: Annotated[
+ int | None, Field(json_schema_extra={_X_DEV_FEATURE_FLAG: True})
+ ]
+
+ settings = DevSettings.create_from_envs()
- # checks whether it is shown to the front-end depending on the value of WEBSERVER_DEV_FEATURES_ENABLED
if is_dev_feature_enabled:
- assert plugin_name not in set(statics["pluginsDisabled"])
+ assert settings.TEST_FOO is True
+ assert settings.TEST_BAR == 42
else:
- assert plugin_name in set(statics["pluginsDisabled"])
+ assert settings.TEST_FOO is False
+ assert settings.TEST_BAR is None
@pytest.mark.filterwarnings("error")
@@ -138,3 +151,37 @@ def test_avoid_sensitive_info_in_public(app_settings: ApplicationSettings):
assert not any("token" in key for key in app_settings.public_dict())
assert not any("secret" in key for key in app_settings.public_dict())
assert not any("private" in key for key in app_settings.public_dict())
+
+
+def test_backwards_compatibility_with_bool_env_vars_turned_into_objects(
+ monkeypatch: pytest.MonkeyPatch,
+ mock_webserver_service_environment: EnvVarsDict,
+):
+ # Sometimes we turn `WEBSERVER_VAR: bool` into `WEBSERVER_VAR: VarSettings`
+ with monkeypatch.context() as patch:
+ patch.setenv("WEBSERVER_LICENSES", "true")
+
+ settings = ApplicationSettings.create_from_envs()
+ assert settings.WEBSERVER_LICENSES is True
+
+ with monkeypatch.context() as patch:
+ patch.setenv("WEBSERVER_LICENSES", "{}")
+ patch.setenv("LICENSES_ITIS_VIP_SYNCER_ENABLED", "1")
+ patch.setenv("LICENSES_ITIS_VIP_API_URL", "https://some-api/{category}")
+ patch.setenv(
+ "LICENSES_ITIS_VIP_CATEGORIES",
+ '{"HumanWholeBody": "Humans", "HumanBodyRegion": "Humans (Region)", "AnimalWholeBody": "Animal"}',
+ )
+
+ settings = ApplicationSettings.create_from_envs()
+ assert settings.WEBSERVER_LICENSES is not None
+ assert not isinstance(settings.WEBSERVER_LICENSES, bool)
+ assert settings.WEBSERVER_LICENSES.LICENSES_ITIS_VIP
+ assert settings.WEBSERVER_LICENSES.LICENSES_ITIS_VIP.LICENSES_ITIS_VIP_API_URL
+ assert settings.WEBSERVER_LICENSES.LICENSES_ITIS_VIP_SYNCER_ENABLED
+
+ with monkeypatch.context() as patch:
+ patch.setenv("WEBSERVER_LICENSES", "null")
+
+ settings = ApplicationSettings.create_from_envs()
+ assert settings.WEBSERVER_LICENSES is None
diff --git a/services/web/server/tests/unit/isolated/test_catalog_models.py b/services/web/server/tests/unit/isolated/test_catalog_models.py
index ec82b0ab367..2a3fb5ad3d8 100644
--- a/services/web/server/tests/unit/isolated/test_catalog_models.py
+++ b/services/web/server/tests/unit/isolated/test_catalog_models.py
@@ -9,8 +9,10 @@
import pytest
from pint import UnitRegistry
from pytest_benchmark.fixture import BenchmarkFixture
-from simcore_service_webserver.catalog._api_units import replace_service_input_outputs
-from simcore_service_webserver.catalog._handlers import RESPONSE_MODEL_POLICY
+from simcore_service_webserver.catalog._controller_rest import RESPONSE_MODEL_POLICY
+from simcore_service_webserver.catalog._units_service import (
+ replace_service_input_outputs,
+)
@pytest.fixture(params=["UnitRegistry", None])
diff --git a/services/web/server/tests/unit/isolated/test_catalog_setup.py b/services/web/server/tests/unit/isolated/test_catalog_setup.py
index 2fdd2e336ef..f16efc1695e 100644
--- a/services/web/server/tests/unit/isolated/test_catalog_setup.py
+++ b/services/web/server/tests/unit/isolated/test_catalog_setup.py
@@ -9,7 +9,7 @@
from aiohttp.test_utils import TestClient
from servicelib.aiohttp.application import create_safe_application
from simcore_service_webserver._meta import api_version_prefix
-from simcore_service_webserver.catalog.client import to_backend_service
+from simcore_service_webserver.catalog import catalog_service
from simcore_service_webserver.catalog.plugin import setup_catalog
from yarl import URL
@@ -35,6 +35,8 @@ def test_url_translation():
assert rel_url.path.startswith(f"/{api_version_prefix}/catalog")
api_target_origin = URL("http://catalog:8000")
- api_target_url = to_backend_service(rel_url, api_target_origin, "v5")
+ api_target_url = catalog_service.to_backend_service(
+ rel_url, api_target_origin, "v5"
+ )
assert str(api_target_url) == "http://catalog:8000/v5/dags/123?page_size=6"
diff --git a/services/web/server/tests/unit/isolated/test_catalog_api_units.py b/services/web/server/tests/unit/isolated/test_catalog_units_service.py
similarity index 98%
rename from services/web/server/tests/unit/isolated/test_catalog_api_units.py
rename to services/web/server/tests/unit/isolated/test_catalog_units_service.py
index 39d1824a775..3fa04b3a933 100644
--- a/services/web/server/tests/unit/isolated/test_catalog_api_units.py
+++ b/services/web/server/tests/unit/isolated/test_catalog_units_service.py
@@ -9,7 +9,7 @@
from models_library.function_services_catalog.services import demo_units
from models_library.services import ServiceInput, ServiceOutput
from pint import UnitRegistry
-from simcore_service_webserver.catalog._api_units import can_connect
+from simcore_service_webserver.catalog._units_service import can_connect
def _create_port_data(schema: dict[str, Any]):
diff --git a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py
index 01626715b48..924f5d55575 100644
--- a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py
+++ b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py
@@ -19,8 +19,8 @@
from pytest_simcore.helpers.typing_env import EnvVarsDict
from servicelib.aiohttp import status
from servicelib.aiohttp.application import create_safe_application
-from simcore_service_webserver._constants import APP_SETTINGS_KEY
from simcore_service_webserver.application_settings import setup_settings
+from simcore_service_webserver.constants import APP_SETTINGS_KEY
from simcore_service_webserver.diagnostics._healthcheck import (
HEALTH_LATENCY_PROBE,
HealthCheckError,
diff --git a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py
index 5205f7fa4da..b944b0d93c1 100644
--- a/services/web/server/tests/unit/isolated/test_garbage_collector_core.py
+++ b/services/web/server/tests/unit/isolated/test_garbage_collector_core.py
@@ -24,9 +24,9 @@
from simcore_service_webserver.resource_manager.registry import UserSessionDict
from simcore_service_webserver.users.exceptions import UserNotFoundError
-MODULE_GC_CORE_ORPHANS: Final[
- str
-] = "simcore_service_webserver.garbage_collector._core_orphans"
+MODULE_GC_CORE_ORPHANS: Final[str] = (
+ "simcore_service_webserver.garbage_collector._core_orphans"
+)
@pytest.fixture
@@ -91,7 +91,7 @@ async def mock_is_node_id_present_in_any_project_workbench(
@pytest.fixture
async def mock_list_dynamic_services(mocker: MockerFixture) -> mock.AsyncMock:
return mocker.patch(
- f"{MODULE_GC_CORE_ORPHANS}.dynamic_scheduler_api.list_dynamic_services",
+ f"{MODULE_GC_CORE_ORPHANS}.dynamic_scheduler_service.list_dynamic_services",
autospec=True,
return_value=[],
)
@@ -100,7 +100,7 @@ async def mock_list_dynamic_services(mocker: MockerFixture) -> mock.AsyncMock:
@pytest.fixture
async def mock_stop_dynamic_service(mocker: MockerFixture) -> mock.AsyncMock:
return mocker.patch(
- f"{MODULE_GC_CORE_ORPHANS}.dynamic_scheduler_api.stop_dynamic_service",
+ f"{MODULE_GC_CORE_ORPHANS}.dynamic_scheduler_service.stop_dynamic_service",
autospec=True,
)
diff --git a/services/web/server/tests/unit/isolated/test_licenses_settings.py b/services/web/server/tests/unit/isolated/test_licenses_settings.py
new file mode 100644
index 00000000000..6205f16c05f
--- /dev/null
+++ b/services/web/server/tests/unit/isolated/test_licenses_settings.py
@@ -0,0 +1,32 @@
+# pylint: disable=protected-access
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+import datetime
+
+import pytest
+from pytest_simcore.helpers.typing_env import EnvVarsDict
+from simcore_service_webserver.licenses.settings import LicensesSettings
+
+
+def test_itis_vip_syncer_settings(
+ mock_webserver_service_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
+):
+
+ assert "LICENSES_ITIS_VIP_SYNCER_ENABLED" in mock_webserver_service_environment
+ assert "LICENSES_ITIS_VIP_SYNCER_PERIODICITY" in mock_webserver_service_environment
+
+ settings = LicensesSettings.create_from_envs()
+ assert settings
+
+ with monkeypatch.context() as patch:
+ patch.setenv("LICENSES_ITIS_VIP_SYNCER_PERIODICITY", "1D02:03:04")
+
+ settings: LicensesSettings = LicensesSettings.create_from_envs()
+ assert settings
+ assert settings.LICENSES_ITIS_VIP_SYNCER_PERIODICITY == datetime.timedelta(
+ days=1, hours=2, minutes=3, seconds=4
+ )
diff --git a/services/web/server/tests/unit/isolated/test_products_model.py b/services/web/server/tests/unit/isolated/test_products_model.py
deleted file mode 100644
index 147540adce6..00000000000
--- a/services/web/server/tests/unit/isolated/test_products_model.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-
-from typing import Any
-
-import pytest
-from common_library.json_serialization import json_dumps
-from pydantic import BaseModel
-from simcore_service_webserver.products._db import Product
-
-
-@pytest.mark.parametrize(
- "model_cls",
- [
- Product,
- ],
-)
-def test_product_examples(
- model_cls: type[BaseModel], model_cls_examples: dict[str, dict[str, Any]]
-):
- for name, example in model_cls_examples.items():
- print(name, ":", json_dumps(example, indent=1))
- model_instance = model_cls(**example)
- assert model_instance, f"Failed with {name}"
-
- if isinstance(model_instance, Product):
- assert model_instance.to_statics()
-
- if "registration_email_template" in example:
- assert model_instance.get_template_name_for("registration_email.jinja2")
-
-
-def test_product_to_static():
-
- product = Product.model_validate(
- Product.model_config["json_schema_extra"]["examples"][0]
- )
- assert product.to_statics() == {
- "displayName": "o²S²PARC",
- "supportEmail": "support@osparc.io",
- }
-
- product = Product.model_validate(
- Product.model_config["json_schema_extra"]["examples"][2]
- )
-
- assert product.to_statics() == {
- "displayName": "o²S²PARC FOO",
- "supportEmail": "foo@osparcf.io",
- "vendor": {
- "copyright": "© ACME correcaminos",
- "name": "ACME",
- "url": "https://acme.com",
- "license_url": "https://acme.com/license",
- "invitation_form": True,
- },
- "issues": [
- {
- "label": "github",
- "login_url": "https://github.com/ITISFoundation/osparc-simcore",
- "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
- },
- {
- "label": "fogbugz",
- "login_url": "https://fogbugz.com/login",
- "new_url": "https://fogbugz.com/new?project=123",
- },
- ],
- "manuals": [
- {"label": "main", "url": "doc.acme.com"},
- {"label": "z43", "url": "yet-another-manual.acme.com"},
- ],
- "support": [
- {"kind": "forum", "label": "forum", "url": "forum.acme.com"},
- {"kind": "email", "label": "email", "email": "more-support@acme.com"},
- {"kind": "web", "label": "web-form", "url": "support.acme.com"},
- ],
- "isPaymentEnabled": False,
- }
-
-
-def test_product_host_regex_with_spaces():
- data = Product.model_config["json_schema_extra"]["examples"][2]
-
- # with leading and trailing spaces and uppercase (tests anystr_strip_whitespace )
- data["support_email"] = " fOO@BaR.COM "
-
- # with leading trailing spaces (tests validator("host_regex", pre=True))
- expected = r"([\.-]{0,1}osparc[\.-])".strip()
- data["host_regex"] = expected + " "
-
- # parsing should strip all whitespaces and normalize email
- product = Product.model_validate(data)
-
- assert product.host_regex.pattern == expected
- assert product.host_regex.search("osparc.bar.com")
-
- assert product.support_email == "foo@bar.com"
diff --git a/services/web/server/tests/unit/isolated/test_projects__db_utils.py b/services/web/server/tests/unit/isolated/test_projects__db_utils.py
index 2a5203ae137..06631e73a4b 100644
--- a/services/web/server/tests/unit/isolated/test_projects__db_utils.py
+++ b/services/web/server/tests/unit/isolated/test_projects__db_utils.py
@@ -16,11 +16,7 @@
from models_library.projects_nodes import Node
from models_library.services import ServiceKey
from models_library.utils.fastapi_encoders import jsonable_encoder
-from simcore_service_webserver.projects._db_utils import (
- DB_EXCLUSIVE_COLUMNS,
- SCHEMA_NON_NULL_KEYS,
-)
-from simcore_service_webserver.projects.db import (
+from simcore_service_webserver.projects._projects_repository_legacy import (
ProjectAccessRights,
assemble_array_groups,
convert_to_db_names,
@@ -29,6 +25,10 @@
patch_workbench,
update_workbench,
)
+from simcore_service_webserver.projects._projects_repository_legacy_utils import (
+ DB_EXCLUSIVE_COLUMNS,
+ SCHEMA_NON_NULL_KEYS,
+)
from simcore_service_webserver.projects.exceptions import (
NodeNotFoundError,
ProjectInvalidUsageError,
diff --git a/services/web/server/tests/unit/isolated/test_projects__nodes_api.py b/services/web/server/tests/unit/isolated/test_projects__nodes_api.py
index e7e4bd8a926..610575306e6 100644
--- a/services/web/server/tests/unit/isolated/test_projects__nodes_api.py
+++ b/services/web/server/tests/unit/isolated/test_projects__nodes_api.py
@@ -2,8 +2,8 @@
from uuid import uuid4
import pytest
-from models_library.api_schemas_storage import FileMetaDataGet
-from simcore_service_webserver.projects._nodes_api import (
+from models_library.api_schemas_storage.storage_schemas import FileMetaDataGet
+from simcore_service_webserver.projects._nodes_service import (
_SUPPORTED_PREVIEW_FILE_EXTENSIONS,
_FileWithThumbnail,
_get_files_with_thumbnails,
diff --git a/services/web/server/tests/unit/isolated/test_rest.py b/services/web/server/tests/unit/isolated/test_rest.py
index 31fdba39eac..335350c5468 100644
--- a/services/web/server/tests/unit/isolated/test_rest.py
+++ b/services/web/server/tests/unit/isolated/test_rest.py
@@ -59,7 +59,7 @@ async def test_frontend_config(
assert client.app
# avoids having to start database etc...
mocker.patch(
- "simcore_service_webserver.rest._handlers.get_product_name",
+ "simcore_service_webserver.rest._handlers.products_web.get_product_name",
spec=True,
return_value="osparc",
)
diff --git a/services/web/server/tests/unit/isolated/test_security_api.py b/services/web/server/tests/unit/isolated/test_security_api.py
index b913d95e5a7..dd10eb4fee5 100644
--- a/services/web/server/tests/unit/isolated/test_security_api.py
+++ b/services/web/server/tests/unit/isolated/test_security_api.py
@@ -25,9 +25,11 @@
from simcore_postgres_database.models.users import UserRole
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.login.decorators import login_required
-from simcore_service_webserver.products._events import _set_app_state
-from simcore_service_webserver.products._middlewares import discover_product_middleware
-from simcore_service_webserver.products._model import Product
+from simcore_service_webserver.products._web_events import _set_app_state
+from simcore_service_webserver.products._web_middlewares import (
+ discover_product_middleware,
+)
+from simcore_service_webserver.products.models import Product
from simcore_service_webserver.security.api import (
check_user_authorized,
clean_auth_policy_cache,
diff --git a/services/web/server/tests/unit/isolated/test_storage_schemas.py b/services/web/server/tests/unit/isolated/test_storage_schemas.py
index 31ea4260bb4..e3a9174fce7 100644
--- a/services/web/server/tests/unit/isolated/test_storage_schemas.py
+++ b/services/web/server/tests/unit/isolated/test_storage_schemas.py
@@ -3,13 +3,15 @@
# pylint: disable=unused-variable
# pylint: disable=too-many-arguments
-import json
from typing import Any
import pytest
import simcore_service_webserver.storage.schemas
from pydantic import BaseModel
-from pytest_simcore.pydantic_models import iter_model_examples_in_module
+from pytest_simcore.pydantic_models import (
+ assert_validation_model,
+ iter_model_examples_in_module,
+)
@pytest.mark.parametrize(
@@ -17,7 +19,8 @@
iter_model_examples_in_module(simcore_service_webserver.storage.schemas),
)
def test_model_examples(
- model_cls: type[BaseModel], example_name: int, example_data: Any
+ model_cls: type[BaseModel], example_name: str, example_data: Any
):
- print(example_name, ":", json.dumps(example_data))
- assert model_cls.model_validate(example_data)
+ assert_validation_model(
+ model_cls, example_name=example_name, example_data=example_data
+ )
diff --git a/services/web/server/tests/unit/isolated/test_studies_dispatcher_projects_permalinks.py b/services/web/server/tests/unit/isolated/test_studies_dispatcher_projects_permalinks.py
index aa9f3c44e5b..9a182352efa 100644
--- a/services/web/server/tests/unit/isolated/test_studies_dispatcher_projects_permalinks.py
+++ b/services/web/server/tests/unit/isolated/test_studies_dispatcher_projects_permalinks.py
@@ -48,7 +48,6 @@ def app_environment(
"WEBSERVER_GARBAGE_COLLECTOR": "null",
"WEBSERVER_GROUPS": "1",
"WEBSERVER_LOGIN": "null",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_PAYMENTS": "null",
"WEBSERVER_PRODUCTS": "1",
"WEBSERVER_PUBLICATIONS": "0",
@@ -59,7 +58,6 @@ def app_environment(
"WEBSERVER_SOCKETIO": "0",
"WEBSERVER_TAGS": "1",
"WEBSERVER_TRACING": "null",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "0",
"STUDIES_ACCESS_ANONYMOUS_ALLOWED": "1",
},
diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py
index e568d0d2ddd..e61f543e211 100644
--- a/services/web/server/tests/unit/isolated/test_users_models.py
+++ b/services/web/server/tests/unit/isolated/test_users_models.py
@@ -1,11 +1,10 @@
+# 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 copy import deepcopy
from datetime import UTC, datetime
-from pprint import pformat
from typing import Any
import pytest
@@ -16,40 +15,12 @@
MyProfilePrivacyGet,
)
from models_library.generics import Envelope
-from models_library.users import UserThirdPartyToken
from models_library.utils.fastapi_encoders import jsonable_encoder
-from pydantic import BaseModel
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
-from simcore_postgres_database.models.users import UserRole
+from simcore_postgres_database import utils_users
from simcore_service_webserver.users._common.models import ToUserUpdateDB
-@pytest.mark.parametrize(
- "model_cls",
- [MyProfileGet, UserThirdPartyToken],
-)
-def test_user_models_examples(
- model_cls: type[BaseModel], model_cls_examples: dict[str, Any]
-):
- for name, example in model_cls_examples.items():
- print(name, ":", pformat(example))
- model_instance = model_cls(**example)
- assert model_instance, f"Failed with {name}"
-
- model_enveloped = Envelope[model_cls].from_data(
- model_instance.model_dump(by_alias=True)
- )
- model_array_enveloped = Envelope[list[model_cls]].from_data(
- [
- model_instance.model_dump(by_alias=True),
- model_instance.model_dump(by_alias=True),
- ]
- )
-
- assert model_enveloped.error is None
- assert model_array_enveloped.error is None
-
-
@pytest.fixture
def fake_profile_get(faker: Faker) -> MyProfileGet:
fake_profile: dict[str, Any] = faker.simple_profile()
@@ -62,7 +33,9 @@ def fake_profile_get(faker: Faker) -> MyProfileGet:
user_name=fake_profile["username"],
login=fake_profile["mail"],
role="USER",
- privacy=MyProfilePrivacyGet(hide_fullname=True, hide_email=True),
+ privacy=MyProfilePrivacyGet(
+ hide_fullname=True, hide_email=True, hide_username=False
+ ),
preferences={},
)
@@ -98,18 +71,6 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet):
assert data["preferences"] == profile.preferences
-@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
-def test_profile_get_role(user_role: str):
- for example in MyProfileGet.model_json_schema()["examples"]:
- data = deepcopy(example)
- data["role"] = user_role
- m1 = MyProfileGet(**data)
-
- data["role"] = UserRole(user_role)
- m2 = MyProfileGet(**data)
- assert m1 == m2
-
-
def test_parsing_output_of_get_user_profile():
result_from_db_query_and_composition = {
"id": 1,
@@ -119,7 +80,7 @@ def test_parsing_output_of_get_user_profile():
"last_name": "",
"role": "Guest",
"gravatar_id": "9d5e02c75fcd4bce1c8861f219f7f8a5",
- "privacy": {"hide_email": True, "hide_fullname": False},
+ "privacy": {"hide_email": True, "hide_fullname": False, "hide_username": False},
"groups": {
"me": {
"gid": 2,
@@ -166,7 +127,7 @@ def test_mapping_update_models_from_rest_to_db():
{
"first_name": "foo",
"userName": "foo1234",
- "privacy": {"hideFullname": False},
+ "privacy": {"hideFullname": False, "hideUsername": True},
}
)
@@ -178,4 +139,14 @@ def test_mapping_update_models_from_rest_to_db():
"first_name": "foo",
"name": "foo1234",
"privacy_hide_fullname": False,
+ "privacy_hide_username": True,
}
+
+
+def test_utils_user_generates_valid_myprofile_patch():
+ username = utils_users._generate_username_from_email("xi@email.com") # noqa: SLF001
+
+ MyProfilePatch.model_validate({"userName": username})
+ MyProfilePatch.model_validate(
+ {"userName": utils_users.generate_alternative_username(username)}
+ )
diff --git a/services/web/server/tests/unit/isolated/test_utils_rate_limiting.py b/services/web/server/tests/unit/isolated/test_utils_rate_limiting.py
index c6da8adf736..1389e9a154d 100644
--- a/services/web/server/tests/unit/isolated/test_utils_rate_limiting.py
+++ b/services/web/server/tests/unit/isolated/test_utils_rate_limiting.py
@@ -81,13 +81,15 @@ async def test_global_rate_limit_route(requests_per_second: float, client: TestC
msg = []
for i, task in enumerate(tasks):
+
while not task.done():
await asyncio.sleep(0.01)
+
assert not task.cancelled()
assert not task.exception()
msg.append(
(
- "request # %2d" % i,
+ f"request # {i:2d}",
f"status={task.result().status}",
f"retry-after={task.result().headers.get('Retry-After')}",
)
diff --git a/services/web/server/tests/unit/with_dbs/01/storage/conftest.py b/services/web/server/tests/unit/with_dbs/01/storage/conftest.py
new file mode 100644
index 00000000000..051a2d23423
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/storage/conftest.py
@@ -0,0 +1,320 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+import logging
+import random
+from collections.abc import Iterator
+from pathlib import Path
+from threading import Thread
+from typing import Annotated
+from urllib.parse import quote
+
+import pytest
+import uvicorn
+from faker import Faker
+from fastapi import APIRouter, Depends, FastAPI, Request, status
+from fastapi_pagination import add_pagination, create_page
+from fastapi_pagination.cursor import CursorPage, CursorParams
+from models_library.api_schemas_storage.storage_schemas import (
+ DatasetMetaDataGet,
+ FileLocation,
+ FileMetaDataGet,
+ FileMetaDataGetv010,
+ FileUploadCompleteResponse,
+ FileUploadCompletionBody,
+ FileUploadSchema,
+ LinkType,
+ PathMetaDataGet,
+)
+from models_library.generics import Envelope
+from models_library.projects import ProjectID
+from models_library.projects_nodes_io import LocationID, StorageFileID
+from models_library.users import UserID
+from pydantic import AnyUrl, TypeAdapter
+from pytest_simcore.helpers.logging_tools import log_context
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
+from servicelib.utils import unused_port
+from yarl import URL
+
+
+@pytest.fixture(scope="session")
+def storage_vtag() -> str:
+ return "v9"
+
+
+@pytest.fixture(scope="module")
+def fake_storage_app(storage_vtag: str) -> FastAPI: # noqa: C901
+ app = FastAPI(debug=True)
+ add_pagination(app)
+
+ router = APIRouter(
+ prefix=f"/{storage_vtag}",
+ )
+
+ @router.get("/")
+ async def _root(request: Request):
+ return {"message": "Hello World"}
+
+ @router.get(
+ "/locations",
+ status_code=status.HTTP_200_OK,
+ response_model=Envelope[list[FileLocation]],
+ )
+ async def _list_storage_locations(user_id: UserID, request: Request):
+ assert "json_schema_extra" in FileLocation.model_config
+
+ return Envelope[list[FileLocation]](
+ data=[
+ FileLocation.model_validate(e)
+ for e in FileLocation.model_json_schema()["examples"]
+ ]
+ )
+
+ @router.get(
+ "/locations/{location_id}/paths",
+ response_model=CursorPage[PathMetaDataGet],
+ )
+ async def _list_paths(
+ page_params: Annotated[CursorParams, Depends()],
+ # dsm: Annotated[BaseDataManager, Depends(get_data_manager)],
+ user_id: UserID,
+ file_filter: Path | None = None,
+ ):
+ assert user_id
+ assert "json_schema_extra" in PathMetaDataGet.model_config
+
+ example_index = len(file_filter.parts) if file_filter else 0
+ assert example_index < len(
+ PathMetaDataGet.model_json_schema()["examples"]
+ ), "fake server unable to server this example"
+ chosen_example = PathMetaDataGet.model_json_schema()["examples"][example_index]
+
+ return create_page(
+ random.randint(3, 15)
+ * [PathMetaDataGet.model_validate(chosen_example)], # noqa: S311
+ params=page_params,
+ next_=None,
+ )
+
+ @router.get(
+ "/locations/{location_id}/files/metadata",
+ response_model=Envelope[list[FileMetaDataGet]],
+ )
+ async def _list_files_metadata(
+ user_id: UserID,
+ request: Request,
+ uuid_filter: str = "",
+ project_id: ProjectID | None = None,
+ expand_dirs: bool = True,
+ ):
+ assert "json_schema_extra" in FileMetaDataGet.model_config
+
+ if uuid_filter:
+ return Envelope[list[FileMetaDataGet]](
+ data=random.sample(
+ [
+ FileMetaDataGet.model_validate(e)
+ for e in FileMetaDataGet.model_json_schema()["examples"]
+ ],
+ 2,
+ )
+ )
+ return Envelope[list[FileMetaDataGet]](
+ data=[
+ FileMetaDataGet.model_validate(e)
+ for e in FileMetaDataGet.model_json_schema()["examples"]
+ ]
+ )
+
+ @router.get(
+ "/locations/{location_id}/files/{file_id:path}/metadata",
+ response_model=Envelope[FileMetaDataGet]
+ | Envelope[FileMetaDataGetv010]
+ | Envelope[dict],
+ )
+ async def _get_file_metadata(user_id: UserID, request: Request):
+ assert "json_schema_extra" in FileMetaDataGet.model_config
+
+ return Envelope[FileMetaDataGet](
+ data=random.choice( # noqa: S311
+ [
+ FileMetaDataGet.model_validate(e)
+ for e in FileMetaDataGet.model_json_schema()["examples"]
+ ]
+ )
+ )
+
+ @router.get(
+ "/locations/{location_id}/datasets",
+ response_model=Envelope[list[DatasetMetaDataGet]],
+ )
+ async def _list_datasets_metadata(user_id: UserID, request: Request):
+ assert "json_schema_extra" in DatasetMetaDataGet.model_config
+
+ return Envelope[list[DatasetMetaDataGet]](
+ data=[
+ DatasetMetaDataGet.model_validate(e)
+ for e in DatasetMetaDataGet.model_json_schema()["examples"]
+ ]
+ )
+
+ @router.get(
+ "/locations/{location_id}/datasets/{dataset_id}/metadata",
+ response_model=Envelope[list[FileMetaDataGet]],
+ )
+ async def _list_dataset_files_metadata(user_id: UserID, request: Request):
+ assert "json_schema_extra" in FileMetaDataGet.model_config
+
+ return Envelope[list[FileMetaDataGet]](
+ data=[
+ FileMetaDataGet.model_validate(e)
+ for e in FileMetaDataGet.model_json_schema()["examples"]
+ ]
+ )
+
+ @router.put(
+ "/locations/{location_id}/files/{file_id:path}",
+ response_model=Envelope[FileUploadSchema],
+ )
+ async def upload_file(
+ user_id: UserID,
+ location_id: LocationID,
+ file_id: StorageFileID,
+ request: Request,
+ link_type: LinkType = LinkType.PRESIGNED,
+ ):
+ assert "json_schema_extra" in FileUploadSchema.model_config
+
+ abort_url = (
+ URL(f"{request.url}")
+ .with_path(
+ quote(
+ request.app.url_path_for(
+ "abort_upload_file",
+ location_id=f"{location_id}",
+ file_id=file_id,
+ ),
+ safe=":/",
+ ),
+ encoded=True,
+ )
+ .with_query(user_id=user_id)
+ )
+
+ complete_url = (
+ URL(f"{request.url}")
+ .with_path(
+ quote(
+ request.app.url_path_for(
+ "complete_upload_file",
+ location_id=f"{location_id}",
+ file_id=file_id,
+ ),
+ safe=":/",
+ ),
+ encoded=True,
+ )
+ .with_query(user_id=user_id)
+ )
+ response = FileUploadSchema.model_validate(
+ random.choice( # noqa: S311
+ FileUploadSchema.model_json_schema()["examples"]
+ )
+ )
+ response.links.abort_upload = TypeAdapter(AnyUrl).validate_python(
+ f"{abort_url}"
+ )
+ response.links.complete_upload = TypeAdapter(AnyUrl).validate_python(
+ f"{complete_url}"
+ )
+
+ return Envelope[FileUploadSchema](data=response)
+
+ @router.post(
+ "/locations/{location_id}/files/{file_id:path}:complete",
+ response_model=Envelope[FileUploadCompleteResponse],
+ status_code=status.HTTP_202_ACCEPTED,
+ )
+ async def complete_upload_file(
+ user_id: UserID,
+ location_id: LocationID,
+ file_id: StorageFileID,
+ body: FileUploadCompletionBody,
+ request: Request,
+ ): ...
+
+ @router.post(
+ "/locations/{location_id}/files/{file_id:path}:abort",
+ status_code=status.HTTP_204_NO_CONTENT,
+ )
+ async def abort_upload_file(
+ user_id: UserID,
+ location_id: LocationID,
+ file_id: StorageFileID,
+ request: Request,
+ ): ...
+
+ app.include_router(router)
+
+ return app
+
+
+@pytest.fixture(scope="module")
+def fake_storage_server(
+ storage_vtag: str,
+ fake_storage_app: FastAPI,
+ # app_environment: EnvVarsDict,
+) -> Iterator[URL]:
+ storage_port = unused_port()
+ with log_context(
+ logging.INFO,
+ msg=f"with fake storage server on 127.0.0.1:{storage_port}/{storage_vtag}",
+ ) as ctx:
+ config = uvicorn.Config(
+ fake_storage_app,
+ host="127.0.0.1",
+ port=storage_port,
+ log_level="error",
+ )
+ server = uvicorn.Server(config)
+
+ thread = Thread(target=server.run)
+ thread.daemon = True
+ thread.start()
+
+ ctx.logger.info(
+ "health at : %s",
+ f"http://127.0.0.1:{storage_port}/{storage_vtag}",
+ )
+
+ yield URL(f"http://127.0.0.1:{storage_port}")
+
+ server.should_exit = True
+ thread.join(timeout=10)
+
+
+@pytest.fixture
+def app_environment(
+ storage_vtag: str,
+ fake_storage_server: URL,
+ app_environment: dict[str, str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> dict[str, str]:
+ # NOTE: overrides app_environment
+
+ return app_environment | setenvs_from_dict(
+ monkeypatch,
+ {
+ "STORAGE_PORT": f"{fake_storage_server.port}",
+ "STORAGE_VTAG": storage_vtag,
+ "WEBSERVER_DB_LISTENER": "0",
+ "WEBSERVER_GARBAGE_COLLECTOR": "null",
+ },
+ )
+
+
+@pytest.fixture
+def location_id(faker: Faker) -> LocationID:
+ return TypeAdapter(LocationID).validate_python(faker.pyint(min_value=0))
diff --git a/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py b/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
new file mode 100644
index 00000000000..3ea1ec40230
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/storage/test_storage.py
@@ -0,0 +1,642 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any, Final
+from urllib.parse import quote
+
+import pytest
+from aiohttp.test_utils import TestClient
+from faker import Faker
+from fastapi_pagination.cursor import CursorPage
+from models_library.api_schemas_long_running_tasks.tasks import (
+ TaskGet,
+ TaskResult,
+ TaskStatus,
+)
+from models_library.api_schemas_rpc_async_jobs.async_jobs import (
+ AsyncJobAbort,
+ AsyncJobGet,
+ AsyncJobId,
+ AsyncJobResult,
+ AsyncJobStatus,
+)
+from models_library.api_schemas_rpc_async_jobs.exceptions import (
+ JobAbortedError,
+ JobError,
+ JobMissingError,
+ JobNotDoneError,
+ JobSchedulerError,
+)
+from models_library.api_schemas_storage.data_export_async_jobs import (
+ AccessRightError,
+ InvalidFileIdentifierError,
+)
+from models_library.api_schemas_storage.storage_schemas import (
+ DatasetMetaDataGet,
+ FileLocation,
+ FileMetaDataGet,
+ FileUploadSchema,
+ PathMetaDataGet,
+)
+from models_library.api_schemas_webserver._base import OutputSchema
+from models_library.api_schemas_webserver.storage import (
+ DataExportPost,
+)
+from models_library.generics import Envelope
+from models_library.progress_bar import ProgressReport
+from models_library.projects_nodes_io import LocationID, StorageFileID
+from pydantic import TypeAdapter
+from pytest_mock import MockerFixture
+from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.webserver_login import UserInfoDict
+from servicelib.aiohttp import status
+from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs
+from servicelib.rabbitmq.rpc_interfaces.async_jobs.async_jobs import (
+ submit,
+)
+from servicelib.rabbitmq.rpc_interfaces.storage.data_export import start_data_export
+from simcore_postgres_database.models.users import UserRole
+from yarl import URL
+
+API_VERSION = "v0"
+
+
+PREFIX = "/" + API_VERSION + "/storage"
+
+_faker = Faker()
+_user_roles: Final[list[UserRole]] = [
+ UserRole.GUEST,
+ UserRole.USER,
+ UserRole.TESTER,
+ UserRole.PRODUCT_OWNER,
+ UserRole.ADMIN,
+]
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_list_storage_locations(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+):
+ url = "/v0/storage/locations"
+ assert url.startswith(PREFIX)
+
+ resp = await client.get(url)
+ data, error = await assert_status(resp, expected)
+
+ if not error:
+ assert "json_schema_extra" in FileLocation.model_config
+
+ assert len(data) == len(FileLocation.model_json_schema()["examples"])
+ assert data == FileLocation.model_json_schema()["examples"]
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_list_storage_paths(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+ location_id: LocationID,
+):
+ assert client.app
+ url = client.app.router["list_storage_paths"].url_for(location_id=f"{location_id}")
+
+ resp = await client.get(f"{url}")
+ data, error = await assert_status(resp, expected)
+ if not error:
+ TypeAdapter(CursorPage[PathMetaDataGet]).validate_python(data)
+
+
+_faker = Faker()
+
+
+@pytest.fixture
+def create_storage_paths_rpc_client_mock(
+ mocker: MockerFixture,
+) -> Callable[[str, Any], None]:
+ def _(method: str, result_or_exception: Any):
+ def side_effect(*args, **kwargs):
+ if isinstance(result_or_exception, Exception):
+ raise result_or_exception
+
+ return result_or_exception
+
+ for fct in (f"servicelib.rabbitmq.rpc_interfaces.storage.paths.{method}",):
+ mocker.patch(fct, side_effect=side_effect)
+
+ return _
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_202_ACCEPTED),
+ (UserRole.USER, status.HTTP_202_ACCEPTED),
+ (UserRole.TESTER, status.HTTP_202_ACCEPTED),
+ ],
+)
+@pytest.mark.parametrize(
+ "backend_result_or_exception",
+ [
+ AsyncJobGet(job_id=AsyncJobId(f"{_faker.uuid4()}")),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_compute_path_size(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+ location_id: LocationID,
+ faker: Faker,
+ create_storage_paths_rpc_client_mock: Callable[[str, Any], None],
+ backend_result_or_exception: Any,
+):
+ create_storage_paths_rpc_client_mock(
+ submit.__name__,
+ backend_result_or_exception,
+ )
+
+ assert client.app
+ url = client.app.router["compute_path_size"].url_for(
+ location_id=f"{location_id}",
+ path=quote(faker.file_path(absolute=False), safe=""),
+ )
+
+ resp = await client.post(f"{url}")
+ data, error = await assert_status(resp, expected)
+ if not error:
+ TypeAdapter(TaskGet).validate_python(data)
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_list_datasets_metadata(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+):
+ url = "/v0/storage/locations/0/datasets"
+ assert url.startswith(PREFIX)
+ assert client.app
+ _url = client.app.router["list_datasets_metadata"].url_for(location_id="0")
+
+ assert url == str(_url)
+
+ resp = await client.get(url)
+ data, error = await assert_status(resp, expected)
+
+ if not error:
+ assert "json_schema_extra" in DatasetMetaDataGet.model_config
+
+ assert len(data) == len(DatasetMetaDataGet.model_json_schema()["examples"])
+ assert data == DatasetMetaDataGet.model_json_schema()["examples"]
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_list_dataset_files_metadata(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+):
+ url = "/v0/storage/locations/0/datasets/N:asdfsdf/metadata"
+ assert url.startswith(PREFIX)
+ assert client.app
+ _url = client.app.router["list_dataset_files_metadata"].url_for(
+ location_id="0", dataset_id="N:asdfsdf"
+ )
+
+ assert url == str(_url)
+
+ resp = await client.get(url)
+ data, error = await assert_status(resp, expected)
+
+ if not error:
+ assert "json_schema_extra" in FileMetaDataGet.model_config
+
+ assert len(data) == len(FileMetaDataGet.model_json_schema()["examples"])
+ assert data == [
+ FileMetaDataGet.model_validate(e).model_dump(mode="json")
+ for e in FileMetaDataGet.model_json_schema()["examples"]
+ ]
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_storage_file_meta(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+ faker: Faker,
+):
+ # tests redirect of path with quotes in path
+ file_id = f"{faker.uuid4()}/{faker.uuid4()}/a/b/c/d/e/dat"
+ quoted_file_id = quote(file_id, safe="")
+ url = f"/v0/storage/locations/0/files/{quoted_file_id}/metadata"
+
+ assert url.startswith(PREFIX)
+
+ resp = await client.get(url)
+ data, error = await assert_status(resp, expected)
+
+ if not error:
+ assert data
+ model = FileMetaDataGet.model_validate(data)
+ assert model
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_storage_list_filter(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+):
+ # tests composition of 2 queries
+ file_id = "a/b/c/d/e/dat"
+ url = "/v0/storage/locations/0/files/metadata?uuid_filter={}".format(
+ quote(file_id, safe="")
+ )
+
+ assert url.startswith(PREFIX)
+
+ resp = await client.get(url)
+ data, error = await assert_status(resp, expected)
+
+ if not error:
+ assert len(data) == 2
+ for item in data:
+ model = FileMetaDataGet.model_validate(item)
+ assert model
+
+
+@pytest.fixture
+def file_id(faker: Faker) -> StorageFileID:
+ return TypeAdapter(StorageFileID).validate_python(
+ f"{faker.uuid4()}/{faker.uuid4()}/{faker.file_name()} with spaces().dat"
+ )
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_200_OK),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ ],
+)
+async def test_upload_file(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ expected: int,
+ file_id: StorageFileID,
+):
+ url = f"/v0/storage/locations/0/files/{quote(file_id, safe='')}"
+
+ assert url.startswith(PREFIX)
+
+ resp = await client.put(url)
+ data, error = await assert_status(resp, expected)
+ if not error:
+ assert not error
+ assert data
+ file_upload_schema = FileUploadSchema.model_validate(data)
+
+ # let's abort
+ resp = await client.post(f"{file_upload_schema.links.abort_upload.path}")
+ data, error = await assert_status(resp, status.HTTP_204_NO_CONTENT)
+ assert not error
+ assert not data
+
+
+@pytest.fixture
+def create_storage_rpc_client_mock(
+ mocker: MockerFixture,
+) -> Callable[[str, str, Any], None]:
+ def _(module: str, method: str, result_or_exception: Any):
+ def side_effect(*args, **kwargs):
+ if isinstance(result_or_exception, Exception):
+ raise result_or_exception
+
+ return result_or_exception
+
+ for fct in (f"{module}.{method}",):
+ mocker.patch(fct, side_effect=side_effect)
+
+ return _
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "backend_result_or_exception, expected_status",
+ [
+ (AsyncJobGet(job_id=AsyncJobId(f"{_faker.uuid4()}")), status.HTTP_202_ACCEPTED),
+ (
+ InvalidFileIdentifierError(file_id=Path("/my/file")),
+ status.HTTP_404_NOT_FOUND,
+ ),
+ (
+ AccessRightError(
+ user_id=_faker.pyint(min_value=0), file_id=Path("/my/file")
+ ),
+ status.HTTP_403_FORBIDDEN,
+ ),
+ (JobSchedulerError(exc=_faker.text()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_data_export(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ faker: Faker,
+ backend_result_or_exception: Any,
+ expected_status: int,
+):
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.storage._rest",
+ start_data_export.__name__,
+ backend_result_or_exception,
+ )
+
+ _body = DataExportPost(
+ paths=[f"{faker.uuid4()}/{faker.uuid4()}/{faker.file_name()}"]
+ )
+ response = await client.post(
+ f"/{API_VERSION}/storage/locations/0/export-data", data=_body.model_dump_json()
+ )
+ assert response.status == expected_status
+ if response.status == status.HTTP_202_ACCEPTED:
+ Envelope[TaskGet].model_validate(await response.json())
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "backend_result_or_exception, expected_status",
+ [
+ (
+ AsyncJobStatus(
+ job_id=AsyncJobId(f"{_faker.uuid4()}"),
+ progress=ProgressReport(actual_value=0.5, total=1.0),
+ done=False,
+ ),
+ status.HTTP_200_OK,
+ ),
+ (JobSchedulerError(exc=_faker.text()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ (JobMissingError(job_id=_faker.uuid4()), status.HTTP_404_NOT_FOUND),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_get_async_jobs_status(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ backend_result_or_exception: Any,
+ expected_status: int,
+):
+ _job_id = AsyncJobId(_faker.uuid4())
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.tasks._rest",
+ f"async_jobs.{async_jobs.status.__name__}",
+ backend_result_or_exception,
+ )
+
+ response = await client.get(f"/{API_VERSION}/tasks/{_job_id}")
+ assert response.status == expected_status
+ if response.status == status.HTTP_200_OK:
+ response_body_data = (
+ Envelope[TaskStatus].model_validate(await response.json()).data
+ )
+ assert response_body_data is not None
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "backend_result_or_exception, expected_status",
+ [
+ (
+ AsyncJobAbort(result=True, job_id=AsyncJobId(_faker.uuid4())),
+ status.HTTP_204_NO_CONTENT,
+ ),
+ (JobSchedulerError(exc=_faker.text()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ (JobMissingError(job_id=_faker.uuid4()), status.HTTP_404_NOT_FOUND),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_abort_async_jobs(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ faker: Faker,
+ backend_result_or_exception: Any,
+ expected_status: int,
+):
+ _job_id = AsyncJobId(faker.uuid4())
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.tasks._rest",
+ f"async_jobs.{async_jobs.cancel.__name__}",
+ backend_result_or_exception,
+ )
+
+ response = await client.delete(f"/{API_VERSION}/tasks/{_job_id}")
+ assert response.status == expected_status
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "backend_result_or_exception, expected_status",
+ [
+ (JobNotDoneError(job_id=_faker.uuid4()), status.HTTP_404_NOT_FOUND),
+ (AsyncJobResult(result=None), status.HTTP_200_OK),
+ (JobError(job_id=_faker.uuid4()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ (JobAbortedError(job_id=_faker.uuid4()), status.HTTP_410_GONE),
+ (JobSchedulerError(exc=_faker.text()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ (JobMissingError(job_id=_faker.uuid4()), status.HTTP_404_NOT_FOUND),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_get_async_job_result(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ faker: Faker,
+ backend_result_or_exception: Any,
+ expected_status: int,
+):
+ _job_id = AsyncJobId(faker.uuid4())
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.tasks._rest",
+ f"async_jobs.{async_jobs.result.__name__}",
+ backend_result_or_exception,
+ )
+
+ response = await client.get(f"/{API_VERSION}/tasks/{_job_id}/result")
+ assert response.status == expected_status
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "backend_result_or_exception, expected_status",
+ [
+ (
+ [
+ AsyncJobGet(
+ job_id=AsyncJobId(_faker.uuid4()),
+ )
+ ],
+ status.HTTP_200_OK,
+ ),
+ (JobSchedulerError(exc=_faker.text()), status.HTTP_500_INTERNAL_SERVER_ERROR),
+ ],
+ ids=lambda x: type(x).__name__,
+)
+async def test_get_user_async_jobs(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ backend_result_or_exception: Any,
+ expected_status: int,
+):
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.tasks._rest",
+ f"async_jobs.{async_jobs.list_jobs.__name__}",
+ backend_result_or_exception,
+ )
+
+ response = await client.get(f"/{API_VERSION}/tasks")
+ assert response.status == expected_status
+ if response.status == status.HTTP_200_OK:
+ Envelope[list[TaskGet]].model_validate(await response.json())
+
+
+@pytest.mark.parametrize("user_role", _user_roles)
+@pytest.mark.parametrize(
+ "http_method, href, backend_method, backend_object, return_status, return_schema",
+ [
+ (
+ "GET",
+ "status_href",
+ async_jobs.status.__name__,
+ AsyncJobStatus(
+ job_id=AsyncJobId(_faker.uuid4()),
+ progress=ProgressReport(actual_value=0.5, total=1.0),
+ done=False,
+ ),
+ status.HTTP_200_OK,
+ TaskStatus,
+ ),
+ (
+ "DELETE",
+ "abort_href",
+ async_jobs.cancel.__name__,
+ AsyncJobAbort(result=True, job_id=AsyncJobId(_faker.uuid4())),
+ status.HTTP_204_NO_CONTENT,
+ None,
+ ),
+ (
+ "GET",
+ "result_href",
+ async_jobs.result.__name__,
+ AsyncJobResult(result=None),
+ status.HTTP_200_OK,
+ TaskResult,
+ ),
+ ],
+)
+async def test_get_async_job_links(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ create_storage_rpc_client_mock: Callable[[str, str, Any], None],
+ faker: Faker,
+ http_method: str,
+ href: str,
+ backend_method: str,
+ backend_object: Any,
+ return_status: int,
+ return_schema: OutputSchema | None,
+):
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.storage._rest",
+ start_data_export.__name__,
+ AsyncJobGet(job_id=AsyncJobId(f"{_faker.uuid4()}")),
+ )
+
+ _body = DataExportPost(
+ paths=[f"{faker.uuid4()}/{faker.uuid4()}/{faker.file_name()}"]
+ )
+ response = await client.post(
+ f"/{API_VERSION}/storage/locations/0/export-data", data=_body.model_dump_json()
+ )
+ assert response.status == status.HTTP_202_ACCEPTED
+ response_body_data = Envelope[TaskGet].model_validate(await response.json()).data
+ assert response_body_data is not None
+
+ # Call the different links and check the correct model and return status
+ create_storage_rpc_client_mock(
+ "simcore_service_webserver.tasks._rest",
+ f"async_jobs.{backend_method}",
+ backend_object,
+ )
+ response = await client.request(
+ http_method, URL(getattr(response_body_data, href)).path
+ )
+ assert response.status == return_status
+ if return_schema:
+ Envelope[return_schema].model_validate(await response.json())
diff --git a/services/web/server/tests/unit/with_dbs/03/test_storage_handlers.py b/services/web/server/tests/unit/with_dbs/01/storage/test_storage_handlers.py
similarity index 67%
rename from services/web/server/tests/unit/with_dbs/03/test_storage_handlers.py
rename to services/web/server/tests/unit/with_dbs/01/storage/test_storage_handlers.py
index 694d49a998b..61909c8742d 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_storage_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/01/storage/test_storage_handlers.py
@@ -5,14 +5,11 @@
import json
-import urllib.parse
from typing import Any
import pytest
-from aiohttp import web
-from aiohttp.test_utils import TestClient, make_mocked_request
-from faker import Faker
-from models_library.api_schemas_storage import (
+from aiohttp.test_utils import TestClient
+from models_library.api_schemas_storage.storage_schemas import (
FileUploadCompleteResponse,
FileUploadLinks,
FileUploadSchema,
@@ -23,14 +20,7 @@
from pytest_simcore.helpers.typing_env import EnvVarsDict
from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp.rest_responses import wrap_as_envelope
-from servicelib.request_keys import RQT_USERID_KEY
from simcore_postgres_database.models.users import UserRole
-from simcore_service_webserver.application_settings import setup_settings
-from simcore_service_webserver.storage._handlers import (
- _from_storage_url,
- _to_storage_url,
-)
-from yarl import URL
@pytest.fixture
@@ -40,8 +30,6 @@ def app_environment(
return app_environment | setenvs_from_dict(
monkeypatch,
{
- "WEBSERVER_DB_LISTENER": "0",
- "WEBSERVER_GARBAGE_COLLECTOR": "null",
"STORAGE_HOST": "fake-storage",
},
)
@@ -53,7 +41,7 @@ async def _resp(*args, **kwargs) -> tuple[Any, int]:
return (wrap_as_envelope(data=expected_response), 200)
mocker.patch(
- "simcore_service_webserver.storage._handlers._forward_request_to_storage",
+ "simcore_service_webserver.storage._rest._forward_request_to_storage",
autospec=True,
side_effect=_resp,
)
@@ -62,7 +50,7 @@ def _resolve(*args, **kwargs) -> AnyUrl:
return TypeAdapter(AnyUrl).validate_python("http://private-url")
mocker.patch(
- "simcore_service_webserver.storage._handlers._from_storage_url",
+ "simcore_service_webserver.storage._rest._from_storage_url",
autospec=True,
side_effect=_resolve,
)
@@ -175,48 +163,3 @@ async def test_openapi_regression_test(
decoded_response = await response.json()
assert decoded_response["error"] is None
assert decoded_response["data"] is not None
-
-
-def test_url_storage_resolver_helpers(faker: Faker, app_environment: EnvVarsDict):
-
- app = web.Application()
- setup_settings(app)
-
- # NOTE: careful, first we need to encode the "/" in this file path.
- # For that we need safe="" option
- assert urllib.parse.quote("/") == "/"
- assert urllib.parse.quote("/", safe="") == "%2F"
- assert urllib.parse.quote("%2F", safe="") == "%252F"
-
- file_id = urllib.parse.quote(f"{faker.uuid4()}/{faker.uuid4()}/file.py", safe="")
- assert "%2F" in file_id
- assert "%252F" not in file_id
-
- url = URL(f"/v0/storage/locations/0/files/{file_id}:complete", encoded=True)
- assert url.raw_parts[-1] == f"{file_id}:complete"
-
- web_request = make_mocked_request("GET", str(url), app=app)
- web_request[RQT_USERID_KEY] = faker.pyint()
-
- # web -> storage
- storage_url = _to_storage_url(web_request)
- # Something like
- # http://storage:123/v5/locations/0/files/e3e70...c07cd%2Ff7...55%2Ffile.py:complete?user_id=8376
-
- assert storage_url.raw_parts[-1] == web_request.url.raw_parts[-1]
-
- assert storage_url.host == app_environment["STORAGE_HOST"]
- assert storage_url.port == int(app_environment["STORAGE_PORT"])
- assert storage_url.query["user_id"] == str(web_request[RQT_USERID_KEY])
-
- # storage -> web
- web_url: AnyUrl = _from_storage_url(
- web_request, TypeAdapter(AnyUrl).validate_python(f"{storage_url}")
- )
-
- assert storage_url.host != web_url.host
- assert storage_url.port != web_url.port
-
- assert isinstance(storage_url, URL) # this is a bit inconvenient
- assert isinstance(web_url, AnyUrl)
- assert f"{web_url}" == f"{web_request.url}"
diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
index 85f63c42b96..cfd7b9f154a 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
@@ -10,19 +10,28 @@
from http.client import HTTPException
import pytest
+import tenacity
from aiohttp.test_utils import TestClient
from faker import Faker
from models_library.products import ProductName
+from pytest_mock import MockerFixture, MockType
from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
+from pytest_simcore.helpers.typing_env import EnvVarsDict
from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
from servicelib.aiohttp import status
-from simcore_service_webserver.api_keys import _repository as repo
-from simcore_service_webserver.api_keys._models import ApiKey
-from simcore_service_webserver.api_keys._service import (
- get_or_create_api_key,
- prune_expired_api_keys,
+from simcore_service_webserver.api_keys import _repository, _service, api_keys_service
+from simcore_service_webserver.api_keys.models import ApiKey
+from simcore_service_webserver.application_settings import (
+ ApplicationSettings,
+ get_application_settings,
)
from simcore_service_webserver.db.models import UserRole
+from tenacity import (
+ retry_if_exception_type,
+ stop_after_delay,
+ wait_fixed,
+)
@pytest.fixture
@@ -31,10 +40,11 @@ async def fake_user_api_keys(
logged_user: UserInfoDict,
osparc_product_name: ProductName,
faker: Faker,
-) -> AsyncIterable[list[int]]:
+) -> AsyncIterable[list[ApiKey]]:
assert client.app
+
api_keys: list[ApiKey] = [
- await repo.create_api_key(
+ await _repository.create_api_key(
client.app,
user_id=logged_user["id"],
product_name=osparc_product_name,
@@ -49,7 +59,7 @@ async def fake_user_api_keys(
yield api_keys
for api_key in api_keys:
- await repo.delete_api_key(
+ await _repository.delete_api_key(
client.app,
api_key_id=api_key.id,
user_id=logged_user["id"],
@@ -74,11 +84,11 @@ def _get_user_access_parametrizations(expected_authed_status_code):
_get_user_access_parametrizations(status.HTTP_200_OK),
)
async def test_list_api_keys(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPStatus,
- disable_gc_manual_guest_users: None,
):
resp = await client.get("/v0/auth/api-keys")
data, errors = await assert_status(resp, expected)
@@ -92,11 +102,11 @@ async def test_list_api_keys(
_get_user_access_parametrizations(status.HTTP_200_OK),
)
async def test_create_api_key(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPStatus,
- disable_gc_manual_guest_users: None,
):
display_name = "foo"
resp = await client.post("/v0/auth/api-keys", json={"displayName": display_name})
@@ -118,12 +128,12 @@ async def test_create_api_key(
_get_user_access_parametrizations(status.HTTP_204_NO_CONTENT),
)
async def test_delete_api_keys(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
fake_user_api_keys: list[ApiKey],
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPStatus,
- disable_gc_manual_guest_users: None,
):
resp = await client.delete("/v0/auth/api-keys/0")
await assert_status(resp, expected)
@@ -133,41 +143,58 @@ async def test_delete_api_keys(
await assert_status(resp, expected)
+EXPIRATION_WAIT_FACTOR = 1.2
+
+
@pytest.mark.parametrize(
"user_role,expected",
_get_user_access_parametrizations(status.HTTP_200_OK),
)
async def test_create_api_key_with_expiration(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPStatus,
- disable_gc_manual_guest_users: None,
+ mocker: MockerFixture,
):
assert client.app
+ # test gc is actually disabled
+ gc_prune_mock = mocker.patch(
+ "simcore_service_webserver.garbage_collector._tasks_api_keys.create_background_task_to_prune_api_keys",
+ spec=True,
+ )
+ assert not gc_prune_mock.called
+
+ expected_api_key = "foo"
+
# create api-keys with expiration interval
expiration_interval = timedelta(seconds=1)
resp = await client.post(
"/v0/auth/api-keys",
- json={"displayName": "foo", "expiration": expiration_interval.seconds},
+ json={
+ "displayName": expected_api_key,
+ "expiration": expiration_interval.seconds,
+ },
)
data, errors = await assert_status(resp, expected)
if not errors:
- assert data["displayName"] == "foo"
+ assert data["displayName"] == expected_api_key
assert "apiKey" in data
assert "apiSecret" in data
# list created api-key
resp = await client.get("/v0/auth/api-keys")
data, _ = await assert_status(resp, expected)
- assert [d["displayName"] for d in data] == ["foo"]
+ assert [d["displayName"] for d in data] == [expected_api_key]
# wait for api-key for it to expire and force-run scheduled task
- await asyncio.sleep(expiration_interval.seconds)
- deleted = await prune_expired_api_keys(client.app)
- assert deleted == ["foo"]
+ await asyncio.sleep(EXPIRATION_WAIT_FACTOR * expiration_interval.seconds)
+
+ deleted = await api_keys_service.prune_expired_api_keys(client.app)
+ assert deleted == [expected_api_key]
resp = await client.get("/v0/auth/api-keys")
data, _ = await assert_status(resp, expected)
@@ -175,6 +202,7 @@ async def test_create_api_key_with_expiration(
async def test_get_or_create_api_key(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
):
async with NewUser(
@@ -190,13 +218,15 @@ async def test_get_or_create_api_key(
}
# create once
- created = await get_or_create_api_key(client.app, **options)
+ created = await _service.get_or_create_api_key(client.app, **options)
assert created.display_name == "foo"
assert created.api_key != created.api_secret
# idempotent
for _ in range(3):
- assert await get_or_create_api_key(client.app, **options) == created
+ assert (
+ await _service.get_or_create_api_key(client.app, **options) == created
+ )
@pytest.mark.parametrize(
@@ -204,14 +234,58 @@ async def test_get_or_create_api_key(
_get_user_access_parametrizations(status.HTTP_404_NOT_FOUND),
)
async def test_get_not_existing_api_key(
+ disabled_setup_garbage_collector: MockType,
client: TestClient,
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPException,
- disable_gc_manual_guest_users: None,
):
resp = await client.get("/v0/auth/api-keys/42")
data, errors = await assert_status(resp, expected)
if not errors:
assert data is None
+
+
+@pytest.fixture
+async def app_environment(
+ app_environment: EnvVarsDict,
+ monkeypatch: pytest.MonkeyPatch,
+) -> EnvVarsDict:
+ return app_environment | setenvs_from_dict(
+ monkeypatch,
+ {
+ "WEBSERVER_GARBAGE_COLLECTOR": '{"GARBAGE_COLLECTOR_INTERVAL_S": 30, "GARBAGE_COLLECTOR_PRUNE_APIKEYS_INTERVAL_S": 1}'
+ },
+ )
+
+
+async def test_prune_expired_api_keys_task_is_triggered(
+ app_environment: EnvVarsDict,
+ mocker: MockerFixture,
+ client: TestClient,
+):
+ assert app_environment["WEBSERVER_GARBAGE_COLLECTOR"] is not None
+
+ delete_expired_spy = mocker.spy(_repository, "delete_expired_api_keys")
+
+ assert client.app
+
+ settings: ApplicationSettings = get_application_settings(client.app)
+ assert settings.WEBSERVER_GARBAGE_COLLECTOR
+
+ assert not delete_expired_spy.called
+
+ async for attempt in tenacity.AsyncRetrying(
+ stop=stop_after_delay(
+ timedelta(
+ seconds=EXPIRATION_WAIT_FACTOR
+ * settings.WEBSERVER_GARBAGE_COLLECTOR.GARBAGE_COLLECTOR_EXPIRED_USERS_CHECK_INTERVAL_S
+ )
+ ),
+ wait=wait_fixed(1),
+ retry=retry_if_exception_type(AssertionError),
+ reraise=True,
+ ):
+ with attempt:
+ delete_expired_spy.assert_called()
diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
index aa45fd9fd2e..3053df38797 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
@@ -23,8 +23,8 @@
from settings_library.rabbit import RabbitSettings
from simcore_postgres_database.models.users import UserRole
from simcore_service_webserver.api_keys import _repository as repo
-from simcore_service_webserver.api_keys._models import ApiKey
from simcore_service_webserver.api_keys.errors import ApiKeyNotFoundError
+from simcore_service_webserver.api_keys.models import ApiKey
from simcore_service_webserver.application_settings import ApplicationSettings
pytest_simcore_core_services_selection = [
diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py
index 35733d100e6..40440879da0 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__pricing_plan.py
@@ -10,7 +10,7 @@
from aiohttp import web
from aiohttp.test_utils import TestClient
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
+ RutPricingPlanGet,
)
from models_library.utils.fastapi_encoders import jsonable_encoder
from pytest_simcore.aioresponses_mocker import AioResponsesMock
@@ -29,8 +29,8 @@ def mock_rut_api_responses(
assert client.app
settings: ResourceUsageTrackerSettings = get_plugin_settings(client.app)
- service_pricing_plan_get = PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ service_pricing_plan_get = RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_json_schema()["examples"][0],
)
aioresponses_mocker.get(
re.compile(f"^{settings.api_base_url}/services/+.+$"),
diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py
index 96ada757900..e005192edae 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py
@@ -2,13 +2,16 @@
# pylint:disable=unused-argument
# pylint:disable=redefined-outer-name
+import re
import urllib.parse
from unittest.mock import MagicMock
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient
-from models_library.api_schemas_catalog.services import ServiceGetV2
+from aioresponses import aioresponses as AioResponsesMock
+from faker import Faker
+from models_library.api_schemas_catalog.services import LatestServiceGet, ServiceGetV2
from models_library.api_schemas_webserver.catalog import (
CatalogServiceGet,
CatalogServiceUpdate,
@@ -22,10 +25,15 @@
from pydantic import NonNegativeInt, TypeAdapter
from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.faker_factories import random_icon_url
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp import status
+from simcore_service_webserver.catalog._controller_rest_schemas import (
+ ServiceInputGet,
+ ServiceOutputGet,
+)
from simcore_service_webserver.db.models import UserRole
@@ -55,12 +63,12 @@ async def _list(
assert product_name
assert user_id
- items = TypeAdapter(list[ServiceGetV2]).validate_python(
- ServiceGetV2.model_config["json_schema_extra"]["examples"],
+ items = TypeAdapter(list[LatestServiceGet]).validate_python(
+ LatestServiceGet.model_json_schema()["examples"],
)
total_count = len(items)
- return PageRpc[ServiceGetV2].create(
+ return PageRpc[LatestServiceGet].create(
items[offset : offset + limit],
total=total_count,
limit=limit,
@@ -80,7 +88,7 @@ async def _get(
assert user_id
got = ServiceGetV2.model_validate(
- ServiceGetV2.model_config["json_schema_extra"]["examples"][0]
+ ServiceGetV2.model_json_schema()["examples"][0]
)
got.version = service_version
got.key = service_key
@@ -101,7 +109,7 @@ async def _update(
assert user_id
got = ServiceGetV2.model_validate(
- ServiceGetV2.model_config["json_schema_extra"]["examples"][0]
+ ServiceGetV2.model_json_schema()["examples"][0]
)
got.version = service_version
got.key = service_key
@@ -109,17 +117,17 @@ async def _update(
return {
"list_services_paginated": mocker.patch(
- "simcore_service_webserver.catalog._api.catalog_rpc.list_services_paginated",
+ "simcore_service_webserver.catalog._service.catalog_rpc.list_services_paginated",
autospec=True,
side_effect=_list,
),
"get_service": mocker.patch(
- "simcore_service_webserver.catalog._api.catalog_rpc.get_service",
+ "simcore_service_webserver.catalog._service.catalog_rpc.get_service",
autospec=True,
side_effect=_get,
),
"update_service": mocker.patch(
- "simcore_service_webserver.catalog._api.catalog_rpc.update_service",
+ "simcore_service_webserver.catalog._service.catalog_rpc.update_service",
autospec=True,
side_effect=_update,
),
@@ -154,6 +162,196 @@ async def test_list_services_latest(
assert mocked_rpc_catalog_service_api["list_services_paginated"].call_count == 1
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_list_inputs(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = client.app.router["list_service_inputs"].url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ )
+
+ response = await client.get(f"{url}")
+ data, _ = await assert_status(response, status.HTTP_200_OK)
+ TypeAdapter(list[ServiceInputGet]).validate_python(data)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_list_outputs(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = client.app.router["list_service_outputs"].url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ )
+
+ response = await client.get(f"{url}")
+ data, _ = await assert_status(response, status.HTTP_200_OK)
+ TypeAdapter(list[ServiceOutputGet]).validate_python(data)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_get_outputs(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = client.app.router["get_service_output"].url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ output_key=next(iter(service_payload["outputs"].keys())),
+ )
+
+ response = await client.get(f"{url}")
+ data, _ = await assert_status(response, status.HTTP_200_OK)
+ ServiceOutputGet.model_validate(data)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_get_inputs(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = client.app.router["get_service_input"].url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ input_key=next(iter(service_payload["inputs"].keys())),
+ )
+ response = await client.get(f"{url}")
+ data, _ = await assert_status(response, status.HTTP_200_OK)
+ ServiceInputGet.model_validate(data)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_get_compatible_inputs_given_source_outputs(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ for _ in range(2):
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = (
+ client.app.router["get_compatible_inputs_given_source_output"]
+ .url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ )
+ .with_query(
+ {
+ "fromService": "simcore/services/comp/itis/sleeper",
+ "fromVersion": "0.1.0",
+ "fromOutput": "output_1",
+ }
+ )
+ )
+ response = await client.get(f"{url}")
+ _, _ = await assert_status(response, status.HTTP_200_OK)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_get_compatible_outputs_given_target_inptuts(
+ client: TestClient, logged_user: UserInfoDict, aioresponses_mocker: AioResponsesMock
+):
+ url_pattern = re.compile(r"http://catalog:8000/v0/services/.*")
+ service_payload = ServiceGetV2.model_json_schema()["examples"][0]
+ for _ in range(2):
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=service_payload,
+ )
+
+ service_key = "simcore/services/comp/itis/sleeper"
+ service_version = "0.1.0"
+ assert client.app and client.app.router
+ url = (
+ client.app.router["get_compatible_outputs_given_target_input"]
+ .url_for(
+ service_key=urllib.parse.quote(service_key, safe=""),
+ service_version=service_version,
+ )
+ .with_query(
+ {
+ "toService": "simcore/services/comp/itis/sleeper",
+ "toVersion": "0.1.0",
+ "toInput": "input_1",
+ }
+ )
+ )
+ response = await client.get(f"{url}")
+ _, _ = await assert_status(response, status.HTTP_200_OK)
+
+
@pytest.mark.parametrize(
"user_role",
[UserRole.USER],
@@ -162,6 +360,7 @@ async def test_get_and_patch_service(
client: TestClient,
logged_user: UserInfoDict,
mocked_rpc_catalog_service_api: dict[str, MagicMock],
+ faker: Faker,
):
assert client.app
assert client.app.router
@@ -190,8 +389,8 @@ async def test_get_and_patch_service(
# PATCH
update = CatalogServiceUpdate(
name="foo",
- thumbnail=None,
description="bar",
+ icon=random_icon_url(faker),
classifiers=None,
versionDisplay="Some nice name",
descriptionUi=True,
@@ -209,6 +408,7 @@ async def test_get_and_patch_service(
assert model.key == service_key
assert model.version == service_version
assert model.name == update.name
+ assert model.icon == update.icon
assert model.description == update.description
assert model.description_ui == update.description_ui
assert model.version_display == update.version_display
diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_rest_client.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_rest_client.py
new file mode 100644
index 00000000000..452ecfd76ec
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_rest_client.py
@@ -0,0 +1,112 @@
+# pylint:disable=unused-argument
+import re
+
+import pytest
+from aiohttp.test_utils import TestClient
+from aioresponses import aioresponses as AioResponsesMock
+from common_library.users_enums import UserRole
+from models_library.api_schemas_catalog.service_access_rights import (
+ ServiceAccessRightsGet,
+)
+from pytest_simcore.helpers.webserver_login import UserInfoDict
+from servicelib.aiohttp import status
+from simcore_service_webserver.catalog._controller_rest_exceptions import (
+ DefaultPricingUnitForServiceNotFoundError,
+)
+from simcore_service_webserver.catalog.catalog_service import (
+ get_service_access_rights,
+ get_services_for_user_in_product,
+ is_catalog_service_responsive,
+)
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+@pytest.mark.parametrize(
+ "backend_status_code", [status.HTTP_200_OK, status.HTTP_500_INTERNAL_SERVER_ERROR]
+)
+async def test_server_responsive(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ aioresponses_mocker: AioResponsesMock,
+ backend_status_code: int,
+):
+ aioresponses_mocker.get("http://catalog:8000", status=backend_status_code)
+
+ assert client.app
+ is_responsive = await is_catalog_service_responsive(app=client.app)
+ if backend_status_code == status.HTTP_200_OK:
+ assert is_responsive == True
+ else:
+ assert is_responsive == False
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+@pytest.mark.parametrize(
+ "backend_status_code", [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND]
+)
+async def test_get_services_for_user_in_product(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ aioresponses_mocker: AioResponsesMock,
+ backend_status_code: int,
+):
+ url_pattern = re.compile(r"http://catalog:8000/.*")
+ aioresponses_mocker.get(
+ url_pattern,
+ status=backend_status_code,
+ )
+ assert client.app
+ _ = await get_services_for_user_in_product(
+ app=client.app,
+ user_id=logged_user["id"],
+ product_name="osparc",
+ only_key_versions=False,
+ )
+
+
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_get_service_access_rights(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ aioresponses_mocker: AioResponsesMock,
+):
+ url_pattern = re.compile(r"http://catalog:8000/.*")
+ example = ServiceAccessRightsGet(
+ service_key="simcore/services/comp/itis/sleeper",
+ service_version="2.1.4",
+ gids_with_access_rights={
+ 1: {"execute_access": True},
+ 5: {"execute_access": True},
+ },
+ )
+ aioresponses_mocker.get(
+ url_pattern,
+ status=status.HTTP_200_OK,
+ payload=example.model_dump(),
+ )
+ assert client.app
+ access_rights = await get_service_access_rights(
+ app=client.app,
+ user_id=logged_user["id"],
+ service_key="simcore/services/comp/itis/sleeper",
+ service_version="2.1.4",
+ product_name="osparc",
+ )
+ assert isinstance(access_rights, ServiceAccessRightsGet)
+
+
+async def test_catalog_exceptions():
+
+ error = DefaultPricingUnitForServiceNotFoundError(
+ service_key="key", service_version="version"
+ )
+ assert isinstance(error.debug_message(), str)
diff --git a/services/web/server/tests/unit/with_dbs/01/test_director_v2.py b/services/web/server/tests/unit/with_dbs/01/test_director_v2.py
index f18bc9e1754..9c5914e3112 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_director_v2.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_director_v2.py
@@ -10,7 +10,7 @@
from models_library.projects_pipeline import ComputationTask
from models_library.projects_state import RunningState
from models_library.users import UserID
-from simcore_service_webserver.director_v2 import api
+from simcore_service_webserver.director_v2 import director_v2_service
@pytest.fixture()
@@ -37,7 +37,7 @@ async def test_create_pipeline(
project_id: ProjectID,
osparc_product_name: str,
):
- task_out = await api.create_or_update_pipeline(
+ task_out = await director_v2_service.create_or_update_pipeline(
client.app, user_id, project_id, osparc_product_name
)
assert task_out
@@ -51,7 +51,9 @@ async def test_get_computation_task(
user_id: UserID,
project_id: ProjectID,
):
- task_out = await api.get_computation_task(client.app, user_id, project_id)
+ task_out = await director_v2_service.get_computation_task(
+ client.app, user_id, project_id
+ )
assert task_out
assert isinstance(task_out, ComputationTask)
assert task_out.state == RunningState.NOT_STARTED
@@ -60,4 +62,4 @@ async def test_get_computation_task(
async def test_delete_pipeline(
mocked_director_v2, client, user_id: UserID, project_id: ProjectID
):
- await api.delete_pipeline(client.app, user_id, project_id)
+ await director_v2_service.delete_pipeline(client.app, user_id, project_id)
diff --git a/services/web/server/tests/unit/with_dbs/01/test_director_v2_handlers.py b/services/web/server/tests/unit/with_dbs/01/test_director_v2_handlers.py
index 613e32cee19..cb40a779378 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_director_v2_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_director_v2_handlers.py
@@ -58,12 +58,13 @@ async def test_start_partial_computation(
project_id: ProjectID,
user_role: UserRole,
expected: ExpectedResponse,
+ faker: Faker,
):
assert client.app
url = client.app.router["start_computation"].url_for(project_id=f"{project_id}")
rsp = await client.post(
- f"{url}", json={"subgraph": ["node_id1", "node_id2", "node_id498"]}
+ f"{url}", json={"subgraph": [faker.uuid4(), faker.uuid4(), faker.uuid4()]}
)
data, error = await assert_status(
rsp,
diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
index 98fa573cd08..c77f1335015 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py
@@ -7,7 +7,7 @@
import pytest
import sqlalchemy as sa
from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine
-from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY
+from simcore_service_webserver.constants import APP_AIOPG_ENGINE_KEY
from simcore_service_webserver.groups._classifiers_service import (
GroupClassifierRepository,
)
diff --git a/services/web/server/tests/unit/with_dbs/01/test_long_running_tasks.py b/services/web/server/tests/unit/with_dbs/01/test_long_running_tasks.py
index 4e3f10a9c4d..c6f58f29ee1 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_long_running_tasks.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_long_running_tasks.py
@@ -2,11 +2,15 @@
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
# pylint: disable=unused-variable
+# pylint: disable=no-self-use
+# pylint: disable=no-self-argument
from typing import Any
import pytest
from aiohttp.test_utils import TestClient
+from faker import Faker
+from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_parametrizations import (
ExpectedResponse,
@@ -63,3 +67,23 @@ async def test_listing_tasks_empty(
assert not data
return
assert data == []
+
+
+@pytest.mark.parametrize("user_role", [UserRole.GUEST, UserRole.TESTER, UserRole.USER])
+async def test_listing_tasks_with_list_inprocess_tasks_error(
+ client: TestClient, logged_user, faker: Faker, mocker: MockerFixture
+):
+ assert client.app
+
+ class _DummyTaskManager:
+ def list_tasks(self, *args, **kwargs):
+ raise Exception() # pylint: disable=broad-exception-raised
+
+ mocker.patch(
+ "servicelib.aiohttp.long_running_tasks._routes.get_tasks_manager",
+ return_value=_DummyTaskManager(),
+ )
+
+ _async_jobs_listing_path = client.app.router["get_async_jobs"].url_for()
+ resp = await client.request("GET", f"{_async_jobs_listing_path}")
+ assert resp.status == status.HTTP_500_INTERNAL_SERVER_ERROR
diff --git a/services/web/server/tests/unit/with_dbs/01/test_statics.py b/services/web/server/tests/unit/with_dbs/01/test_statics.py
index d3ba4448061..dfd32405f23 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_statics.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_statics.py
@@ -22,7 +22,10 @@
from simcore_service_webserver.statics._constants import (
APP_FRONTEND_CACHED_STATICS_JSON_KEY,
)
-from simcore_service_webserver.statics._events import create_and_cache_statics_json
+from simcore_service_webserver.statics._events import (
+ _get_release_notes_vtag,
+ create_and_cache_statics_json,
+)
from simcore_service_webserver.statics.plugin import setup_statics
@@ -154,3 +157,15 @@ async def test_create_and_cache_statics_json_vendor_vcs_overwrite(
product_dict = json.loads(product_data)
assert product_dict.get("vcsReleaseTag") == vcs_release_tag
assert product_dict.get("vcsReleaseUrl") == expected_vcs_url
+
+
+@pytest.mark.parametrize(
+ "vtag, expected_vtag",
+ [
+ ("v1.11.34", "v1.11.0"),
+ ("v1.11.8", "v1.11.0"),
+ ("v1.11.0", "v1.11.0"),
+ ],
+)
+def test__get_release_notes_vtag(vtag: str, expected_vtag: str):
+ assert _get_release_notes_vtag(vtag) == expected_vtag
diff --git a/services/web/server/tests/unit/with_dbs/01/test_storage.py b/services/web/server/tests/unit/with_dbs/01/test_storage.py
deleted file mode 100644
index e03c838cd0a..00000000000
--- a/services/web/server/tests/unit/with_dbs/01/test_storage.py
+++ /dev/null
@@ -1,317 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-# pylint: disable=too-many-arguments
-
-import asyncio
-from collections.abc import Awaitable, Callable
-from urllib.parse import quote
-
-import pytest
-from aiohttp import web
-from aiohttp.test_utils import TestClient, TestServer
-from faker import Faker
-from pytest_simcore.helpers.assert_checks import assert_status
-from pytest_simcore.helpers.typing_env import EnvVarsDict
-from servicelib.aiohttp import status
-from servicelib.aiohttp.application import create_safe_application
-from simcore_postgres_database.models.users import UserRole
-
-API_VERSION = "v0"
-
-
-# TODO: create a fake storage service here
-@pytest.fixture()
-def storage_server(
- event_loop: asyncio.AbstractEventLoop,
- aiohttp_server: Callable[..., Awaitable[TestServer]],
- app_environment: EnvVarsDict,
- storage_test_server_port: int,
-) -> TestServer:
- async def _get_locs(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "user_id" in query
-
- assert query["user_id"], "Expected user id"
- return web.json_response(
- {
- "data": [
- {"user_id": int(query["user_id"])},
- ]
- }
- )
-
- async def _post_sync_meta_data(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "dry_run" in query
-
- assert query["dry_run"] == "true"
- return web.json_response(
- {
- "data": {"removed": []},
- }
- )
-
- async def _get_filemeta(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "user_id" in query
-
- assert query["user_id"], "Expected user id"
-
- return web.json_response(
- {
- "data": [
- {"filemeta": 42},
- ]
- }
- )
-
- async def _get_filtered_list(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "user_id" in query
- assert query["user_id"], "Expected user id"
- assert query["uuid_filter"], "expected a filter"
-
- return web.json_response(
- {
- "data": [
- {"uuid_filter": query["uuid_filter"]},
- ]
- }
- )
-
- async def _get_datasets(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "user_id" in query
-
- assert query["user_id"], "Expected user id"
-
- return web.json_response(
- {
- "data": [
- {"dataset_id": "asdf", "display_name": "bbb"},
- ]
- }
- )
-
- async def _get_datasets_meta(request: web.Request):
- assert not request.can_read_body
-
- query = request.query
- assert query
- assert "user_id" in query
-
- assert query["user_id"], "Expected user id"
-
- return web.json_response(
- {
- "data": [
- {"dataset_id": "asdf", "display_name": "bbb"},
- ]
- }
- )
-
- storage_api_version = app_environment["STORAGE_VTAG"]
- storage_port = int(app_environment["STORAGE_PORT"])
- assert storage_port == storage_test_server_port
-
- assert (
- storage_api_version != API_VERSION
- ), "backend service w/ different version as webserver entrypoint"
-
- app = create_safe_application()
- app.router.add_get(f"/{storage_api_version}/locations", _get_locs)
- app.router.add_post(
- f"/{storage_api_version}/locations/0:sync", _post_sync_meta_data
- )
- app.router.add_get(
- f"/{storage_api_version}/locations/0/files/{{file_id}}/metadata", _get_filemeta
- )
- app.router.add_get(
- f"/{storage_api_version}/locations/0/files/metadata", _get_filtered_list
- )
- app.router.add_get(f"/{storage_api_version}/locations/0/datasets", _get_datasets)
- app.router.add_get(
- f"/{storage_api_version}/locations/0/datasets/{{dataset_id}}/metadata",
- _get_datasets_meta,
- )
-
- return event_loop.run_until_complete(aiohttp_server(app, port=storage_port))
-
-
-# --------------------------------------------------------------------------
-PREFIX = "/" + API_VERSION + "/storage"
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_200_OK),
- (UserRole.USER, status.HTTP_200_OK),
- (UserRole.TESTER, status.HTTP_200_OK),
- ],
-)
-async def test_get_storage_locations(
- client: TestClient, storage_server: TestServer, logged_user, expected
-):
- url = "/v0/storage/locations"
- assert url.startswith(PREFIX)
-
- resp = await client.get(url, params={"user_id": logged_user["id"]})
- data, error = await assert_status(resp, expected)
-
- if not error:
- assert len(data) == 1
- assert data[0]["user_id"] == logged_user["id"]
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_403_FORBIDDEN),
- (UserRole.USER, status.HTTP_403_FORBIDDEN),
- (UserRole.TESTER, status.HTTP_403_FORBIDDEN),
- (UserRole.ADMIN, status.HTTP_200_OK),
- ],
-)
-async def test_sync_file_meta_table(
- client: TestClient, storage_server: TestServer, logged_user, expected
-):
- url = "/v0/storage/locations/0:sync"
- assert url.startswith(PREFIX)
-
- resp = await client.post(url, params={"dry_run": "true"})
- data, error = await assert_status(resp, expected)
-
- if not error:
- # the test of the functionality is already done in storage
- assert "removed" in data
- assert not data["removed"]
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_200_OK),
- (UserRole.USER, status.HTTP_200_OK),
- (UserRole.TESTER, status.HTTP_200_OK),
- ],
-)
-async def test_get_datasets_metadata(
- client: TestClient, storage_server: TestServer, logged_user, expected
-):
- url = "/v0/storage/locations/0/datasets"
- assert url.startswith(PREFIX)
-
- _url = client.app.router["get_datasets_metadata"].url_for(location_id="0")
-
- assert url == str(_url)
-
- resp = await client.get(url, params={"user_id": logged_user["id"]})
- data, error = await assert_status(resp, expected)
-
- if not error:
- assert len(data) == 1
- assert data[0]["dataset_id"] == "asdf"
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_200_OK),
- (UserRole.USER, status.HTTP_200_OK),
- (UserRole.TESTER, status.HTTP_200_OK),
- ],
-)
-async def test_get_files_metadata_dataset(
- client: TestClient, storage_server: TestServer, logged_user, expected
-):
- url = "/v0/storage/locations/0/datasets/N:asdfsdf/metadata"
- assert url.startswith(PREFIX)
-
- _url = client.app.router["get_files_metadata_dataset"].url_for(
- location_id="0", dataset_id="N:asdfsdf"
- )
-
- assert url == str(_url)
-
- resp = await client.get(url, params={"user_id": logged_user["id"]})
- data, error = await assert_status(resp, expected)
-
- if not error:
- assert len(data) == 1
- assert data[0]["dataset_id"] == "asdf"
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_200_OK),
- (UserRole.USER, status.HTTP_200_OK),
- (UserRole.TESTER, status.HTTP_200_OK),
- ],
-)
-async def test_storage_file_meta(
- client: TestClient, storage_server: TestServer, logged_user, expected, faker: Faker
-):
- # tests redirect of path with quotes in path
- file_id = f"{faker.uuid4()}/{faker.uuid4()}/a/b/c/d/e/dat"
- quoted_file_id = quote(file_id, safe="")
- url = f"/v0/storage/locations/0/files/{quoted_file_id}/metadata"
-
- assert url.startswith(PREFIX)
-
- resp = await client.get(url, params={"user_id": logged_user["id"]})
- data, error = await assert_status(resp, expected)
-
- if not error:
- assert len(data) == 1
- assert data[0]["filemeta"] == 42
-
-
-@pytest.mark.parametrize(
- "user_role,expected",
- [
- (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
- (UserRole.GUEST, status.HTTP_200_OK),
- (UserRole.USER, status.HTTP_200_OK),
- (UserRole.TESTER, status.HTTP_200_OK),
- ],
-)
-async def test_storage_list_filter(
- client: TestClient, storage_server: TestServer, logged_user, expected
-):
- # tests composition of 2 queries
- file_id = "a/b/c/d/e/dat"
- url = "/v0/storage/locations/0/files/metadata?uuid_filter={}".format(
- quote(file_id, safe="")
- )
-
- assert url.startswith(PREFIX)
-
- resp = await client.get(url, params={"user_id": logged_user["id"]})
- data, error = await assert_status(resp, expected)
-
- if not error:
- assert len(data) == 1
- assert data[0]["uuid_filter"] == file_id
diff --git a/services/web/server/tests/unit/with_dbs/02/conftest.py b/services/web/server/tests/unit/with_dbs/02/conftest.py
index 25be7db87c8..cf8cbb179de 100644
--- a/services/web/server/tests/unit/with_dbs/02/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/02/conftest.py
@@ -92,12 +92,12 @@ def mock_catalog_api(
) -> dict[str, mock.Mock]:
return {
"get_service_resources": mocker.patch(
- "simcore_service_webserver.projects.projects_service.catalog_client.get_service_resources",
+ "simcore_service_webserver.projects._projects_service.catalog_service.get_service_resources",
return_value=mock_service_resources,
autospec=True,
),
"get_service": mocker.patch(
- "simcore_service_webserver.projects.projects_service.catalog_client.get_service",
+ "simcore_service_webserver.projects._projects_service.catalog_service.get_service",
return_value=mock_service,
autospec=True,
),
@@ -107,8 +107,8 @@ def mock_catalog_api(
@pytest.fixture
async def user_project(
client: TestClient,
- fake_project,
- logged_user,
+ fake_project: ProjectDict,
+ logged_user: UserInfoDict,
tests_data_dir: Path,
osparc_product_name: str,
) -> AsyncIterator[ProjectDict]:
@@ -223,7 +223,7 @@ async def _creator(**prj_kwargs) -> ProjectDict:
@pytest.fixture
def fake_services(
- create_dynamic_service_mock: Callable[..., Awaitable[DynamicServiceGet]]
+ create_dynamic_service_mock: Callable[..., Awaitable[DynamicServiceGet]],
) -> Callable[..., Awaitable[list[DynamicServiceGet]]]:
async def create_fakes(number_services: int) -> list[DynamicServiceGet]:
return [await create_dynamic_service_mock() for _ in range(number_services)]
@@ -374,7 +374,7 @@ def mock_get_total_project_dynamic_nodes_creation_interval(
) -> None:
_VERY_LONG_LOCK_TIMEOUT_S: Final[float] = 300
mocker.patch(
- "simcore_service_webserver.projects.projects_service._nodes_api"
+ "simcore_service_webserver.projects._projects_service._nodes_service"
".get_total_project_dynamic_nodes_creation_interval",
return_value=_VERY_LONG_LOCK_TIMEOUT_S,
)
diff --git a/services/web/server/tests/unit/with_dbs/02/test_announcements.py b/services/web/server/tests/unit/with_dbs/02/test_announcements.py
index 19ca7413827..11005e824e1 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_announcements.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_announcements.py
@@ -20,7 +20,10 @@
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
-from pytest_simcore.pydantic_models import iter_model_examples_in_module
+from pytest_simcore.pydantic_models import (
+ assert_validation_model,
+ iter_model_examples_in_module,
+)
from servicelib.aiohttp import status
from settings_library.redis import RedisDatabase, RedisSettings
from simcore_service_webserver.announcements._redis import (
@@ -185,9 +188,9 @@ async def test_list_announcements_filtered(
def test_model_examples(
model_cls: type[BaseModel], example_name: int, example_data: Any
):
- assert model_cls.model_validate(
- example_data
- ), f"Failed {example_name} : {json.dumps(example_data)}"
+ assert_validation_model(
+ model_cls, example_name=example_name, example_data=example_data
+ )
def test_invalid_announcement(faker: Faker):
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects__ports_api.py b/services/web/server/tests/unit/with_dbs/02/test_projects__ports_api.py
index 0ff4d0f28f8..ec4eabc9665 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects__ports_api.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects__ports_api.py
@@ -9,7 +9,7 @@
import pytest
from models_library.projects_nodes import Node, NodeID
from models_library.utils.json_schema import jsonschema_validate_schema
-from simcore_service_webserver.projects._ports_api import (
+from simcore_service_webserver.projects._ports_service import (
InvalidInputValue,
_get_outputs_in_workbench,
get_project_inputs,
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_comments_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_comments_handlers.py
index 604ee40308c..9a187a1d081 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_comments_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_comments_handlers.py
@@ -16,7 +16,9 @@
from servicelib.aiohttp import status
from simcore_service_webserver._meta import api_version_prefix
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
+from simcore_service_webserver.projects._groups_repository import (
+ update_or_insert_project_group,
+)
from simcore_service_webserver.projects.models import ProjectDict
API_PREFIX = "/" + api_version_prefix
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
index 5345d7e41b8..25d48013e13 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py
@@ -39,9 +39,11 @@
from simcore_service_webserver.groups._groups_service import get_product_group_for_user
from simcore_service_webserver.groups.api import auto_add_user_to_product_group
from simcore_service_webserver.groups.exceptions import GroupNotFoundError
-from simcore_service_webserver.products.api import get_product
-from simcore_service_webserver.projects._permalink_api import ProjectPermalink
+from simcore_service_webserver.products.products_service import get_product
from simcore_service_webserver.projects.models import ProjectDict
+from simcore_service_webserver.projects.projects_permalink_service import (
+ ProjectPermalink,
+)
from simcore_service_webserver.utils import to_datetime
from yarl import URL
@@ -422,15 +424,68 @@ async def test_new_project(
logged_user: UserInfoDict,
primary_group,
expected: ExpectedResponse,
+ request_create_project: Callable[..., Awaitable[ProjectDict]],
storage_subsystem_mock,
project_db_cleaner,
- request_create_project: Callable[..., Awaitable[ProjectDict]],
):
await request_create_project(
client, expected.accepted, expected.created, logged_user, primary_group
)
+@pytest.mark.parametrize(
+ "user_role",
+ [UserRole.USER],
+)
+async def test_create_get_and_patch_project_ui_field(
+ mock_dynamic_scheduler: None,
+ storage_subsystem_mock,
+ client: TestClient,
+ logged_user: UserInfoDict,
+ primary_group: dict[str, str],
+ request_create_project: Callable[..., Awaitable[ProjectDict]],
+ catalog_subsystem_mock: Callable[[list[ProjectDict]], None],
+ project_db_cleaner,
+):
+ assert client.app
+
+ gid = logged_user["primary_gid"]
+ assert primary_group["gid"] == gid
+
+ # Step 1: Create project (long running task)
+ new_project = await request_create_project(
+ client,
+ status.HTTP_202_ACCEPTED,
+ status.HTTP_201_CREATED,
+ logged_user,
+ primary_group,
+ )
+ project_id = new_project["uuid"]
+
+ catalog_subsystem_mock([new_project])
+
+ # Step 2: Get the project and check the ui.icon
+ url = client.app.router["get_project"].url_for(project_id=project_id)
+ resp = await client.get(f"{url}")
+ got_project, _ = await assert_status(resp, status.HTTP_200_OK)
+ assert got_project["ui"] == {}
+
+ # Step 3: Patch the project to set ui.icon to null
+ patch_data = {"ui": {"icon": "http://example.com/icon.png"}}
+ url = client.app.router["patch_project"].url_for(project_id=project_id)
+ resp = await client.patch(f"{url}", json=patch_data)
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # Step 4: Get the project again and check the ui.icon is now null
+ resp = await client.get(f"{url}")
+ got_project, _ = await assert_status(resp, status.HTTP_200_OK)
+ assert got_project["ui"]["icon"] == "http://example.com/icon.png"
+
+ # Step 5: Delete project
+ resp = await client.delete(f"{url}")
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+
@pytest.mark.parametrize(*standard_user_role_response())
async def test_new_project_from_template(
mock_dynamic_scheduler: None,
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py
index 15542cff620..fa7ed48abeb 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py
@@ -21,7 +21,7 @@
from simcore_postgres_database.models.workspaces import workspaces
from simcore_service_webserver.db.models import UserRole
from simcore_service_webserver.folders._folders_service import create_folder
-from simcore_service_webserver.projects._folders_api import move_project_into_folder
+from simcore_service_webserver.projects._folders_service import move_project_into_folder
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.workspaces._workspaces_service import create_workspace
from yarl import URL
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py
index 8324aff33a2..b81d8b99dd3 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py
@@ -42,7 +42,7 @@ def standard_user_role() -> tuple[str, tuple[UserRole, ExpectedResponse]]:
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__patch.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__patch.py
index 04b4db5b7e8..dbb33ea5ecd 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__patch.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__patch.py
@@ -25,7 +25,7 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._controller.projects_rest.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
@@ -34,7 +34,7 @@ def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
@pytest.fixture
def mock_project_uses_available_services(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py
index 76569151068..5f89e4aa032 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py
@@ -20,7 +20,7 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._controller.projects_rest.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
@@ -29,21 +29,12 @@ def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
@pytest.fixture
def mock_project_uses_available_services(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
-@pytest.fixture
-def mock_catalog_api_get_services_for_user_in_product_2(mocker: MockerFixture):
- mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
- spec=True,
- return_value=[],
- )
-
-
@pytest.mark.acceptance_test(
"Driving test for https://github.com/ITISFoundation/osparc-issues/issues/1547"
)
@@ -55,7 +46,6 @@ async def test_projects_groups_full_workflow(
expected: HTTPStatus,
mock_catalog_api_get_services_for_user_in_product,
mock_project_uses_available_services,
- mock_catalog_api_get_services_for_user_in_product_2,
):
assert client.app
# check the default project permissions
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py
index ce87970f75c..dae450a88fe 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py
@@ -63,7 +63,7 @@ async def test_custom_metadata_handlers(
response = await client.get(f"{url}")
_, error = await assert_status(response, expected_status_code=expected.not_found)
- error_message = error["errors"][0]["message"]
+ error_message = error["message"]
assert invalid_project_id in error_message
assert "project" in error_message.lower()
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py
index c5fa6330978..4db7e6f8a22 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handler.py
@@ -24,7 +24,10 @@
from models_library.api_schemas_dynamic_scheduler.dynamic_services import (
DynamicServiceStop,
)
-from models_library.api_schemas_storage import FileMetaDataGet, PresignedLink
+from models_library.api_schemas_storage.storage_schemas import (
+ FileMetaDataGet,
+ PresignedLink,
+)
from models_library.generics import Envelope
from models_library.projects_nodes_io import NodeID
from models_library.services_resources import (
@@ -45,7 +48,9 @@
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
from simcore_postgres_database.models.projects import projects as projects_db_model
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.projects._nodes_handlers import _ProjectNodePreview
+from simcore_service_webserver.projects._controller.nodes_rest import (
+ _ProjectNodePreview,
+)
from simcore_service_webserver.projects.models import ProjectDict
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py
index 1f6f18cc00a..3bba1eaf118 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__patch.py
@@ -29,7 +29,7 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._controller.projects_rest.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
@@ -38,7 +38,7 @@ def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
@pytest.fixture
def mock_project_uses_available_services(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
@@ -47,7 +47,7 @@ def mock_project_uses_available_services(mocker: MockerFixture):
@pytest.fixture
def mock_catalog_rpc_check_for_service(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects.projects_service.catalog_rpc.check_for_service",
+ "simcore_service_webserver.projects._projects_service.catalog_rpc.check_for_service",
spec=True,
return_value=True,
)
@@ -56,7 +56,7 @@ def mock_catalog_rpc_check_for_service(mocker: MockerFixture):
@pytest.fixture
def mocked_notify_project_node_update(mocker: MockerFixture):
return mocker.patch(
- "simcore_service_webserver.projects.projects_service.notify_project_node_update",
+ "simcore_service_webserver.projects._projects_service.notify_project_node_update",
)
@@ -356,21 +356,21 @@ async def test_patch_project_node_service_key_with_error(
):
node_id = next(iter(user_project["workbench"]))
assert client.app
- base_url = client.app.router["patch_project_node"].url_for(
+ url = client.app.router["patch_project_node"].url_for(
project_id=user_project["uuid"], node_id=node_id
)
_patch_version = {"version": "2.0.9"}
with mocker.patch(
- "simcore_service_webserver.projects.projects_service.catalog_rpc.check_for_service",
+ "simcore_service_webserver.projects._projects_service.catalog_rpc.check_for_service",
side_effect=CatalogForbiddenError(name="test"),
):
- resp = await client.patch(f"{base_url}", json=_patch_version)
+ resp = await client.patch(f"{url}", json=_patch_version)
assert resp.status == status.HTTP_403_FORBIDDEN
with mocker.patch(
- "simcore_service_webserver.projects.projects_service.catalog_rpc.check_for_service",
+ "simcore_service_webserver.projects._projects_service.catalog_rpc.check_for_service",
side_effect=CatalogItemNotFoundError(name="test"),
):
- resp = await client.patch(f"{base_url}", json=_patch_version)
+ resp = await client.patch(f"{url}", json=_patch_version)
assert resp.status == status.HTTP_404_NOT_FOUND
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__services_access.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__services_access.py
index 3e8a4d9e2b4..238cba62055 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__services_access.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_handlers__services_access.py
@@ -13,9 +13,13 @@
from models_library.api_schemas_catalog.service_access_rights import (
ServiceAccessRightsGet,
)
+from models_library.api_schemas_catalog.services import MyServiceGet
+from models_library.services_history import ServiceRelease
from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp import status
+from servicelib.rabbitmq import RPCServerError
from simcore_service_webserver.db.models import UserRole
from simcore_service_webserver.projects.models import ProjectDict
from yarl import URL
@@ -55,7 +59,7 @@ def fake_project(
@pytest.fixture
def mock_catalog_api_get_service_access_rights_response(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -95,9 +99,9 @@ def mock_catalog_api_get_service_access_rights_response(mocker: MockerFixture):
async def test_user_role_access(
client: TestClient,
user_project: ProjectDict,
- logged_user: dict,
+ logged_user: UserInfoDict,
expected: HTTPStatus,
- mock_catalog_api_get_service_access_rights_response,
+ mock_catalog_api_get_service_access_rights_response: None,
):
assert client.app
@@ -123,10 +127,10 @@ async def test_accessible_thanks_to_everyone_group_id(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -176,12 +180,12 @@ async def test_accessible_thanks_to_concrete_group_id(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
for_gid = logged_user["primary_gid"]
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -229,12 +233,12 @@ async def test_accessible_through_product_group(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
for_gid = logged_user["primary_gid"]
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -288,12 +292,12 @@ async def test_accessible_for_one_service(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
for_gid = logged_user["primary_gid"]
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -348,10 +352,10 @@ async def test_not_accessible_for_more_services(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -412,12 +416,12 @@ async def test_not_accessible_for_service_because_of_execute_access_false(
client: TestClient,
user_project: ProjectDict,
mocker: MockerFixture,
- logged_user: dict,
+ logged_user: UserInfoDict,
):
for_gid = logged_user["primary_gid"]
mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.catalog_client.get_service_access_rights",
+ "simcore_service_webserver.projects._controller.nodes_rest.catalog_service.get_service_access_rights",
spec=True,
side_effect=[
ServiceAccessRightsGet(
@@ -461,3 +465,122 @@ async def test_not_accessible_for_service_because_of_execute_access_false(
{"key": "simcore/services/comp/itis/sleeper", "version": "2.1.4"}
],
}
+
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_get_project_services(
+ client: TestClient,
+ user_project: ProjectDict,
+ mocker: MockerFixture,
+ logged_user: UserInfoDict,
+):
+ fake_services_in_project = [
+ (sv["key"], sv["version"]) for sv in user_project["workbench"].values()
+ ]
+
+ mocker.patch(
+ "simcore_service_webserver.catalog._service.catalog_rpc.batch_get_my_services",
+ spec=True,
+ return_value=[
+ MyServiceGet(
+ key=service_key,
+ release=ServiceRelease(
+ version=service_version,
+ version_display=f"v{service_version}",
+ released="2023-01-01T00:00:00Z",
+ retired=None,
+ compatibility=None,
+ ),
+ owner=logged_user["primary_gid"],
+ my_access_rights={"execute": True, "write": False},
+ )
+ for service_key, service_version in fake_services_in_project
+ ],
+ )
+
+ assert client.app
+
+ project_id = user_project["uuid"]
+
+ expected_url = client.app.router["get_project_services"].url_for(
+ project_id=project_id
+ )
+ assert URL(f"/v0/projects/{project_id}/nodes/-/services") == expected_url
+
+ resp = await client.get(f"/v0/projects/{project_id}/nodes/-/services")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ assert data == {
+ "projectUuid": project_id,
+ "services": [
+ {
+ "key": "simcore/services/comp/itis/sleeper",
+ "myAccessRights": {"execute": True, "write": False},
+ "owner": logged_user["primary_gid"],
+ "release": {
+ "compatibility": None,
+ "released": "2023-01-01T00:00:00+00:00",
+ "retired": None,
+ "version": "2.1.4",
+ "versionDisplay": "v2.1.4",
+ },
+ },
+ {
+ "key": "simcore/services/frontend/parameter/integer",
+ "myAccessRights": {"execute": True, "write": False},
+ "owner": logged_user["primary_gid"],
+ "release": {
+ "compatibility": None,
+ "released": "2023-01-01T00:00:00+00:00",
+ "retired": None,
+ "version": "1.0.0",
+ "versionDisplay": "v1.0.0",
+ },
+ },
+ {
+ "key": "simcore/services/comp/itis/sleeper",
+ "myAccessRights": {"execute": True, "write": False},
+ "owner": logged_user["primary_gid"],
+ "release": {
+ "compatibility": None,
+ "released": "2023-01-01T00:00:00+00:00",
+ "retired": None,
+ "version": "2.1.5",
+ "versionDisplay": "v2.1.5",
+ },
+ },
+ ],
+ }
+
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_get_project_services_service_unavailable(
+ client: TestClient,
+ user_project: ProjectDict,
+ mocker: MockerFixture,
+ logged_user: UserInfoDict,
+):
+ mocker.patch(
+ "simcore_service_webserver.catalog._service.catalog_rpc.batch_get_my_services",
+ spec=True,
+ side_effect=RPCServerError(
+ exc_message="Service Unavailable",
+ method_name="batch_get_my_services",
+ exc_type="Exception",
+ ),
+ )
+
+ assert client.app
+
+ project_id = user_project["uuid"]
+
+ expected_url = client.app.router["get_project_services"].url_for(
+ project_id=project_id
+ )
+ assert URL(f"/v0/projects/{project_id}/nodes/-/services") == expected_url
+
+ resp = await client.get(f"/v0/projects/{project_id}/nodes/-/services")
+ data, error = await assert_status(resp, status.HTTP_503_SERVICE_UNAVAILABLE)
+
+ assert error
+ assert not data
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_pricing_unit_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_pricing_unit_handlers.py
index dad139ec8bb..3e59aebe177 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_pricing_unit_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_nodes_pricing_unit_handlers.py
@@ -15,7 +15,7 @@
from faker import Faker
from models_library.api_schemas_clusters_keeper.ec2_instances import EC2InstanceTypeGet
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingUnitGet,
+ RutPricingUnitGet,
)
from models_library.utils.fastapi_encoders import jsonable_encoder
from pytest_mock.plugin import MockerFixture
@@ -97,8 +97,8 @@ def mock_rut_api_responses(
assert client.app
settings: ResourceUsageTrackerSettings = get_plugin_settings(client.app)
- pricing_unit_get_base = PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0]
+ pricing_unit_get_base = RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_config["json_schema_extra"]["examples"][0]
)
pricing_unit_get_1 = pricing_unit_get_base.model_copy()
pricing_unit_get_1.pricing_unit_id = _PRICING_UNIT_ID_1
@@ -136,7 +136,7 @@ def _fake_instance_type_details(
]
return mocker.patch(
- "simcore_service_webserver.projects.projects_service.get_instance_type_details",
+ "simcore_service_webserver.projects._projects_service.get_instance_type_details",
side_effect=_fake_instance_type_details,
)
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
new file mode 100644
index 00000000000..6dccd486a0b
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_repository.py
@@ -0,0 +1,194 @@
+# pylint: disable=redefined-outer-name
+# 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.test_utils import TestClient
+from common_library.users_enums import UserRole
+from pytest_simcore.helpers.webserver_login import UserInfoDict
+from simcore_service_webserver.projects import (
+ _projects_repository as projects_service_repository,
+)
+from simcore_service_webserver.projects.exceptions import ProjectNotFoundError
+from simcore_service_webserver.projects.models import ProjectDBGet, ProjectDict
+
+
+@pytest.fixture
+def user_role() -> UserRole:
+ return UserRole.USER
+
+
+async def test_get_project(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+):
+ assert client.app
+
+ # Get valid project
+ got_project = await projects_service_repository.get_project(
+ client.app, project_uuid=user_project["uuid"]
+ )
+
+ assert got_project.uuid == UUID(user_project["uuid"])
+ assert got_project.name == user_project["name"]
+ assert got_project.description == user_project["description"]
+
+ # Get non-existent project
+ non_existent_project_uuid = UUID("00000000-0000-0000-0000-000000000000")
+ with pytest.raises(ProjectNotFoundError):
+ await projects_service_repository.get_project(
+ client.app, project_uuid=non_existent_project_uuid
+ )
+
+
+async def test_patch_project(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+):
+ assert client.app
+
+ # This will change after in patched_project
+ creation_date = datetime.fromisoformat(user_project["creationDate"])
+ last_change_date = datetime.fromisoformat(user_project["lastChangeDate"])
+ assert abs(creation_date - last_change_date) < timedelta(seconds=1)
+
+ # Patch valid project
+ patch_data = {"name": "Updated Project Name"}
+ patched_project = await projects_service_repository.patch_project(
+ client.app,
+ project_uuid=user_project["uuid"],
+ new_partial_project_data=patch_data,
+ )
+
+ assert patched_project.uuid == UUID(user_project["uuid"])
+ assert patched_project.name == patch_data["name"]
+ assert patched_project.creation_date < patched_project.last_change_date
+
+ # Patch non-existent project
+ non_existent_project_uuid = UUID("00000000-0000-0000-0000-000000000000")
+ with pytest.raises(ProjectNotFoundError):
+ await projects_service_repository.patch_project(
+ client.app,
+ project_uuid=non_existent_project_uuid,
+ new_partial_project_data=patch_data,
+ )
+
+
+async def test_delete_project(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+):
+ assert client.app
+
+ # Delete valid project
+ deleted_project = await projects_service_repository.delete_project(
+ client.app, project_uuid=user_project["uuid"]
+ )
+
+ assert deleted_project.uuid == UUID(user_project["uuid"])
+
+ # Check deleted
+ with pytest.raises(ProjectNotFoundError):
+ await projects_service_repository.delete_project(
+ client.app, project_uuid=user_project["uuid"]
+ )
+
+ # Delete non-existent project
+ non_existent_project_uuid = UUID("00000000-0000-0000-0000-000000000000")
+ with pytest.raises(ProjectNotFoundError):
+ await projects_service_repository.delete_project(
+ client.app, project_uuid=non_existent_project_uuid
+ )
+
+
+@pytest.fixture
+async def trashed_project(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+) -> ProjectDBGet:
+ assert client.app
+
+ # Patch project to be trashed
+ trashed_at = arrow.utcnow().datetime
+ patch_data = {
+ "trashed": trashed_at,
+ "trashed_by": logged_user["id"],
+ "trashed_explicitly": True,
+ }
+ return await projects_service_repository.patch_project(
+ client.app,
+ project_uuid=user_project["uuid"],
+ new_partial_project_data=patch_data,
+ )
+
+
+async def test_list_trashed_projects(client: TestClient, trashed_project: ProjectDBGet):
+ assert client.app
+
+ (
+ total_count,
+ trashed_projects,
+ ) = await projects_service_repository.list_trashed_projects(
+ client.app,
+ trashed_explicitly=True,
+ trashed_before=arrow.utcnow().datetime + timedelta(days=1),
+ )
+
+ assert total_count == 1
+ assert len(trashed_projects) == 1
+ assert trashed_projects[0] == trashed_project
+
+
+async def test_get_trashed_by_primary_gid(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ trashed_project: ProjectDBGet,
+):
+ assert client.app
+
+ # Get trashed by primary gid
+ trashed_by_primary_gid = (
+ await projects_service_repository.get_trashed_by_primary_gid(
+ client.app,
+ projects_uuid=trashed_project.uuid,
+ )
+ )
+
+ assert trashed_by_primary_gid == logged_user["primary_gid"]
+
+
+async def test_batch_get_trashed_by_primary_gid(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ trashed_project: ProjectDBGet,
+):
+ assert client.app
+
+ non_existent_project_uuid = UUID("00000000-0000-0000-0000-000000000000")
+
+ # Batch get trashed by primary gid
+ trashed_by_primary_gid = (
+ await projects_service_repository.batch_get_trashed_by_primary_gid(
+ client.app,
+ projects_uuids=[
+ trashed_project.uuid,
+ non_existent_project_uuid, # non-existent
+ trashed_project.uuid, # repeated
+ ],
+ )
+ )
+
+ assert trashed_by_primary_gid == [
+ logged_user["primary_gid"],
+ None,
+ logged_user["primary_gid"],
+ ]
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py
index 3963a10bf7b..3d4b26894b8 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py
@@ -453,7 +453,7 @@ async def test_open_project__in_debt(
added_wallet, _ = await assert_status(resp, status.HTTP_201_CREATED)
mock_get_project_wallet_total_credits = mocker.patch(
- "simcore_service_webserver.projects._wallets_api.credit_transactions.get_project_wallet_total_credits",
+ "simcore_service_webserver.projects._wallets_service.credit_transactions.get_project_wallet_total_credits",
spec=True,
return_value=WalletTotalCredits(
wallet_id=added_wallet["walletId"],
@@ -1073,7 +1073,7 @@ async def test_project_node_lifetime( # noqa: PLR0915
create_dynamic_service_mock: Callable[..., Awaitable[DynamicServiceGet]],
):
mock_storage_api_delete_data_folders_of_project_node = mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.projects_service.storage_api.delete_data_folders_of_project_node",
+ "simcore_service_webserver.projects._projects_service.storage_service.delete_data_folders_of_project_node",
return_value="",
)
assert client.app
diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py
index 07a447de907..436581d9de3 100644
--- a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py
@@ -111,7 +111,7 @@ def mock_get_project_wallet_total_credits(
mocker: MockerFixture, setup_wallets_db: list[WalletGet]
):
mocker.patch(
- "simcore_service_webserver.projects._wallets_api.credit_transactions.get_project_wallet_total_credits",
+ "simcore_service_webserver.projects._wallets_service.credit_transactions.get_project_wallet_total_credits",
spec=True,
return_value=WalletTotalCredits(
wallet_id=setup_wallets_db[0].wallet_id, available_osparc_credits=Decimal(0)
@@ -122,7 +122,7 @@ def mock_get_project_wallet_total_credits(
@pytest.fixture
def mock_get_service_run_page(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._wallets_api.service_runs.get_service_run_page",
+ "simcore_service_webserver.projects._wallets_service.service_runs.get_service_run_page",
spec=True,
return_value=ServiceRunPage(items=[], total=0),
)
@@ -181,7 +181,7 @@ async def test_project_wallets_full_workflow(
@pytest.fixture
def mock_pay_project_debt(mocker: MockerFixture):
return mocker.patch(
- "simcore_service_webserver.projects._wallets_api.credit_transactions.pay_project_debt",
+ "simcore_service_webserver.projects._wallets_service.credit_transactions.pay_project_debt",
spec=True,
)
diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py
index 48d8f1ac41f..475dc12812b 100644
--- a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py
@@ -29,7 +29,8 @@
InvitationsSettings,
get_plugin_settings,
)
-from simcore_service_webserver.products.api import Product, list_products
+from simcore_service_webserver.products import products_service
+from simcore_service_webserver.products.models import Product
from yarl import URL
@@ -52,7 +53,7 @@ def invitations_service_openapi_specs(
@pytest.fixture
def current_product(client: TestClient) -> Product:
assert client.app
- products = list_products(client.app)
+ products = products_service.list_products(client.app)
assert products
assert products[0].name == "osparc"
return products[0]
@@ -192,7 +193,6 @@ def app_environment(
"WEBSERVER_DIAGNOSTICS": "null",
"WEBSERVER_EXPORTER": "null",
"WEBSERVER_GARBAGE_COLLECTOR": "null",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_NOTIFICATIONS": "0",
"WEBSERVER_PUBLICATIONS": "0",
"WEBSERVER_REMOTE_DEBUG": "0",
@@ -200,7 +200,6 @@ def app_environment(
"WEBSERVER_STUDIES_ACCESS_ENABLED": "0",
"WEBSERVER_TAGS": "0",
"WEBSERVER_TRACING": "null",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "0",
# set INVITATIONS_* variables using those in .env-devel
**{
diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py
index ad31dda87c3..030a88e55cc 100644
--- a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py
+++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py
@@ -20,7 +20,7 @@
InvalidInvitationError,
InvitationsServiceUnavailableError,
)
-from simcore_service_webserver.products.api import Product
+from simcore_service_webserver.products.models import Product
from yarl import URL
diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py
index 8c4daca29df..7a081e39cb6 100644
--- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py
+++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_handlers_registration_invitations.py
@@ -113,7 +113,7 @@ def _extract_invitation_code_from_url(invitation_url: HttpUrl) -> str:
@pytest.mark.acceptance_test()
async def test_registration_to_different_product(
mocker: MockerFixture,
- all_products_names: list[ProductName],
+ app_products_names: list[ProductName],
client: TestClient,
guest_email: str,
guest_password: str,
@@ -146,8 +146,8 @@ async def _register_account(invitation_url: HttpUrl, product_deployed: ProductNa
headers={X_PRODUCT_NAME_HEADER: product_deployed},
)
- product_a = all_products_names[0]
- product_b = all_products_names[1]
+ product_a = app_products_names[0]
+ product_b = app_products_names[1]
# PO creates an two invitations for guest in product A and product B
invitation_product_a = await generate_invitation(
diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py
similarity index 97%
rename from services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py
rename to services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py
index 9f347239acd..f384bbe46fb 100644
--- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py
@@ -11,8 +11,8 @@
import pytest
from aiohttp.test_utils import TestClient
from faker import Faker
-from models_library.api_schemas_webserver.product import (
- GenerateInvitation,
+from models_library.api_schemas_webserver.products import (
+ InvitationGenerate,
InvitationGenerated,
)
from models_library.invitations import _MAX_LEN
@@ -83,7 +83,7 @@ async def test_product_owner_generates_invitation(
):
before_dt = datetime.now(tz=UTC)
- request_model = GenerateInvitation(
+ request_model = InvitationGenerate(
guest=guest_email,
trial_account_days=trial_account_days,
extra_credits_in_usd=extra_credits_in_usd,
@@ -146,7 +146,7 @@ async def test_pre_registration_and_invitation_workflow(
"country": faker.country(),
}
- invitation = GenerateInvitation(
+ invitation = InvitationGenerate(
guest=guest_email,
trial_account_days=None,
extra_credits_in_usd=10,
diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py
index b3f8049ff51..c0eaf628d2e 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py
@@ -32,7 +32,6 @@ def app_environment(
"WEBSERVER_EXPORTER": "null",
"WEBSERVER_GARBAGE_COLLECTOR": "null",
"WEBSERVER_GROUPS": "1",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_NOTIFICATIONS": "0",
"WEBSERVER_PRODUCTS": "1",
"WEBSERVER_PUBLICATIONS": "0",
@@ -40,7 +39,6 @@ def app_environment(
"WEBSERVER_SOCKETIO": "1", # for login notifications
"WEBSERVER_STUDIES_DISPATCHER": "null",
"WEBSERVER_TAGS": "1",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "1",
"WEBSERVER_TRACING": "null",
},
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py
index 29324b2af23..588e95182b6 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py
@@ -32,10 +32,12 @@
)
from simcore_service_webserver.login._constants import (
CODE_2FA_SMS_CODE_REQUIRED,
- MSG_2FA_UNAVAILABLE_OEC,
+ MSG_2FA_UNAVAILABLE,
)
from simcore_service_webserver.login.storage import AsyncpgStorage
-from simcore_service_webserver.products.api import Product, get_current_product
+from simcore_service_webserver.products import products_web
+from simcore_service_webserver.products.errors import UnknownProductError
+from simcore_service_webserver.products.models import Product
from simcore_service_webserver.users import preferences_api as user_preferences_api
from twilio.base.exceptions import TwilioRestException
@@ -299,7 +301,7 @@ def _get_confirmation_link_from_email():
},
)
data, _ = await assert_status(response, status.HTTP_200_OK)
- assert data["message"] == "You are logged in"
+ assert "logged in" in data["message"]
async def test_can_register_same_phone_in_different_accounts(
@@ -356,7 +358,7 @@ async def test_can_register_same_phone_in_different_accounts(
)
data, error = await assert_status(response, status.HTTP_202_ACCEPTED)
assert data
- assert "Code" in data["message"]
+ assert "SMS" in data["message"]
assert data["name"] == CODE_2FA_SMS_CODE_REQUIRED
assert not error
@@ -369,9 +371,9 @@ async def test_send_email_code(
):
request = make_mocked_request("GET", "/dummy", app=client.app)
- with pytest.raises(KeyError):
+ with pytest.raises(UnknownProductError):
# NOTE: this is a fake request and did not go through middlewares
- get_current_product(request)
+ products_web.get_current_product(request)
user_email = faker.email()
support_email = faker.email()
@@ -417,9 +419,9 @@ async def test_2fa_sms_failure_during_login(
):
assert client.app
- # Mocks error in graylog https://monitoring.osparc.io/graylog/search/649e7619ce6e0838a96e9bf1?q=%222FA%22&rangetype=relative&from=172800
mocker.patch(
- "simcore_service_webserver.login._2fa_api.TwilioSettings.is_alphanumeric_supported",
+ # MD: Emulates error in graylog https://monitoring.osparc.io/graylog/search/649e7619ce6e0838a96e9bf1?q=%222FA%22&rangetype=relative&from=172800
+ "simcore_service_webserver.login._2fa_api.twilio.rest.Client",
autospec=True,
side_effect=TwilioRestException(
status=400,
@@ -454,9 +456,7 @@ async def test_2fa_sms_failure_during_login(
response, status.HTTP_503_SERVICE_UNAVAILABLE
)
assert not data
- assert error["errors"][0]["message"].startswith(
- MSG_2FA_UNAVAILABLE_OEC[:10]
- )
+ assert error["errors"][0]["message"].startswith(MSG_2FA_UNAVAILABLE[:10])
# Expects logs like 'Failed while setting up 2FA code and sending SMS to 157XXXXXXXX3 [OEC:140392495277888]'
assert f"{fake_user_phone_number[:3]}" in caplog.text
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py
index 7d16e912414..97042d6ed1c 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py
@@ -14,7 +14,7 @@
from pytest_simcore.helpers.webserver_login import NewUser
from servicelib.aiohttp import status
from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME
-from simcore_service_webserver._constants import APP_SETTINGS_KEY
+from simcore_service_webserver.constants import APP_SETTINGS_KEY
from simcore_service_webserver.db.models import UserStatus
from simcore_service_webserver.login._constants import (
MSG_ACTIVATION_REQUIRED,
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py
index 77b6bbd0b0e..c256edb25cb 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py
@@ -7,7 +7,7 @@
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, parse_link
from servicelib.aiohttp import status
-from simcore_service_webserver._constants import INDEX_RESOURCE_NAME
+from simcore_service_webserver.constants import INDEX_RESOURCE_NAME
from simcore_service_webserver.login._constants import (
MSG_CHANGE_EMAIL_REQUESTED,
MSG_LOGGED_IN,
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py
index a171ec63ae2..f496f0545d8 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py
@@ -8,7 +8,7 @@
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import LoggedUser
from servicelib.aiohttp import status
-from servicelib.aiohttp.rest_responses import unwrap_envelope
+from servicelib.rest_responses import unwrap_envelope
from simcore_service_webserver.login._constants import (
MSG_LOGGED_IN,
MSG_PASSWORD_CHANGED,
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
index 0ece8630d0f..1abc63ac9f5 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py
@@ -16,7 +16,7 @@
from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict
from pytest_simcore.helpers.webserver_login import NewInvitation, NewUser, parse_link
from servicelib.aiohttp import status
-from servicelib.aiohttp.rest_responses import unwrap_envelope
+from servicelib.rest_responses import unwrap_envelope
from simcore_service_webserver.db.models import UserStatus
from simcore_service_webserver.groups.api import auto_add_user_to_product_group
from simcore_service_webserver.login._confirmation import _url_for_confirmation
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py
index 03b543e9038..5d019f4fb57 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py
@@ -18,7 +18,7 @@
from servicelib.aiohttp import status
from simcore_postgres_database.models.users import UserRole
from simcore_service_webserver.login._constants import MSG_USER_DELETED
-from simcore_service_webserver.products.api import get_product
+from simcore_service_webserver.products.products_service import get_product
@pytest.mark.parametrize(
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py
index a5c95ba7c3b..ea121adb288 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_reset_password.py
@@ -1,29 +1,38 @@
# pylint: disable=redefined-outer-name
# pylint: disable=unused-argument
# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
import asyncio
-from collections.abc import Callable
+import contextlib
+from collections.abc import AsyncIterator, Callable
import pytest
from aiohttp.test_utils import TestClient, TestServer
+from models_library.products import ProductName
+from pytest_mock import MockType
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import NewUser, parse_link, parse_test_marks
from servicelib.aiohttp import status
+from servicelib.rest_constants import X_PRODUCT_NAME_HEADER
from servicelib.utils_secrets import generate_password
from simcore_service_webserver.db.models import ConfirmationAction, UserStatus
+from simcore_service_webserver.groups import api as groups_service
from simcore_service_webserver.login._constants import (
MSG_ACTIVATION_REQUIRED,
MSG_EMAIL_SENT,
MSG_LOGGED_IN,
- MSG_OFTEN_RESET_PASSWORD,
MSG_PASSWORD_CHANGED,
- MSG_UNKNOWN_EMAIL,
MSG_USER_BANNED,
MSG_USER_EXPIRED,
)
from simcore_service_webserver.login.settings import LoginOptions
-from simcore_service_webserver.login.storage import AsyncpgStorage
+from simcore_service_webserver.login.storage import (
+ AsyncpgStorage,
+ ConfirmationTokenDict,
+)
+from simcore_service_webserver.users import api as users_service
from yarl import URL
#
@@ -40,20 +49,97 @@
def client(
event_loop: asyncio.AbstractEventLoop,
aiohttp_client: Callable,
- web_server: TestServer,
- mock_orphaned_services,
+ app_products_names: list[ProductName],
+ disabled_setup_garbage_collector: MockType,
mocked_email_core_remove_comments: None,
+ # fixtures above must run before `web_server`
+ web_server: TestServer,
) -> TestClient:
+ assert app_products_names
return event_loop.run_until_complete(aiohttp_client(web_server))
+async def test_two_steps_action_confirmation_workflow(
+ client: TestClient,
+ login_options: LoginOptions,
+ capsys: pytest.CaptureFixture,
+ caplog: pytest.LogCaptureFixture,
+):
+ assert client.app
+
+ async with NewUser(app=client.app) as user:
+ reset_url = client.app.router["initiate_reset_password"].url_for()
+ response = await client.post(
+ f"{reset_url}",
+ json={
+ "email": user["email"],
+ },
+ )
+ assert response.url.path == reset_url.path
+ await assert_status(response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(**user))
+
+ # Email is printed in the out
+ out, _ = capsys.readouterr()
+ confirmation_url = parse_link(out)
+ code = URL(confirmation_url).parts[-1]
+
+ # Emulates USER clicks on email's link
+ response = await client.get(confirmation_url)
+ assert response.status == 200
+ assert (
+ response.url.path_qs
+ == URL(login_options.LOGIN_REDIRECT)
+ .with_fragment(f"reset-password?code={code}")
+ .path_qs
+ ), "Should redirect to front-end with special fragment"
+
+ # Emulates FRONT-END:
+ # SEE api/specs/webserver/v0/components/schemas/auth.yaml#/ResetPasswordForm
+ complete_reset_password_url = client.app.router[
+ "complete_reset_password"
+ ].url_for(code=code)
+ new_password = generate_password(10)
+ response = await client.post(
+ f"{complete_reset_password_url}",
+ json={
+ "password": new_password,
+ "confirm": new_password,
+ },
+ )
+ await assert_status(response, status.HTTP_200_OK, MSG_PASSWORD_CHANGED)
+ assert response.url.path == complete_reset_password_url.path
+
+ # Try NEW password
+ logout_url = client.app.router["auth_logout"].url_for()
+ response = await client.post(f"{logout_url}")
+ assert response.url.path == logout_url.path
+ await assert_status(response, status.HTTP_401_UNAUTHORIZED, "Unauthorized")
+
+ login_url = client.app.router["auth_login"].url_for()
+ response = await client.post(
+ f"{login_url}",
+ json={
+ "email": user["email"],
+ "password": new_password,
+ },
+ )
+ await assert_status(response, status.HTTP_200_OK, MSG_LOGGED_IN)
+ assert response.url.path == login_url.path
+
+ # Ensure there are no warnings
+ assert not any(
+ record.levelname == "WARNING" for record in caplog.records
+ ), "Unexpected warnings found"
+
+
async def test_unknown_email(
client: TestClient,
capsys: pytest.CaptureFixture,
+ caplog: pytest.LogCaptureFixture,
fake_user_email: str,
):
assert client.app
- reset_url = client.app.router["auth_reset_password"].url_for()
+ reset_url = client.app.router["initiate_reset_password"].url_for()
response = await client.post(
f"{reset_url}",
@@ -66,8 +152,18 @@ async def test_unknown_email(
response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(email=fake_user_email)
)
+ # email is not sent
out, _ = capsys.readouterr()
- assert parse_test_marks(out)["reason"] == MSG_UNKNOWN_EMAIL
+ assert not parse_test_marks(out), "Expected no email to be sent"
+
+ # Check logger warning
+ logged_warnings = [
+ record.message for record in caplog.records if record.levelname == "WARNING"
+ ]
+
+ assert any(
+ message.startswith("Password reset initiated") for message in logged_warnings
+ ), f"Missing warning in {logged_warnings}"
@pytest.mark.parametrize(
@@ -80,11 +176,12 @@ async def test_unknown_email(
async def test_blocked_user(
client: TestClient,
capsys: pytest.CaptureFixture,
+ caplog: pytest.LogCaptureFixture,
user_status: UserStatus,
expected_msg: str,
):
assert client.app
- reset_url = client.app.router["auth_reset_password"].url_for()
+ reset_url = client.app.router["initiate_reset_password"].url_for()
async with NewUser({"status": user_status.name}, app=client.app) as user:
response = await client.post(
@@ -97,14 +194,26 @@ async def test_blocked_user(
assert response.url.path == reset_url.path
await assert_status(response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(**user))
+ # email is not sent
out, _ = capsys.readouterr()
+ assert not parse_test_marks(out), "Expected no email to be sent"
+
# expected_msg contains {support_email} at the end of the string
- assert expected_msg[:-20] in parse_test_marks(out)["reason"]
+ logged_warnings = [
+ record.message for record in caplog.records if record.levelname == "WARNING"
+ ]
+ assert any(
+ message.startswith("Password reset initiated") and expected_msg[:10] in message
+ for message in logged_warnings
+ ), f"Missing warning in {logged_warnings}"
-async def test_inactive_user(client: TestClient, capsys: pytest.CaptureFixture):
+
+async def test_inactive_user(
+ client: TestClient, capsys: pytest.CaptureFixture, caplog: pytest.LogCaptureFixture
+):
assert client.app
- reset_url = client.app.router["auth_reset_password"].url_for()
+ reset_url = client.app.router["initiate_reset_password"].url_for()
async with NewUser(
{"status": UserStatus.CONFIRMATION_PENDING.name}, app=client.app
@@ -119,97 +228,86 @@ async def test_inactive_user(client: TestClient, capsys: pytest.CaptureFixture):
assert response.url.path == reset_url.path
await assert_status(response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(**user))
+ # email is not sent
out, _ = capsys.readouterr()
- assert parse_test_marks(out)["reason"] == MSG_ACTIVATION_REQUIRED
+ assert not parse_test_marks(out), "Expected no email to be sent"
+
+ logged_warnings = [
+ record.message for record in caplog.records if record.levelname == "WARNING"
+ ]
+
+ assert any(
+ message.startswith("Password reset initiated")
+ and MSG_ACTIVATION_REQUIRED[:20] in message
+ for message in logged_warnings
+ ), f"Missing warning in {logged_warnings}"
-async def test_too_often(
+@pytest.fixture
+def other_product_name(
+ app_products_names: list[ProductName],
+ default_product_name: ProductName,
+) -> ProductName:
+ return next(name for name in app_products_names if name != default_product_name)
+
+
+async def test_unregistered_product(
+ default_product_name: ProductName,
+ other_product_name: ProductName,
client: TestClient,
- db: AsyncpgStorage,
capsys: pytest.CaptureFixture,
+ caplog: pytest.LogCaptureFixture,
):
assert client.app
- reset_url = client.app.router["auth_reset_password"].url_for()
async with NewUser(app=client.app) as user:
- confirmation = await db.create_confirmation(
- user["id"], ConfirmationAction.RESET_PASSWORD.name
+
+ # allow in
+ await groups_service.auto_add_user_to_product_group(
+ client.app, user_id=user["id"], product_name=default_product_name
)
- response = await client.post(
- f"{reset_url}",
- json={
- "email": user["email"],
- },
+ assert await users_service.is_user_in_product(
+ client.app, user_id=user["id"], product_name=default_product_name
+ )
+ assert not await users_service.is_user_in_product(
+ client.app, user_id=user["id"], product_name=other_product_name
)
- await db.delete_confirmation(confirmation)
-
- assert response.url.path == reset_url.path
- await assert_status(response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(**user))
-
- out, _ = capsys.readouterr()
- assert parse_test_marks(out)["reason"] == MSG_OFTEN_RESET_PASSWORD
-
-
-async def test_reset_and_confirm(
- client: TestClient, login_options: LoginOptions, capsys: pytest.CaptureFixture
-):
- assert client.app
- async with NewUser(app=client.app) as user:
- reset_url = client.app.router["auth_reset_password"].url_for()
+ # Simulate user registered in a different product
+ reset_url = client.app.router["initiate_reset_password"].url_for()
response = await client.post(
f"{reset_url}",
json={
"email": user["email"],
},
+ headers={X_PRODUCT_NAME_HEADER: other_product_name},
)
assert response.url.path == reset_url.path
await assert_status(response, status.HTTP_200_OK, MSG_EMAIL_SENT.format(**user))
- out, err = capsys.readouterr()
- confirmation_url = parse_link(out)
- code = URL(confirmation_url).parts[-1]
+ # Email is printed in the out
+ out, _ = capsys.readouterr()
+ assert not parse_test_marks(out), "Expected no email to be sent"
- # emulates user click on email url
- response = await client.get(confirmation_url)
- assert response.status == 200
- assert (
- response.url.path_qs
- == URL(login_options.LOGIN_REDIRECT)
- .with_fragment(f"reset-password?code={code}")
- .path_qs
- )
+ # expected_msg contains {support_email} at the end of the string
+ logged_warnings = [
+ record.message for record in caplog.records if record.levelname == "WARNING"
+ ]
- # api/specs/webserver/v0/components/schemas/auth.yaml#/ResetPasswordForm
- reset_allowed_url = client.app.router["auth_reset_password_allowed"].url_for(
- code=code
- )
- new_password = generate_password(10)
- response = await client.post(
- f"{reset_allowed_url}",
- json={
- "password": new_password,
- "confirm": new_password,
- },
- )
- payload = await response.json()
- assert response.status == 200, payload
- assert response.url.path == reset_allowed_url.path
- await assert_status(response, status.HTTP_200_OK, MSG_PASSWORD_CHANGED)
+ assert any(
+ message.startswith("Password reset initiated")
+ for message in logged_warnings
+ ), f"Missing warning in {logged_warnings}"
- # Try new password
- logout_url = client.app.router["auth_logout"].url_for()
- response = await client.post(f"{logout_url}")
- assert response.url.path == logout_url.path
- await assert_status(response, status.HTTP_401_UNAUTHORIZED, "Unauthorized")
- login_url = client.app.router["auth_login"].url_for()
- response = await client.post(
- f"{login_url}",
- json={
- "email": user["email"],
- "password": new_password,
- },
- )
- assert response.url.path == login_url.path
- await assert_status(response, status.HTTP_200_OK, MSG_LOGGED_IN)
+@contextlib.asynccontextmanager
+async def confirmation_ctx(
+ db: AsyncpgStorage, user
+) -> AsyncIterator[ConfirmationTokenDict]:
+ confirmation = await db.create_confirmation(
+ user["id"], ConfirmationAction.RESET_PASSWORD.name
+ )
+
+ yield confirmation
+
+ await db.delete_confirmation(confirmation)
diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py
index e5e417bb8fc..1eb7f810faa 100644
--- a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py
+++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py
@@ -11,11 +11,10 @@
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from faker import Faker
-from json2html import json2html
from pytest_mock import MockerFixture
from pytest_simcore.helpers.typing_env import EnvVarsDict
-from simcore_service_webserver._constants import RQ_PRODUCT_KEY
from simcore_service_webserver.application_settings import setup_settings
+from simcore_service_webserver.constants import RQ_PRODUCT_KEY
from simcore_service_webserver.email.plugin import setup_email
from simcore_service_webserver.login.plugin import setup_login
from simcore_service_webserver.login.utils_email import (
@@ -23,6 +22,7 @@
get_template_path,
send_email_from_template,
)
+from simcore_service_webserver.publications._utils import json2html
from simcore_service_webserver.statics._constants import FRONTEND_APPS_AVAILABLE
@@ -109,22 +109,6 @@ async def test_render_and_send_mail_for_password(
):
link = faker.url() # some url link
- await send_email_from_template(
- http_request,
- from_=f"no-reply@{product_name}.test",
- to=destination_email,
- template=await get_template_path(
- http_request, "reset_password_email_failed.jinja2"
- ),
- context={
- "host": http_request.host,
- "reason": faker.text(),
- "product": SimpleNamespace(
- display_name=product_name.capitalize(), name=product_name
- ),
- },
- )
-
await send_email_from_template(
http_request,
from_=f"no-reply@{product_name}.test",
diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/conftest.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/conftest.py
deleted file mode 100644
index d7e3dc7529e..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/conftest.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-# pylint: disable=too-many-arguments
-import pytest
-from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
-from pytest_simcore.helpers.typing_env import EnvVarsDict
-from simcore_postgres_database.models.users import UserRole
-
-
-@pytest.fixture
-def user_role() -> UserRole:
- return UserRole.TESTER
-
-
-@pytest.fixture
-def app_environment(
- monkeypatch: pytest.MonkeyPatch,
- app_environment: EnvVarsDict,
-) -> EnvVarsDict:
- return app_environment | setenvs_from_dict(
- monkeypatch,
- {
- # exclude
- "WEBSERVER_ACTIVITY": "null",
- "WEBSERVER_CLUSTERS": "null",
- "WEBSERVER_COMPUTATION": "null",
- "WEBSERVER_DIAGNOSTICS": "null",
- "WEBSERVER_GROUPS": "0",
- "WEBSERVER_PUBLICATIONS": "0",
- "WEBSERVER_GARBAGE_COLLECTOR": "null",
- "WEBSERVER_EMAIL": "null",
- "WEBSERVER_SOCKETIO": "0",
- "WEBSERVER_STORAGE": "null",
- "WEBSERVER_STUDIES_DISPATCHER": "null",
- "WEBSERVER_TAGS": "0",
- "WEBSERVER_TRACING": "null",
- # Module under test
- "WEBSERVER_DEV_FEATURES_ENABLED": "1",
- "WEBSERVER_VERSION_CONTROL": "1",
- "WEBSERVER_META_MODELING": "1",
- },
- )
diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_function_nodes.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_function_nodes.py
deleted file mode 100644
index d9a487e0075..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_function_nodes.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# pylint: disable=protected-access
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-import collections.abc
-import inspect
-from typing import get_origin
-
-from simcore_service_webserver.meta_modeling._function_nodes import catalog
-
-# TODO: test i/o schemas on FRONTEND_SERVICES_CATALOG fit the _fun Callable
-
-
-def test_frontend_service_to_callable_registry():
-
- print(f"\n{len(catalog)=}")
-
- for (node_key, node_version), func in catalog._items():
- if node_call := func.implementation:
- print(" -", node_key, node_version, node_call.__name__)
- assert (
- get_origin(inspect.signature(node_call).return_annotation)
- is collections.abc.Iterator
- ), f"Expected iterable nodes only {(node_key, node_version)=}"
diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py
deleted file mode 100644
index e00b67c0673..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py
+++ /dev/null
@@ -1,312 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-from collections.abc import Awaitable, Callable
-from typing import Any
-
-import pytest
-from aiohttp import ClientResponse
-from aiohttp.test_utils import TestClient
-from common_library.json_serialization import json_dumps, json_loads
-from faker import Faker
-from models_library.projects import Project
-from models_library.projects_nodes import Node
-from models_library.services_resources import ServiceResourcesDict
-from pytest_mock import MockerFixture
-from pytest_simcore.helpers.assert_checks import assert_status
-from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict
-from pytest_simcore.helpers.webserver_login import UserInfoDict
-from pytest_simcore.simcore_webserver_projects_rest_api import (
- NEW_PROJECT,
- REPLACE_PROJECT_ON_MODIFIED,
- RUN_PROJECT,
-)
-from servicelib.aiohttp import status
-from simcore_postgres_database.models.projects import projects
-from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY
-from simcore_service_webserver.director_v2.api import get_project_run_policy
-from simcore_service_webserver.meta_modeling._handlers import (
- Page,
- ProjectIterationItem,
- ProjectIterationResultItem,
-)
-from simcore_service_webserver.meta_modeling._projects import (
- meta_project_policy,
- projects_redirection_middleware,
-)
-from simcore_service_webserver.projects.db import ProjectDBAPI
-from simcore_service_webserver.projects.models import ProjectDict
-
-REQUEST_MODEL_POLICY = {
- "by_alias": True,
- "exclude_defaults": True,
- "exclude_none": True, # e.g. thumbnail: None will fail validation TODO: remove when new project model is in place. It might lead to wrong errors
- "exclude_unset": True,
-}
-
-
-@pytest.fixture
-def app_environment(
- app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
-) -> EnvVarsDict:
- envs_plugins = setenvs_from_dict(
- monkeypatch,
- {
- "WEBSERVER_RABBITMQ": "null",
- },
- )
- return app_environment | envs_plugins
-
-
-@pytest.fixture
-async def context_with_logged_user(client: TestClient, logged_user: UserInfoDict):
- yield
-
- assert client.app
- engine = client.app[APP_AIOPG_ENGINE_KEY]
- async with engine.acquire() as conn:
- # cascade deletes everything except projects_vc_snapshot
- await conn.execute(projects.delete())
-
-
-@pytest.mark.skip(
- reason="Blocking testing. Will follow up in https://github.com/ITISFoundation/osparc-simcore/issues/6976 "
-)
-@pytest.mark.acceptance_test()
-async def test_iterators_workflow(
- client: TestClient,
- logged_user: UserInfoDict,
- primary_group: dict[str, Any],
- context_with_logged_user: None,
- mocker: MockerFixture,
- faker: Faker,
- mock_dynamic_scheduler: None,
- director_v2_service_mock: None,
- request_create_project: Callable[..., Awaitable[ProjectDict]],
-):
- # pylint: disable=too-many-statements
-
- #
- # NOTE: all TODOs below shall be addressed in next version of the iterator
- # SEE SEE https://github.com/ITISFoundation/osparc-simcore/issues/2735
- #
-
- response: ClientResponse
-
- # check init meta is correct
- assert client.app
- assert projects_redirection_middleware in client.app.middlewares
- assert get_project_run_policy(client.app) == meta_project_policy
-
- # NEW project --------------------------------------------------------------
- mocker.patch(
- "simcore_service_webserver.director_v2.api.create_or_update_pipeline",
- return_value=None,
- )
- mocker.patch(
- "simcore_service_webserver.director_v2.api.get_computation_task",
- return_value=None,
- )
- mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.projects_api.is_service_deprecated",
- autospec=True,
- return_value=False,
- )
- mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.projects_api.catalog_client.get_service_resources",
- autospec=True,
- return_value=ServiceResourcesDict(),
- )
- # ----
- project_data = await request_create_project(
- client,
- status.HTTP_202_ACCEPTED,
- status.HTTP_201_CREATED,
- logged_user,
- primary_group,
- project=NEW_PROJECT.request_payload,
- )
-
- project_uuid = project_data["uuid"]
-
- # CREATE meta-project: iterator 0:3 -> sleeper -> sleeper_2 ---------------
- modifications = REPLACE_PROJECT_ON_MODIFIED.request_payload
- assert modifications
- create_node_url = client.app.router["create_node"].url_for(
- project_id=project_data["uuid"]
- )
- for node_id, node_data in modifications["workbench"].items():
- node = Node.model_validate(node_data)
- response = await client.post(
- f"{create_node_url}",
- json={
- "service_key": node.key,
- "service_version": node.version,
- "service_id": f"{node_id}",
- },
- )
- assert response.status == status.HTTP_201_CREATED
- project_data.update({key: modifications[key] for key in ("workbench", "ui")})
- project_data["ui"].setdefault("currentNodeId", project_uuid)
-
- db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(client.app)
- project_data.pop("state")
- await db.replace_project(
- project_data,
- logged_user["id"],
- project_uuid=project_uuid,
- product_name="osparc",
- )
-
- # TODO: create iterations, so user could explore parametrizations?
-
- # RUN metaproject ----------------------------------------------------------
- async def _mock_start(project_id, user_id, product_name, **options):
- return f"{project_id}"
-
- mocker.patch(
- "simcore_service_webserver.director_v2._core_computations.ComputationsApi.start",
- side_effect=_mock_start,
- )
- # ----
-
- response = await client.post(
- f"/v0/computations/{project_uuid}:start",
- json=RUN_PROJECT.request_payload,
- )
- data, _ = await assert_status(response, status.HTTP_201_CREATED)
- assert project_uuid == data["pipeline_id"]
- ref_ids = data["ref_ids"]
- assert len(ref_ids) == 3
-
- # TODO: check: has auto-commited
- # TODO: check: has iterations as branches
- # TODO: retrieve results of iter1
-
- # GET iterations ----------------------------------------------------------
- response = await client.get(f"/v0/repos/projects/{project_uuid}/checkpoints/HEAD")
- body = await response.json()
- head_ref_id = body["data"]["id"]
-
- assert head_ref_id == 1
-
- response = await client.get(
- f"/v0/projects/{project_uuid}/checkpoint/{head_ref_id}/iterations?offset=0"
- )
- body = await response.json()
- first_iterlist = Page[ProjectIterationItem].model_validate(body).data
-
- assert len(first_iterlist) == 3
-
- # GET workcopy project for iter 0 ----------------------------------------------
- async def _mock_catalog_get(*args, **kwarg):
- return [
- {"key": s["key"], "version": s["version"]}
- for _, s in project_data["workbench"].items()
- ] + [{"key": "simcore/services/frontend/parameter/integer", "version": "1.0.0"}]
-
- mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
- side_effect=_mock_catalog_get,
- autospec=True,
- )
- # extract outputs
- for i, prj_iter in enumerate(first_iterlist):
- assert prj_iter.workcopy_project_url.path
- response = await client.get(prj_iter.workcopy_project_url.path)
- assert response.status == status.HTTP_200_OK
-
- body = await response.json()
- project_iter0 = body["data"]
-
- outputs = {}
- for nid, node in project_iter0["workbench"].items():
- if out := node.get("outputs"):
- outputs[nid] = out
-
- assert len(outputs) == 1
- assert outputs["fc9208d9-1a0a-430c-9951-9feaf1de3368"]["out_1"] == i
-
- # ----------------------------------------------
-
- # GET results of all iterations
- # /projects/{project_uuid}/checkpoint/{ref_id}/iterations/-/results
- response = await client.get(
- f"/v0/projects/{project_uuid}/checkpoint/{head_ref_id}/iterations/-/results"
- )
- assert response.status == status.HTTP_200_OK, await response.text()
- body = await response.json()
-
- assert Page[ProjectIterationResultItem].model_validate(body).data is not None
-
- # GET project and MODIFY iterator values----------------------------------------------
- # - Change iterations from 0:4 -> HEAD+1
- response = await client.get(f"/v0/projects/{project_uuid}")
- assert response.status == status.HTTP_200_OK, await response.text()
- body = await response.json()
-
- # NOTE: updating a project fields can be daunting because
- # it combines nested field attributes with dicts and from the
- # json you cannot distinguish easily what-is-what automatically
- # Dict keys are usually some sort of identifier, typically a UUID or
- # and index but nothing prevents a dict from using any other type of key types
- #
- project = Project.model_validate(body["data"])
- new_project = project.model_copy(
- update={
- # TODO: HACK to overcome export from None -> string
- # SOLUTION 1: thumbnail should not be required (check with team!)
- # SOLUTION 2: make thumbnail nullable
- "thumbnail": faker.image_url(),
- }
- )
- assert new_project.workbench is not None
- assert new_project.workbench
- node = new_project.workbench["fc9208d9-1a0a-430c-9951-9feaf1de3368"]
- assert node.inputs
- node.inputs["linspace_stop"] = 4
-
- _new_project_data = new_project.model_dump(**REQUEST_MODEL_POLICY)
- _new_project_data.pop("state")
- await db.replace_project(
- json_loads(json_dumps(_new_project_data)),
- logged_user["id"],
- project_uuid=project_uuid,
- product_name="osparc",
- )
-
- # RUN again them ---------------------------------------------------------------------------
- response = await client.post(
- f"/v0/computations/{project_uuid}:start",
- json=RUN_PROJECT.request_payload,
- )
- data, _ = await assert_status(response, status.HTTP_201_CREATED)
- assert project_uuid == data["pipeline_id"]
- ref_ids = data["ref_ids"]
- assert len(ref_ids) == 4
-
- # GET iterations -----------------------------------------------------------------
- # check iters 1, 2 and 3 share working copies
- #
- response = await client.get(f"/v0/repos/projects/{project_uuid}/checkpoints/HEAD")
- body = await response.json()
- head_ref_id = body["data"]["id"]
-
- assert head_ref_id == 5
-
- response = await client.get(
- f"/v0/projects/{project_uuid}/checkpoint/{head_ref_id}/iterations?offset=0"
- )
- body = await response.json()
- assert response.status == status.HTTP_200_OK, f"{body=}" # nosec
- second_iterlist = Page[ProjectIterationItem].model_validate(body).data
-
- assert len(second_iterlist) == 4
- assert len({it.workcopy_project_id for it in second_iterlist}) == len(
- second_iterlist
- ), "unique"
-
- # TODO: cached iterations will be implemented in next PR
- # for i in range(len(first_iterlist)):
- # assert second_iterlist[i].workcopy_project_id == first_iterlist[i].workcopy_project_id
diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_results.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_results.py
deleted file mode 100644
index fdec06806ed..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_results.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-
-import json
-from typing import Any
-
-import pytest
-from pydantic import BaseModel
-from simcore_service_webserver.meta_modeling._results import (
- ExtractedResults,
- extract_project_results,
-)
-
-
-@pytest.fixture
-def fake_workbench() -> dict[str, Any]:
- return {
- "0f1e38c9-dcb7-443c-a745-91b97ac28ccc": {
- "key": "simcore/services/frontend/data-iterator/funky-range",
- "version": "1.0.0",
- "label": "Integer iterator",
- "inputs": {"linspace_start": 0, "linspace_stop": 2, "linspace_step": 1},
- "inputNodes": [],
- # some funky output of iterator/param,
- "outputs": {"out_1": 1, "out_2": [3, 4]},
- },
- "e33c6880-1b1d-4419-82d7-270197738aa9": {
- "key": "simcore/services/comp/itis/sleeper",
- "version": "2.0.0",
- "label": "sleeper",
- "inputs": {
- "input_2": {
- "nodeUuid": "0f1e38c9-dcb7-443c-a745-91b97ac28ccc",
- "output": "out_1",
- },
- "input_3": False,
- },
- "inputNodes": ["0f1e38c9-dcb7-443c-a745-91b97ac28ccc"],
- "state": {
- "currentStatus": "SUCCESS",
- "modified": False,
- "dependencies": [],
- },
- "progress": 100,
- "outputs": {
- "output_1": {
- "store": "0",
- "path": "30359da5-ca4d-3288-a553-5f426a204fe6/e33c6880-1b1d-4419-82d7-270197738aa9/single_number.txt",
- "eTag": "a87ff679a2f3e71d9181a67b7542122c",
- },
- "output_2": 7,
- },
- "runHash": "f92d1836aa1b6b1b031f9e1b982e631814708675c74ba5f02161e0f256382b2b",
- },
- "4c08265a-427b-4ac3-9eab-1d11c822ada4": {
- "key": "simcore/services/comp/itis/sleeper",
- "version": "2.0.0",
- "label": "sleeper",
- "inputNodes": [],
- },
- "2d0ce8b9-c9c3-43ce-ad2f-ad493898de37": {
- "key": "simcore/services/frontend/iterator-consumer/probe/int",
- "version": "1.0.0",
- "label": "Probe Sensor - Integer",
- "inputs": {
- "in_1": {
- "nodeUuid": "e33c6880-1b1d-4419-82d7-270197738aa9",
- "output": "output_2",
- }
- },
- "inputNodes": ["e33c6880-1b1d-4419-82d7-270197738aa9"],
- },
- "445b44d1-59b3-425c-ac48-7c13e0f2ea5b": {
- "key": "simcore/services/frontend/iterator-consumer/probe/int",
- "version": "1.0.0",
- "label": "Probe Sensor - Integer_2",
- "inputs": {
- "in_1": {
- "nodeUuid": "0f1e38c9-dcb7-443c-a745-91b97ac28ccc",
- "output": "out_1",
- }
- },
- "inputNodes": ["0f1e38c9-dcb7-443c-a745-91b97ac28ccc"],
- },
- "d76fca06-f050-4790-88a8-0aac10c87b39": {
- "key": "simcore/services/frontend/parameter/boolean",
- "version": "1.0.0",
- "label": "Boolean Parameter",
- "inputs": {},
- "inputNodes": [],
- "outputs": {"out_1": True},
- },
- }
-
-
-def test_extract_project_results(fake_workbench: dict[str, Any]):
-
- results = extract_project_results(fake_workbench)
-
- print(json.dumps(results.progress, indent=1))
- print(json.dumps(results.labels, indent=1))
- print(json.dumps(results.values, indent=1))
-
- # this has to be something that shall be deployable in a table
- assert results.progress == {
- "4c08265a-427b-4ac3-9eab-1d11c822ada4": 0,
- "e33c6880-1b1d-4419-82d7-270197738aa9": 100,
- }
-
- # labels are not unique, so there is a map to nodeids
- assert results.labels == {
- "0f1e38c9-dcb7-443c-a745-91b97ac28ccc": "Integer iterator",
- "2d0ce8b9-c9c3-43ce-ad2f-ad493898de37": "Probe Sensor - Integer",
- "445b44d1-59b3-425c-ac48-7c13e0f2ea5b": "Probe Sensor - Integer_2",
- "d76fca06-f050-4790-88a8-0aac10c87b39": "Boolean Parameter",
- }
- # this is basically a tree that defines columns
- assert results.values == {
- "0f1e38c9-dcb7-443c-a745-91b97ac28ccc": {"out_1": 1, "out_2": [3, 4]},
- "2d0ce8b9-c9c3-43ce-ad2f-ad493898de37": {"in_1": 7},
- "445b44d1-59b3-425c-ac48-7c13e0f2ea5b": {"in_1": 1},
- "d76fca06-f050-4790-88a8-0aac10c87b39": {"out_1": True},
- }
-
-
-@pytest.mark.parametrize(
- "model_cls",
- [ExtractedResults],
-)
-def test_models_examples(
- model_cls: type[BaseModel], model_cls_examples: dict[str, Any]
-):
- for name, example in model_cls_examples.items():
- print(name, ":", json.dumps(example, indent=1))
- model_instance = model_cls(**example)
- assert model_instance, f"Failed with {name}"
diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py
index 28a01dacbc8..ef79e68ee1b 100644
--- a/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py
@@ -8,10 +8,10 @@
import pytest
from faker import Faker
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
- PricingPlanPage,
PricingPlanToServiceGet,
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingPlanPage,
+ RutPricingUnitGet,
)
from pytest_mock import MockerFixture
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
@@ -45,13 +45,15 @@ def mock_rpc_resource_usage_tracker_service_api(
) -> dict[str, MagicMock]:
return {
## Pricing plans
- "list_pricing_plans": mocker.patch(
- "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.list_pricing_plans",
+ "list_pricing_plans_without_pricing_units": mocker.patch(
+ "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.list_pricing_plans_without_pricing_units",
autospec=True,
- return_value=PricingPlanPage(
+ return_value=RutPricingPlanPage(
items=[
- PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_config["json_schema_extra"]["examples"][
+ 0
+ ],
)
],
total=1,
@@ -60,44 +62,44 @@ def mock_rpc_resource_usage_tracker_service_api(
"get_pricing_plan": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.get_pricing_plan",
autospec=True,
- return_value=PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_config["json_schema_extra"]["examples"][0],
),
),
"create_pricing_plan": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.create_pricing_plan",
autospec=True,
- return_value=PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_config["json_schema_extra"]["examples"][0],
),
),
"update_pricing_plan": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.update_pricing_plan",
autospec=True,
- return_value=PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_config["json_schema_extra"]["examples"][0],
),
),
## Pricing units
"get_pricing_unit": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.get_pricing_unit",
autospec=True,
- return_value=PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_config["json_schema_extra"]["examples"][0],
),
),
"create_pricing_unit": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.create_pricing_unit",
autospec=True,
- return_value=PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_config["json_schema_extra"]["examples"][0],
),
),
"update_pricing_unit": mocker.patch(
"simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.update_pricing_unit",
autospec=True,
- return_value=PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0],
+ return_value=RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_config["json_schema_extra"]["examples"][0],
),
),
## Pricing plan to service
diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py
index cfb2b06a789..35f6255a5e1 100644
--- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py
+++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py
@@ -23,7 +23,7 @@
def mock_catalog_client(mocker: MockerFixture, faker: Faker) -> dict[str, MagicMock]:
return {
"get_service": mocker.patch(
- "simcore_service_webserver.resource_usage._pricing_plans_admin_service.catalog_client.get_service",
+ "simcore_service_webserver.resource_usage._pricing_plans_admin_service.catalog_service.get_service",
autospec=True,
)
}
diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py
index bbe5fa9f951..046bf286d48 100644
--- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py
+++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py
@@ -11,8 +11,8 @@
import pytest
from aiohttp.test_utils import TestClient
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingPlanGet,
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingUnitGet,
)
from models_library.api_schemas_webserver import resource_usage as webserver_api
from models_library.utils.fastapi_encoders import jsonable_encoder
@@ -32,12 +32,12 @@ def mock_rut_api_responses(
assert client.app
settings: ResourceUsageTrackerSettings = get_plugin_settings(client.app)
- pricing_unit_get = PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0]
+ pricing_unit_get = RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_config["json_schema_extra"]["examples"][0]
)
- service_pricing_plan_get = PricingPlanGet.model_validate(
- PricingPlanGet.model_config["json_schema_extra"]["examples"][0],
+ service_pricing_plan_get = RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_config["json_schema_extra"]["examples"][0],
)
aioresponses_mocker.get(
diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py
index 0f70c98856c..9669f1eea90 100644
--- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py
+++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py
@@ -30,7 +30,7 @@
from simcore_postgres_database.models.tags import tags
from simcore_service_webserver.db.models import UserRole
from simcore_service_webserver.db.plugin import get_database_engine
-from simcore_service_webserver.products._api import get_product
+from simcore_service_webserver.products._service import get_product
from simcore_service_webserver.projects.models import ProjectDict
diff --git a/services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py b/services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py
index 1128f9a707a..9c6ad78f4a8 100644
--- a/services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py
+++ b/services/web/server/tests/unit/with_dbs/03/test__openapi_specs.py
@@ -13,6 +13,7 @@
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
from pytest_simcore.openapi_specs import Entrypoint
+from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.application import create_application
from simcore_service_webserver.application_settings import get_application_settings
from simcore_service_webserver.rest._utils import get_openapi_specs_path
@@ -41,8 +42,6 @@ def app_environment(
"WEBSERVER_GARBAGE_COLLECTOR": "null",
# enable plugins that by default are disabled
"WEBSERVER_DEV_FEATURES_ENABLED": "1",
- "WEBSERVER_VERSION_CONTROL": "1",
- "WEBSERVER_META_MODELING": "1",
"WEBSERVER_CLUSTERS": "1",
# enables activity WEBSERVER_ACTIVITY
"PROMETHEUS_URL": f"https://{faker.domain_name()}",
@@ -77,7 +76,16 @@ def test_app_named_resources_against_openapi_specs(
openapi_specs_entrypoints: set[Entrypoint],
app_rest_entrypoints: set[Entrypoint],
):
- assert app_rest_entrypoints == openapi_specs_entrypoints
+ # remove task-legacy routes. These should not be exposed.
+ # this test compares directly against the openapi specs. In future it would be
+ # cleaner to compare against the fastapi app entry points in specs and
+ # avoid including the endpoints there
+ required_entry_points = {
+ e
+ for e in app_rest_entrypoints
+ if not e.path.startswith(f"/{API_VTAG}/tasks-legacy")
+ }
+ assert required_entry_points == openapi_specs_entrypoints
# NOTE: missing here is:
# - input schemas (path, query and body)
diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py
index c208162d318..244f090ab40 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_email.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_email.py
@@ -44,7 +44,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_EXPORTER": "null",
"WEBSERVER_GARBAGE_COLLECTOR": "null",
"WEBSERVER_GROUPS": "1",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_PRODUCTS": "1",
"WEBSERVER_PUBLICATIONS": "0",
"WEBSERVER_REMOTE_DEBUG": "0",
@@ -52,7 +51,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_STUDIES_DISPATCHER": "null",
"WEBSERVER_TAGS": "1",
"WEBSERVER_TRACING": "null",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "0",
},
)
diff --git a/services/web/server/tests/unit/with_dbs/03/test_project_db.py b/services/web/server/tests/unit/with_dbs/03/test_project_db.py
index d62cac76a51..4aa9791d226 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_project_db.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_project_db.py
@@ -29,19 +29,26 @@
from simcore_postgres_database.models.projects_to_products import projects_to_products
from simcore_postgres_database.models.users import UserRole
from simcore_postgres_database.utils_projects_nodes import ProjectNodesRepo
-from simcore_service_webserver.projects._db_utils import PermissionStr
-from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
+from simcore_service_webserver.projects._groups_repository import (
+ update_or_insert_project_group,
+)
+from simcore_service_webserver.projects._projects_repository_legacy import (
+ ProjectAccessRights,
+ ProjectDBAPI,
+)
+from simcore_service_webserver.projects._projects_repository_legacy_utils import (
+ PermissionStr,
+)
+from simcore_service_webserver.projects._projects_service import (
+ _check_project_node_has_all_required_inputs,
+)
from simcore_service_webserver.projects.api import has_user_project_access_rights
-from simcore_service_webserver.projects.db import ProjectAccessRights, ProjectDBAPI
from simcore_service_webserver.projects.exceptions import (
NodeNotFoundError,
ProjectNodeRequiredInputsNotSetError,
ProjectNotFoundError,
)
from simcore_service_webserver.projects.models import ProjectDict
-from simcore_service_webserver.projects.projects_service import (
- _check_project_node_has_all_required_inputs,
-)
from simcore_service_webserver.users.exceptions import UserNotFoundError
from simcore_service_webserver.utils import to_datetime
from sqlalchemy.engine.result import Row
@@ -350,7 +357,7 @@ async def test_insert_project_to_db(
@pytest.mark.parametrize(
"user_role",
- [(UserRole.USER)],
+ [UserRole.USER],
)
async def test_patch_user_project_workbench_raises_if_project_does_not_exist(
fake_project: dict[str, Any],
@@ -376,7 +383,7 @@ async def test_patch_user_project_workbench_raises_if_project_does_not_exist(
@pytest.mark.parametrize(
"user_role",
- [(UserRole.USER)],
+ [UserRole.USER],
)
async def test_patch_user_project_workbench_creates_nodes(
fake_project: dict[str, Any],
@@ -420,7 +427,7 @@ async def test_patch_user_project_workbench_creates_nodes(
@pytest.mark.parametrize(
"user_role",
- [(UserRole.USER)],
+ [UserRole.USER],
)
async def test_patch_user_project_workbench_creates_nodes_raises_if_invalid_node_is_passed(
fake_project: dict[str, Any],
@@ -457,7 +464,7 @@ async def test_patch_user_project_workbench_creates_nodes_raises_if_invalid_node
@pytest.mark.parametrize(
"user_role",
- [(UserRole.USER)],
+ [UserRole.USER],
)
@pytest.mark.parametrize("number_of_nodes", [1, randint(250, 300)]) # noqa: S311
async def test_patch_user_project_workbench_concurrently(
@@ -514,18 +521,18 @@ async def test_patch_user_project_workbench_concurrently(
for n in range(_NUMBER_OF_NODES):
expected_project["workbench"][node_uuids[n]].update(randomly_created_outputs[n])
- patched_projects: list[
- tuple[dict[str, Any], dict[str, Any]]
- ] = await asyncio.gather(
- *[
- db_api._update_project_workbench( # noqa: SLF001
- {NodeIDStr(node_uuids[n]): randomly_created_outputs[n]},
- user_id=logged_user["id"],
- project_uuid=new_project["uuid"],
- allow_workbench_changes=False,
- )
- for n in range(_NUMBER_OF_NODES)
- ]
+ patched_projects: list[tuple[dict[str, Any], dict[str, Any]]] = (
+ await asyncio.gather(
+ *[
+ db_api._update_project_workbench( # noqa: SLF001
+ {NodeIDStr(node_uuids[n]): randomly_created_outputs[n]},
+ user_id=logged_user["id"],
+ project_uuid=new_project["uuid"],
+ allow_workbench_changes=False,
+ )
+ for n in range(_NUMBER_OF_NODES)
+ ]
+ )
)
# NOTE: each returned project contains the project with some updated workbenches
# the ordering is uncontrolled.
@@ -743,9 +750,9 @@ async def test_replace_user_project(
},
"output_2": 5,
}
- node_data[
- "runHash"
- ] = "5b0583fa546ac82f0e41cef9705175b7187ce3928ba42892e842add912c16676"
+ node_data["runHash"] = (
+ "5b0583fa546ac82f0e41cef9705175b7187ce3928ba42892e842add912c16676"
+ )
# replacing with the new entries shall return the very same data
replaced_project = await db_api.replace_project(
working_project,
@@ -917,7 +924,7 @@ async def inserted_project(
),
],
)
-@pytest.mark.parametrize("user_role", [(UserRole.USER)])
+@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_check_project_node_has_all_required_inputs_raises(
client: TestClient,
logged_user: dict[str, Any],
@@ -948,7 +955,7 @@ async def test_check_project_node_has_all_required_inputs_raises(
),
],
)
-@pytest.mark.parametrize("user_role", [(UserRole.USER)])
+@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_check_project_node_has_all_required_inputs_ok(
client: TestClient,
logged_user: dict[str, Any],
diff --git a/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py b/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py
index dd79e1a9b6c..88970409a8e 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py
@@ -13,8 +13,8 @@
from aiohttp.test_utils import TestClient
from pytest_simcore.helpers.typing_env import EnvVarsDict
from servicelib.aiohttp import status
-from simcore_service_webserver._constants import APP_SETTINGS_KEY
from simcore_service_webserver.application_settings import ApplicationSettings
+from simcore_service_webserver.constants import APP_SETTINGS_KEY
from simcore_service_webserver.login._constants import (
MAX_2FA_CODE_RESEND,
MAX_2FA_CODE_TRIALS,
diff --git a/services/web/server/tests/unit/with_dbs/03/test_socketio.py b/services/web/server/tests/unit/with_dbs/03/test_socketio.py
index 5b63d8f3c84..deca5e69c4e 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_socketio.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_socketio.py
@@ -27,7 +27,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_EXPORTER": "null",
"WEBSERVER_GARBAGE_COLLECTOR": "null",
"WEBSERVER_GROUPS": "0",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_NOTIFICATIONS": "0",
"WEBSERVER_PROJECTS": "null",
"WEBSERVER_PUBLICATIONS": "0",
@@ -41,7 +40,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_CATALOG": "null",
"WEBSERVER_REDIS": "null",
"WEBSERVER_SCICRUNCH": "null",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "0",
},
)
diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py
index 6b0ba408cc0..c4008f75235 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_users.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_users.py
@@ -35,7 +35,11 @@
random_pre_registration_details,
)
from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict
-from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
+from pytest_simcore.helpers.webserver_login import (
+ NewUser,
+ UserInfoDict,
+ switch_client_session_to,
+)
from servicelib.aiohttp import status
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
from simcore_service_webserver.users._common.schemas import (
@@ -62,15 +66,36 @@ def app_environment(
@pytest.fixture
-async def private_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
+def partial_first_name() -> str:
+ return "Jaimito"
+
+
+@pytest.fixture
+def partial_username() -> str:
+ return "COMMON_USERNAME"
+
+
+@pytest.fixture
+def partial_email() -> str:
+ return "@acme.com"
+
+
+@pytest.fixture
+async def private_user(
+ client: TestClient,
+ partial_username: str,
+ partial_email: str,
+ partial_first_name: str,
+) -> AsyncIterable[UserInfoDict]:
assert client.app
async with NewUser(
app=client.app,
user_data={
- "name": "jamie01",
- "first_name": "James",
+ "name": f"james{partial_username}",
+ "first_name": partial_first_name,
"last_name": "Bond",
- "email": "james@find.me",
+ "email": f"james{partial_email}",
+ "privacy_hide_username": True,
"privacy_hide_email": True,
"privacy_hide_fullname": True,
},
@@ -79,15 +104,18 @@ async def private_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
@pytest.fixture
-async def semi_private_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
+async def semi_private_user(
+ client: TestClient, partial_username: str, partial_first_name: str
+) -> AsyncIterable[UserInfoDict]:
assert client.app
async with NewUser(
app=client.app,
user_data={
- "name": "maxwell",
- "first_name": "James",
+ "name": f"maxwell{partial_username}",
+ "first_name": partial_first_name,
"last_name": "Maxwell",
"email": "j@maxwell.me",
+ "privacy_hide_username": False,
"privacy_hide_email": True,
"privacy_hide_fullname": False, # <--
},
@@ -96,15 +124,18 @@ async def semi_private_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
@pytest.fixture
-async def public_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
+async def public_user(
+ client: TestClient, partial_username: str, partial_email: str
+) -> AsyncIterable[UserInfoDict]:
assert client.app
async with NewUser(
app=client.app,
user_data={
- "name": "taylie01",
+ "name": f"taylor{partial_username}",
"first_name": "Taylor",
"last_name": "Swift",
- "email": "taylor@find.me",
+ "email": f"taylor{partial_email}",
+ "privacy_hide_username": False,
"privacy_hide_email": False,
"privacy_hide_fullname": False,
},
@@ -112,44 +143,56 @@ async def public_user(client: TestClient) -> AsyncIterable[UserInfoDict]:
yield usr
-@pytest.mark.acceptance_test(
- "https://github.com/ITISFoundation/osparc-issues/issues/1779"
-)
@pytest.mark.parametrize("user_role", [UserRole.USER])
-async def test_search_users(
+async def test_search_users_by_partial_fullname(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
- public_user: UserInfoDict,
- semi_private_user: UserInfoDict,
+ partial_first_name: str,
private_user: UserInfoDict,
+ semi_private_user: UserInfoDict,
+ public_user: UserInfoDict,
):
assert client.app
assert user_role.value == logged_user["role"]
+ # logged_user has default settings
assert private_user["id"] != logged_user["id"]
assert public_user["id"] != logged_user["id"]
# SEARCH by partial first_name
- partial_name = "james"
- assert partial_name in private_user.get("first_name", "").lower()
- assert partial_name in semi_private_user.get("first_name", "").lower()
+ assert partial_first_name in private_user.get("first_name", "")
+ assert partial_first_name in semi_private_user.get("first_name", "")
+ assert partial_first_name not in public_user.get("first_name", "")
url = client.app.router["search_users"].url_for()
- resp = await client.post(f"{url}", json={"match": partial_name})
+ resp = await client.post(f"{url}", json={"match": partial_first_name})
data, _ = await assert_status(resp, status.HTTP_200_OK)
+ # expected `semi_private_user` found
found = TypeAdapter(list[UserGet]).validate_python(data)
assert found
assert len(found) == 1
- assert semi_private_user["name"] == found[0].user_name
+ assert found[0].user_name == semi_private_user["name"]
assert found[0].first_name == semi_private_user.get("first_name")
assert found[0].last_name == semi_private_user.get("last_name")
assert found[0].email is None
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_search_users_by_partial_email(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ partial_email: str,
+ public_user: UserInfoDict,
+ semi_private_user: UserInfoDict,
+ private_user: UserInfoDict,
+):
+
# SEARCH by partial email
- partial_email = "@find.m"
assert partial_email in private_user["email"]
+ assert partial_email not in semi_private_user["email"]
assert partial_email in public_user["email"]
url = client.app.router["search_users"].url_for()
@@ -159,15 +202,39 @@ async def test_search_users(
found = TypeAdapter(list[UserGet]).validate_python(data)
assert found
assert len(found) == 1
+
+ # expected `public_user` found
assert found[0].user_id == public_user["id"]
assert found[0].user_name == public_user["name"]
assert found[0].email == public_user["email"]
assert found[0].first_name == public_user.get("first_name")
assert found[0].last_name == public_user.get("last_name")
+ # SEARCH user for admin (from a USER)
+ url = (
+ client.app.router["search_users_for_admin"]
+ .url_for()
+ .with_query(email=partial_email)
+ )
+ resp = await client.get(f"{url}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+
+@pytest.mark.parametrize("user_role", [UserRole.USER])
+async def test_search_users_by_partial_username(
+ user_role: UserRole,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ partial_username: str,
+ public_user: UserInfoDict,
+ semi_private_user: UserInfoDict,
+ private_user: UserInfoDict,
+):
+ assert client.app
+
# SEARCH by partial username
- partial_username = "ie01"
assert partial_username in private_user["name"]
+ assert partial_username in semi_private_user["name"]
assert partial_username in public_user["name"]
url = client.app.router["search_users"].url_for()
@@ -178,24 +245,45 @@ async def test_search_users(
assert found
assert len(found) == 2
+ # expected `public_user` found
index = [u.user_id for u in found].index(public_user["id"])
assert found[index].user_name == public_user["name"]
+ assert found[index].email == public_user["email"]
+ assert found[index].first_name == public_user.get("first_name")
+ assert found[index].last_name == public_user.get("last_name")
- # check privacy
+ # expected `semi_private_user` found
index = (index + 1) % 2
- assert found[index].user_name == private_user["name"]
+ assert found[index].user_name == semi_private_user["name"]
assert found[index].email is None
- assert found[index].first_name is None
- assert found[index].last_name is None
+ assert found[index].first_name == semi_private_user.get("first_name")
+ assert found[index].last_name == semi_private_user.get("last_name")
- # SEARCH user for admin (from a USER)
- url = (
- client.app.router["search_users_for_admin"]
- .url_for()
- .with_query(email=partial_email)
- )
- resp = await client.get(f"{url}")
- await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+async def test_search_myself(
+ client: TestClient,
+ public_user: UserInfoDict,
+ semi_private_user: UserInfoDict,
+ private_user: UserInfoDict,
+):
+ assert client.app
+ for user in [public_user, semi_private_user, private_user]:
+ async with switch_client_session_to(client, user):
+
+ # search me
+ url = client.app.router["search_users"].url_for()
+ resp = await client.post(f"{url}", json={"match": user["name"]})
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+
+ found = TypeAdapter(list[UserGet]).validate_python(data)
+ assert found
+ assert len(found) == 1
+
+ # I can see my own data
+ assert found[0].user_name == user["name"]
+ assert found[0].email == user["email"]
+ assert found[0].first_name == user.get("first_name")
+ assert found[0].last_name == user.get("last_name")
@pytest.mark.acceptance_test(
@@ -203,9 +291,9 @@ async def test_search_users(
)
@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_get_user_by_group_id(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
public_user: UserInfoDict,
private_user: UserInfoDict,
):
@@ -215,7 +303,7 @@ async def test_get_user_by_group_id(
assert private_user["id"] != logged_user["id"]
assert public_user["id"] != logged_user["id"]
- # GET user by primary GID
+ # GET public_user by its primary gid
url = client.app.router["get_all_group_users"].url_for(
gid=f"{public_user['primary_gid']}"
)
@@ -229,6 +317,7 @@ async def test_get_user_by_group_id(
assert users[0].first_name == public_user.get("first_name")
assert users[0].last_name == public_user.get("last_name")
+ # GET private_user by its primary gid
url = client.app.router["get_all_group_users"].url_for(
gid=f"{private_user['primary_gid']}"
)
@@ -238,9 +327,9 @@ async def test_get_user_by_group_id(
users = TypeAdapter(list[GroupUserGet]).validate_python(data)
assert len(users) == 1
assert users[0].id == private_user["id"]
- assert users[0].user_name == private_user["name"]
- assert users[0].first_name is None
- assert users[0].last_name is None
+ assert users[0].user_name is None, "It's private"
+ assert users[0].first_name is None, "It's private"
+ assert users[0].last_name is None, "It's private"
@pytest.mark.parametrize(
@@ -274,9 +363,9 @@ async def test_access_rights_on_get_profile(
],
)
async def test_access_update_profile(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
expected: HTTPStatus,
):
assert client.app
@@ -290,9 +379,9 @@ async def test_access_update_profile(
@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_get_profile(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
primary_group: dict[str, Any],
standard_groups: list[dict[str, Any]],
all_group: dict[str, str],
@@ -338,9 +427,9 @@ async def test_get_profile(
@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_update_profile(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
):
assert client.app
@@ -379,9 +468,9 @@ def _copy(data: dict, exclude: set) -> dict:
@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_profile_workflow(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
):
assert client.app
@@ -414,6 +503,7 @@ async def test_profile_workflow(
assert updated_profile.user_name == "odei123"
assert updated_profile.privacy != my_profile.privacy
+ assert updated_profile.privacy.hide_username == my_profile.privacy.hide_username
assert updated_profile.privacy.hide_email == my_profile.privacy.hide_email
assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname
@@ -421,9 +511,9 @@ async def test_profile_workflow(
@pytest.mark.parametrize("user_role", [UserRole.USER])
@pytest.mark.parametrize("invalid_username", ["", "_foo", "superadmin", "foo..-123"])
async def test_update_wrong_user_name(
+ user_role: UserRole,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
invalid_username: str,
):
assert client.app
@@ -440,10 +530,10 @@ async def test_update_wrong_user_name(
@pytest.mark.parametrize("user_role", [UserRole.USER])
async def test_update_existing_user_name(
+ user_role: UserRole,
user: UserInfoDict,
logged_user: UserInfoDict,
client: TestClient,
- user_role: UserRole,
):
assert client.app
@@ -699,7 +789,7 @@ def test_preuserprofile_parse_model_from_request_form_data(
def test_preuserprofile_parse_model_without_extras(
- account_request_form: dict[str, Any]
+ account_request_form: dict[str, Any],
):
required = {
f.alias or f_name
diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py
index bdfe1af8d81..0a33d8f8921 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py
+++ b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py
@@ -14,8 +14,8 @@
PreferenceName,
)
from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict
-from simcore_service_webserver._constants import APP_SETTINGS_KEY
from simcore_service_webserver.application_settings import ApplicationSettings
+from simcore_service_webserver.constants import APP_SETTINGS_KEY
from simcore_service_webserver.users._preferences_models import (
ALL_FRONTEND_PREFERENCES,
TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference,
diff --git a/services/web/server/tests/unit/with_dbs/03/trash/conftest.py b/services/web/server/tests/unit/with_dbs/03/trash/conftest.py
new file mode 100644
index 00000000000..5c742b12144
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/03/trash/conftest.py
@@ -0,0 +1,102 @@
+# pylint: disable=protected-access
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+import logging
+from collections.abc import AsyncIterable, Callable
+from pathlib import Path
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient
+from aioresponses import aioresponses
+from models_library.products import ProductName
+from pytest_mock import MockerFixture
+from pytest_simcore.helpers.logging_tools import log_context
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
+from pytest_simcore.helpers.typing_env import EnvVarsDict
+from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
+from pytest_simcore.helpers.webserver_parametrizations import MockedStorageSubsystem
+from pytest_simcore.helpers.webserver_projects import NewProject
+from simcore_service_webserver.projects.models import ProjectDict
+
+_logger = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def app_environment(
+ app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
+) -> EnvVarsDict:
+ return app_environment | setenvs_from_dict(
+ monkeypatch, {"WEBSERVER_DEV_FEATURES_ENABLED": "1"}
+ )
+
+
+@pytest.fixture
+async def other_user(
+ client: TestClient, logged_user: UserInfoDict
+) -> AsyncIterable[UserInfoDict]:
+ # new user different from logged_user
+ async with NewUser(
+ {
+ "name": f"other_user_than_{logged_user['name']}",
+ "role": "USER",
+ },
+ client.app,
+ ) as user:
+ yield user
+
+
+@pytest.fixture
+async def other_user_project(
+ client: TestClient,
+ fake_project: ProjectDict,
+ other_user: UserInfoDict,
+ tests_data_dir: Path,
+ osparc_product_name: ProductName,
+) -> AsyncIterable[ProjectDict]:
+ async with NewProject(
+ fake_project,
+ client.app,
+ user_id=other_user["id"],
+ product_name=osparc_product_name,
+ tests_data_dir=tests_data_dir,
+ ) as project:
+ yield project
+
+
+@pytest.fixture
+def mocked_catalog(
+ user_project: ProjectDict,
+ catalog_subsystem_mock: Callable[[list[ProjectDict]], None],
+):
+ catalog_subsystem_mock([user_project])
+
+
+@pytest.fixture
+def mocked_director_v2(director_v2_service_mock: aioresponses):
+ ...
+
+
+@pytest.fixture
+def mocked_storage(storage_subsystem_mock: MockedStorageSubsystem):
+ ...
+
+
+@pytest.fixture
+def with_disabled_background_task_to_prune_trash(mocker: MockerFixture) -> None:
+ async def _empty_lifespan(app: web.Application):
+ with log_context(
+ logging.INFO, "Fake background_task_to_prune_trash event", logger=_logger
+ ):
+ yield
+
+ mocker.patch(
+ "simcore_service_webserver.garbage_collector._tasks_trash.create_background_task_to_prune_trash",
+ autospec=True,
+ return_value=_empty_lifespan,
+ )
diff --git a/services/web/server/tests/unit/with_dbs/03/test_trash.py b/services/web/server/tests/unit/with_dbs/03/trash/test_trash.py
similarity index 78%
rename from services/web/server/tests/unit/with_dbs/03/test_trash.py
rename to services/web/server/tests/unit/with_dbs/03/trash/test_trash.py
index eb598bbdb1d..396dfcc7c97 100644
--- a/services/web/server/tests/unit/with_dbs/03/test_trash.py
+++ b/services/web/server/tests/unit/with_dbs/03/trash/test_trash.py
@@ -7,57 +7,33 @@
import asyncio
-from collections.abc import AsyncIterable, Callable
+from collections.abc import AsyncIterable
from unittest.mock import MagicMock
from uuid import UUID
import arrow
import pytest
from aiohttp.test_utils import TestClient
-from aioresponses import aioresponses
from models_library.api_schemas_webserver.folders_v2 import FolderGet
from models_library.api_schemas_webserver.projects import ProjectGet, ProjectListItem
from models_library.api_schemas_webserver.workspaces import WorkspaceGet
from models_library.rest_pagination import Page
from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
-from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
-from pytest_simcore.helpers.typing_env import EnvVarsDict
-from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
+from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp import status
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.projects._groups_api import ProjectGroupGet
+from simcore_service_webserver.projects._groups_service import ProjectGroupGet
from simcore_service_webserver.projects.models import ProjectDict
+from tenacity import AsyncRetrying, stop_after_attempt, wait_fixed
from yarl import URL
-@pytest.fixture
-def app_environment(
- app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch
-) -> EnvVarsDict:
- return app_environment | setenvs_from_dict(
- monkeypatch, {"WEBSERVER_DEV_FEATURES_ENABLED": "1"}
- )
-
-
@pytest.fixture
def user_role() -> UserRole:
return UserRole.USER
-@pytest.fixture
-def mocked_catalog(
- user_project: ProjectDict,
- catalog_subsystem_mock: Callable[[list[ProjectDict]], None],
-):
- catalog_subsystem_mock([user_project])
-
-
-@pytest.fixture
-def mocked_director_v2(director_v2_service_mock: aioresponses):
- ...
-
-
@pytest.mark.acceptance_test(
"For https://github.com/ITISFoundation/osparc-simcore/pull/6579"
)
@@ -77,20 +53,20 @@ async def test_trash_projects( # noqa: PLR0915
# this test should emulate NO errors stopping services
mock_remove_dynamic_services = mocker.patch(
- "simcore_service_webserver.projects._trash_service.projects_service.remove_project_dynamic_services",
+ "simcore_service_webserver.projects._trash_service._projects_service_delete._projects_service.remove_project_dynamic_services",
autospec=True,
)
mock_stop_pipeline = mocker.patch(
- "simcore_service_webserver.projects._trash_service.director_v2_api.stop_pipeline",
+ "simcore_service_webserver.projects._trash_service._projects_service_delete.director_v2_service.stop_pipeline",
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects._trash_service.director_v2_api.is_pipeline_running",
+ "simcore_service_webserver.projects._trash_service.director_v2_service.is_pipeline_running",
return_value=is_project_running,
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects._trash_service.dynamic_scheduler_api.list_dynamic_services",
+ "simcore_service_webserver.projects._trash_service.dynamic_scheduler_service.list_dynamic_services",
return_value=[mocker.MagicMock()] if is_project_running else [],
autospec=True,
)
@@ -183,21 +159,6 @@ async def test_trash_projects( # noqa: PLR0915
mock_remove_dynamic_services.assert_awaited()
-@pytest.fixture
-async def other_user(
- client: TestClient, logged_user: UserInfoDict
-) -> AsyncIterable[UserInfoDict]:
- # new user different from logged_user
- async with NewUser(
- {
- "name": f"other_user_than_{logged_user['name']}",
- "role": "USER",
- },
- client.app,
- ) as user:
- yield user
-
-
async def test_trash_projects_shared_among_users(
client: TestClient,
logged_user: UserInfoDict,
@@ -377,6 +338,7 @@ async def test_trash_folder_with_content(
resp = await client.post("/v0/folders", json={"name": "My first folder"})
data, _ = await assert_status(resp, status.HTTP_201_CREATED)
folder = FolderGet.model_validate(data)
+ assert folder.trashed_at is None
# CREATE a SUB-folder
resp = await client.post(
@@ -420,11 +382,12 @@ async def test_trash_folder_with_content(
resp = await client.post(f"/v0/folders/{folder.folder_id}:trash")
await assert_status(resp, status.HTTP_204_NO_CONTENT)
- # ONLY folder listed in trash. The rest is not listed anymore!
+ # ONLY folder listed in trash. The rest is not listed anymore since they are implicitly trashed!
resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'})
await assert_status(resp, status.HTTP_200_OK)
page = Page[FolderGet].model_validate(await resp.json())
assert page.meta.total == 1
+ assert page.data[0].trashed_at is not None
assert page.data[0].folder_id == folder.folder_id
resp = await client.get(
@@ -789,3 +752,175 @@ async def test_trash_project_in_subfolder(
page = Page[ProjectGet].model_validate(await resp.json())
assert page.meta.total == 1
assert page.data[0].uuid == project_uuid
+
+
+async def test_trash_project_explitictly_and_empty_trash_bin(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+ mocked_catalog: None,
+ mocked_director_v2: None,
+ mocked_dynamic_services_interface: dict[str, MagicMock],
+ mocked_storage: None,
+):
+ assert client.app
+
+ project_uuid = UUID(user_project["uuid"])
+
+ # TRASH project
+ trashing_at = arrow.utcnow().datetime
+ resp = await client.post(
+ f"/v0/projects/{project_uuid}:trash", params={"force": "true"}
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # LIST trashed projects
+ resp = await client.get("/v0/projects", params={"filters": '{"trashed": true}'})
+ await assert_status(resp, status.HTTP_200_OK)
+
+ page = Page[ProjectListItem].model_validate(await resp.json())
+ assert page.meta.total == 1
+ assert page.data[0].uuid == project_uuid
+
+ # GET trashed project
+ resp = await client.get(f"/v0/projects/{project_uuid}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ got = ProjectGet.model_validate(data)
+ assert got.uuid == project_uuid
+ assert got.trashed_at is not None
+ assert trashing_at < got.trashed_at < arrow.utcnow().datetime
+
+ # force EMPTY trash
+ resp = await client.post("/v0/trash:empty")
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # waits for deletion
+ async for attempt in AsyncRetrying(
+ stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True
+ ):
+ with attempt:
+ # LIST trashed projects again
+ resp = await client.get(
+ "/v0/projects", params={"filters": '{"trashed": true}'}
+ )
+ await assert_status(resp, status.HTTP_200_OK)
+ page = Page[ProjectListItem].model_validate(await resp.json())
+ assert page.meta.total == 0
+
+ # GET trahsed project
+ resp = await client.get(f"/v0/projects/{project_uuid}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+
+async def test_trash_folder_with_subfolder_and_project_and_empty_bin(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+ mocked_catalog: None,
+ mocked_director_v2: None,
+ mocked_dynamic_services_interface: dict[str, MagicMock],
+ mocked_storage: None,
+):
+ assert client.app
+
+ # CREATE a folder
+ resp = await client.post("/v0/folders", json={"name": "Parent Folder"})
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ parent_folder = FolderGet.model_validate(data)
+
+ # CREATE a subfolder
+ resp = await client.post(
+ "/v0/folders",
+ json={"name": "Sub Folder", "parentFolderId": parent_folder.folder_id},
+ )
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ sub_folder = FolderGet.model_validate(data)
+
+ # MOVE project to subfolder
+ project_uuid = UUID(user_project["uuid"])
+ resp = await client.put(
+ f"/v0/projects/{project_uuid}/folders/{sub_folder.folder_id}"
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # TRASH the parent folder
+ resp = await client.post(f"/v0/folders/{parent_folder.folder_id}:trash")
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # CHECK BIN
+ # - LIST trashed folders as shown in the bin (will show only explicilty)
+ resp = await client.get(
+ "/v0/folders:search", params={"filters": '{"trashed": true}'}
+ )
+ await assert_status(resp, status.HTTP_200_OK)
+ page = Page[FolderGet].model_validate(await resp.json())
+ assert page.meta.total == 1
+ assert page.data[0].folder_id == parent_folder.folder_id
+
+ # - LIST trashed projects (will show only explicit!)
+ resp = await client.get(
+ "/v0/projects:search", params={"filters": '{"trashed": true}'}
+ )
+ await assert_status(resp, status.HTTP_200_OK)
+ page = Page[ProjectListItem].model_validate(await resp.json())
+ assert page.meta.total == 0
+
+ # CHECK items
+ # - GET trashed parent folder (explicit)
+ resp = await client.get(f"/v0/folders/{parent_folder.folder_id}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ got = FolderGet.model_validate(data)
+ assert got.trashed_at is not None
+ assert got.trashed_by == logged_user["primary_gid"]
+
+ # - GET trashed subfolder (implicit)
+ resp = await client.get(f"/v0/folders/{sub_folder.folder_id}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ got = FolderGet.model_validate(data)
+ assert got.trashed_at is not None
+ assert got.trashed_by == logged_user["primary_gid"]
+
+ # GET trashed project (implicit)
+ resp = await client.get(f"/v0/projects/{project_uuid}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ got = ProjectGet.model_validate(data)
+ assert got.trashed_at is not None
+ assert got.trashed_by == logged_user["primary_gid"]
+
+ # EMPTY trash
+ resp = await client.post("/v0/trash:empty")
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # waits for deletion
+ async for attempt in AsyncRetrying(
+ stop=stop_after_attempt(3), wait=wait_fixed(1), reraise=True
+ ):
+ with attempt:
+ # GET trashed parent folder
+ resp = await client.get(f"/v0/folders/{parent_folder.folder_id}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ # GET trashed subfolder
+ resp = await client.get(f"/v0/folders/{sub_folder.folder_id}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ # GET trashed project
+ resp = await client.get(f"/v0/projects/{project_uuid}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+ # CHECK BIN
+ # LIST trashed (will show only explicit)
+ resp = await client.get(
+ "/v0/folders:search", params={"filters": '{"trashed": true}'}
+ )
+ await assert_status(resp, status.HTTP_200_OK)
+ page = Page[FolderGet].model_validate(await resp.json())
+ assert page.meta.total == 0
+
+ # - LIST trashed projects (will show only explicit!)
+ resp = await client.get(
+ "/v0/projects:search", params={"filters": '{"trashed": true}'}
+ )
+ await assert_status(resp, status.HTTP_200_OK)
+ page = Page[ProjectListItem].model_validate(await resp.json())
+ assert page.meta.total == 0
diff --git a/services/web/server/tests/unit/with_dbs/03/trash/test_trash_service.py b/services/web/server/tests/unit/with_dbs/03/trash/test_trash_service.py
new file mode 100644
index 00000000000..a58f32f6e3d
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/03/trash/test_trash_service.py
@@ -0,0 +1,193 @@
+# pylint: disable=protected-access
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+from unittest.mock import MagicMock
+
+import pytest
+from aiohttp.test_utils import TestClient
+from models_library.api_schemas_webserver.projects import ProjectGet
+from pytest_simcore.helpers.assert_checks import assert_status
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
+from pytest_simcore.helpers.typing_env import EnvVarsDict
+from pytest_simcore.helpers.webserver_login import (
+ UserInfoDict,
+ switch_client_session_to,
+)
+from servicelib.aiohttp import status
+from simcore_service_webserver.db.models import UserRole
+from simcore_service_webserver.projects import _trash_service
+from simcore_service_webserver.projects.models import ProjectDict
+from simcore_service_webserver.trash import trash_service
+
+
+@pytest.fixture
+def app_environment(
+ app_environment: EnvVarsDict,
+ monkeypatch: pytest.MonkeyPatch,
+ with_disabled_background_task_to_prune_trash: None,
+) -> EnvVarsDict:
+ return app_environment | setenvs_from_dict(
+ monkeypatch,
+ {
+ "TRASH_RETENTION_DAYS": "0",
+ "WEBSERVER_GARBAGE_COLLECTOR": "null",
+ },
+ )
+
+
+@pytest.fixture
+def user_role() -> UserRole:
+ return UserRole.USER
+
+
+async def test_trash_service__delete_expired_trash(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+ other_user: UserInfoDict,
+ other_user_project: ProjectDict,
+ mocked_catalog: None,
+ mocked_director_v2: None,
+ mocked_dynamic_services_interface: dict[str, MagicMock],
+):
+ assert client.app
+ assert logged_user["id"] != other_user["id"]
+
+ # TRASH projects
+ # logged_user trashes his project
+ user_project_id = user_project["uuid"]
+ await _trash_service.trash_project(
+ client.app,
+ product_name="osparc",
+ user_id=logged_user["id"],
+ project_id=user_project_id,
+ force_stop_first=True,
+ explicit=True,
+ )
+
+ # other_user trashes his project
+ other_user_project_id = other_user_project["uuid"]
+ await _trash_service.trash_project(
+ client.app,
+ product_name="osparc",
+ user_id=other_user["id"],
+ project_id=other_user_project_id,
+ force_stop_first=True,
+ explicit=True,
+ )
+
+ resp = await client.get(f"/v0/projects/{user_project_id}")
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ assert ProjectGet.model_validate(data).trashed_by == logged_user["primary_gid"]
+
+ # UNDER TEST: Run delete_expired_trash
+ await trash_service.safe_delete_expired_trash_as_admin(client.app)
+
+ # ASSERT: logged_user tries to get the project and expects 404
+ resp = await client.get(f"/v0/projects/{user_project_id}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+ # ASSERT: other_user tries to get the project and expects 404
+ async with switch_client_session_to(client, other_user):
+ resp = await client.get(f"/v0/projects/{other_user_project_id}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+
+async def test_trash_nested_folders_and_projects(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+ other_user: UserInfoDict,
+ other_user_project: ProjectDict,
+ mocked_catalog: None,
+ mocked_director_v2: None,
+ mocked_dynamic_services_interface: dict[str, MagicMock],
+):
+ assert client.app
+ assert logged_user["id"] != other_user["id"]
+
+ async with switch_client_session_to(client, logged_user):
+ # CREATE folders hierarchy for logged_user
+ resp = await client.post("/v0/folders", json={"name": "Root Folder"})
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ logged_user_root_folder = data
+
+ resp = await client.post(
+ "/v0/folders",
+ json={
+ "name": "Sub Folder",
+ "parentFolderId": logged_user_root_folder["folderId"],
+ },
+ )
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ logged_user_sub_folder = data
+
+ # MOVE project to subfolder
+ resp = await client.put(
+ f"/v0/projects/{user_project['uuid']}/folders/{logged_user_sub_folder['folderId']}"
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # TRASH root folders
+ resp = await client.post(
+ f"/v0/folders/{logged_user_root_folder['folderId']}:trash"
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ async with switch_client_session_to(client, other_user):
+ # CREATE folders hierarchy for other_user
+ resp = await client.post("/v0/folders", json={"name": "Root Folder"})
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ other_user_root_folder = data
+
+ resp = await client.post(
+ "/v0/folders",
+ json={
+ "name": "Sub Folder (other)",
+ "parentFolderId": other_user_root_folder["folderId"],
+ },
+ )
+ data, _ = await assert_status(resp, status.HTTP_201_CREATED)
+ other_user_sub_folder = data
+
+ # MOVE project to subfolder
+ resp = await client.put(
+ f"/v0/projects/{other_user_project['uuid']}/folders/{other_user_sub_folder['folderId']}"
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # TRASH root folders
+ resp = await client.post(
+ f"/v0/folders/{other_user_root_folder['folderId']}:trash"
+ )
+ await assert_status(resp, status.HTTP_204_NO_CONTENT)
+
+ # UNDER TEST
+ await trash_service.safe_delete_expired_trash_as_admin(client.app)
+
+ async with switch_client_session_to(client, logged_user):
+ # Verify logged_user's resources are gone
+ resp = await client.get(f"/v0/folders/{logged_user_root_folder['folderId']}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ resp = await client.get(f"/v0/folders/{logged_user_sub_folder['folderId']}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ resp = await client.get(f"/v0/projects/{user_project['uuid']}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
+
+ # Verify other_user's resources are gone
+ async with switch_client_session_to(client, other_user):
+ resp = await client.get(f"/v0/folders/{other_user_root_folder['folderId']}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ resp = await client.get(f"/v0/folders/{other_user_sub_folder['folderId']}")
+ await assert_status(resp, status.HTTP_403_FORBIDDEN)
+
+ resp = await client.get(f"/v0/projects/{other_user_project['uuid']}")
+ await assert_status(resp, status.HTTP_404_NOT_FOUND)
diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py b/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py
deleted file mode 100644
index 4b6504bdfd3..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py
+++ /dev/null
@@ -1,246 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-from collections.abc import AsyncIterator, Awaitable, Callable
-from pathlib import Path
-from unittest import mock
-from uuid import UUID
-
-import aiohttp
-import pytest
-from aiohttp.test_utils import TestClient
-from common_library.dict_tools import remap_keys
-from faker import Faker
-from models_library.projects import ProjectID
-from models_library.projects_nodes import Node
-from models_library.services_resources import ServiceResourcesDict
-from models_library.users import UserID
-from models_library.utils.fastapi_encoders import jsonable_encoder
-from pytest_mock import MockerFixture
-from pytest_simcore.helpers.faker_factories import random_project
-from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
-from pytest_simcore.helpers.typing_env import EnvVarsDict
-from pytest_simcore.helpers.webserver_login import UserInfoDict
-from pytest_simcore.helpers.webserver_projects import NewProject
-from servicelib.aiohttp import status
-from simcore_postgres_database.models.projects_version_control import (
- projects_vc_repos,
- projects_vc_snapshots,
-)
-from simcore_service_webserver._meta import API_VTAG as VX
-from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.db.plugin import APP_AIOPG_ENGINE_KEY
-from simcore_service_webserver.projects.db import ProjectDBAPI
-from simcore_service_webserver.projects.models import ProjectDict
-from tenacity.asyncio import AsyncRetrying
-from tenacity.stop import stop_after_delay
-
-
-@pytest.fixture
-def user_role() -> UserRole:
- return UserRole.USER
-
-
-@pytest.fixture
-def fake_project(faker: Faker) -> ProjectDict:
- # API model project data
- suffix = faker.word()
- return random_project(
- name=f"{__file__}-project",
- workbench={
- faker.uuid4(): {
- "key": f"simcore/services/comp/test_{__name__}_{suffix}",
- "version": "1.2.3",
- "label": f"test_{__name__}_{suffix}",
- "inputs": {"x": faker.pyint(), "y": faker.pyint()},
- }
- },
- )
-
-
-@pytest.fixture
-def catalog_subsystem_mock_override(
- catalog_subsystem_mock: Callable[[list[ProjectDict]], None],
- fake_project: ProjectDict,
-) -> None:
- catalog_subsystem_mock([fake_project])
-
-
-@pytest.fixture
-def app_environment(
- catalog_subsystem_mock_override: None,
- monkeypatch: pytest.MonkeyPatch,
- app_environment: EnvVarsDict,
-) -> EnvVarsDict:
-
- return app_environment | setenvs_from_dict(
- monkeypatch,
- {
- # exclude
- "WEBSERVER_ACTIVITY": "null",
- "WEBSERVER_CLUSTERS": "null",
- "WEBSERVER_COMPUTATION": "null",
- "WEBSERVER_DIAGNOSTICS": "null",
- "WEBSERVER_GARBAGE_COLLECTOR": "null",
- "WEBSERVER_GROUPS": "0",
- "WEBSERVER_PUBLICATIONS": "0",
- "WEBSERVER_SOCKETIO": "0",
- "WEBSERVER_STUDIES_DISPATCHER": "null",
- "WEBSERVER_TAGS": "0",
- "WEBSERVER_TRACING": "null",
- # Module under test
- "WEBSERVER_DEV_FEATURES_ENABLED": "1",
- "WEBSERVER_VERSION_CONTROL": "1",
- },
- )
-
-
-@pytest.fixture
-async def user_id(logged_user: UserInfoDict) -> UserID:
- return logged_user["id"]
-
-
-@pytest.fixture()
-def project_uuid(user_project: ProjectDict) -> ProjectID:
- return UUID(user_project["uuid"])
-
-
-@pytest.fixture
-async def user_project(
- client: TestClient,
- fake_project: ProjectDict,
- user_id: int,
- tests_data_dir: Path,
- osparc_product_name: str,
-) -> AsyncIterator[ProjectDict]:
- # pylint: disable=no-value-for-parameter
-
- async with NewProject(
- fake_project,
- client.app,
- user_id=user_id,
- tests_data_dir=tests_data_dir,
- product_name=osparc_product_name,
- ) as project:
- yield project
-
- # cleanup repos
- assert client.app
- engine = client.app[APP_AIOPG_ENGINE_KEY]
- async with engine.acquire() as conn:
- # cascade deletes everything except projects_vc_snapshot
- await conn.execute(projects_vc_repos.delete())
- await conn.execute(projects_vc_snapshots.delete())
-
-
-@pytest.fixture
-def request_update_project(
- logged_user: UserInfoDict,
- faker: Faker,
- mocker: MockerFixture,
-) -> Callable[[TestClient, UUID], Awaitable]:
- mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.projects_service.is_service_deprecated",
- autospec=True,
- return_value=False,
- )
- mocker.patch(
- "simcore_service_webserver.projects._nodes_handlers.projects_service.catalog_client.get_service_resources",
- autospec=True,
- return_value=ServiceResourcesDict(),
- )
- mocker.patch(
- "simcore_service_webserver.dynamic_scheduler.api.list_dynamic_services",
- return_value=[],
- )
-
- async def _go(client: TestClient, project_uuid: UUID) -> None:
- resp: aiohttp.ClientResponse = await client.get(f"{VX}/projects/{project_uuid}")
-
- assert resp.status == 200
- body = await resp.json()
- assert body
-
- project = body["data"]
-
- # remove all the nodes first
- assert client.app
- for node_id in project.get("workbench", {}):
- delete_node_url = client.app.router["delete_node"].url_for(
- project_id=f"{project_uuid}", node_id=node_id
- )
- response = await client.delete(f"{delete_node_url}")
- assert response.status == status.HTTP_204_NO_CONTENT
-
- # add a node
- node_id = faker.uuid4()
- node = Node.model_validate(
- {
- "key": f"simcore/services/comp/test_{__name__}",
- "version": "1.0.0",
- "label": f"test_{__name__}",
- "inputs": {"x": faker.pyint(), "y": faker.pyint()},
- }
- )
-
- create_node_url = client.app.router["create_node"].url_for(
- project_id=f"{project_uuid}"
- )
- response = await client.post(
- f"{create_node_url}",
- json={
- "service_key": node.key,
- "service_version": node.version,
- "service_id": f"{node_id}",
- },
- )
- assert response.status == status.HTTP_201_CREATED
- project["workbench"] = {node_id: jsonable_encoder(node)}
-
- db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(client.app)
- project_db = remap_keys(project, rename={"trashedAt": "trashed"})
- project_db.pop("state")
-
- await db.replace_project(
- project_db,
- logged_user["id"],
- project_uuid=project_db["uuid"],
- product_name="osparc",
- )
-
- return _go
-
-
-@pytest.fixture
-async def request_delete_project(
- logged_user: UserInfoDict,
- mocker: MockerFixture,
-) -> AsyncIterator[Callable[[TestClient, UUID], Awaitable]]:
- director_v2_api_delete_pipeline: mock.AsyncMock = mocker.patch(
- "simcore_service_webserver.projects.projects_service.director_v2_api.delete_pipeline",
- autospec=True,
- )
- dynamic_scheduler_api_stop_dynamic_services_in_project: mock.AsyncMock = mocker.patch(
- "simcore_service_webserver.projects.projects_service.dynamic_scheduler_api.stop_dynamic_services_in_project",
- autospec=True,
- )
- fire_and_forget_call_to_storage: mock.Mock = mocker.patch(
- "simcore_service_webserver.projects._crud_api_delete.delete_data_folders_of_project",
- autospec=True,
- )
-
- async def _go(client: TestClient, project_uuid: UUID) -> None:
- resp: aiohttp.ClientResponse = await client.delete(
- f"{VX}/projects/{project_uuid}"
- )
- assert resp.status == 204
-
- yield _go
-
- # ensure the call to delete data was completed
- async for attempt in AsyncRetrying(reraise=True, stop=stop_after_delay(20)):
- with attempt:
- director_v2_api_delete_pipeline.assert_called()
- dynamic_scheduler_api_stop_dynamic_services_in_project.assert_awaited()
- fire_and_forget_call_to_storage.assert_called()
diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control.py b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control.py
deleted file mode 100644
index ae95f95f9f9..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control.py
+++ /dev/null
@@ -1,29 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-
-from models_library.projects import NodesDict
-from pydantic import ConfigDict, RootModel
-from simcore_service_webserver.projects.models import ProjectDict
-from simcore_service_webserver.version_control.db import compute_workbench_checksum
-
-
-class WorkbenchModel(RootModel[NodesDict]):
- model_config = ConfigDict(populate_by_name=True)
-
-
-def test_compute_workbench_checksum(fake_project: ProjectDict):
-
- # as a dict
- sha1_w_dict = compute_workbench_checksum(fake_project["workbench"])
-
- workbench = WorkbenchModel.model_validate(fake_project["workbench"])
-
- # with pydantic models, i.e. Nodes
- #
- # e.g. order after parse maps order in BaseModel but not in dict
- #
- sha1_w_model = compute_workbench_checksum(workbench.root)
-
- assert sha1_w_model == sha1_w_dict
diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_core.py b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_core.py
deleted file mode 100644
index 9ae6a29c127..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_core.py
+++ /dev/null
@@ -1,104 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-
-from collections.abc import Awaitable, Callable
-from uuid import UUID
-
-import pytest
-from aiohttp import web
-from aiohttp.test_utils import TestClient, make_mocked_request
-from simcore_service_webserver._constants import RQT_USERID_KEY
-from simcore_service_webserver.projects import projects_service
-from simcore_service_webserver.projects.models import ProjectDict
-from simcore_service_webserver.version_control._core import (
- checkout_checkpoint,
- create_checkpoint,
- list_checkpoints,
- update_checkpoint,
-)
-from simcore_service_webserver.version_control.db import HEAD, VersionControlRepository
-
-
-@pytest.fixture
-def aiohttp_mocked_request(client: TestClient, user_id: int) -> web.Request:
- req = make_mocked_request("GET", "/", app=client.app)
- req[RQT_USERID_KEY] = user_id
- return req
-
-
-@pytest.mark.acceptance_test()
-async def test_workflow(
- client: TestClient,
- project_uuid: UUID,
- user_id: int,
- user_project: ProjectDict,
- aiohttp_mocked_request: web.Request,
- request_update_project: Callable[[TestClient, UUID], Awaitable],
- mock_dynamic_scheduler: None,
- director_v2_service_mock: None,
-):
- vc_repo = VersionControlRepository.create_from_request(aiohttp_mocked_request)
-
- # -------------------------------------
- checkpoint1 = await create_checkpoint(
- vc_repo, project_uuid, tag="v0", message="first commit"
- )
-
- assert not checkpoint1.parents_ids
- assert checkpoint1.tags == ("v0",)
- assert checkpoint1.message == "first commit"
-
- # -------------------------------------
- await request_update_project(client, project_uuid)
-
- checkpoint2 = await create_checkpoint(
- vc_repo, project_uuid, tag="v1", message="second commit"
- )
-
- assert checkpoint2.tags == ("v1",)
- assert (checkpoint1.id,) == checkpoint2.parents_ids
- assert checkpoint1.checksum != checkpoint2.checksum
-
- # -------------------------------------
- checkpoints, total_count = await list_checkpoints(vc_repo, project_uuid)
- assert total_count == 2
- assert checkpoints == [checkpoint2, checkpoint1]
-
- # -------------------------------------
- checkpoint2_updated = await update_checkpoint(
- vc_repo, project_uuid, HEAD, message="updated message"
- )
-
- assert checkpoint2_updated.model_dump(
- exclude={"message"}
- ) == checkpoint2.model_dump(exclude={"message"})
-
- # -------------------------------------
- # checking out to v1
- checkpoint_co = await checkout_checkpoint(vc_repo, project_uuid, checkpoint1.id)
- assert checkpoint1 == checkpoint_co
-
- project = await projects_service.get_project_for_user(
- aiohttp_mocked_request.app, str(project_uuid), user_id
- )
- assert project["workbench"] == user_project["workbench"]
-
- # -------------------------------------
- # creating branches
- await request_update_project(client, project_uuid)
-
- checkpoint3 = await create_checkpoint(
- vc_repo,
- project_uuid,
- tag="v1.1",
- message="second commit", # new_branch="v1.*"
- )
-
- checkpoints, total_count = await list_checkpoints(vc_repo, project_uuid)
- assert total_count == 3
- assert checkpoints == [checkpoint3, checkpoint2_updated, checkpoint1]
-
- assert checkpoint3.parents_ids == checkpoint2.parents_ids
- assert checkpoint2.parents_ids == (checkpoint1.id,)
- # This is detached!
diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py
deleted file mode 100644
index df0d767a9e9..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py
+++ /dev/null
@@ -1,262 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-# pylint: disable=too-many-arguments
-
-
-from collections.abc import Awaitable, Callable
-from http import HTTPStatus
-from uuid import UUID
-
-import aiohttp
-import pytest
-from aiohttp.test_utils import TestClient
-from models_library.api_schemas_webserver.projects import ProjectGet
-from models_library.projects import ProjectID
-from models_library.rest_pagination import Page
-from models_library.users import UserID
-from pydantic.main import BaseModel
-from pytest_simcore.helpers.assert_checks import assert_status
-from servicelib.aiohttp import status
-from simcore_service_webserver._meta import API_VTAG as VX
-from simcore_service_webserver.projects.models import ProjectDict
-from simcore_service_webserver.version_control.models import (
- CheckpointApiModel,
- RepoApiModel,
-)
-
-
-async def _assert_resp_page(
- resp: aiohttp.ClientResponse,
- expected_page_cls: type[Page],
- expected_total: int,
- expected_count: int,
-):
- assert resp.status == status.HTTP_200_OK, f"Got {await resp.text()}"
- body = await resp.json()
-
- page = expected_page_cls.model_validate(body)
- assert page.meta.total == expected_total
- assert page.meta.count == expected_count
- return page
-
-
-async def _assert_status_and_body(
- resp, expected_cls: HTTPStatus, expected_model: type[BaseModel]
-) -> BaseModel:
- data, _ = await assert_status(resp, expected_cls)
- return expected_model.model_validate(data)
-
-
-@pytest.mark.acceptance_test()
-async def test_workflow(
- client: TestClient,
- user_project: ProjectDict,
- request_update_project: Callable[[TestClient, UUID], Awaitable],
- mock_dynamic_scheduler: None,
- director_v2_service_mock: None,
-):
- # pylint: disable=too-many-statements
-
- project_uuid = user_project["uuid"]
-
- # get existing project
- resp = await client.get(f"/{VX}/projects/{project_uuid}")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- project = ProjectGet.model_validate(data)
- assert project.uuid == UUID(project_uuid)
-
- #
- # list repos i.e. versioned projects
- resp = await client.get(f"/{VX}/repos/projects")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
-
- assert data == []
-
- #
- # CREATE a checkpoint
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints",
- json={"tag": "v1", "message": "init"},
- )
- data, _ = await assert_status(resp, status.HTTP_201_CREATED)
-
- assert data
- checkpoint1 = CheckpointApiModel.model_validate(data) # NOTE: this is NOT API model
-
- #
- # this project now has a repo
- resp = await client.get(f"/{VX}/repos/projects")
- page = await _assert_resp_page(
- resp, expected_page_cls=Page[ProjectDict], expected_total=1, expected_count=1
- )
-
- repo = RepoApiModel.model_validate(page.data[0])
- assert repo.project_uuid == UUID(project_uuid)
-
- # GET checkpoint with HEAD
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints/HEAD")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert CheckpointApiModel.model_validate(data) == checkpoint1
-
- # TODO: GET checkpoint with tag
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints/v1")
- with pytest.raises(aiohttp.ClientResponseError) as excinfo:
- resp.raise_for_status()
-
- assert CheckpointApiModel.model_validate(data) == checkpoint1
-
- assert excinfo.value.status == status.HTTP_501_NOT_IMPLEMENTED
-
- # GET checkpoint with id
- resp = await client.get(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints/{checkpoint1.id}"
- )
- assert f"{resp.url}" == f"{checkpoint1.url}"
- assert CheckpointApiModel.model_validate(data) == checkpoint1
-
- # LIST checkpoints
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints")
- page = await _assert_resp_page(
- resp,
- expected_page_cls=Page[CheckpointApiModel],
- expected_total=1,
- expected_count=1,
- )
-
- assert CheckpointApiModel.model_validate(page.data[0]) == checkpoint1
- # UPDATE checkpoint annotations
- resp = await client.patch(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints/{checkpoint1.id}",
- json={"message": "updated message", "tag": "Version 1"},
- )
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- checkpoint1_updated = CheckpointApiModel.model_validate(data)
-
- assert checkpoint1.id == checkpoint1_updated.id
- assert checkpoint1.checksum == checkpoint1_updated.checksum
- assert checkpoint1_updated.tags == ("Version 1",)
- assert checkpoint1_updated.message == "updated message"
-
- # GET view
- resp = await client.get(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints/HEAD/workbench/view"
- )
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert (
- data["workbench"]
- == project.model_dump(exclude_none=True, exclude_unset=True)["workbench"]
- )
-
- # do some changes in project
- await request_update_project(client, project.uuid)
-
- # CREATE new checkpoint
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints",
- json={"tag": "v2", "message": "new commit"},
- )
- data, _ = await assert_status(resp, status.HTTP_201_CREATED)
- checkpoint2 = CheckpointApiModel.model_validate(data)
- assert checkpoint2.tags == ("v2",)
-
- # GET checkpoint with HEAD
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints/HEAD")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert CheckpointApiModel.model_validate(data) == checkpoint2
-
- # CHECKOUT
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints/{checkpoint1.id}:checkout"
- )
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert CheckpointApiModel.model_validate(data) == checkpoint1_updated
-
- # GET checkpoint with HEAD
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints/HEAD")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert CheckpointApiModel.model_validate(data) == checkpoint1_updated
-
- # get working copy
- resp = await client.get(f"/{VX}/projects/{project_uuid}")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- project_wc = ProjectGet.model_validate(data)
- assert project_wc.uuid == UUID(project_uuid)
- assert project_wc != project
-
-
-async def test_create_checkpoint_without_changes(
- client: TestClient, project_uuid: UUID
-):
- # CREATE a checkpoint
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints",
- json={"tag": "v1", "message": "first commit"},
- )
- data, _ = await assert_status(resp, status.HTTP_201_CREATED)
-
- assert data
- checkpoint1 = CheckpointApiModel.model_validate(data) # NOTE: this is NOT API model
-
- # CREATE checkpoint WITHOUT changes
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints",
- json={"tag": "v2", "message": "second commit"},
- )
- data, _ = await assert_status(resp, status.HTTP_201_CREATED)
-
- assert data
- checkpoint2 = CheckpointApiModel.model_validate(data) # NOTE: this is NOT API model
-
- assert (
- checkpoint1 == checkpoint2
- ), "Consecutive create w/o changes shall not add a new checkpoint"
-
-
-async def test_delete_project_and_repo(
- client: TestClient,
- user_id: UserID,
- project_uuid: ProjectID,
- request_delete_project: Callable[[TestClient, UUID], Awaitable],
-):
-
- # CREATE a checkpoint
- resp = await client.post(
- f"/{VX}/repos/projects/{project_uuid}/checkpoints",
- json={"tag": "v1", "message": "first commit"},
- )
- data, _ = await assert_status(resp, status.HTTP_201_CREATED)
-
- # LIST
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints")
- await _assert_resp_page(
- resp,
- expected_page_cls=Page[CheckpointApiModel],
- expected_total=1,
- expected_count=1,
- )
-
- # DELETE project -> projects_vc_* deletion follow
- await request_delete_project(client, project_uuid)
-
- # TMP fix here waits ------------
- # FIXME: mark as deleted, still gets entrypoints!!
- from simcore_service_webserver.projects import projects_service
-
- delete_task = projects_service.get_delete_project_task(project_uuid, user_id)
- assert delete_task
- await delete_task
- # --------------------------------
-
- # LIST empty
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/checkpoints")
- await _assert_resp_page(
- resp,
- expected_page_cls=Page[CheckpointApiModel],
- expected_total=0,
- expected_count=0,
- )
-
- # GET HEAD
- resp = await client.get(f"/{VX}/repos/projects/{project_uuid}/HEAD")
- await assert_status(resp, status.HTTP_404_NOT_FOUND)
diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_tags.py b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_tags.py
deleted file mode 100644
index 1e77bba0383..00000000000
--- a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_tags.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from faker import Faker
-from simcore_service_webserver.version_control.vc_tags import (
- compose_workcopy_project_tag_name,
- parse_workcopy_project_tag_name,
-)
-
-
-def test_parse_and_compose_tag_names(faker: Faker):
-
- workcopy_project_id = faker.uuid4(cast_to=None)
-
- tag = compose_workcopy_project_tag_name(workcopy_project_id)
- assert parse_workcopy_project_tag_name(tag) == workcopy_project_id
diff --git a/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py b/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py
index f4b2df540ae..1ba0859bab3 100644
--- a/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py
+++ b/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py
@@ -23,7 +23,7 @@
from servicelib.aiohttp import status
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.projects._groups_db import (
+from simcore_service_webserver.projects._groups_repository import (
GroupID,
update_or_insert_project_group,
)
@@ -274,7 +274,7 @@ async def test_project_folder_movement_full_workflow(
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
@@ -387,11 +387,11 @@ def mock_storage_delete_data_folders(mocker: MockerFixture) -> mock.Mock:
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects.projects_service.remove_project_dynamic_services",
+ "simcore_service_webserver.projects._projects_service.remove_project_dynamic_services",
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects._crud_api_delete.api.delete_pipeline",
+ "simcore_service_webserver.projects._crud_api_delete.director_v2_service.delete_pipeline",
autospec=True,
)
return mocker.patch(
diff --git a/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py b/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py
new file mode 100644
index 00000000000..c1485b0a2af
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/folders/test_folders_repository.py
@@ -0,0 +1,82 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+from typing import Any
+
+import arrow
+import pytest
+from aiohttp.test_utils import TestClient
+from common_library.users_enums import UserRole
+from models_library.products import ProductName
+from simcore_service_webserver.folders import _folders_repository
+
+
+@pytest.fixture
+def user_role():
+ return UserRole.USER
+
+
+@pytest.fixture
+def product_name():
+ return "osparc"
+
+
+async def test_batch_get_trashed_by_primary_gid(
+ client: TestClient,
+ logged_user: dict[str, Any],
+ product_name: ProductName,
+):
+ assert client.app
+
+ # Create two folders
+ folder_1 = await _folders_repository.create(
+ client.app,
+ created_by_gid=logged_user["primary_gid"],
+ folder_name="Folder 1",
+ product_name=product_name,
+ parent_folder_id=None,
+ user_id=logged_user["id"],
+ workspace_id=None,
+ )
+ folder_2 = await _folders_repository.create(
+ client.app,
+ created_by_gid=logged_user["primary_gid"],
+ folder_name="Folder 2",
+ product_name=product_name,
+ parent_folder_id=None,
+ user_id=logged_user["id"],
+ workspace_id=None,
+ )
+
+ # Update the trashed flag for folder_1
+ await _folders_repository.update(
+ client.app,
+ folders_id_or_ids=folder_1.folder_id,
+ product_name=product_name,
+ trashed=arrow.now().datetime,
+ trashed_explicitly=True,
+ trashed_by=logged_user["id"],
+ )
+
+ # Test batch_get_trashed_by_primary_gid
+ trashed_by_primary_gid = await _folders_repository.batch_get_trashed_by_primary_gid(
+ client.app,
+ folders_ids=[folder_1.folder_id, folder_2.folder_id],
+ )
+ assert trashed_by_primary_gid == [logged_user["primary_gid"], None]
+
+ # flipped
+ trashed_by_primary_gid = await _folders_repository.batch_get_trashed_by_primary_gid(
+ client.app,
+ folders_ids=[folder_2.folder_id, folder_1.folder_id],
+ )
+ assert trashed_by_primary_gid == [None, logged_user["primary_gid"]]
+
+ # repeated
+ trashed_by_primary_gid = await _folders_repository.batch_get_trashed_by_primary_gid(
+ client.app,
+ folders_ids=[folder_1.folder_id] * 3,
+ )
+ assert trashed_by_primary_gid == [logged_user["primary_gid"]] * 3
diff --git a/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py b/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py
index 79969076f92..f5b37b61960 100644
--- a/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py
+++ b/services/web/server/tests/unit/with_dbs/04/garbage_collector/test_resource_manager.py
@@ -44,12 +44,11 @@
from simcore_service_webserver.login.plugin import setup_login
from simcore_service_webserver.notifications.plugin import setup_notifications
from simcore_service_webserver.products.plugin import setup_products
-from simcore_service_webserver.projects.exceptions import ProjectNotFoundError
-from simcore_service_webserver.projects.plugin import setup_projects
-from simcore_service_webserver.projects.projects_service import (
+from simcore_service_webserver.projects._projects_service import (
remove_project_dynamic_services,
submit_delete_project_task,
)
+from simcore_service_webserver.projects.plugin import setup_projects
from simcore_service_webserver.rabbitmq import setup_rabbitmq
from simcore_service_webserver.resource_manager.plugin import setup_resource_manager
from simcore_service_webserver.resource_manager.registry import (
@@ -106,7 +105,6 @@ def app_environment(
monkeypatch: pytest.MonkeyPatch,
app_environment: EnvVarsDict,
) -> EnvVarsDict:
-
# NOTE: undos some app_environment settings
monkeypatch.delenv("WEBSERVER_GARBAGE_COLLECTOR", raising=False)
app_environment.pop("WEBSERVER_GARBAGE_COLLECTOR", None)
@@ -653,7 +651,7 @@ async def test_interactive_services_remain_after_websocket_reconnection_from_2_t
async def mocked_notification_system(mocker):
mocks = {}
mocked_notification_system = mocker.patch(
- "simcore_service_webserver.projects.projects_service.retrieve_and_notify_project_locked_state",
+ "simcore_service_webserver.projects._projects_service.retrieve_and_notify_project_locked_state",
return_value=Future(),
)
mocked_notification_system.return_value.set_result("")
@@ -946,14 +944,13 @@ async def test_regression_removing_unexisting_user(
app=client.app,
simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
)
- with pytest.raises(ProjectNotFoundError):
- await remove_project_dynamic_services(
- user_id=user_id,
- project_uuid=empty_user_project["uuid"],
- app=client.app,
- user_name={"first_name": "my name is", "last_name": "pytest"},
- simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
- )
+ await remove_project_dynamic_services(
+ user_id=user_id,
+ project_uuid=empty_user_project["uuid"],
+ app=client.app,
+ user_name={"first_name": "my name is", "last_name": "pytest"},
+ simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
+ )
# since the call to delete is happening as fire and forget task, let's wait until it is done
async for attempt in AsyncRetrying(**_TENACITY_ASSERT_RETRY):
with attempt:
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py b/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py
index 5971ed9f168..02a5346f296 100644
--- a/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py
@@ -6,6 +6,7 @@
import pytest
from aiohttp.test_utils import TestClient
from simcore_postgres_database.models.licensed_items import licensed_items
+from simcore_postgres_database.models.licensed_resources import licensed_resources
from simcore_postgres_database.models.resource_tracker_pricing_plans import (
resource_tracker_pricing_plans,
)
@@ -25,15 +26,15 @@ async def pricing_plan_id(
resource_tracker_pricing_plans.insert()
.values(
product_name=osparc_product_name,
- display_name="ISolve Thermal",
+ display_name="VIP Model A",
description="",
- classification="TIER",
+ classification="LICENSE",
is_active=True,
- pricing_plan_key="isolve-thermal",
+ pricing_plan_key="vip-model-a",
)
.returning(resource_tracker_pricing_plans.c.pricing_plan_id)
)
- row = result.first()
+ row = result.one()
assert row
@@ -42,3 +43,17 @@ async def pricing_plan_id(
async with transaction_context(get_asyncpg_engine(client.app)) as conn:
await conn.execute(licensed_items.delete())
await conn.execute(resource_tracker_pricing_plans.delete())
+
+
+@pytest.fixture
+async def ensure_empty_licensed_resources(client: TestClient):
+ async def _cleanup():
+ assert client.app
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(licensed_resources.delete())
+
+ await _cleanup()
+
+ yield
+
+ await _cleanup()
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_models.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_models.py
new file mode 100644
index 00000000000..c73feba511b
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_models.py
@@ -0,0 +1,81 @@
+# pylint: disable=protected-access
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+
+import datetime
+from typing import Any
+
+import pytest
+from faker import Faker
+from pydantic import ValidationError
+from pytest_simcore.helpers.faker_factories import (
+ random_itis_vip_available_download_item,
+)
+from simcore_service_webserver.licenses._itis_vip_models import (
+ ItisVipData,
+ ItisVipResourceData,
+ _feature_descriptor_to_dict,
+)
+
+
+def test_pre_validator_feature_descriptor_to_dict():
+ # Makes sure the regex used here, which is vulnerable to polynomial runtime due to backtracking, cannot lead to denial of service.
+ with pytest.raises(ValidationError) as err_info:
+ _feature_descriptor_to_dict("a" * 10000 + ": " + "b" * 10000)
+ assert err_info.value.errors()[0]["type"] == "string_too_long"
+
+
+@pytest.mark.parametrize(
+ "features_str,expected",
+ [
+ (
+ # checks fix: regex expected at least one space after `:`
+ "{species:Mouse, functionality:Static, height:95 mm, date: 2012-01-01, name:Male OF1 Mouse, sex:Male, version:1.0, weight:35.5 g}",
+ {
+ "version": "1.0",
+ "weight": "35.5 g",
+ "species": "Mouse",
+ "functionality": "Static",
+ },
+ ),
+ (
+ # Checks spaces before `,` are removed
+ "{date: 2012-01-01, name: Male OF1 Mouse , sex:Male}",
+ {
+ "date": datetime.date(2012, 1, 1),
+ "name": "Male OF1 Mouse",
+ "sex": "Male",
+ },
+ ),
+ ],
+)
+def test_validation_of_itis_vip_response_model(
+ faker: Faker, features_str: str, expected: dict[str, Any]
+):
+
+ available_download = random_itis_vip_available_download_item(
+ identifier=0,
+ fake=faker,
+ Features=features_str,
+ )
+
+ vip_data = ItisVipData.model_validate(available_download)
+
+ # Checks how features BeforeValidator and parser
+ assert {k: vip_data.features[k] for k in expected} == expected
+
+ # Dumped as in the source
+ assert vip_data.model_dump(by_alias=True)["Features"] == vip_data.features
+
+ license_resource_data = ItisVipResourceData.model_validate(
+ {
+ "category_id": "SomeCategoryID",
+ "category_display": "This is a resource",
+ "source": vip_data,
+ }
+ )
+
+ assert license_resource_data.source.features == vip_data.features
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py
new file mode 100644
index 00000000000..5923a30f9e2
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py
@@ -0,0 +1,218 @@
+# pylint: disable=protected-access
+# pylint: disable=redefined-outer-name
+# pylint: disable=too-many-arguments
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+
+from collections.abc import Iterator
+
+import pytest
+import respx
+from aiohttp.test_utils import TestClient
+from faker import Faker
+from httpx import AsyncClient
+from models_library.licenses import LicensedResourceType
+from pydantic import ValidationError
+from pytest_mock import MockerFixture
+from pytest_simcore.helpers.faker_factories import (
+ random_itis_vip_available_download_item,
+)
+from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
+from pytest_simcore.helpers.typing_env import EnvVarsDict
+from servicelib.aiohttp import status
+from simcore_service_webserver.licenses import (
+ _itis_vip_service,
+ _itis_vip_syncer_service,
+ _licensed_resources_service,
+)
+from simcore_service_webserver.licenses._itis_vip_models import ItisVipData
+from simcore_service_webserver.licenses._itis_vip_service import _ItisVipApiResponse
+from simcore_service_webserver.licenses._itis_vip_settings import ItisVipSettings
+from simcore_service_webserver.licenses._licensed_resources_service import (
+ RegistrationState,
+)
+
+
+@pytest.fixture(scope="session")
+def fake_api_base_url() -> str:
+ return "https://testserver-itis-vip.xyz"
+
+
+@pytest.fixture
+def app_environment(
+ monkeypatch: pytest.MonkeyPatch,
+ app_environment: EnvVarsDict,
+ fake_api_base_url: str,
+ mocker: MockerFixture,
+):
+ # prevents syncer setup
+ mocker.patch(
+ "simcore_service_webserver.licenses.plugin._itis_vip_syncer_service.setup_itis_vip_syncer",
+ autospec=True,
+ )
+
+ return app_environment | setenvs_from_dict(
+ monkeypatch,
+ {
+ "LICENSES_ITIS_VIP_API_URL": f"{fake_api_base_url}/PD_DirectDownload/getDownloadableItems/{{category}}",
+ # NOTE: ItisVipSettings will decode with json.dumps(). Use " and not ' the json keys!!
+ "LICENSES_ITIS_VIP_CATEGORIES": '{"ComputationalPantom": "Phantoms", "HumanBodyRegion": "Humans (Regions)"}',
+ },
+ )
+
+
+@pytest.fixture
+def mock_itis_vip_downloadables_api(
+ faker: Faker, fake_api_base_url: str
+) -> Iterator[respx.MockRouter]:
+ response_data = {
+ "msg": 0,
+ "availableDownloads": [
+ random_itis_vip_available_download_item(
+ identifier=i,
+ features_functionality="Posable",
+ fake=faker,
+ )
+ for i in range(8)
+ ],
+ }
+
+ with respx.mock(base_url=fake_api_base_url) as mock:
+ mock.post(path__regex=r"/getDownloadableItems/(?P\w+)").respond(
+ status_code=200, json=response_data
+ )
+ yield mock
+
+
+async def test_fetch_and_validate_itis_vip_api(
+ mock_itis_vip_downloadables_api: respx.MockRouter, fake_api_base_url: str
+):
+ async with AsyncClient(base_url=fake_api_base_url) as client:
+ response = await client.post("/getDownloadableItems/ComputationalPantom")
+ assert response.status_code == status.HTTP_200_OK
+ response_json = response.json()
+
+ try:
+ response = _ItisVipApiResponse(**response_json)
+ except ValidationError as e:
+ pytest.fail(f"Response validation failed: {e}")
+
+ assert response.msg == 0
+ assert len(response.available_downloads) == 8
+
+
+async def test_get_category_items(
+ mock_itis_vip_downloadables_api: respx.MockRouter,
+ app_environment: EnvVarsDict,
+):
+ settings = ItisVipSettings.create_from_envs()
+ assert settings.LICENSES_ITIS_VIP_CATEGORIES
+
+ async with AsyncClient() as client:
+ for url, category in zip(
+ settings.get_urls(), settings.LICENSES_ITIS_VIP_CATEGORIES, strict=True
+ ):
+ assert f"{url}".endswith(category)
+
+ items = await _itis_vip_service.get_category_items(client, url)
+
+ assert items[0].features.get("functionality") == "Posable"
+
+
+async def test_sync_itis_vip_as_licensed_resources(
+ mock_itis_vip_downloadables_api: respx.MockRouter,
+ app_environment: EnvVarsDict,
+ client: TestClient,
+ ensure_empty_licensed_resources: None,
+):
+ assert client.app
+
+ settings = ItisVipSettings.create_from_envs()
+ assert settings.LICENSES_ITIS_VIP_CATEGORIES
+
+ async with AsyncClient() as http_client:
+ for url, category in zip(
+ settings.get_urls(), settings.LICENSES_ITIS_VIP_CATEGORIES, strict=True
+ ):
+ assert f"{url}".endswith(category)
+
+ vip_resources: list[
+ ItisVipData
+ ] = await _itis_vip_service.get_category_items(http_client, url)
+ assert vip_resources[0].features.get("functionality") == "Posable"
+
+ for vip in vip_resources:
+
+ # register a NEW resource
+ (
+ licensed_resource1,
+ state1,
+ _,
+ ) = await _licensed_resources_service.register_licensed_resource(
+ client.app,
+ licensed_resource_name=f"{category}/{vip.id}",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=vip,
+ licensed_item_display_name="foo",
+ )
+ assert state1 == RegistrationState.NEWLY_REGISTERED
+
+ # register the SAME resource
+ (
+ licensed_resource2,
+ state2,
+ _,
+ ) = await _licensed_resources_service.register_licensed_resource(
+ client.app,
+ licensed_resource_name=f"{category}/{vip.id}",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=vip,
+ licensed_item_display_name="foo",
+ )
+
+ assert state2 == RegistrationState.ALREADY_REGISTERED
+ assert licensed_resource1 == licensed_resource2
+
+ # register a MODIFIED version of the same resource
+ (
+ licensed_item3,
+ state3,
+ msg,
+ ) = await _licensed_resources_service.register_licensed_resource(
+ client.app,
+ licensed_resource_name=f"{category}/{vip.id}",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=vip.model_copy(
+ update={
+ "features": {
+ **vip.features,
+ "functionality": "Non-Posable",
+ }
+ }
+ ),
+ licensed_item_display_name="foo",
+ )
+ assert state3 == RegistrationState.DIFFERENT_RESOURCE
+ assert licensed_resource2 == licensed_item3
+ # {'values_changed': {"root['features']['functionality']": {'new_value': 'Non-Posable', 'old_value': 'Posable'}}}
+ assert "functionality" in msg
+
+
+async def test_itis_vip_syncer_service(
+ mock_itis_vip_downloadables_api: respx.MockRouter,
+ app_environment: EnvVarsDict,
+ client: TestClient,
+ ensure_empty_licensed_resources: None,
+):
+ assert client.app
+
+ settings = ItisVipSettings.create_from_envs()
+ assert settings.LICENSES_ITIS_VIP_CATEGORIES
+
+ categories = settings.to_categories()
+
+ # one round
+ await _itis_vip_syncer_service.sync_licensed_resources(client.app, categories)
+
+ # second round
+ await _itis_vip_syncer_service.sync_licensed_resources(client.app, categories)
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py
index a164c1b6406..7abbd37b296 100644
--- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py
@@ -26,6 +26,8 @@
"licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef",
"product_name": "osparc",
"licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953",
+ "key": "Duke",
+ "version": "1.0.0",
"wallet_id": 1,
"wallet_name": "My Wallet",
"pricing_unit_cost_id": 1,
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py
index 4f68cdce9a8..01df2519fe6 100644
--- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py
@@ -4,19 +4,31 @@
# pylint: disable=too-many-arguments
# pylint: disable=too-many-statements
-import arrow
+import copy
+
import pytest
from aiohttp.test_utils import TestClient
-from models_library.licensed_items import (
+from models_library.licenses import (
VIP_DETAILS_EXAMPLE,
- LicensedItemUpdateDB,
+ LicensedItemPatchDB,
LicensedResourceType,
)
from models_library.rest_ordering import OrderBy
from pytest_simcore.helpers.webserver_login import UserInfoDict
+from simcore_postgres_database.models.licensed_item_to_resource import (
+ licensed_item_to_resource,
+)
+from simcore_postgres_database.utils_repos import transaction_context
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.licenses import _licensed_items_repository
-from simcore_service_webserver.licenses.errors import LicensedItemNotFoundError
+from simcore_service_webserver.db.plugin import get_asyncpg_engine
+from simcore_service_webserver.licenses import (
+ _licensed_items_repository,
+ _licensed_resources_repository,
+)
+from simcore_service_webserver.licenses.errors import (
+ LicensedItemNotFoundError,
+ LicensedKeyVersionNotFoundError,
+)
from simcore_service_webserver.projects.models import ProjectDict
@@ -25,7 +37,7 @@ def user_role() -> UserRole:
return UserRole.USER
-async def test_licensed_items_db_crud(
+async def test_licensed_items_db_domain_crud(
client: TestClient,
logged_user: UserInfoDict,
user_project: ProjectDict,
@@ -46,10 +58,10 @@ async def test_licensed_items_db_crud(
got = await _licensed_items_repository.create(
client.app,
product_name=osparc_product_name,
- display_name="Model A Display Name",
- licensed_resource_name="Model A",
+ display_name="Renting A Display Name",
+ key="Duke",
+ version="1.0.0",
licensed_resource_type=LicensedResourceType.VIP_MODEL,
- licensed_resource_data=VIP_DETAILS_EXAMPLE,
pricing_plan_id=pricing_plan_id,
)
licensed_item_id = got.licensed_item_id
@@ -69,13 +81,13 @@ async def test_licensed_items_db_crud(
licensed_item_id=licensed_item_id,
product_name=osparc_product_name,
)
- assert got.licensed_resource_name == "Model A"
+ assert got.display_name == "Renting A Display Name"
await _licensed_items_repository.update(
client.app,
licensed_item_id=licensed_item_id,
product_name=osparc_product_name,
- updates=LicensedItemUpdateDB(licensed_resource_name="Model B"),
+ updates=LicensedItemPatchDB(display_name="Renting B Display Name"),
)
got = await _licensed_items_repository.get(
@@ -83,7 +95,7 @@ async def test_licensed_items_db_crud(
licensed_item_id=licensed_item_id,
product_name=osparc_product_name,
)
- assert got.licensed_resource_name == "Model B"
+ assert got.display_name == "Renting B Display Name"
got = await _licensed_items_repository.delete(
client.app,
@@ -99,7 +111,7 @@ async def test_licensed_items_db_crud(
)
-async def test_licensed_items_db_trash(
+async def test_licensed_items_domain_listing(
client: TestClient,
logged_user: UserInfoDict,
user_project: ProjectDict,
@@ -107,77 +119,134 @@ async def test_licensed_items_db_trash(
pricing_plan_id: int,
):
assert client.app
-
- # Create two licensed items
- licensed_item_ids = []
- for name in ["Model A", "Model B"]:
- licensed_item_db = await _licensed_items_repository.create(
- client.app,
- product_name=osparc_product_name,
- display_name="Model A Display Name",
- licensed_resource_name=name,
- licensed_resource_type=LicensedResourceType.VIP_MODEL,
- licensed_resource_data=VIP_DETAILS_EXAMPLE,
- pricing_plan_id=pricing_plan_id,
- )
- licensed_item_ids.append(licensed_item_db.licensed_item_id)
-
- # Trash one licensed item
- trashing_at = arrow.now().datetime
- trashed_item = await _licensed_items_repository.update(
+ total_count, items = await _licensed_items_repository.list_licensed_items(
client.app,
- licensed_item_id=licensed_item_ids[0],
product_name=osparc_product_name,
- updates=LicensedItemUpdateDB(trash=True),
+ offset=0,
+ limit=10,
+ order_by=OrderBy(field="modified"),
)
+ assert total_count == 0
+ assert not items
- assert trashed_item.licensed_item_id == licensed_item_ids[0]
- assert trashed_item.trashed
- assert trashing_at < trashed_item.trashed
- assert trashed_item.trashed < arrow.now().datetime
-
- # List with filter_trashed include
- total_count, items = await _licensed_items_repository.list_(
+ got_duke1 = await _licensed_items_repository.create(
client.app,
product_name=osparc_product_name,
- offset=0,
- limit=10,
- order_by=OrderBy(field="display_name"),
- trashed="include",
+ display_name="Renting Duke 1.0.0 Display Name",
+ key="Duke",
+ version="1.0.0",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ pricing_plan_id=pricing_plan_id,
)
- assert total_count == 2
- assert {i.licensed_item_id for i in items} == set(licensed_item_ids)
- # List with filter_trashed exclude
- total_count, items = await _licensed_items_repository.list_(
+ got_duke2 = await _licensed_items_repository.create(
client.app,
product_name=osparc_product_name,
- offset=0,
- limit=10,
- order_by=OrderBy(field="display_name"),
- trashed="exclude",
+ display_name="Renting Duke 2.0.0 Display Name",
+ key="Duke",
+ version="2.0.0",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ pricing_plan_id=pricing_plan_id,
)
- assert total_count == 1
- assert items[0].licensed_item_id == licensed_item_ids[1]
- assert items[0].trashed is None
- # List with filter_trashed all
- total_count, items = await _licensed_items_repository.list_(
+ # Create Licensed Resource with licensed key and version (Duke V1)
+ example_duke1 = copy.deepcopy(VIP_DETAILS_EXAMPLE)
+ example_duke1["license_key"] = "ABC"
+ example_duke1["license_version"] = "1.0.0"
+ example_duke1["id"] = 1
+
+ got_licensed_resource_duke1 = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke 1",
+ licensed_resource_name="Duke 1",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=example_duke1,
+ )
+ )
+
+ example_duke1_different_id = copy.deepcopy(VIP_DETAILS_EXAMPLE)
+ example_duke1_different_id["license_key"] = "ABC"
+ example_duke1_different_id["license_version"] = "1.0.0"
+ example_duke1_different_id["id"] = 2
+
+ # Create Licensed Resource with the same licensed key and version (Duke V1) but different external ID
+ got_licensed_resource_duke1_different_id = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke 1 (different external ID)",
+ licensed_resource_name="Duke 1 different external ID",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=example_duke1_different_id,
+ )
+ )
+
+ example_duke2 = copy.deepcopy(VIP_DETAILS_EXAMPLE)
+ example_duke2["license_key"] = "ABC"
+ example_duke2["license_version"] = "2.0.0"
+ example_duke2["id"] = 3
+
+ # Create Licensed Resource with the same licensed key but different version (Duke V2)
+ got_licensed_resource_duke2 = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke 2",
+ licensed_resource_name="Duke 2",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=example_duke2,
+ )
+ )
+
+ # Connect them via licensed_item_to_resorce DB table
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(
+ licensed_item_to_resource.insert(),
+ [
+ {
+ "licensed_item_id": got_duke1.licensed_item_id,
+ "licensed_resource_id": got_licensed_resource_duke1.licensed_resource_id,
+ "product_name": osparc_product_name,
+ },
+ {
+ "licensed_item_id": got_duke1.licensed_item_id,
+ "licensed_resource_id": got_licensed_resource_duke1_different_id.licensed_resource_id,
+ "product_name": osparc_product_name,
+ },
+ {
+ "licensed_item_id": got_duke2.licensed_item_id,
+ "licensed_resource_id": got_licensed_resource_duke2.licensed_resource_id,
+ "product_name": osparc_product_name,
+ },
+ ],
+ )
+
+ total_count, items = await _licensed_items_repository.list_licensed_items(
client.app,
product_name=osparc_product_name,
offset=0,
limit=10,
order_by=OrderBy(field="display_name"),
- trashed="only",
)
- assert total_count == 1
- assert items[0].licensed_item_id == trashed_item.licensed_item_id
- assert items[0].trashed
+ assert total_count == 2
+ assert items[0].licensed_item_id == got_duke1.licensed_item_id
+ assert len(items[0].licensed_resources) == 2
+ assert items[1].licensed_item_id == got_duke2.licensed_item_id
+ assert len(items[1].licensed_resources) == 1
- # Get the trashed licensed item
- got = await _licensed_items_repository.get(
- client.app,
- licensed_item_id=trashed_item.licensed_item_id,
- product_name=osparc_product_name,
+ got = await _licensed_items_repository.get_licensed_item_by_key_version(
+ client.app, key="Duke", version="1.0.0", product_name=osparc_product_name
+ )
+ assert got.display_name == "Renting Duke 1.0.0 Display Name"
+
+ got = await _licensed_items_repository.get_licensed_item_by_key_version(
+ client.app, key="Duke", version="2.0.0", product_name=osparc_product_name
)
- assert got == trashed_item
+ assert got.display_name == "Renting Duke 2.0.0 Display Name"
+
+ with pytest.raises(LicensedKeyVersionNotFoundError):
+ await _licensed_items_repository.get_licensed_item_by_key_version(
+ client.app,
+ key="Non-Existing",
+ version="2.0.0",
+ product_name=osparc_product_name,
+ )
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py
index 2f00b9733ff..914187e5d4c 100644
--- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py
@@ -3,22 +3,39 @@
# pylint: disable=unused-variable
# pylint: disable=too-many-arguments
# pylint: disable=too-many-statements
+from decimal import Decimal
from http import HTTPStatus
import pytest
from aiohttp.test_utils import TestClient
+from models_library.api_schemas_resource_usage_tracker import (
+ licensed_items_purchases as rut_licensed_items_purchases,
+)
from models_library.api_schemas_resource_usage_tracker.pricing_plans import (
- PricingUnitGet,
+ RutPricingPlanGet,
+ RutPricingUnitGet,
)
from models_library.api_schemas_webserver.licensed_items import LicensedItemRestGet
+from models_library.api_schemas_webserver.licensed_items_purchases import (
+ LicensedItemPurchaseGet,
+)
from models_library.api_schemas_webserver.wallets import WalletGetWithAvailableCredits
-from models_library.licensed_items import VIP_DETAILS_EXAMPLE, LicensedResourceType
+from models_library.licenses import VIP_DETAILS_EXAMPLE, LicensedResourceType
from pytest_mock.plugin import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp import status
+from simcore_postgres_database.models.licensed_item_to_resource import (
+ licensed_item_to_resource,
+)
+from simcore_postgres_database.models.licensed_items import licensed_items
+from simcore_postgres_database.utils_repos import transaction_context
from simcore_service_webserver.db.models import UserRole
-from simcore_service_webserver.licenses import _licensed_items_repository
+from simcore_service_webserver.db.plugin import get_asyncpg_engine
+from simcore_service_webserver.licenses import (
+ _licensed_items_repository,
+ _licensed_resources_repository,
+)
from simcore_service_webserver.projects.models import ProjectDict
@@ -41,35 +58,92 @@ async def test_licensed_items_listing(
licensed_item_db = await _licensed_items_repository.create(
client.app,
+ key="Duke",
+ version="1.0.0",
product_name=osparc_product_name,
display_name="Model A display name",
- licensed_resource_name="Model A",
licensed_resource_type=LicensedResourceType.VIP_MODEL,
pricing_plan_id=pricing_plan_id,
- licensed_resource_data=VIP_DETAILS_EXAMPLE,
)
_licensed_item_id = licensed_item_db.licensed_item_id
+ got_licensed_resource_duke = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke",
+ licensed_resource_name="Duke",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data={
+ "category_id": "HumanWholeBody",
+ "category_display": "Humans",
+ "source": VIP_DETAILS_EXAMPLE,
+ },
+ )
+ )
+
+ # Connect them via licensed_item_to_resorce DB table
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(
+ licensed_item_to_resource.insert().values(
+ licensed_item_id=_licensed_item_id,
+ licensed_resource_id=got_licensed_resource_duke.licensed_resource_id,
+ product_name=osparc_product_name,
+ )
+ )
+
# list
url = client.app.router["list_licensed_items"].url_for()
resp = await client.get(f"{url}")
data, _ = await assert_status(resp, status.HTTP_200_OK)
assert len(data) == 1
assert LicensedItemRestGet(**data[0])
- assert data[0]["licensedResourceData"][
- "additionalField"
- ] # <-- Testing nested camel case
- assert data[0]["licensedResourceData"]["features"][
- "additionalField"
- ] # <-- Testing nested camel case
-
- # get
- url = client.app.router["get_licensed_item"].url_for(
- licensed_item_id=f"{_licensed_item_id}"
- )
+
+ # <-- Testing nested camel case
+ source = data[0]["licensedResources"][0]["source"]
+ assert all("_" not in key for key in source), f"got {source=}"
+
+ # Testing trimmed
+ assert "additionalField" not in source
+ assert "additional_field" not in source
+
+ # Testing hidden flag
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(
+ licensed_items.update()
+ .values(
+ is_hidden_on_market=True,
+ )
+ .where(licensed_items.c.licensed_item_id == _licensed_item_id)
+ )
+
+ url = client.app.router["list_licensed_items"].url_for()
resp = await client.get(f"{url}")
data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert LicensedItemRestGet(**data)
+ assert data == []
+
+
+_LICENSED_ITEM_PURCHASE_GET = (
+ rut_licensed_items_purchases.LicensedItemPurchaseGet.model_validate(
+ {
+ "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef",
+ "product_name": "osparc",
+ "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953",
+ "key": "Duke",
+ "version": "1.0.0",
+ "wallet_id": 1,
+ "wallet_name": "My Wallet",
+ "pricing_unit_cost_id": 1,
+ "pricing_unit_cost": Decimal(10),
+ "start_at": "2023-01-11 13:11:47.293595",
+ "expire_at": "2023-01-11 13:11:47.293595",
+ "num_of_seats": 1,
+ "purchased_by_user": 1,
+ "user_email": "test@test.com",
+ "purchased_at": "2023-01-11 13:11:47.293595",
+ "modified": "2023-01-11 13:11:47.293595",
+ }
+ )
+)
@pytest.fixture
@@ -78,25 +152,31 @@ def mock_licensed_items_purchase_functions(mocker: MockerFixture) -> tuple:
"simcore_service_webserver.licenses._licensed_items_service.get_wallet_with_available_credits_by_user_and_wallet",
spec=True,
return_value=WalletGetWithAvailableCredits.model_validate(
- WalletGetWithAvailableCredits.model_config["json_schema_extra"]["examples"][
- 0
- ]
+ WalletGetWithAvailableCredits.model_json_schema()["examples"][0]
+ ),
+ )
+ mock_get_pricing_plan = mocker.patch(
+ "simcore_service_webserver.licenses._licensed_items_service.get_pricing_plan",
+ spec=True,
+ return_value=RutPricingPlanGet.model_validate(
+ RutPricingPlanGet.model_json_schema()["examples"][2]
),
)
mock_get_pricing_unit = mocker.patch(
"simcore_service_webserver.licenses._licensed_items_service.get_pricing_plan_unit",
spec=True,
- return_value=PricingUnitGet.model_validate(
- PricingUnitGet.model_config["json_schema_extra"]["examples"][0]
+ return_value=RutPricingUnitGet.model_validate(
+ RutPricingUnitGet.model_json_schema()["examples"][2]
),
)
mock_create_licensed_item_purchase = mocker.patch(
"simcore_service_webserver.licenses._licensed_items_service.licensed_items_purchases.create_licensed_item_purchase",
- spec=True,
+ return_value=_LICENSED_ITEM_PURCHASE_GET,
)
return (
mock_wallet_credits,
+ mock_get_pricing_plan,
mock_get_pricing_unit,
mock_create_licensed_item_purchase,
)
@@ -116,22 +196,38 @@ async def test_licensed_items_purchase(
licensed_item_db = await _licensed_items_repository.create(
client.app,
+ key="Duke",
+ version="1.0.0",
product_name=osparc_product_name,
display_name="Model A display name",
- licensed_resource_name="Model A",
licensed_resource_type=LicensedResourceType.VIP_MODEL,
pricing_plan_id=pricing_plan_id,
- licensed_resource_data=VIP_DETAILS_EXAMPLE,
)
_licensed_item_id = licensed_item_db.licensed_item_id
- # get
- url = client.app.router["get_licensed_item"].url_for(
- licensed_item_id=f"{_licensed_item_id}"
+ got_licensed_resource_duke = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke",
+ licensed_resource_name="Duke",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data={
+ "category_id": "HumanWholeBody",
+ "category_display": "Humans",
+ "source": VIP_DETAILS_EXAMPLE,
+ },
+ )
)
- resp = await client.get(f"{url}")
- data, _ = await assert_status(resp, status.HTTP_200_OK)
- assert LicensedItemRestGet(**data)
+
+ # Connect them via licensed_item_to_resorce DB table
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(
+ licensed_item_to_resource.insert().values(
+ licensed_item_id=_licensed_item_id,
+ licensed_resource_id=got_licensed_resource_duke.licensed_resource_id,
+ product_name=osparc_product_name,
+ )
+ )
# purchase
url = client.app.router["purchase_licensed_item"].url_for(
@@ -146,4 +242,5 @@ async def test_licensed_items_purchase(
"pricing_unit_id": 1,
},
)
- await assert_status(resp, status.HTTP_204_NO_CONTENT)
+ data, _ = await assert_status(resp, status.HTTP_200_OK)
+ assert LicensedItemPurchaseGet(**data)
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py
new file mode 100644
index 00000000000..22069d92914
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py
@@ -0,0 +1,59 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+# pylint: disable=too-many-statements
+import arrow
+import pytest
+from aiohttp.test_utils import TestClient
+from models_library.licenses import (
+ VIP_DETAILS_EXAMPLE,
+ LicensedResourcePatchDB,
+ LicensedResourceType,
+)
+from pytest_simcore.helpers.webserver_login import UserInfoDict
+from simcore_service_webserver.db.models import UserRole
+from simcore_service_webserver.licenses import _licensed_resources_repository
+from simcore_service_webserver.projects.models import ProjectDict
+
+
+@pytest.fixture
+def user_role() -> UserRole:
+ return UserRole.USER
+
+
+async def test_licensed_items_db_trash(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_project: ProjectDict,
+ osparc_product_name: str,
+ pricing_plan_id: int,
+):
+ assert client.app
+
+ # Create two licensed items
+ licensed_resource_ids = []
+ for name in ["Model A", "Model B"]:
+ licensed_resource_db = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Model A Display Name",
+ licensed_resource_name=name,
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data=VIP_DETAILS_EXAMPLE,
+ )
+ )
+ licensed_resource_ids.append(licensed_resource_db.licensed_resource_id)
+
+ # Trash one licensed item
+ trashing_at = arrow.now().datetime
+ trashed_item = await _licensed_resources_repository.update(
+ client.app,
+ licensed_resource_id=licensed_resource_ids[0],
+ updates=LicensedResourcePatchDB(trash=True),
+ )
+
+ assert trashed_item.licensed_resource_id == licensed_resource_ids[0]
+ assert trashed_item.trashed
+ assert trashing_at < trashed_item.trashed
+ assert trashed_item.trashed < arrow.now().datetime
diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py
index fcfcbf2479e..65bb4ae66c4 100644
--- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py
+++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py
@@ -11,7 +11,7 @@
LicensedItemCheckoutGet,
)
from models_library.api_schemas_webserver.licensed_items import LicensedItemRpcGetPage
-from models_library.licensed_items import VIP_DETAILS_EXAMPLE, LicensedResourceType
+from models_library.licenses import VIP_DETAILS_EXAMPLE, LicensedResourceType
from models_library.products import ProductName
from pytest_mock import MockerFixture
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
@@ -25,9 +25,17 @@
release_licensed_item_for_wallet,
)
from settings_library.rabbit import RabbitSettings
+from simcore_postgres_database.models.licensed_item_to_resource import (
+ licensed_item_to_resource,
+)
from simcore_postgres_database.models.users import UserRole
+from simcore_postgres_database.utils_repos import transaction_context
from simcore_service_webserver.application_settings import ApplicationSettings
-from simcore_service_webserver.licenses import _licensed_items_repository
+from simcore_service_webserver.db.plugin import get_asyncpg_engine
+from simcore_service_webserver.licenses import (
+ _licensed_items_repository,
+ _licensed_resources_repository,
+)
pytest_simcore_core_services_selection = [
"rabbit",
@@ -133,15 +141,40 @@ async def test_license_checkout_workflow(
assert len(result.items) == 0
assert result.total == 0
- license_item_db = await _licensed_items_repository.create(
+ licensed_item_db = await _licensed_items_repository.create(
client.app,
+ key="Duke",
+ version="1.0.0",
product_name=osparc_product_name,
display_name="Model A display name",
- licensed_resource_name="Model A",
licensed_resource_type=LicensedResourceType.VIP_MODEL,
pricing_plan_id=pricing_plan_id,
- licensed_resource_data=VIP_DETAILS_EXAMPLE,
)
+ _licensed_item_id = licensed_item_db.licensed_item_id
+
+ got_licensed_resource_duke = (
+ await _licensed_resources_repository.create_if_not_exists(
+ client.app,
+ display_name="Duke",
+ licensed_resource_name="Duke",
+ licensed_resource_type=LicensedResourceType.VIP_MODEL,
+ licensed_resource_data={
+ "category_id": "HumanWholeBody",
+ "category_display": "Humans",
+ "source": VIP_DETAILS_EXAMPLE,
+ },
+ )
+ )
+
+ # Connect them via licensed_item_to_resorce DB table
+ async with transaction_context(get_asyncpg_engine(client.app)) as conn:
+ await conn.execute(
+ licensed_item_to_resource.insert().values(
+ licensed_item_id=_licensed_item_id,
+ licensed_resource_id=got_licensed_resource_duke.licensed_resource_id,
+ product_name=osparc_product_name,
+ )
+ )
result = await get_licensed_items(
rpc_client, product_name=osparc_product_name, offset=0, limit=20
@@ -163,7 +196,7 @@ async def test_license_checkout_workflow(
product_name=osparc_product_name,
user_id=logged_user["id"],
wallet_id=1,
- licensed_item_id=license_item_db.licensed_item_id,
+ licensed_item_id=licensed_item_db.licensed_item_id,
num_of_seats=1,
service_run_id="run_1",
)
diff --git a/services/web/server/tests/unit/with_dbs/04/products/conftest.py b/services/web/server/tests/unit/with_dbs/04/products/conftest.py
index 99f086477a5..236ce3ec224 100644
--- a/services/web/server/tests/unit/with_dbs/04/products/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/04/products/conftest.py
@@ -4,8 +4,15 @@
import pytest
+from models_library.products import ProductName
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
+from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT
+
+
+@pytest.fixture
+def default_product_name() -> ProductName:
+ return FRONTEND_APP_DEFAULT
@pytest.fixture
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py
deleted file mode 100644
index bd399948c14..00000000000
--- a/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py
+++ /dev/null
@@ -1,153 +0,0 @@
-# pylint: disable=redefined-outer-name
-# pylint: disable=unused-argument
-# pylint: disable=unused-variable
-# pylint: disable=too-many-arguments
-
-
-from typing import Any
-
-import pytest
-import sqlalchemy as sa
-from aiohttp import web
-from aiohttp.test_utils import TestClient
-from aiopg.sa.result import RowProxy
-from pytest_mock import MockerFixture
-from simcore_postgres_database import utils_products
-from simcore_postgres_database.models.products import (
- EmailFeedback,
- Forum,
- IssueTracker,
- Manual,
- Vendor,
- WebFeedback,
- products,
-)
-from simcore_service_webserver.db.plugin import APP_AIOPG_ENGINE_KEY
-from simcore_service_webserver.products._db import ProductRepository
-from simcore_service_webserver.products._middlewares import _get_default_product_name
-from simcore_service_webserver.products._model import Product
-
-
-@pytest.fixture
-def app(client: TestClient) -> web.Application:
- assert client.app
- return client.app
-
-
-@pytest.fixture
-async def product_row(app: web.Application, product_data: dict[str, Any]) -> RowProxy:
- """Injects product_data in products table and returns the associated table's database row
-
- Note that product_data is a SUBSET of product_row (e.g. modified dattimes etc)!
- """
- engine = app[APP_AIOPG_ENGINE_KEY]
- assert engine
-
- async with engine.acquire() as conn:
- # writes
- insert_stmt = (
- products.insert().values(**product_data).returning(products.c.name)
- )
- name = await conn.scalar(insert_stmt)
-
- # reads
- select_stmt = sa.select(products).where(products.c.name == name)
- row = await (await conn.execute(select_stmt)).fetchone()
- assert row
-
- return row
-
-
-@pytest.fixture
-async def product_repository(
- app: web.Application, mocker: MockerFixture
-) -> ProductRepository:
- assert product_row
-
- fake_request = mocker.MagicMock()
- fake_request.app = app
-
- return ProductRepository.create_from_request(request=fake_request)
-
-
-@pytest.mark.parametrize(
- "product_data",
- [
- # DATA introduced by operator e.g. in adminer
- {
- "name": "tis",
- "display_name": "COMPLETE example",
- "short_name": "dummy",
- "host_regex": r"([\.-]{0,1}dummy[\.-])",
- "support_email": "foo@osparc.io",
- "twilio_messaging_sid": None,
- "vendor": Vendor(
- name="ACME",
- copyright="© ACME correcaminos",
- url="https://acme.com",
- license_url="http://docs.acme.app/#/license-terms",
- invitation_url="http://docs.acme.app/#/how-to-request-invitation",
- ),
- "issues": [
- IssueTracker(
- label="github",
- login_url="https://github.com/ITISFoundation/osparc-simcore",
- new_url="https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
- ),
- IssueTracker(
- label="fogbugz",
- login_url="https://fogbugz.com/login",
- new_url="https://fogbugz.com/new?project=123",
- ),
- ],
- "manuals": [
- Manual(label="main", url="doc.acme.com"),
- Manual(label="z43", url="yet-another-manual.acme.com"),
- ],
- "support": [
- Forum(label="forum", kind="forum", url="forum.acme.com"),
- EmailFeedback(label="email", kind="email", email="support@acme.com"),
- WebFeedback(label="web-form", kind="web", url="support.acme.com"),
- ],
- },
- # Minimal
- {
- "name": "s4llite",
- "display_name": "MINIMAL example",
- "short_name": "dummy",
- "host_regex": "([\\.-]{0,1}osparc[\\.-])",
- "support_email": "support@osparc.io",
- },
- ],
- ids=lambda d: d["display_name"],
-)
-async def test_product_repository_get_product(
- product_repository: ProductRepository,
- product_data: dict[str, Any],
- product_row: RowProxy,
- app: web.Application,
- mocker: MockerFixture,
-):
-
- # check differences between the original product_data and the product_row in database
- assert set(product_data.keys()).issubset(set(product_row.keys()))
-
- common_keys = set(product_data.keys()).intersection(set(product_row.keys()))
- assert {k: product_data[k] for k in common_keys} == {
- k: product_row[k] for k in common_keys
- }
-
- # check RowProxy -> pydantic's Product
- product = Product.model_validate(product_row)
-
- print(product.model_dump_json(indent=1))
-
- # product repo
- assert product_repository.engine
-
- assert await product_repository.get_product(product.name) == product
-
- # tests definitions of default from utle_products and web-server.products are in sync
- async with product_repository.engine.acquire() as conn:
- default_product = await utils_products.get_default_product_name(conn)
- assert default_product == _get_default_product_name(app)
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py
new file mode 100644
index 00000000000..ed4550eee6d
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py
@@ -0,0 +1,262 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+import contextlib
+from collections.abc import Iterable
+from decimal import Decimal
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from aiohttp import web
+from aiohttp.test_utils import TestClient, make_mocked_request
+from models_library.products import ProductName
+from pytest_simcore.helpers.faker_factories import random_product, random_product_price
+from pytest_simcore.helpers.postgres_tools import sync_insert_and_get_row_lifespan
+from simcore_postgres_database import utils_products
+from simcore_postgres_database.models.products import (
+ EmailFeedback,
+ Forum,
+ IssueTracker,
+ Manual,
+ Vendor,
+ WebFeedback,
+ products,
+)
+from simcore_postgres_database.models.products_prices import products_prices
+from simcore_postgres_database.utils_products_prices import ProductPriceInfo
+from simcore_service_webserver.constants import (
+ FRONTEND_APP_DEFAULT,
+ FRONTEND_APPS_AVAILABLE,
+)
+from simcore_service_webserver.products._repository import ProductRepository
+from simcore_service_webserver.products._web_middlewares import (
+ _get_default_product_name,
+)
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+
+@pytest.fixture(scope="module")
+def products_raw_data() -> dict[ProductName, dict[str, Any]]:
+ adminer_example = {
+ # DATA introduced by operator e.g. in adminer
+ "name": "tis",
+ "display_name": "COMPLETE example",
+ "short_name": "dummy",
+ "host_regex": r"([\.-]{0,1}dummy[\.-])",
+ "support_email": "foo@osparc.io",
+ "twilio_messaging_sid": None,
+ "vendor": Vendor(
+ name="ACME",
+ copyright="© ACME correcaminos",
+ url="https://acme.com",
+ license_url="http://docs.acme.app/#/license-terms",
+ invitation_url="http://docs.acme.app/#/how-to-request-invitation",
+ ),
+ "issues": [
+ IssueTracker(
+ label="github",
+ login_url="https://github.com/ITISFoundation/osparc-simcore",
+ new_url="https://github.com/ITISFoundation/osparc-simcore/issues/new/choose",
+ ),
+ IssueTracker(
+ label="fogbugz",
+ login_url="https://fogbugz.com/login",
+ new_url="https://fogbugz.com/new?project=123",
+ ),
+ ],
+ "manuals": [
+ Manual(label="main", url="doc.acme.com"),
+ Manual(label="z43", url="yet-another-manual.acme.com"),
+ ],
+ "support": [
+ Forum(label="forum", kind="forum", url="forum.acme.com"),
+ EmailFeedback(label="email", kind="email", email="support@acme.com"),
+ WebFeedback(label="web-form", kind="web", url="support.acme.com"),
+ ],
+ }
+
+ minimal_example = {
+ "name": "s4llite",
+ "display_name": "MINIMAL example",
+ "short_name": "dummy",
+ "host_regex": "([\\.-]{0,1}osparc[\\.-])",
+ "support_email": "support@osparc.io",
+ }
+
+ examples = {}
+
+ def _add(data):
+ assert data["name"] not in examples
+ assert data.get("group_id") is None # note that group is not assigned
+ examples.update({data["name"]: data})
+
+ _add(adminer_example)
+ _add(minimal_example)
+
+ for name in FRONTEND_APPS_AVAILABLE:
+ if name not in examples and name != FRONTEND_APP_DEFAULT:
+ _add(random_product(name=name))
+
+ return examples
+
+
+@pytest.fixture(scope="module")
+def products_prices_raw_data() -> dict[ProductName, dict[str, Any]]:
+
+ return {
+ "osparc": random_product_price(
+ product_name="osparc",
+ # free of charge
+ usd_per_credit=Decimal(0),
+ ),
+ "tis": random_product_price(
+ product_name="tis",
+ usd_per_credit=Decimal(0),
+ ),
+ }
+
+
+@pytest.fixture(scope="module")
+def db_products_table_with_data_before_app(
+ postgres_db: sa.engine.Engine,
+ products_raw_data: dict[ProductName, dict[str, Any]],
+ products_prices_raw_data: dict[ProductName, dict[str, Any]],
+) -> Iterable[dict[ProductName, dict[str, Any]]]:
+ """
+ All tests in this module are reading from the database
+ and the database for products are setup before the app is started
+
+ This fixture replicate those two conditions
+ """
+
+ with contextlib.ExitStack() as fixture_stack:
+ product_to_row: dict[ProductName, dict[str, Any]] = {}
+
+ for product_name, product_values in products_raw_data.items():
+ product_row = fixture_stack.enter_context(
+ sync_insert_and_get_row_lifespan(
+ postgres_db,
+ table=products,
+ values=product_values,
+ pk_col=products.c.name,
+ pk_value=product_name,
+ )
+ )
+ product_to_row[product_name] = product_row
+
+ if prices := products_prices_raw_data.get(product_name):
+ fixture_stack.enter_context(
+ sync_insert_and_get_row_lifespan(
+ postgres_db,
+ table=products_prices,
+ values=prices,
+ pk_col=products_prices.c.product_name,
+ pk_value=product_name,
+ )
+ )
+
+ yield product_to_row
+
+ # will rm products
+
+
+@pytest.fixture
+def app(
+ db_products_table_with_data_before_app: dict[ProductName, dict[str, Any]],
+ client: TestClient,
+) -> web.Application:
+ assert db_products_table_with_data_before_app
+ assert client.app
+ return client.app
+
+
+@pytest.fixture
+async def product_repository(app: web.Application) -> ProductRepository:
+ repo = ProductRepository.create_from_request(
+ request=make_mocked_request("GET", "/fake", app=app)
+ )
+ assert repo.engine
+
+ return repo
+
+
+async def test_utils_products_and_webserver_default_product_in_sync(
+ app: web.Application,
+ product_repository: ProductRepository,
+ asyncpg_engine: AsyncEngine,
+):
+ # tests definitions of default from utle_products and web-server.products are in sync
+ async with asyncpg_engine.connect() as conn:
+ default_product_name = await utils_products.get_default_product_name(conn)
+ assert default_product_name == _get_default_product_name(app)
+
+ default_product = await product_repository.get_product(default_product_name)
+ assert default_product
+ assert default_product.name == default_product_name
+
+
+async def test_product_repository_get_product(
+ product_repository: ProductRepository,
+):
+ product_name = "tis"
+
+ product = await product_repository.get_product(product_name)
+ assert product
+ assert product.name == product_name
+
+ assert await product_repository.get_product("undefined") is None
+
+
+async def test_product_repository_list_products_names(
+ product_repository: ProductRepository,
+):
+ product_names = await product_repository.list_products_names()
+ assert isinstance(product_names, list)
+ assert all(isinstance(name, str) for name in product_names)
+
+
+async def test_product_repository_get_product_latest_price_info_or_none(
+ product_repository: ProductRepository,
+):
+ product_name = "tis"
+ price_info = await product_repository.get_product_latest_price_info_or_none(
+ product_name
+ )
+ assert price_info is None or isinstance(price_info, ProductPriceInfo)
+
+
+async def test_product_repository_get_product_stripe_info(
+ product_repository: ProductRepository,
+):
+ product_name = "tis"
+ stripe_info = await product_repository.get_product_stripe_info_or_none(product_name)
+ assert stripe_info
+
+ product_name = "s4l"
+ stripe_info = await product_repository.get_product_stripe_info_or_none(product_name)
+ assert stripe_info is None
+
+
+async def test_product_repository_get_template_content(
+ product_repository: ProductRepository,
+):
+ template_name = "some_template"
+ content = await product_repository.get_template_content(template_name)
+ assert content is None or isinstance(content, str)
+
+
+async def test_product_repository_get_product_template_content(
+ product_repository: ProductRepository,
+):
+ product_name = "tis"
+ content = await product_repository.get_product_template_content(product_name)
+ assert content is None or isinstance(content, str)
+
+
+async def test_product_repository_get_product_ui(product_repository: ProductRepository):
+ product_name = "tis"
+ ui = await product_repository.get_product_ui(product_name)
+ assert ui is None or isinstance(ui, dict)
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_handlers.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_rest.py
similarity index 61%
rename from services/web/server/tests/unit/with_dbs/04/products/test_products_handlers.py
rename to services/web/server/tests/unit/with_dbs/04/products/test_products_rest.py
index a36fc493ad6..f9a047ef50e 100644
--- a/services/web/server/tests/unit/with_dbs/04/products/test_products_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_rest.py
@@ -10,12 +10,13 @@
import pytest
from aiohttp.test_utils import TestClient
-from models_library.api_schemas_webserver.product import GetProduct
+from models_library.api_schemas_webserver.products import ProductGet, ProductUIGet
from models_library.products import ProductName
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.aiohttp import status
from servicelib.rest_constants import X_PRODUCT_NAME_HEADER
+from servicelib.status_codes_utils import is_2xx_success
from simcore_postgres_database.constants import QUANTIZE_EXP_ARG
from simcore_service_webserver.db.models import UserRole
from simcore_service_webserver.groups.api import auto_add_user_to_product_group
@@ -95,21 +96,73 @@ async def test_get_product(
client.app, user_id=logged_user["id"], product_name=product_name
)
- current_project_headers = {X_PRODUCT_NAME_HEADER: product_name}
- response = await client.get("/v0/products/current", headers=current_project_headers)
+ current_product_headers = {X_PRODUCT_NAME_HEADER: product_name}
+ response = await client.get("/v0/products/current", headers=current_product_headers)
data, error = await assert_status(response, status.HTTP_200_OK)
- got_product = GetProduct(**data)
+ got_product = ProductGet(**data)
assert got_product.name == product_name
assert got_product.credits_per_usd == expected_credits_per_usd
assert not error
response = await client.get(f"/v0/products/{product_name}")
data, error = await assert_status(response, status.HTTP_200_OK)
- assert got_product == GetProduct(**data)
+ assert got_product == ProductGet(**data)
assert not error
- response = await client.get("/v0/product/invalid")
+ response = await client.get("/v0/products/invalid")
data, error = await assert_status(response, status.HTTP_404_NOT_FOUND)
assert not data
assert error
+
+
+@pytest.mark.parametrize(
+ "user_role, expected_status_code",
+ [
+ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
+ (UserRole.GUEST, status.HTTP_403_FORBIDDEN),
+ (UserRole.USER, status.HTTP_200_OK),
+ (UserRole.TESTER, status.HTTP_200_OK),
+ (UserRole.PRODUCT_OWNER, status.HTTP_200_OK),
+ (UserRole.ADMIN, status.HTTP_200_OK),
+ ],
+)
+async def test_get_current_product_ui(
+ app_products_names: list[ProductName],
+ product_name: ProductName,
+ logged_user: UserInfoDict,
+ client: TestClient,
+ user_role: UserRole,
+ expected_status_code: int,
+):
+ assert logged_user["role"] == user_role.value
+ assert product_name in app_products_names
+
+ # give access to user to this product
+ assert client.app
+ await auto_add_user_to_product_group(
+ client.app, user_id=logged_user["id"], product_name=product_name
+ )
+
+ assert (
+ client.app.router["get_current_product_ui"].url_for().path
+ == "/v0/products/current/ui"
+ )
+ response = await client.get(
+ "/v0/products/current/ui", headers={X_PRODUCT_NAME_HEADER: product_name}
+ )
+
+ data, error = await assert_status(response, expected_status_code)
+
+ if is_2xx_success(expected_status_code):
+ # ui is something owned and fully controlled by the front-end
+ # Will be something like the data stored in this file
+ # https://github.com/itisfoundation/osparc-simcore/blob/1dcd369717959348099cc6241822a1f0aff0382c/services/static-webserver/client/source/resource/osparc/new_studies.json
+ assert not error
+ assert data
+
+ product_ui = ProductUIGet.model_validate(data)
+ assert product_ui.product_name == product_name
+ else:
+ assert error
+ assert not data
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_rpc.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_rpc.py
index 4505a6f4e3e..08763afefa2 100644
--- a/services/web/server/tests/unit/with_dbs/04/products/test_products_rpc.py
+++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_rpc.py
@@ -8,7 +8,8 @@
import pytest
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
-from models_library.products import CreditResultGet, ProductName
+from models_library.api_schemas_webserver.products import CreditResultRpcGet
+from models_library.products import ProductName
from models_library.rabbitmq_basic_types import RPCMethodName
from pydantic import TypeAdapter
from pytest_mock import MockerFixture
@@ -74,7 +75,7 @@ async def test_get_credit_amount(
dollar_amount=Decimal(900),
product_name="s4l",
)
- credit_result = CreditResultGet.model_validate(result)
+ credit_result = CreditResultRpcGet.model_validate(result)
assert credit_result.credit_amount == 100
result = await rpc_client.request(
@@ -83,7 +84,7 @@ async def test_get_credit_amount(
dollar_amount=Decimal(900),
product_name="tis",
)
- credit_result = CreditResultGet.model_validate(result)
+ credit_result = CreditResultRpcGet.model_validate(result)
assert credit_result.credit_amount == 180
with pytest.raises(RPCServerError) as exc_info:
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py
new file mode 100644
index 00000000000..3f30f84b929
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py
@@ -0,0 +1,196 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+
+from decimal import Decimal
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestServer
+from models_library.products import ProductName
+from pydantic import TypeAdapter, ValidationError
+from pytest_mock import MockerFixture
+from servicelib.exceptions import InvalidConfig
+from simcore_postgres_database.utils_products_prices import ProductPriceInfo
+from simcore_service_webserver.products import _service, products_service
+from simcore_service_webserver.products._models import ProductStripeInfo
+from simcore_service_webserver.products._repository import ProductRepository
+from simcore_service_webserver.products.errors import (
+ BelowMinimumPaymentError,
+ MissingStripeConfigError,
+ ProductNotFoundError,
+ ProductPriceNotDefinedError,
+ ProductTemplateNotFoundError,
+)
+from simcore_service_webserver.products.models import Product
+
+
+@pytest.fixture
+def app(
+ web_server: TestServer,
+) -> web.Application:
+ # app initialized and server running
+ assert web_server.app
+ return web_server.app
+
+
+async def test_load_products(app: web.Application):
+ products = await _service.load_products(app)
+ assert isinstance(products, list)
+ assert all(isinstance(product, Product) for product in products)
+
+
+async def test_load_products_validation_error(app: web.Application, mocker):
+ mock_repo = mocker.patch(
+ "simcore_service_webserver.products._service.ProductRepository.create_from_app"
+ )
+
+ try:
+ TypeAdapter(int).validate_python("not-an-int")
+ except ValidationError as validation_error:
+ mock_repo.return_value.list_products.side_effect = validation_error
+
+ with pytest.raises(InvalidConfig, match="Invalid product configuration in db"):
+ await _service.load_products(app)
+
+
+async def test_get_default_product_name(app: web.Application):
+ default_product_name = await _service.get_default_product_name(app)
+ assert isinstance(default_product_name, ProductName)
+
+
+async def test_get_product(app: web.Application, default_product_name: ProductName):
+ product = products_service.get_product(app, product_name=default_product_name)
+ assert product.name == default_product_name
+
+ products = products_service.list_products(app)
+ assert len(products) == 1
+ assert products[0] == product
+
+
+async def test_products_on_uninitialized_app(default_product_name: ProductName):
+ uninit_app = web.Application()
+ with pytest.raises(ProductNotFoundError):
+ _service.get_product(uninit_app, default_product_name)
+
+
+async def test_list_products_names(app: web.Application):
+ product_names = await products_service.list_products_names(app)
+ assert isinstance(product_names, list)
+ assert all(isinstance(name, ProductName) for name in product_names)
+
+
+async def test_get_credit_price_info(
+ app: web.Application, default_product_name: ProductName
+):
+ price_info = await _service.get_credit_price_info(
+ app, product_name=default_product_name
+ )
+ assert price_info is None or isinstance(price_info, ProductPriceInfo)
+
+
+async def test_get_product_ui(app: web.Application, default_product_name: ProductName):
+ repo = ProductRepository.create_from_app(app)
+ ui = await products_service.get_product_ui(repo, product_name=default_product_name)
+ assert ui == {}, "Expected empty by default"
+
+ with pytest.raises(ProductNotFoundError):
+ await products_service.get_product_ui(repo, product_name="undefined")
+
+
+async def test_get_credit_amount(
+ app: web.Application, default_product_name: ProductName, mocker: MockerFixture
+):
+ # Test when ProductPriceNotDefinedError is raised
+ with pytest.raises(ProductPriceNotDefinedError):
+ await products_service.get_credit_amount(
+ app, dollar_amount=1, product_name=default_product_name
+ )
+
+
+async def test_get_credit_amount_with_repo_faking_data(
+ default_product_name: ProductName, mocker: MockerFixture
+):
+ # NO need of database since repo is mocked
+ app = web.Application()
+
+ # Mock the repository to return a valid price info
+ mock_repo = mocker.patch(
+ "simcore_service_webserver.products._service.ProductRepository.create_from_app"
+ )
+
+ async def _get_product_latest_price_info_or_none(*args, **kwargs):
+ return ProductPriceInfo(
+ usd_per_credit=Decimal("10.0"), min_payment_amount_usd=Decimal("5.0")
+ )
+
+ mock_repo.return_value.get_product_latest_price_info_or_none.side_effect = (
+ _get_product_latest_price_info_or_none
+ )
+
+ # Test when BelowMinimumPaymentError is raised
+ with pytest.raises(BelowMinimumPaymentError):
+ await products_service.get_credit_amount(
+ app, dollar_amount=Decimal("3.0"), product_name=default_product_name
+ )
+
+ # Test when CreditResultGet is returned successfully
+ credit_result = await products_service.get_credit_amount(
+ app, dollar_amount=Decimal("10.0"), product_name=default_product_name
+ )
+ assert credit_result.credit_amount == Decimal("1.0")
+ assert credit_result.product_name == default_product_name
+
+
+async def test_get_product_stripe_info(
+ app: web.Application, default_product_name: ProductName
+):
+ # database has no info
+ with pytest.raises(MissingStripeConfigError, match=default_product_name):
+ await products_service.get_product_stripe_info(
+ app, product_name=default_product_name
+ )
+
+
+async def test_get_product_stripe_info_with_repo_faking_data(
+ default_product_name: ProductName, mocker: MockerFixture
+):
+ # NO need of database since repo is mocked
+ app = web.Application()
+
+ # Mock the repository to return a valid stripe info
+ mock_repo = mocker.patch(
+ "simcore_service_webserver.products._service.ProductRepository.create_from_app"
+ )
+
+ # Test when stripe info is returned successfully
+ expected_stripe_info = ProductStripeInfo(
+ stripe_price_id="price_id", stripe_tax_rate_id="tax_id"
+ )
+
+ async def _mock(*args, **kw):
+ return expected_stripe_info
+
+ mock_repo.return_value.get_product_stripe_info_or_none.side_effect = _mock
+
+ stripe_info = await products_service.get_product_stripe_info(
+ app, product_name=default_product_name
+ )
+ assert stripe_info == expected_stripe_info
+
+
+async def test_get_template_content(app: web.Application):
+ template_name = "some_template"
+ with pytest.raises(ProductTemplateNotFoundError):
+ await _service.get_template_content(app, template_name=template_name)
+
+
+async def test_auto_create_products_groups(app: web.Application):
+ groups = await _service.auto_create_products_groups(app)
+ assert isinstance(groups, dict)
+
+ assert all(
+ group_id is not None for group_id in groups.values()
+ ), f"Invalid {groups}"
diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py
new file mode 100644
index 00000000000..4db0e38867c
--- /dev/null
+++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py
@@ -0,0 +1,158 @@
+# pylint: disable=redefined-outer-name
+# pylint: disable=unused-argument
+# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
+
+
+import pytest
+from aiohttp import web
+from aiohttp.test_utils import TestClient, make_mocked_request
+from models_library.products import ProductName
+from pytest_mock import MockerFixture, MockType
+from servicelib.rest_constants import X_PRODUCT_NAME_HEADER
+from simcore_service_webserver._meta import API_VTAG
+from simcore_service_webserver.products import products_web
+from simcore_service_webserver.products.plugin import setup_products
+
+
+@pytest.fixture
+def setup_products_mocked(mocker: MockerFixture) -> MockType:
+ def _wrap(app: web.Application):
+ setup_products(app)
+
+ # register test handlers
+ app.router.add_get(
+ f"/{API_VTAG}/test-helpers",
+ _test_helpers_handler,
+ name=_test_helpers_handler.__name__,
+ )
+ app.router.add_get(
+ f"/{API_VTAG}/test-product-template-helpers",
+ _test_product_template_handler,
+ name=_test_product_template_handler.__name__,
+ )
+
+ return True
+
+ return mocker.patch(
+ "simcore_service_webserver.application.setup_products",
+ autospec=True,
+ side_effect=_wrap,
+ )
+
+
+@pytest.fixture
+def client(
+ setup_products_mocked: MockType, # keep before client fixture!
+ client: TestClient,
+) -> TestClient:
+ assert setup_products_mocked.called
+
+ assert client.app
+ assert client.app.router
+
+ registered_routes = {
+ route.resource.canonical
+ for route in client.app.router.routes()
+ if route.resource
+ }
+ assert f"/{API_VTAG}/test-helpers" in registered_routes
+
+ return client
+
+
+async def _test_helpers_handler(request: web.Request):
+ product_name = products_web.get_product_name(request)
+ current_product = products_web.get_current_product(request)
+
+ assert current_product.name == product_name
+
+ credit_price_info = await products_web.get_current_product_credit_price_info(
+ request
+ )
+ assert credit_price_info is None
+
+ return web.json_response(
+ {
+ "current_product": current_product.model_dump(mode="json"),
+ "product_name": product_name,
+ "credit_price_info": credit_price_info,
+ }
+ )
+
+
+async def test_request_helpers(client: TestClient, default_product_name: ProductName):
+
+ resp = await client.get(
+ f"/{API_VTAG}/test-helpers",
+ headers={X_PRODUCT_NAME_HEADER: default_product_name},
+ )
+
+ assert resp.ok, f"Got {await resp.text()}"
+
+ got = await resp.json()
+ assert got["product_name"] == default_product_name
+
+
+async def _test_product_template_handler(request: web.Request):
+ product_name = products_web.get_product_name(request)
+
+ # if no product, it should return common
+
+ # if no template for product, it should return common
+ # template/common/close_account.jinja2"
+ template_path = await products_web.get_product_template_path(
+ request, filename="close_account.jinja2"
+ )
+ assert template_path.exists()
+ assert template_path.name == "close_account.jinja2"
+ assert "common/" in f"{template_path.resolve().absolute()}"
+
+ # if specific template, it gets and caches in file
+ # "templates/osparc/registration_email.jinja2"
+ template_path = await products_web.get_product_template_path(
+ request, filename="registration_email.jinja2"
+ )
+ assert template_path.exists()
+ assert template_path.name == "registration_email.jinja2"
+ assert f"{product_name}/" in f"{template_path.resolve().absolute()}"
+
+ # get again and should use file
+
+ for _ in range(2):
+ got = await products_web.get_product_template_path(
+ request, filename="registration_email.jinja2"
+ )
+ assert got == template_path
+
+ with pytest.raises(ValueError, match="not part of the templates/common"):
+ await products_web.get_product_template_path(
+ request, filename="invalid-template-name.jinja"
+ )
+
+ return web.json_response()
+
+
+async def test_product_template_helpers(
+ client: TestClient, default_product_name: ProductName
+):
+
+ resp = await client.get(
+ f"/{API_VTAG}/test-product-template-helpers",
+ headers={X_PRODUCT_NAME_HEADER: default_product_name},
+ )
+
+ assert resp.ok, f"Got {await resp.text()}"
+
+
+async def test_get_product_template_path_without_product():
+ fake_request = make_mocked_request("GET", "/fake", app=web.Application())
+
+ # if no product, it should return common
+ template_path = await products_web.get_product_template_path(
+ fake_request, filename="close_account.jinja2"
+ )
+
+ assert template_path.exists()
+ assert template_path.name == "close_account.jinja2"
+ assert "common/" in f"{template_path.resolve().absolute()}"
diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/conftest.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/conftest.py
index bfa6fe9fece..4abc988d577 100644
--- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/conftest.py
@@ -26,7 +26,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_DIAGNOSTICS": "null",
"WEBSERVER_EXPORTER": "null",
"WEBSERVER_GROUPS": "1",
- "WEBSERVER_META_MODELING": "0",
"WEBSERVER_PRODUCTS": "1",
"WEBSERVER_PUBLICATIONS": "0",
"WEBSERVER_RABBITMQ": "null",
@@ -35,7 +34,6 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc
"WEBSERVER_STORAGE": "null",
"WEBSERVER_TAGS": "1",
"WEBSERVER_TRACING": "null",
- "WEBSERVER_VERSION_CONTROL": "0",
"WEBSERVER_WALLETS": "0",
},
)
diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py
index 4a3194ca9a4..3498fd2abcb 100644
--- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py
+++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py
@@ -5,7 +5,6 @@
# pylint: disable=unused-variable
import asyncio
-import json
import re
import urllib.parse
from collections.abc import AsyncIterator
@@ -18,11 +17,14 @@
from aiohttp.test_utils import TestClient, TestServer
from aioresponses import aioresponses
from models_library.projects_state import ProjectLocked, ProjectStatus
-from pydantic import BaseModel, ByteSize, TypeAdapter, ValidationError
+from pydantic import BaseModel, ByteSize, TypeAdapter
from pytest_mock import MockerFixture
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import UserInfoDict, UserRole
-from pytest_simcore.pydantic_models import walk_model_examples_in_package
+from pytest_simcore.pydantic_models import (
+ assert_validation_model,
+ walk_model_examples_in_package,
+)
from servicelib.aiohttp import status
from settings_library.redis import RedisSettings
from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME
@@ -238,16 +240,11 @@ async def test_api_list_supported_filetypes(client: TestClient):
walk_model_examples_in_package(simcore_service_webserver.studies_dispatcher),
)
def test_model_examples(
- model_cls: type[BaseModel], example_name: int, example_data: Any
+ model_cls: type[BaseModel], example_name: str, example_data: Any
):
- try:
- assert model_cls.model_validate(example_data) is not None
- except ValidationError as err:
- pytest.fail(
- f"{example_name} is invalid {model_cls.__module__}.{model_cls.__name__}:"
- f"\n{json.dumps(example_data, indent=1)}"
- f"\nError: {err}"
- )
+ assert_validation_model(
+ model_cls, example_name=example_name, example_data=example_data
+ )
async def test_api_list_services(client: TestClient):
@@ -286,7 +283,7 @@ def catalog_subsystem_mock(mocker: MockerFixture) -> None:
]
mock = mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
autospec=True,
)
@@ -302,7 +299,7 @@ def mocks_on_projects_api(mocker) -> None:
All projects in this module are UNLOCKED
"""
mocker.patch(
- "simcore_service_webserver.projects.projects_service._get_project_lock_state",
+ "simcore_service_webserver.projects._projects_service._get_project_lock_state",
return_value=ProjectLocked(value=False, status=ProjectStatus.CLOSED),
)
@@ -399,11 +396,11 @@ async def test_dispatch_study_anonymously(
):
assert client.app
mock_client_director_v2_func = mocker.patch(
- "simcore_service_webserver.director_v2.api.create_or_update_pipeline",
+ "simcore_service_webserver.director_v2.director_v2_service.create_or_update_pipeline",
return_value=None,
)
mock_dynamic_scheduler_update_project_networks = mocker.patch(
- "simcore_service_webserver.studies_dispatcher._redirects_handlers.dynamic_scheduler_api.update_projects_networks",
+ "simcore_service_webserver.studies_dispatcher._redirects_handlers.dynamic_scheduler_service.update_projects_networks",
return_value=None,
)
@@ -465,11 +462,11 @@ async def test_dispatch_logged_in_user(
):
assert client.app
mock_client_director_v2_pipline_update = mocker.patch(
- "simcore_service_webserver.director_v2.api.create_or_update_pipeline",
+ "simcore_service_webserver.director_v2.director_v2_service.create_or_update_pipeline",
return_value=None,
)
mock_dynamic_scheduler_update_project_networks = mocker.patch(
- "simcore_service_webserver.studies_dispatcher._redirects_handlers.dynamic_scheduler_api.update_projects_networks",
+ "simcore_service_webserver.studies_dispatcher._redirects_handlers.dynamic_scheduler_service.update_projects_networks",
return_value=None,
)
diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py
index b1c7e7259d2..2ae68f22182 100644
--- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py
+++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_projects.py
@@ -18,7 +18,7 @@
from pytest_simcore.helpers.webserver_login import NewUser
from pytest_simcore.helpers.webserver_projects import delete_all_projects
from simcore_service_webserver.groups.api import auto_add_user_to_groups
-from simcore_service_webserver.projects.projects_service import get_project_for_user
+from simcore_service_webserver.projects._projects_service import get_project_for_user
from simcore_service_webserver.studies_dispatcher._models import ServiceInfo
from simcore_service_webserver.studies_dispatcher._projects import (
UserInfo,
@@ -94,7 +94,7 @@ async def test_add_new_project_from_model_instance(
assert client.app
mock_directorv2_api = mocker.patch(
- "simcore_service_webserver.director_v2.api.create_or_update_pipeline",
+ "simcore_service_webserver.director_v2.director_v2_service.create_or_update_pipeline",
return_value=None,
)
diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py
index 10a9367d101..16dfde75956 100644
--- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py
+++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py
@@ -28,11 +28,13 @@
from servicelib.aiohttp import status
from servicelib.aiohttp.long_running_tasks.client import LRTask
from servicelib.aiohttp.long_running_tasks.server import TaskProgress
-from servicelib.aiohttp.rest_responses import unwrap_envelope
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
+from servicelib.rest_responses import unwrap_envelope
from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME
+from simcore_service_webserver.projects._projects_service import (
+ submit_delete_project_task,
+)
from simcore_service_webserver.projects.models import ProjectDict
-from simcore_service_webserver.projects.projects_service import submit_delete_project_task
from simcore_service_webserver.users.api import (
delete_user_without_projects,
get_user_role,
@@ -134,7 +136,7 @@ def mocks_on_projects_api(mocker: MockerFixture) -> None:
All projects in this module are UNLOCKED
"""
mocker.patch(
- "simcore_service_webserver.projects.projects_service._get_project_lock_state",
+ "simcore_service_webserver.projects._projects_service._get_project_lock_state",
return_value=ProjectLocked(value=False, status=ProjectStatus.CLOSED),
)
@@ -411,6 +413,7 @@ async def enforce_garbage_collect_guest(uid):
assert data["login"] != user_email
+@pytest.mark.flaky(max_runs=3)
@pytest.mark.parametrize("number_of_simultaneous_requests", [1, 2, 32])
async def test_guest_user_is_not_garbage_collected(
number_of_simultaneous_requests: int,
diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py
index b5ddcaf6f31..1a615af2551 100644
--- a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py
+++ b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py
@@ -28,7 +28,7 @@
from servicelib.aiohttp import status
from simcore_service_webserver.db.models import UserRole
from simcore_service_webserver.login.utils import notify_user_confirmation
-from simcore_service_webserver.products.api import get_product
+from simcore_service_webserver.products.products_service import get_product
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.users.api import UserDisplayAndIdNamesTuple
from simcore_service_webserver.wallets._events import (
diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py
index c301ead5f90..ac519fba507 100644
--- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py
+++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py
@@ -27,17 +27,12 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
- spec=True,
- return_value=[],
- )
- mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
@@ -259,11 +254,11 @@ def mock_storage_delete_data_folders(mocker: MockerFixture) -> mock.Mock:
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects.projects_service.remove_project_dynamic_services",
+ "simcore_service_webserver.projects._projects_service.remove_project_dynamic_services",
autospec=True,
)
mocker.patch(
- "simcore_service_webserver.projects._crud_api_delete.api.delete_pipeline",
+ "simcore_service_webserver.projects._crud_api_delete.director_v2_service.delete_pipeline",
autospec=True,
)
return mocker.patch(
diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py
index 99bbaffc4a2..c71cdf4fb40 100644
--- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py
+++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py
@@ -23,17 +23,12 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
- spec=True,
- return_value=[],
- )
- mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py
index ea7105a3338..b18252fbdd1 100644
--- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py
+++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py
@@ -28,17 +28,12 @@ def user_role() -> UserRole:
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
- spec=True,
- return_value=[],
- )
- mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py
index a81c76012a0..a308040670b 100644
--- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py
+++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py
@@ -28,17 +28,12 @@
@pytest.fixture
def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture):
mocker.patch(
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
spec=True,
return_value=[],
)
mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
- spec=True,
- return_value=[],
- )
- mocker.patch(
- "simcore_service_webserver.projects._crud_handlers.project_uses_available_services",
+ "simcore_service_webserver.projects._controller.projects_rest.project_uses_available_services",
spec=True,
return_value=True,
)
diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py
index d583e3c783e..38e96c4367d 100644
--- a/services/web/server/tests/unit/with_dbs/conftest.py
+++ b/services/web/server/tests/unit/with_dbs/conftest.py
@@ -1,20 +1,13 @@
-""" Configuration for unit testing with a postgress fixture
-
- - Unit testing of webserver app with a postgress service as fixture
- - Starts test session by running a postgres container as a fixture (see postgress_service)
-
- IMPORTANT: remember that these are still unit-tests!
-"""
-
-# nopycln: file
# pylint: disable=redefined-outer-name
# pylint: disable=unused-argument
# pylint: disable=unused-variable
+# pylint: disable=too-many-arguments
import asyncio
import random
import sys
import textwrap
+import warnings
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterator
from copy import deepcopy
from decimal import Decimal
@@ -35,7 +28,6 @@
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from aiopg.sa import create_engine
-from aiopg.sa.connection import SAConnection
from faker import Faker
from models_library.api_schemas_directorv2.dynamic_services import DynamicServiceGet
from models_library.products import ProductName
@@ -66,9 +58,9 @@
get_default_product_name,
get_or_create_product_group,
)
-from simcore_service_webserver._constants import INDEX_RESOURCE_NAME
from simcore_service_webserver.application import create_application
from simcore_service_webserver.application_settings_utils import AppConfigDict
+from simcore_service_webserver.constants import INDEX_RESOURCE_NAME
from simcore_service_webserver.db.plugin import get_database_engine
from simcore_service_webserver.projects.models import ProjectDict
from simcore_service_webserver.statics._constants import (
@@ -76,6 +68,7 @@
FRONTEND_APPS_AVAILABLE,
)
from sqlalchemy import exc as sql_exceptions
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent
@@ -288,8 +281,7 @@ async def _mocked_get_services_for_user(*args, **kwargs):
return services_in_project
for namespace in (
- "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product",
- "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product",
+ "simcore_service_webserver.projects._crud_api_read.catalog_service.get_services_for_user_in_product",
):
mock = mocker.patch(
namespace,
@@ -383,7 +375,7 @@ async def _mock_result():
)
mock2 = mocker.patch(
- "simcore_service_webserver.projects.projects_service.storage_api.delete_data_folders_of_project_node",
+ "simcore_service_webserver.projects._projects_service.storage_service.delete_data_folders_of_project_node",
autospec=True,
return_value=None,
)
@@ -425,7 +417,7 @@ async def mocked_dynamic_services_interface(
)
mock["director_v2.api.create_or_update_pipeline"] = mocker.patch(
- "simcore_service_webserver.director_v2.api.create_or_update_pipeline",
+ "simcore_service_webserver.director_v2.director_v2_service.create_or_update_pipeline",
autospec=True,
return_value=None,
)
@@ -535,6 +527,13 @@ async def aiopg_engine(postgres_db: sa.engine.Engine) -> AsyncIterator[aiopg.sa.
engine = await create_engine(f"{postgres_db.url}")
assert engine
+ warnings.warn(
+ "The 'aiopg_engine' fixture is deprecated and will be removed in a future release. "
+ "Please use 'asyncpg_engine' fixture instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
yield engine
if engine:
@@ -542,6 +541,34 @@ async def aiopg_engine(postgres_db: sa.engine.Engine) -> AsyncIterator[aiopg.sa.
await engine.wait_closed()
+@pytest.fixture
+async def asyncpg_engine( # <-- WE SHOULD USE THIS ONE instead of aiopg_engine
+ postgres_db: sa.engine.Engine, is_pdb_enabled: bool
+) -> AsyncIterable[AsyncEngine]:
+ # NOTE: call to postgres BEFORE app starts
+ dsn = f"{postgres_db.url}".replace("postgresql://", "postgresql+asyncpg://")
+ minsize = 1
+ maxsize = 50
+
+ engine: AsyncEngine = create_async_engine(
+ dsn,
+ pool_size=minsize,
+ max_overflow=maxsize - minsize,
+ connect_args={
+ "server_settings": {
+ "application_name": "webserver_tests_with_dbs:asyncpg_engine"
+ }
+ },
+ pool_pre_ping=True, # https://docs.sqlalchemy.org/en/14/core/pooling.html#dealing-with-disconnects
+ future=True, # this uses sqlalchemy 2.0 API, shall be removed when sqlalchemy 2.0 is released
+ echo=is_pdb_enabled,
+ )
+
+ yield engine
+
+ await engine.dispose()
+
+
# REDIS CORE SERVICE ------------------------------------------------------
def _is_redis_responsive(host: str, port: int, password: str) -> bool:
# username via https://stackoverflow.com/a/78236235
@@ -631,7 +658,7 @@ async def user_project(
fake_project: ProjectDict,
logged_user: UserInfoDict,
tests_data_dir: Path,
- osparc_product_name: str,
+ osparc_product_name: ProductName,
) -> AsyncIterator[ProjectDict]:
async with NewProject(
fake_project,
@@ -679,23 +706,13 @@ async def with_permitted_override_services_specifications(
@pytest.fixture
-async def _pre_connection(postgres_db: sa.engine.Engine) -> AsyncIterable[SAConnection]:
- # NOTE: call to postgres BEFORE app starts
- async with await create_engine(
- f"{postgres_db.url}"
- ) as engine, engine.acquire() as conn:
- yield conn
-
-
-@pytest.fixture
-async def all_products_names(
- _pre_connection: SAConnection,
+async def app_products_names(
+ asyncpg_engine: AsyncEngine,
) -> AsyncIterable[list[ProductName]]:
- # default product
- result = await _pre_connection.execute(
- products.select().order_by(products.c.priority)
- )
- rows = await result.fetchall()
+ async with asyncpg_engine.connect() as conn:
+ # default product
+ result = await conn.execute(products.select().order_by(products.c.priority))
+ rows = result.fetchall()
assert rows
assert len(rows) == 1
osparc_product_row = rows[0]
@@ -706,37 +723,41 @@ async def all_products_names(
priority = 1
for name in FRONTEND_APPS_AVAILABLE:
if name != FRONTEND_APP_DEFAULT:
- result = await _pre_connection.execute(
- products.insert().values(
- random_product(
- name=name,
- priority=priority,
- login_settings=osparc_product_row.login_settings,
- group_id=None,
+
+ async with asyncpg_engine.begin() as conn:
+ result = await conn.execute(
+ products.insert().values(
+ random_product(
+ name=name,
+ priority=priority,
+ login_settings=osparc_product_row.login_settings,
+ group_id=None,
+ )
)
)
- )
- await get_or_create_product_group(_pre_connection, product_name=name)
+ await get_or_create_product_group(conn, product_name=name)
priority += 1
- # get all products
- result = await _pre_connection.execute(
- sa.select(products.c.name).order_by(products.c.priority)
- )
- rows = await result.fetchall()
+ async with asyncpg_engine.connect() as conn:
+ # get all products
+ result = await conn.execute(
+ sa.select(products.c.name).order_by(products.c.priority)
+ )
+ rows = result.fetchall()
yield [r.name for r in rows]
- await _pre_connection.execute(products_prices.delete())
- await _pre_connection.execute(
- products.delete().where(products.c.name != FRONTEND_APP_DEFAULT)
- )
+ async with asyncpg_engine.begin() as conn:
+ await conn.execute(products_prices.delete())
+ await conn.execute(
+ products.delete().where(products.c.name != FRONTEND_APP_DEFAULT)
+ )
@pytest.fixture
async def all_product_prices(
- _pre_connection: SAConnection,
- all_products_names: list[ProductName],
+ asyncpg_engine: AsyncEngine,
+ app_products_names: list[ProductName],
faker: Faker,
) -> dict[ProductName, Decimal | None]:
"""Initial list of prices for all products"""
@@ -748,23 +769,24 @@ async def all_product_prices(
"tiplite": Decimal(5),
"s4l": Decimal(9),
"s4llite": Decimal(0), # free of charge
- "s4lacad": Decimal(1.1),
+ "s4lacad": Decimal("1.1"),
}
result = {}
- for product_name in all_products_names:
+ for product_name in app_products_names:
usd_or_none = product_price.get(product_name)
if usd_or_none is not None:
- await _pre_connection.execute(
- products_prices.insert().values(
- product_name=product_name,
- usd_per_credit=usd_or_none,
- comment=faker.sentence(),
- min_payment_amount_usd=10,
- stripe_price_id=faker.pystr(),
- stripe_tax_rate_id=faker.pystr(),
+ async with asyncpg_engine.begin() as conn:
+ await conn.execute(
+ products_prices.insert().values(
+ product_name=product_name,
+ usd_per_credit=usd_or_none,
+ comment=faker.sentence(),
+ min_payment_amount_usd=10,
+ stripe_price_id=faker.pystr(),
+ stripe_tax_rate_id=faker.pystr(),
+ )
)
- )
result[product_name] = usd_or_none
@@ -774,23 +796,23 @@ async def all_product_prices(
@pytest.fixture
async def latest_osparc_price(
all_product_prices: dict[ProductName, Decimal],
- _pre_connection: SAConnection,
+ asyncpg_engine: AsyncEngine,
) -> Decimal:
"""This inserts a new price for osparc in the history
(i.e. the old price of osparc is still in the database)
"""
-
- usd = await _pre_connection.scalar(
- products_prices.insert()
- .values(
- product_name="osparc",
- usd_per_credit=all_product_prices["osparc"] + 5,
- comment="New price for osparc",
- stripe_price_id="stripe-price-id",
- stripe_tax_rate_id="stripe-tax-rate-id",
+ async with asyncpg_engine.begin() as conn:
+ usd = await conn.scalar(
+ products_prices.insert()
+ .values(
+ product_name="osparc",
+ usd_per_credit=all_product_prices["osparc"] + 5,
+ comment="New price for osparc",
+ stripe_price_id="stripe-price-id",
+ stripe_tax_rate_id="stripe-tax-rate-id",
+ )
+ .returning(products_prices.c.usd_per_credit)
)
- .returning(products_prices.c.usd_per_credit)
- )
assert usd is not None
assert usd != all_product_prices["osparc"]
return Decimal(usd)
diff --git a/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml b/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml
index 2a4402c85a2..016cb6f7ca2 100644
--- a/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml
+++ b/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml
@@ -1,6 +1,6 @@
services:
postgres:
- image: "postgres:14.8-alpine@sha256:150dd39ccb7ae6c7ba6130c3582c39a30bb5d3d22cb08ad0ba37001e3f829abc"
+ image: "postgres:17.2-alpine3.21@sha256:17143ad87797f511036cf8f50ada164aeb371f0d8068a172510549fb5d2cd65f"
restart: always
init: true
environment:
@@ -34,6 +34,9 @@ services:
- "log_min_duration_statement=500"
- "-c"
- "log_lock_waits=on"
+ # -c fsync=off is not recommended for production as this disable writing to disk https://pythonspeed.com/articles/faster-db-tests/
+ - "-c"
+ - "fsync=off"
adminer:
image: adminer:4.8.1
init: true
@@ -85,7 +88,7 @@ services:
- "18081:8081"
rabbit:
- image: itisfoundation/rabbitmq:3.11.2-management
+ image: itisfoundation/rabbitmq:3.13.7-management
init: true
environment:
- RABBITMQ_DEFAULT_USER=admin
diff --git a/services/web/server/tests/unit/with_dbs/docker-compose.yml b/services/web/server/tests/unit/with_dbs/docker-compose.yml
index 6fde4baab74..2fbc51dec19 100644
--- a/services/web/server/tests/unit/with_dbs/docker-compose.yml
+++ b/services/web/server/tests/unit/with_dbs/docker-compose.yml
@@ -1,6 +1,6 @@
services:
postgres:
- image: "postgres:14.8-alpine@sha256:150dd39ccb7ae6c7ba6130c3582c39a30bb5d3d22cb08ad0ba37001e3f829abc"
+ image: "postgres:17.2-alpine3.21@sha256:17143ad87797f511036cf8f50ada164aeb371f0d8068a172510549fb5d2cd65f"
restart: always
init: true
environment:
@@ -29,6 +29,9 @@ services:
- "tcp_keepalives_count=5"
- "-c"
- "log_lock_waits=on"
+ # -c fsync=off is not recommended for production as this disable writing to disk https://pythonspeed.com/articles/faster-db-tests/
+ - "-c"
+ - "fsync=off"
redis:
image: "redis:6.2.6@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d"
init: true
@@ -50,5 +53,5 @@ services:
"${TEST_REDIS_PASSWORD}"
]
rabbit:
- image: itisfoundation/rabbitmq:3.11.2-management
+ image: itisfoundation/rabbitmq:3.13.7-management
init: true
diff --git a/tests/e2e-playwright/Makefile b/tests/e2e-playwright/Makefile
index dfee6eb774a..4b684cfa823 100644
--- a/tests/e2e-playwright/Makefile
+++ b/tests/e2e-playwright/Makefile
@@ -161,6 +161,12 @@ $(SLEEPERS_INPUT_FILE) $(JUPYTER_LAB_INPUT_FILE) $(CLASSIC_TIP_INPUT_FILE) $(S4L
if [ "$@" = "$(JUPYTER_LAB_INPUT_FILE)" ]; then \
read -p "Enter the size of the large file (human readable form e.g. 3Gib): " LARGE_FILE_SIZE; \
echo "--service-key=jupyter-math --large-file-size=$$LARGE_FILE_SIZE" >> $@; \
+ read -p "Enter the service version (default to latest): " SERVICE_VERSION; \
+ if [ -z "$$SERVICE_VERSION" ]; then \
+ echo "No service version specified, using default."; \
+ else \
+ echo "--service-version=$$SERVICE_VERSION" >> $@; \
+ fi; \
elif [ "$@" = "$(S4L_INPUT_FILE)" ]; then \
read -p "Do you want to check the videostreaming ? (requires to run with chrome/msedge) [y/n]: " VIDEOSTREAM; \
if [ "$$VIDEOSTREAM" = "y" ]; then \
@@ -173,6 +179,12 @@ $(SLEEPERS_INPUT_FILE) $(JUPYTER_LAB_INPUT_FILE) $(CLASSIC_TIP_INPUT_FILE) $(S4L
else \
read -p "Enter the service key: " SERVICE_KEY; \
echo "--service-key=$$SERVICE_KEY" >> $@; \
+ read -p "Enter the service version (default to latest): " SERVICE_VERSION; \
+ if [ -z "$$SERVICE_VERSION" ]; then \
+ echo "No service version specified, using default."; \
+ else \
+ echo "--service-version=$$SERVICE_VERSION" >> $@; \
+ fi; \
fi; \
elif [ "$@" = "$(SLEEPERS_INPUT_FILE)" ]; then \
read -p "Enter the number of sleepers: " NUM_SLEEPERS; \
diff --git a/tests/e2e-playwright/requirements/_test.txt b/tests/e2e-playwright/requirements/_test.txt
index 59eb1576513..43eb1c9e8d0 100644
--- a/tests/e2e-playwright/requirements/_test.txt
+++ b/tests/e2e-playwright/requirements/_test.txt
@@ -8,7 +8,7 @@ anyio==4.8.0
# httpx
arrow==1.3.0
# via -r requirements/_test_wo_playwright.txt
-certifi==2024.12.14
+certifi==2025.1.31
# via
# -r requirements/_test_wo_playwright.txt
# httpcore
@@ -26,7 +26,7 @@ docker==7.1.0
# via -r requirements/_test_wo_playwright.txt
email-validator==2.2.0
# via -r requirements/_test_wo_playwright.txt
-faker==35.0.0
+faker==36.1.1
# via -r requirements/_test_wo_playwright.txt
greenlet==3.1.1
# via playwright
@@ -64,7 +64,7 @@ packaging==24.2
# -r requirements/_test_wo_playwright.txt
# pytest
# pytest-sugar
-playwright==1.49.1
+playwright==1.50.0
# via pytest-playwright
pluggy==1.5.0
# via
@@ -76,9 +76,9 @@ pydantic-core==2.27.2
# via
# -r requirements/_test_wo_playwright.txt
# pydantic
-pyee==12.0.0
+pyee==12.1.1
# via playwright
-pytest==8.3.4
+pytest==8.3.5
# via
# -r requirements/_test_wo_playwright.txt
# pytest-base-url
@@ -97,7 +97,7 @@ pytest-metadata==3.1.1
# via
# -r requirements/_test_wo_playwright.txt
# pytest-html
-pytest-playwright==0.6.2
+pytest-playwright==0.7.0
# via -r requirements/_test.in
pytest-runner==6.0.1
# via -r requirements/_test_wo_playwright.txt
@@ -107,7 +107,6 @@ python-dateutil==2.9.0.post0
# via
# -r requirements/_test_wo_playwright.txt
# arrow
- # faker
python-slugify==8.0.4
# via pytest-playwright
pyyaml==6.0.2
@@ -141,10 +140,13 @@ typing-extensions==4.12.2
# via
# -r requirements/_test_wo_playwright.txt
# anyio
- # faker
# pydantic
# pydantic-core
# pyee
+tzdata==2025.1
+ # via
+ # -r requirements/_test_wo_playwright.txt
+ # faker
urllib3==2.3.0
# via
# -r requirements/_test_wo_playwright.txt
diff --git a/tests/e2e-playwright/requirements/_test_wo_playwright.txt b/tests/e2e-playwright/requirements/_test_wo_playwright.txt
index 442c520fc24..6bb18aa518f 100644
--- a/tests/e2e-playwright/requirements/_test_wo_playwright.txt
+++ b/tests/e2e-playwright/requirements/_test_wo_playwright.txt
@@ -4,7 +4,7 @@ anyio==4.8.0
# via httpx
arrow==1.3.0
# via -r requirements/_test_wo_playwright.in
-certifi==2024.12.14
+certifi==2025.1.31
# via
# httpcore
# httpx
@@ -17,7 +17,7 @@ docker==7.1.0
# via -r requirements/_test_wo_playwright.in
email-validator==2.2.0
# via pydantic
-faker==35.0.0
+faker==36.1.1
# via -r requirements/_test_wo_playwright.in
h11==0.14.0
# via httpcore
@@ -47,7 +47,7 @@ pydantic==2.10.6
# via -r requirements/_test_wo_playwright.in
pydantic-core==2.27.2
# via pydantic
-pytest==8.3.4
+pytest==8.3.5
# via
# pytest-html
# pytest-instafail
@@ -64,9 +64,7 @@ pytest-runner==6.0.1
pytest-sugar==1.0.0
# via -r requirements/_test_wo_playwright.in
python-dateutil==2.9.0.post0
- # via
- # arrow
- # faker
+ # via arrow
pyyaml==6.0.2
# via -r requirements/_test_wo_playwright.in
requests==2.32.3
@@ -84,9 +82,10 @@ types-python-dateutil==2.9.0.20241206
typing-extensions==4.12.2
# via
# anyio
- # faker
# pydantic
# pydantic-core
+tzdata==2025.1
+ # via faker
urllib3==2.3.0
# via
# docker
diff --git a/tests/e2e-playwright/requirements/_tools.txt b/tests/e2e-playwright/requirements/_tools.txt
index 645bb336ed6..853cda1d8ca 100644
--- a/tests/e2e-playwright/requirements/_tools.txt
+++ b/tests/e2e-playwright/requirements/_tools.txt
@@ -1,6 +1,6 @@
astroid==3.3.8
# via pylint
-black==24.10.0
+black==25.1.0
# via -r requirements/../../../requirements/devenv.txt
build==1.2.2.post1
# via pip-tools
@@ -18,15 +18,15 @@ distlib==0.3.9
# via virtualenv
filelock==3.17.0
# via virtualenv
-identify==2.6.6
+identify==2.6.8
# via pre-commit
-isort==5.13.2
+isort==6.0.1
# via
# -r requirements/../../../requirements/devenv.txt
# pylint
mccabe==0.7.0
# via pylint
-mypy==1.14.1
+mypy==1.15.0
# via -r requirements/../../../requirements/devenv.txt
mypy-extensions==1.0.0
# via
@@ -41,7 +41,7 @@ packaging==24.2
# build
pathspec==0.12.1
# via black
-pip==25.0
+pip==25.0.1
# via pip-tools
pip-tools==7.4.1
# via -r requirements/../../../requirements/devenv.txt
@@ -52,7 +52,7 @@ platformdirs==4.3.6
# virtualenv
pre-commit==4.1.0
# via -r requirements/../../../requirements/devenv.txt
-pylint==3.3.3
+pylint==3.3.4
# via -r requirements/../../../requirements/devenv.txt
pyproject-hooks==1.2.0
# via
@@ -63,9 +63,9 @@ pyyaml==6.0.2
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_test.txt
# pre-commit
-ruff==0.9.3
+ruff==0.9.9
# via -r requirements/../../../requirements/devenv.txt
-setuptools==75.8.0
+setuptools==75.8.2
# via pip-tools
tomlkit==0.13.2
# via pylint
@@ -73,7 +73,7 @@ typing-extensions==4.12.2
# via
# -c requirements/_test.txt
# mypy
-virtualenv==20.29.1
+virtualenv==20.29.2
# via pre-commit
wheel==0.45.1
# via pip-tools
diff --git a/tests/e2e-playwright/tests/conftest.py b/tests/e2e-playwright/tests/conftest.py
index a3402b7746d..7df7c63c5e6 100644
--- a/tests/e2e-playwright/tests/conftest.py
+++ b/tests/e2e-playwright/tests/conftest.py
@@ -112,6 +112,13 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=False,
help="Whether service is a legacy service (no sidecar)",
)
+ group.addoption(
+ "--service-version",
+ action="store",
+ type=str,
+ default=None,
+ help="The service version option defines a service specific version",
+ )
group.addoption(
"--template-id",
action="store",
@@ -272,6 +279,14 @@ def is_service_legacy(request: pytest.FixtureRequest) -> bool:
return TypeAdapter(bool).validate_python(autoscaled)
+@pytest.fixture(scope="session")
+def service_version(request: pytest.FixtureRequest) -> str | None:
+ if key := request.config.getoption("--service-version"):
+ assert isinstance(key, str)
+ return key
+ return None
+
+
@pytest.fixture(scope="session")
def template_id(request: pytest.FixtureRequest) -> str | None:
if key := request.config.getoption("--template-id"):
@@ -438,6 +453,22 @@ def _open_with_resources(page: Page, *, click_it: bool):
return open_with_resources_button
+def _select_service_version(page: Page, *, version: str) -> None:
+ try:
+ # since https://github.com/ITISFoundation/osparc-simcore/pull/7060
+ with log_context(logging.INFO, msg=f"selecting version {version}"):
+ page.get_by_test_id("serviceSelectBox").click(timeout=5 * SECOND)
+ page.get_by_test_id(f"serviceVersionItem_{version}").click(
+ timeout=5 * SECOND
+ )
+ # the call is cached so the best is to wait here a bit (sic)
+ page.wait_for_timeout(2 * SECOND)
+
+ except TimeoutError:
+ # we try the non robust way
+ page.get_by_label("Version").select_option(version)
+
+
@pytest.fixture
def create_new_project_and_delete(
page: Page,
@@ -445,16 +476,19 @@ def create_new_project_and_delete(
is_product_billable: bool,
api_request_context: APIRequestContext,
product_url: AnyUrl,
-) -> Iterator[Callable[[tuple[RunningState], bool], dict[str, Any]]]:
+) -> Iterator[
+ Callable[[tuple[RunningState], bool, str | None, str | None], dict[str, Any]]
+]:
"""The first available service currently displayed in the dashboard will be opened
NOTE: cannot be used multiple times or going back to dashboard will fail!!
"""
created_project_uuids = []
def _(
- expected_states: tuple[RunningState] = (RunningState.NOT_STARTED,),
- press_open: bool = True,
- template_id: str | None = None,
+ expected_states: tuple[RunningState],
+ press_open: bool,
+ template_id: str | None,
+ service_version: str | None,
) -> dict[str, Any]:
assert (
len(created_project_uuids) == 0
@@ -527,6 +561,8 @@ def wait_for_done(response):
# not expected in the sim4life context though
...
else:
+ if service_version is not None:
+ _select_service_version(page, version=service_version)
open_button.click()
if is_product_billable:
_open_with_resources(page, click_it=True)
@@ -618,7 +654,9 @@ def find_and_start_service_in_dashboard(
page: Page,
) -> Callable[[ServiceType, str, str | None], None]:
def _(
- service_type: ServiceType, service_name: str, service_key_prefix: str | None
+ service_type: ServiceType,
+ service_name: str,
+ service_key_prefix: str | None,
) -> None:
with log_context(logging.INFO, f"Finding {service_name=} in dashboard"):
page.get_by_test_id("servicesTabBtn").click()
@@ -638,13 +676,13 @@ def _(
def create_project_from_new_button(
start_study_from_plus_button: Callable[[str], None],
create_new_project_and_delete: Callable[
- [tuple[RunningState], bool], dict[str, Any]
+ [tuple[RunningState], bool, str | None, str | None], dict[str, Any]
],
) -> Callable[[str], dict[str, Any]]:
def _(plus_button_test_id: str) -> dict[str, Any]:
start_study_from_plus_button(plus_button_test_id)
expected_states = (RunningState.UNKNOWN,)
- return create_new_project_and_delete(expected_states, False)
+ return create_new_project_and_delete(expected_states, False, None, None)
return _
@@ -652,12 +690,14 @@ def _(plus_button_test_id: str) -> dict[str, Any]:
@pytest.fixture
def create_project_from_template_dashboard(
find_and_click_template_in_dashboard: Callable[[str], None],
- create_new_project_and_delete: Callable[[tuple[RunningState]], dict[str, Any]],
-) -> Callable[[ServiceType, str, str | None], dict[str, Any]]:
+ create_new_project_and_delete: Callable[
+ [tuple[RunningState], bool, str | None, str | None], dict[str, Any]
+ ],
+) -> Callable[[str], dict[str, Any]]:
def _(template_id: str) -> dict[str, Any]:
find_and_click_template_in_dashboard(template_id)
expected_states = (RunningState.UNKNOWN,)
- return create_new_project_and_delete(expected_states, True, template_id)
+ return create_new_project_and_delete(expected_states, True, template_id, None)
return _
@@ -665,10 +705,15 @@ def _(template_id: str) -> dict[str, Any]:
@pytest.fixture
def create_project_from_service_dashboard(
find_and_start_service_in_dashboard: Callable[[ServiceType, str, str | None], None],
- create_new_project_and_delete: Callable[[tuple[RunningState]], dict[str, Any]],
-) -> Callable[[ServiceType, str, str | None], dict[str, Any]]:
+ create_new_project_and_delete: Callable[
+ [tuple[RunningState], bool, str | None, str | None], dict[str, Any]
+ ],
+) -> Callable[[ServiceType, str, str | None, str | None], dict[str, Any]]:
def _(
- service_type: ServiceType, service_name: str, service_key_prefix: str | None
+ service_type: ServiceType,
+ service_name: str,
+ service_key_prefix: str | None,
+ service_version: str | None,
) -> dict[str, Any]:
find_and_start_service_in_dashboard(
service_type, service_name, service_key_prefix
@@ -676,7 +721,9 @@ def _(
expected_states = (RunningState.UNKNOWN,)
if service_type is ServiceType.COMPUTATIONAL:
expected_states = (RunningState.NOT_STARTED,)
- return create_new_project_and_delete(expected_states, True)
+ return create_new_project_and_delete(
+ expected_states, True, None, service_version
+ )
return _
diff --git a/tests/e2e-playwright/tests/jupyterlabs/test_jupyterlab.py b/tests/e2e-playwright/tests/jupyterlabs/test_jupyterlab.py
index fcd20bbbd04..f61d510b09b 100644
--- a/tests/e2e-playwright/tests/jupyterlabs/test_jupyterlab.py
+++ b/tests/e2e-playwright/tests/jupyterlabs/test_jupyterlab.py
@@ -64,9 +64,10 @@ def test_jupyterlab(
page: Page,
log_in_and_out: RestartableWebSocket,
create_project_from_service_dashboard: Callable[
- [ServiceType, str, str | None], dict[str, Any]
+ [ServiceType, str, str | None, str | None], dict[str, Any]
],
service_key: str,
+ service_version: str | None,
large_file_size: ByteSize,
large_file_block_size: ByteSize,
product_url: AnyUrl,
@@ -86,7 +87,7 @@ def test_jupyterlab(
),
):
project_data = create_project_from_service_dashboard(
- ServiceType.DYNAMIC, service_key, None
+ ServiceType.DYNAMIC, service_key, None, service_version
)
assert "workbench" in project_data, "Expected workbench to be in project data!"
assert isinstance(
diff --git a/tests/e2e-playwright/tests/sim4life/test_sim4life.py b/tests/e2e-playwright/tests/sim4life/test_sim4life.py
index 10a9ddcf97e..b3747da27b1 100644
--- a/tests/e2e-playwright/tests/sim4life/test_sim4life.py
+++ b/tests/e2e-playwright/tests/sim4life/test_sim4life.py
@@ -23,11 +23,12 @@
def test_sim4life(
page: Page,
create_project_from_service_dashboard: Callable[
- [ServiceType, str, str | None], dict[str, Any]
+ [ServiceType, str, str | None, str | None], dict[str, Any]
],
create_project_from_new_button: Callable[[str], dict[str, Any]],
log_in_and_out: RestartableWebSocket,
service_key: str,
+ service_version: str | None,
use_plus_button: bool,
is_autoscaled: bool,
check_videostreaming: bool,
@@ -38,7 +39,7 @@ def test_sim4life(
project_data = create_project_from_new_button(service_key)
else:
project_data = create_project_from_service_dashboard(
- ServiceType.DYNAMIC, service_key, None
+ ServiceType.DYNAMIC, service_key, None, service_version
)
assert "workbench" in project_data, "Expected workbench to be in project data!"
diff --git a/tests/e2e-playwright/tests/sleepers/test_sleepers.py b/tests/e2e-playwright/tests/sleepers/test_sleepers.py
index 4415fcf3e49..570511b158d 100644
--- a/tests/e2e-playwright/tests/sleepers/test_sleepers.py
+++ b/tests/e2e-playwright/tests/sleepers/test_sleepers.py
@@ -37,9 +37,9 @@
_WAITING_FOR_CLUSTER_MAX_WAITING_TIME: Final[int] = 5 * MINUTE
_WAITING_FOR_STARTED_MAX_WAITING_TIME: Final[int] = 5 * MINUTE
_WAITING_FOR_SUCCESS_MAX_WAITING_TIME_PER_SLEEPER: Final[int] = 1 * MINUTE
-_WAITING_FOR_FILE_NAMES_MAX_WAITING_TIME: Final[
- datetime.timedelta
-] = datetime.timedelta(seconds=30)
+_WAITING_FOR_FILE_NAMES_MAX_WAITING_TIME: Final[datetime.timedelta] = (
+ datetime.timedelta(seconds=30)
+)
_WAITING_FOR_FILE_NAMES_WAIT_INTERVAL: Final[datetime.timedelta] = datetime.timedelta(
seconds=1
)
@@ -67,6 +67,7 @@ def _get_expected_file_names_for_version(version: Version) -> list[str]:
)
def _get_file_names(page: Page) -> list[str]:
file_names_found = []
+ page.get_by_test_id("folderGridView").click()
for file in page.get_by_test_id("FolderViewerItem").all():
file_name = file.text_content()
assert file_name
@@ -81,14 +82,14 @@ def test_sleepers(
page: Page,
log_in_and_out: RestartableWebSocket,
create_project_from_service_dashboard: Callable[
- [ServiceType, str, str | None], dict[str, Any]
+ [ServiceType, str, str | None, str | None], dict[str, Any]
],
start_and_stop_pipeline: Callable[..., SocketIOEvent],
num_sleepers: int,
input_sleep_time: int | None,
):
project_data = create_project_from_service_dashboard(
- ServiceType.COMPUTATIONAL, "sleeper", "itis"
+ ServiceType.COMPUTATIONAL, "sleeper", "itis", None
)
# we are now in the workbench
@@ -216,7 +217,7 @@ def test_sleepers(
sleeper.click()
# waiting for this response is not enough, the frontend needs some time to show the files
# therefore _get_file_names is wrapped with tenacity
- with page.expect_response(re.compile(r"files/metadata")):
+ with page.expect_response(re.compile(r"paths\?file_filter=")):
page.get_by_test_id("nodeFilesBtn").click()
output_file_names_found = _get_file_names(page)
diff --git a/tests/e2e-playwright/tests/tip/conftest.py b/tests/e2e-playwright/tests/tip/conftest.py
index b0d979921ed..094c8b8f78e 100644
--- a/tests/e2e-playwright/tests/tip/conftest.py
+++ b/tests/e2e-playwright/tests/tip/conftest.py
@@ -27,12 +27,12 @@ def _(
def create_tip_plan_from_dashboard(
find_and_start_tip_plan_in_dashboard: Callable[[str], None],
create_new_project_and_delete: Callable[
- [tuple[RunningState], bool], dict[str, Any]
+ [tuple[RunningState], bool, str | None, str | None], dict[str, Any]
],
) -> Callable[[str], dict[str, Any]]:
def _(plan_name_test_id: str) -> dict[str, Any]:
find_and_start_tip_plan_in_dashboard(plan_name_test_id)
expected_states = (RunningState.UNKNOWN,)
- return create_new_project_and_delete(expected_states, press_open=False)
+ return create_new_project_and_delete(expected_states, False, None, None)
return _
diff --git a/tests/e2e-playwright/tests/tip/test_ti_plan.py b/tests/e2e-playwright/tests/tip/test_ti_plan.py
index 7d1561efc7b..de4488ac310 100644
--- a/tests/e2e-playwright/tests/tip/test_ti_plan.py
+++ b/tests/e2e-playwright/tests/tip/test_ti_plan.py
@@ -152,6 +152,10 @@ def test_classic_ti_plan( # noqa: PLR0915
page.wait_for_timeout(_ELECTRODE_SELECTOR_FLICKERING_WAIT_TIME)
with log_context(logging.INFO, "Configure selector"):
+ assert (
+ page.get_by_test_id("settingsForm_" + node_ids[0]).count() == 0
+ ), "service settings should not be visible"
+
electrode_selector_iframe.get_by_test_id("TargetStructure_Selector").click()
electrode_selector_iframe.get_by_test_id(
"TargetStructure_Target_(Targets_combined) Hypothalamus"
diff --git a/tests/e2e/jest.config.js b/tests/e2e/jest.config.js
index ad2b7b1ed11..8c91e5eea9f 100644
--- a/tests/e2e/jest.config.js
+++ b/tests/e2e/jest.config.js
@@ -1,11 +1,13 @@
module.exports = {
preset: "jest-puppeteer",
verbose: true,
- collectCoverage: true,
+ collectCoverage: false,
coverageReporters: ["lcov", "text"],
globals: {
url: "http://127.0.0.1.nip.io:9081/", // For local testing, set your deployed url here
apiVersion: 'v0/',
ourTimeout: 40000,
- }
+ },
+ maxWorkers: 1,
+ maxConcurrency: 1
}
diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json
index d5aaa2c02e9..c1b902569fd 100644
--- a/tests/e2e/package-lock.json
+++ b/tests/e2e/package-lock.json
@@ -30,11 +30,13 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
- "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "license": "MIT",
"dependencies": {
- "@babel/highlight": "^7.24.7",
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
@@ -224,17 +226,19 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz",
- "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+ "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
- "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -248,35 +252,26 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz",
- "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
+ "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
+ "license": "MIT",
"dependencies": {
- "@babel/template": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/template": "^7.26.9",
+ "@babel/types": "^7.26.10"
},
"engines": {
"node": ">=6.9.0"
}
},
- "node_modules/@babel/highlight": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
- "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
+ "node_modules/@babel/parser": {
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
+ "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+ "license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.24.7",
- "chalk": "^2.4.2",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.0.0"
+ "@babel/types": "^7.26.10"
},
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
- "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -448,13 +443,14 @@
}
},
"node_modules/@babel/template": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
- "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
+ "version": "7.26.9",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
+ "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+ "license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.24.7",
- "@babel/parser": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/code-frame": "^7.26.2",
+ "@babel/parser": "^7.26.9",
+ "@babel/types": "^7.26.9"
},
"engines": {
"node": ">=6.9.0"
@@ -502,13 +498,13 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/@babel/types": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz",
- "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
+ "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+ "license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.24.7",
- "@babel/helper-validator-identifier": "^7.24.7",
- "to-fast-properties": "^2.0.0"
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -4564,7 +4560,8 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.1",
@@ -5715,14 +5712,6 @@
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
},
- "node_modules/to-fast-properties": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
- "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6090,11 +6079,12 @@
}
},
"@babel/code-frame": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
- "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
+ "version": "7.26.2",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
+ "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"requires": {
- "@babel/highlight": "^7.24.7",
+ "@babel/helper-validator-identifier": "^7.25.9",
+ "js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
}
},
@@ -6232,14 +6222,14 @@
}
},
"@babel/helper-string-parser": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz",
- "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg=="
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
+ "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="
},
"@babel/helper-validator-identifier": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
- "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w=="
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
+ "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="
},
"@babel/helper-validator-option": {
"version": "7.24.7",
@@ -6247,30 +6237,22 @@
"integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw=="
},
"@babel/helpers": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz",
- "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
+ "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
"requires": {
- "@babel/template": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/template": "^7.26.9",
+ "@babel/types": "^7.26.10"
}
},
- "@babel/highlight": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
- "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
+ "@babel/parser": {
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
+ "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
"requires": {
- "@babel/helper-validator-identifier": "^7.24.7",
- "chalk": "^2.4.2",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.0.0"
+ "@babel/types": "^7.26.10"
}
},
- "@babel/parser": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
- "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw=="
- },
"@babel/plugin-syntax-async-generators": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
@@ -6384,13 +6366,13 @@
}
},
"@babel/template": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
- "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
+ "version": "7.26.9",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
+ "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
"requires": {
- "@babel/code-frame": "^7.24.7",
- "@babel/parser": "^7.24.7",
- "@babel/types": "^7.24.7"
+ "@babel/code-frame": "^7.26.2",
+ "@babel/parser": "^7.26.9",
+ "@babel/types": "^7.26.9"
}
},
"@babel/traverse": {
@@ -6426,13 +6408,12 @@
}
},
"@babel/types": {
- "version": "7.24.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz",
- "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==",
+ "version": "7.26.10",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
+ "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
"requires": {
- "@babel/helper-string-parser": "^7.24.7",
- "@babel/helper-validator-identifier": "^7.24.7",
- "to-fast-properties": "^2.0.0"
+ "@babel/helper-string-parser": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.25.9"
}
},
"@bcoe/v8-coverage": {
@@ -10316,11 +10297,6 @@
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
},
- "to-fast-properties": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
- "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="
- },
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
diff --git a/tests/e2e/portal/BIOS_VNS_Calibrator.js b/tests/e2e/portal/BIOS_VNS_Calibrator.js
index e6959fedc85..db0aaabf943 100644
--- a/tests/e2e/portal/BIOS_VNS_Calibrator.js
+++ b/tests/e2e/portal/BIOS_VNS_Calibrator.js
@@ -28,10 +28,10 @@ async function runTutorial () {
console.log("Workbench Data:", workbenchData);
const BIOSIdViewer = workbenchData["nodeIds"][0];
await tutorial.waitForServices(workbenchData["studyId"],
- [BIOSIdViewer],
- startTimeout,
- false
- );
+ [BIOSIdViewer],
+ startTimeout,
+ false
+ );
await tutorial.waitFor(5000, 'Some time for starting the service');
await utils.takeScreenshot(page, screenshotPrefix + 'service_started');
diff --git a/tests/e2e/requirements/requirements.txt b/tests/e2e/requirements/requirements.txt
index 2f743be8691..c5473cc7305 100644
--- a/tests/e2e/requirements/requirements.txt
+++ b/tests/e2e/requirements/requirements.txt
@@ -1,4 +1,4 @@
-certifi==2024.12.14
+certifi==2025.1.31
# via
# -c requirements/../../../requirements/constraints.txt
# requests
diff --git a/tests/e2e/tests/register.js b/tests/e2e/tests/register.js
new file mode 100644
index 00000000000..560a1e535fe
--- /dev/null
+++ b/tests/e2e/tests/register.js
@@ -0,0 +1,75 @@
+const auto = require('../utils/auto');
+const utils = require('../utils/utils');
+
+
+const {
+ user,
+ pass
+} = utils.getUserAndPass();
+
+module.exports = {
+ registerAndLogOut: () => {
+ describe('Register and LogOut', () => {
+ const firstHandler = async response => {
+ if (response.url().endsWith("/config")) {
+ try {
+ const respStatus = response.status();
+ expect(respStatus).toBe(200);
+ const responseBody = await response.json();
+ expect(responseBody.data["invitation_required"]).toBeFalsy();
+ } catch (e) {
+ console.log("Puppeteer error", e);
+ }
+ } else if (response.url().endsWith("/register")) {
+ try {
+ const respStatus = response.status();
+ expect(respStatus).toBe(200);
+ } catch (e) {
+ console.log("Puppeteer error", e);
+ }
+ }
+ }
+
+ const secondHandler = response => {
+ if (response.url().endsWith("/login")) {
+ try {
+ const respStatus = response.status();
+ expect(respStatus).toBe(200);
+ } catch (e) {
+ console.log("Puppeteer error", e);
+ }
+ } else if (response.url().endsWith("/me")) {
+ try {
+ const respStatus = response.status();
+ expect(respStatus).toBe(200);
+ } catch (e) {
+ console.log("Puppeteer error", e);
+ }
+ } else if (response.url().endsWith("/logout")) {
+ expect(response.status()).toBe(200);
+ }
+ }
+
+ beforeAll(async () => {
+ console.log("Start:", new Date().toUTCString());
+
+ await page.goto(url);
+ }, ourTimeout);
+
+ afterAll(async () => {
+ page.off('response', firstHandler);
+ page.off('response', secondHandler);
+
+ console.log("End:", new Date().toUTCString());
+ })
+
+ test('Register and Log Out', async () => {
+ page.on('response', firstHandler);
+ await auto.register(page, user, pass);
+ page.on('response', secondHandler);
+ await auto.logOut(page);
+ await page.waitFor(5000);
+ }, ourTimeout);
+ });
+ }
+}
diff --git a/tests/e2e/tests/register.test.js b/tests/e2e/tests/register.test.js
deleted file mode 100644
index 65bf701bd82..00000000000
--- a/tests/e2e/tests/register.test.js
+++ /dev/null
@@ -1,71 +0,0 @@
-const auto = require('../utils/auto');
-const utils = require('../utils/utils');
-
-const {
- user,
- pass
-} = utils.getUserAndPass();
-
-const firstHandler = async response => {
- if (response.url().endsWith("/config")) {
- try {
- const respStatus = response.status();
- expect(respStatus).toBe(200);
- const responseBody = await response.json();
- expect(responseBody.data["invitation_required"]).toBeFalsy();
- }
- catch (e) {
- console.log("Pptr error", e);
- }
- }
- else if (response.url().endsWith("/register")) {
- try {
- const respStatus = response.status();
- expect(respStatus).toBe(200);
- }
- catch (e) {
- console.log("Pptr error", e);
- }
- }
-}
-
-const secondHandler = response => {
- if (response.url().endsWith("/login")) {
- try {
- const respStatus = response.status();
- expect(respStatus).toBe(200);
- }
- catch (e) {
- console.log("Pptr error", e);
- }
- }
- else if (response.url().endsWith("/me")) {
- try {
- const respStatus = response.status();
- expect(respStatus).toBe(200);
- }
- catch (e) {
- console.log("Pptr error", e);
- }
- }
- else if (response.url().endsWith("/logout")) {
- expect(response.status()).toBe(200);
- }
-}
-
-beforeAll(async () => {
- await page.goto(url);
-}, ourTimeout);
-
-afterAll(async () => {
- page.off('response', firstHandler);
- page.off('response', secondHandler);
-})
-
-test('Register and Log Out', async () => {
- page.on('response', firstHandler);
- await auto.register(page, user, pass);
- page.on('response', secondHandler);
- await auto.logOut(page);
- await page.waitFor(5000);
-}, ourTimeout);
diff --git a/tests/e2e/tests/startupCalls.js b/tests/e2e/tests/startupCalls.js
new file mode 100644
index 00000000000..78d080c1367
--- /dev/null
+++ b/tests/e2e/tests/startupCalls.js
@@ -0,0 +1,102 @@
+const auto = require('../utils/auto');
+const utils = require('../utils/utils');
+
+module.exports = {
+ startupCalls: () => {
+ describe('Calls after logging in', () => {
+ const {
+ user,
+ pass
+ } = utils.getUserAndPass();
+
+ const responses = {
+ me: null,
+ tags: null,
+ tasks: null,
+ uiConfig: null,
+ studies: null,
+ templates: null,
+ services: null,
+ };
+
+ beforeAll(async () => {
+ console.log("Start:", new Date().toUTCString());
+
+ page.on('response', response => {
+ const url = response.url();
+ if (url.endsWith('/me')) {
+ responses.me = response.json();
+ } else if (url.endsWith('/tags')) {
+ responses.tags = response.json();
+ } else if (url.endsWith('/tasks')) {
+ responses.tasks = response.json();
+ } else if (url.endsWith('/ui')) {
+ responses.uiConfig = response.json();
+ } else if (url.includes('projects?type=user')) {
+ responses.studies = response.json();
+ } else if (url.includes('projects?type=template')) {
+ responses.templates = response.json();
+ } else if (url.includes('catalog/services/-/latest')) {
+ responses.services = response.json();
+ }
+ });
+
+ await page.goto(url);
+
+ console.log("Registering user");
+ await auto.register(page, user, pass);
+ console.log("Registered");
+
+ await page.waitFor(10000);
+ }, ourTimeout);
+
+ afterAll(async () => {
+ await auto.logOut(page);
+
+ console.log("End:", new Date().toUTCString());
+ }, ourTimeout);
+
+ test('Profile', async () => {
+ const responseEnv = await responses.me;
+ expect(responseEnv.data["login"]).toBe(user);
+ }, ourTimeout);
+
+ test('Tags', async () => {
+ const responseEnv = await responses.tags;
+ expect(Array.isArray(responseEnv.data)).toBeTruthy();
+ }, ourTimeout);
+
+ /*
+ test('Tasks', async () => {
+ const responseEnv = await responses.tasks;
+ expect(Array.isArray(responseEnv.data)).toBeTruthy();
+ }, ourTimeout);
+ */
+
+ test('UI Config', async () => {
+ const responseEnv = await responses.uiConfig;
+ expect(responseEnv.data["productName"]).toBe("osparc");
+ const uiConfig = responseEnv.data["ui"];
+ const isObject = typeof uiConfig === 'object' && !Array.isArray(uiConfig) && uiConfig !== null;
+ expect(isObject).toBeTruthy();
+ }, ourTimeout);
+
+ test('Studies', async () => {
+ const responseEnv = await responses.studies;
+ expect(Array.isArray(responseEnv.data)).toBeTruthy();
+ }, ourTimeout);
+
+ test('Templates', async () => {
+ const responseEnv = await responses.templates;
+ expect(Array.isArray(responseEnv.data)).toBeTruthy();
+ }, ourTimeout);
+
+ test('Services', async () => {
+ const responseEnv = await responses.services;
+ expect(responseEnv.data._meta.total).toBeGreaterThan(0);
+ expect(Array.isArray(responseEnv.data.data)).toBeTruthy();
+ expect(responseEnv.data.data.length).toBeGreaterThan(0);
+ }, ourTimeout);
+ });
+ }
+}
diff --git a/tests/e2e/tests/startupCalls.test.js b/tests/e2e/tests/startupCalls.test.js
deleted file mode 100644
index c9ce849056f..00000000000
--- a/tests/e2e/tests/startupCalls.test.js
+++ /dev/null
@@ -1,47 +0,0 @@
-const auto = require('../utils/auto');
-const utils = require('../utils/utils');
-
-describe('Calls after logging in', () => {
- const {
- user,
- pass
- } = utils.getUserAndPass();
-
- beforeAll(async () => {
- await page.goto(url);
- await auto.register(page, user, pass);
- await page.waitFor(1000);
- }, ourTimeout);
-
- afterAll(async () => {
- await auto.logOut(page);
- }, ourTimeout);
-
- test('Profile', async () => {
- const responseEnv = await utils.fetchReq('me');
- expect(responseEnv.data["login"]).toBe(user);
- }, ourTimeout);
-
- test('Studies', async () => {
- const responseEnv = await utils.fetchReq('projects?type=user');
- expect(Array.isArray(responseEnv.data)).toBeTruthy();
- }, ourTimeout);
-
- test('Templates', async () => {
- const responseEnv = await utils.fetchReq('projects?type=template');
- expect(Array.isArray(responseEnv.data)).toBeTruthy();
- }, ourTimeout);
-
- test('Services', async () => {
- const responseEnv = await utils.fetchReq('catalog/services/-/latest');
- expect(responseEnv.data._meta.total).toBeGreaterThan(0);
- expect(Array.isArray(responseEnv.data.data)).toBeTruthy();
- expect(responseEnv.data.data.length).toBeGreaterThan(0);
- }, ourTimeout);
-
- test('Locations', async () => {
- const responseEnv = await utils.fetchReq('storage/locations');
- expect(Array.isArray(responseEnv.data)).toBeTruthy();
- expect(responseEnv.data.length).toBeGreaterThan(0);
- }, ourTimeout);
-});
diff --git a/tests/e2e/tests/tags.js b/tests/e2e/tests/tags.js
new file mode 100644
index 00000000000..0814f11d47d
--- /dev/null
+++ b/tests/e2e/tests/tags.js
@@ -0,0 +1,159 @@
+const utils = require('../utils/utils');
+const auto = require('../utils/auto');
+const waitAndClick = require('../utils/utils').waitAndClick;
+
+
+module.exports = {
+ testTags: () => {
+ describe('tags testing', () => {
+ const {
+ user,
+ pass,
+ } = utils.getUserAndPass();
+
+ const TAG_NAME = 'tag_test';
+ const TAG_NAME_2 = 'tag_test_2';
+ let studyId = null;
+ let tagId = null;
+
+ /**
+ * This function records the IDs of the study and tag created in order to later remove them.
+ */
+ const responseHandler = response => {
+ if (response.url().endsWith('/tags') && response.request().method() === 'POST') {
+ response.json()
+ .then(({
+ data: {
+ id
+ }
+ }) => {
+ console.log("Tag created, id", id);
+ tagId = id;
+ });
+ }
+ if (response.url().endsWith('/projects') && response.request().method() === 'POST') {
+ response.json()
+ .then(({
+ data: {
+ uuid
+ }
+ }) => {
+ console.log("Study created, uuid", uuid);
+ studyId = uuid;
+ });
+ }
+ }
+
+ beforeAll(async () => {
+ page.on('response', responseHandler);
+ await page.goto(url);
+ await auto.register(page, user, pass);
+ // Create new study
+ const uiConfig = await page.evaluate(async () => await osparc.store.Products.getInstance().fetchUiConfig());
+ if ("plusButton" in uiConfig) {
+ await waitAndClick(page, '[osparc-test-id="newPlusBtn"]');
+ }
+ await waitAndClick(page, '[osparc-test-id="emptyStudyBtn"]');
+ // Wait until project is created and Dashboard button is enabled
+ await utils.sleep(4000);
+ await auto.toDashboard(page);
+ }, ourTimeout * 2);
+
+ afterAll(async () => {
+ // Cleaning
+ await page.evaluate(async function(studyId, tagId) {
+ await osparc.data.Resources.fetch('studies', 'delete', {
+ url: {
+ "studyId": studyId
+ }
+ }, studyId);
+ await osparc.data.Resources.fetch('tags', 'delete', {
+ url: {
+ tagId: tagId
+ }
+ }, tagId);
+ }, studyId, tagId);
+ page.off('response', responseHandler);
+ await auto.logOut(page);
+ }, ourTimeout);
+
+ test('add a tag', async () => {
+ // Add a tag
+ await waitAndClick(page, '[osparc-test-id="userMenuBtn"]');
+ await waitAndClick(page, '[osparc-test-id="userMenuPreferencesBtn"]');
+ await waitAndClick(page, '[osparc-test-id="preferencesTagsTabBtn"]');
+ await waitAndClick(page, '[osparc-test-id="addTagBtn"]');
+ await utils.typeInInputElement(page, '[qxclass="osparc.form.tag.TagItem"]:last-of-type input[type="text"]', TAG_NAME);
+ await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"]:last-of-type [qxclass="osparc.ui.form.FetchButton"]');
+ // Check tag was added
+ await page.waitForFunction(tagName => {
+ const el = document.querySelector(
+ '[qxclass="osparc.form.tag.TagItem"]:last-of-type [qxclass="osparc.ui.basic.Tag"]'
+ );
+ return el && el.innerText === tagName;
+ }, {}, TAG_NAME);
+ // Close properties
+ await waitAndClick(page, '[osparc-test-id="preferencesWindowCloseBtn"]');
+ }, ourTimeout);
+
+ test('tag shows in filters', async () => {
+ // Check that tag shows in filter
+ await waitAndClick(page, '[osparc-test-id="searchBarFilter-textField-study"]');
+ await waitAndClick(page, '[osparc-test-id="searchBarFilter-tags-button"]');
+ const tagFilterMenu = await page.waitForSelector('[osparc-test-id="searchBarFilter-tags-menu"]:not([style*="display: none"])');
+ expect(await tagFilterMenu.evaluate(el => el.innerText)).toContain(TAG_NAME);
+ }, ourTimeout);
+
+ // wait until card gets unlocked. Tags will anyway be replaced by folder in the coming weeks
+ test.skip('assign tag and reflect changes', async () => {
+ await page.waitForSelector(
+ '[qxclass="osparc.dashboard.GridButtonItem"] > [qxclass="osparc.ui.basic.Thumbnail"]',
+ {
+ hidden: true
+ }
+ );
+ // Assign to study
+ await waitAndClick(page, '[qxclass="osparc.dashboard.GridButtonItem"] [osparc-test-id="studyItemMenuButton"]');
+ await waitAndClick(page, '[osparc-test-id="moreInfoBtn"]');
+ await waitAndClick(page, '[osparc-test-id="editStudyEditTagsBtn"]');
+ await waitAndClick(page, '[qxclass="osparc.form.tag.TagToggleButton"]');
+ await waitAndClick(page, '[osparc-test-id="saveTagsBtn"]');
+ // UI displays the change
+ let displayedTag = await page.waitForSelector('[qxclass="osparc.dashboard.GridButtonItem"] [qxclass="osparc.ui.basic.Tag"]')
+ await waitAndClick(page, '.qx-service-window[qxclass="osparc.ui.window.Window"] > .qx-workbench-small-cap-captionbar [qxclass="qx.ui.form.Button"]');
+ expect(await displayedTag.evaluate(el => el.innerText)).toContain(TAG_NAME);
+ }, ourTimeout);
+
+ // wait until card gets unlocked. Tags will anyway be replaced by folder in the coming weeks
+ test.skip('change tag and reflect changes', async () => {
+ // Change the tag
+ await waitAndClick(page, '[osparc-test-id="userMenuBtn"]');
+ await waitAndClick(page, '[osparc-test-id="userMenuPreferencesBtn"]');
+ await waitAndClick(page, '[osparc-test-id="preferencesTagsTabBtn"]');
+ await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"] [qxclass="qx.ui.form.Button"]');
+ await utils.clearInput(page, '[qxclass="osparc.form.tag.TagItem"] input[type="text"]');
+ await utils.typeInInputElement(page, '[qxclass="osparc.form.tag.TagItem"] input[type="text"]', TAG_NAME_2);
+ await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"] [qxclass="osparc.ui.form.FetchButton"]');
+ await page.waitForFunction(tagName => {
+ const el = document.querySelector(
+ '[qxclass="osparc.form.tag.TagItem"] [qxclass="osparc.ui.basic.Tag"]'
+ );
+ return el && el.innerText === tagName;
+ }, {}, TAG_NAME_2);
+ // Close properties
+ await waitAndClick(page, '[osparc-test-id="preferencesWindowCloseBtn"]');
+ // Check that tag name changed in filter and study list
+ await waitAndClick(page, '[osparc-test-id="searchBarFilter-textField-study"]');
+ await waitAndClick(page, '[osparc-test-id="searchBarFilter-tags-button"]');
+ const tagFilterMenu = await page.waitForSelector('[osparc-test-id="searchBarFilter-tags-menu"]:not([style*="display: none"])');
+ expect(await tagFilterMenu.evaluate(el => el.innerText)).toContain(TAG_NAME_2);
+ await page.waitForFunction(tagName => {
+ const el = document.querySelector(
+ '[qxclass="osparc.dashboard.GridButtonItem"] [qxclass="osparc.ui.basic.Tag"]'
+ );
+ return el && el.innerText === tagName;
+ }, {}, TAG_NAME_2);
+ }, ourTimeout);
+ });
+ }
+}
diff --git a/tests/e2e/tests/tags.tes.js b/tests/e2e/tests/tags.tes.js
deleted file mode 100644
index c7026220b88..00000000000
--- a/tests/e2e/tests/tags.tes.js
+++ /dev/null
@@ -1,153 +0,0 @@
-// OM rename this file and fix the test
-
-const utils = require('../utils/utils');
-const auto = require('../utils/auto');
-const waitAndClick = require('../utils/utils').waitAndClick;
-
-describe('tags testing', () => {
- const {
- user,
- pass,
- } = utils.getUserAndPass();
-
- const TAG_NAME = 'tag_test';
- const TAG_NAME_2 = 'tag_test_2';
- let studyId = null;
- let tagId = null;
-
- /**
- * This function records the IDs of the study and tag created in order to later remove them.
- */
- const responseHandler = response => {
- if (response.url().endsWith('/tags') && response.request().method() === 'POST') {
- response.json()
- .then(({
- data: {
- id
- }
- }) => {
- console.log("Tag created, id", id);
- tagId = id;
- });
- }
- if (response.url().endsWith('/projects') && response.request().method() === 'POST') {
- response.json()
- .then(({
- data: {
- uuid
- }
- }) => {
- console.log("Study created, uuid", uuid);
- studyId = uuid;
- });
- }
- }
-
- beforeAll(async () => {
- page.on('response', responseHandler);
- await page.goto(url);
- await auto.register(page, user, pass);
- // Create new study
- await waitAndClick(page, '[osparc-test-id="newPlusBtn"]');
- await waitAndClick(page, '[osparc-test-id="emptyStudyBtn"]');
- // Wait until project is created and Dashboard button is enabled
- await utils.sleep(4000);
- await auto.toDashboard(page);
- }, ourTimeout * 2);
-
- afterAll(async () => {
- // Cleaning
- await page.evaluate(async function(studyId, tagId) {
- await osparc.data.Resources.fetch('studies', 'delete', {
- url: {
- "studyId": studyId
- }
- }, studyId);
- await osparc.data.Resources.fetch('tags', 'delete', {
- url: {
- tagId: tagId
- }
- }, tagId);
- }, studyId, tagId);
- page.off('response', responseHandler);
- await auto.logOut(page);
- }, ourTimeout);
-
- test('add a tag', async () => {
- // Add a tag
- await waitAndClick(page, '[osparc-test-id="userMenuBtn"]');
- await waitAndClick(page, '[osparc-test-id="userMenuPreferencesBtn"]');
- await waitAndClick(page, '[osparc-test-id="preferencesTagsTabBtn"]');
- await waitAndClick(page, '[osparc-test-id="addTagBtn"]');
- await utils.typeInInputElement(page, '[qxclass="osparc.form.tag.TagItem"]:last-of-type input[type="text"]', TAG_NAME);
- await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"]:last-of-type [qxclass="osparc.ui.form.FetchButton"]');
- // Check tag was added
- await page.waitForFunction(tagName => {
- const el = document.querySelector(
- '[qxclass="osparc.form.tag.TagItem"]:last-of-type [qxclass="osparc.ui.basic.Tag"]'
- );
- return el && el.innerText === tagName;
- }, {}, TAG_NAME);
- // Close properties
- await waitAndClick(page, '[osparc-test-id="preferencesWindowCloseBtn"]');
- }, ourTimeout);
-
- test('tag shows in filters', async () => {
- // Check that tag shows in filter
- await waitAndClick(page, '[osparc-test-id="searchBarFilter-textField-study"]');
- await waitAndClick(page, '[osparc-test-id="searchBarFilter-tags-button"]');
- const tagFilterMenu = await page.waitForSelector('[osparc-test-id="searchBarFilter-tags-menu"]:not([style*="display: none"])');
- expect(await tagFilterMenu.evaluate(el => el.innerText)).toContain(TAG_NAME);
- }, ourTimeout);
-
- // wait until card gets unlocked. Tags will anyway be replaced by folder in the coming weeks
- test.skip('assign tag and reflect changes', async () => {
- await page.waitForSelector(
- '[qxclass="osparc.dashboard.GridButtonItem"] > [qxclass="osparc.ui.basic.Thumbnail"]',
- {
- hidden: true
- }
- );
- // Assign to study
- await waitAndClick(page, '[qxclass="osparc.dashboard.GridButtonItem"] [osparc-test-id="studyItemMenuButton"]');
- await waitAndClick(page, '[osparc-test-id="moreInfoBtn"]');
- await waitAndClick(page, '[osparc-test-id="editStudyEditTagsBtn"]');
- await waitAndClick(page, '[qxclass="osparc.form.tag.TagToggleButton"]');
- await waitAndClick(page, '[osparc-test-id="saveTagsBtn"]');
- // UI displays the change
- let displayedTag = await page.waitForSelector('[qxclass="osparc.dashboard.GridButtonItem"] [qxclass="osparc.ui.basic.Tag"]')
- await waitAndClick(page, '.qx-service-window[qxclass="osparc.ui.window.Window"] > .qx-workbench-small-cap-captionbar [qxclass="qx.ui.form.Button"]');
- expect(await displayedTag.evaluate(el => el.innerText)).toContain(TAG_NAME);
- }, ourTimeout);
-
- // wait until card gets unlocked. Tags will anyway be replaced by folder in the coming weeks
- test.skip('change tag and reflect changes', async () => {
- // Change the tag
- await waitAndClick(page, '[osparc-test-id="userMenuBtn"]');
- await waitAndClick(page, '[osparc-test-id="userMenuPreferencesBtn"]');
- await waitAndClick(page, '[osparc-test-id="preferencesTagsTabBtn"]');
- await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"] [qxclass="qx.ui.form.Button"]');
- await utils.clearInput(page, '[qxclass="osparc.form.tag.TagItem"] input[type="text"]');
- await utils.typeInInputElement(page, '[qxclass="osparc.form.tag.TagItem"] input[type="text"]', TAG_NAME_2);
- await waitAndClick(page, '[qxclass="osparc.form.tag.TagItem"] [qxclass="osparc.ui.form.FetchButton"]');
- await page.waitForFunction(tagName => {
- const el = document.querySelector(
- '[qxclass="osparc.form.tag.TagItem"] [qxclass="osparc.ui.basic.Tag"]'
- );
- return el && el.innerText === tagName;
- }, {}, TAG_NAME_2);
- // Close properties
- await waitAndClick(page, '[osparc-test-id="preferencesWindowCloseBtn"]');
- // Check that tag name changed in filter and study list
- await waitAndClick(page, '[osparc-test-id="searchBarFilter-textField-study"]');
- await waitAndClick(page, '[osparc-test-id="searchBarFilter-tags-button"]');
- const tagFilterMenu = await page.waitForSelector('[osparc-test-id="searchBarFilter-tags-menu"]:not([style*="display: none"])');
- expect(await tagFilterMenu.evaluate(el => el.innerText)).toContain(TAG_NAME_2);
- await page.waitForFunction(tagName => {
- const el = document.querySelector(
- '[qxclass="osparc.dashboard.GridButtonItem"] [qxclass="osparc.ui.basic.Tag"]'
- );
- return el && el.innerText === tagName;
- }, {}, TAG_NAME_2);
- }, ourTimeout);
-});
diff --git a/tests/e2e/tests/testsToRunSequentially.test.js b/tests/e2e/tests/testsToRunSequentially.test.js
new file mode 100644
index 00000000000..b7f989e28bc
--- /dev/null
+++ b/tests/e2e/tests/testsToRunSequentially.test.js
@@ -0,0 +1,10 @@
+const { checkUrl } = require('./url.js');
+const { checkMetadata } = require('./title');
+const { startupCalls } = require('./startupCalls');
+
+
+describe('Sequentially run tests', () => {
+ checkUrl();
+ checkMetadata();
+ startupCalls();
+});
diff --git a/tests/e2e/tests/title.js b/tests/e2e/tests/title.js
new file mode 100644
index 00000000000..4f16b285eb7
--- /dev/null
+++ b/tests/e2e/tests/title.js
@@ -0,0 +1,42 @@
+const appMetadata = require('../../../services/static-webserver/client/scripts/apps_metadata.json')
+
+module.exports = {
+ checkMetadata: () => {
+ describe('Check Metadata', () => {
+ beforeAll(async () => {
+ console.log("Start:", new Date().toUTCString());
+
+ await page.goto(url);
+ }, ourTimeout);
+
+ afterAll(() => {
+ console.log("End:", new Date().toUTCString());
+ }, ourTimeout);
+
+ test('Check site metadata', async () => {
+ const title = await page.title();
+ expect(title).toContain("PARC");
+
+ // oSPARC ([0]) is the product served by default
+ const replacements = appMetadata["applications"][0]["replacements"];
+
+ const description = await page.$$eval("head > meta[name='description']", descriptions => {
+ return descriptions[0].content;
+ });
+ expect(description).toBe(replacements["replace_me_og_description"]);
+
+ // Open Graph metadata
+ const ogTitle = await page.$$eval("head > meta[property='og:title']", ogTitles => {
+ return ogTitles[0].content;
+ });
+ expect(ogTitle).toBe(replacements["replace_me_og_title"]);
+
+ const ogDescription = await page.$$eval("head > meta[property='og:description']", ogDescriptions => {
+ return ogDescriptions[0].content;
+ });
+ expect(ogDescription).toBe(replacements["replace_me_og_description"]);
+
+ }, 20000);
+ });
+ }
+}
diff --git a/tests/e2e/tests/title.test.js b/tests/e2e/tests/title.test.js
deleted file mode 100644
index dc5c5e0e9f9..00000000000
--- a/tests/e2e/tests/title.test.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const appMetadata = require('../../../services/static-webserver/client/scripts/apps_metadata.json')
-
-beforeAll(async () => {
- await page.goto(url);
-}, ourTimeout);
-
-test('Check site title', async () => {
- const title = await page.title();
- expect(title).toBe("oSPARC");
-
- // oSPARC ([0]) is the product served by default
- const replacements = appMetadata["applications"][0]["replacements"];
-
- const description = await page.$$eval("head > meta[name='description']", descriptions => {
- return descriptions[0].content;
- });
- expect(description).toBe(replacements["replace_me_og_description"]);
-
- // Open Graph metadata
- const ogTitle = await page.$$eval("head > meta[property='og:title']", ogTitles => {
- return ogTitles[0].content;
- });
- expect(ogTitle).toBe(replacements["replace_me_og_title"]);
-
- const ogDescription = await page.$$eval("head > meta[property='og:description']", ogDescriptions => {
- return ogDescriptions[0].content;
- });
- expect(ogDescription).toBe(replacements["replace_me_og_description"]);
-
-}, 20000);
diff --git a/tests/e2e/tests/url.js b/tests/e2e/tests/url.js
new file mode 100644
index 00000000000..d44092edf20
--- /dev/null
+++ b/tests/e2e/tests/url.js
@@ -0,0 +1,20 @@
+module.exports = {
+ checkUrl: () => {
+ describe('Check URL', () => {
+ beforeAll(async () => {
+ console.log("Start:", new Date().toUTCString());
+
+ await page.goto(url);
+ }, ourTimeout);
+
+ afterAll(async () => {
+ console.log("End:", new Date().toUTCString());
+ }, ourTimeout);
+
+ test('Check site url', async () => {
+ const url2 = page.url();
+ expect(url2).toBe(url);
+ }, 20000);
+ });
+ }
+}
diff --git a/tests/e2e/tests/url.test.js b/tests/e2e/tests/url.test.js
deleted file mode 100644
index 13380177eeb..00000000000
--- a/tests/e2e/tests/url.test.js
+++ /dev/null
@@ -1,8 +0,0 @@
-beforeAll(async () => {
- await page.goto(url);
-}, ourTimeout);
-
-test('Check site url', async () => {
- const url2 = page.url();
- expect(url2).toBe(url);
-}, 20000);
diff --git a/tests/e2e/tutorials/tutorialBase.js b/tests/e2e/tutorials/tutorialBase.js
index 6899d3bcb26..4739f1f7c33 100644
--- a/tests/e2e/tutorials/tutorialBase.js
+++ b/tests/e2e/tutorials/tutorialBase.js
@@ -26,6 +26,7 @@ class TutorialBase {
this.__responsesQueue = null;
this.__services = null;
+ this.__studyId = null;
this.__interval = null;
@@ -216,8 +217,9 @@ class TutorialBase {
let resp = null;
try {
resp = await this.__responsesQueue.waitUntilResponse(":open");
- }
- catch (err) {
+ const studyId = this.__studyId = resp["data"]["uuid"];
+ console.log("Study ID:", studyId);
+ } catch (err) {
console.error("Error:", this.__templateName, "could not be started", err);
throw (err);
}
@@ -234,10 +236,9 @@ class TutorialBase {
await auto.dashboardNewTIPlan(this.__page);
await this.__responsesQueue.waitUntilResponse("projects?from_study=");
resp = await this.__responsesQueue.waitUntilResponse(":open");
- const studyId = resp["data"]["uuid"];
+ const studyId = this.__studyId = resp["data"]["uuid"];
console.log("Study ID:", studyId);
- }
- catch (err) {
+ } catch (err) {
console.error(`Error: Classic TI could not be started:\n`, err);
throw (err);
}
@@ -254,10 +255,9 @@ class TutorialBase {
await this.waitFor(2000);
await auto.dashboardStartSim4LifeLite(this.__page);
resp = await this.__responsesQueue.waitUntilResponse(":open");
- const studyId = resp["data"]["uuid"];
+ const studyId = this.__studyId = resp["data"]["uuid"];
console.log("Study ID:", studyId);
- }
- catch (err) {
+ } catch (err) {
console.error(`Error: Sim4Life Lite could not be started:\n`, err);
throw (err);
}
@@ -274,10 +274,9 @@ class TutorialBase {
await this.__goTo();
resp = await this.__responsesQueue.waitUntilResponse(":open", openStudyTimeout);
await this.__printMe();
- const studyId = resp["data"]["uuid"];
+ const studyId = this.__studyId = resp["data"]["uuid"];
console.log("Study ID:", studyId);
- }
- catch (err) {
+ } catch (err) {
console.error("Error:", this.__templateName, "could not be started", err);
throw (err);
}
@@ -294,10 +293,9 @@ class TutorialBase {
assert(templateFound, "Expected template, got nothing. TIP: did you inject templates in database??")
await this.__responsesQueue.waitUntilResponse("projects?from_study=");
resp = await this.__responsesQueue.waitUntilResponse(":open");
- const studyId = resp["data"]["uuid"];
+ const studyId = this.__studyId = resp["data"]["uuid"];
console.log("Study ID:", studyId);
- }
- catch (err) {
+ } catch (err) {
console.error(`Error: "${this.__templateName}" template could not be started:\n`, err);
throw (err);
}
@@ -314,10 +312,9 @@ class TutorialBase {
const serviceFound = await auto.dashboardOpenService(this.__page, this.__templateName);
assert(serviceFound, "Expected service, got nothing. TIP: is it available??");
resp = await this.__responsesQueue.waitUntilResponse(":open");
- const studyId = resp["data"]["uuid"];
+ const studyId = this.__studyId = resp["data"]["uuid"];
console.log("Study ID:", studyId);
- }
- catch (err) {
+ } catch (err) {
console.error(`Error: "${this.__templateName}" service could not be started:\n`, err);
throw (err);
}
@@ -448,24 +445,26 @@ class TutorialBase {
}
async openNodeFiles(nodeId) {
- this.__responsesQueue.addResponseListener("storage/locations/0/files/metadata?uuid_filter=" + nodeId);
+ const pathFilter = `${this.__studyId}/${nodeId}`;
+ const path = "storage/locations/0/paths?file_filter=" + pathFilter;
+ this.__responsesQueue.addResponseListener(path);
await auto.openNodeFiles(this.__page);
try {
- await this.__responsesQueue.waitUntilResponse("storage/locations/0/files/metadata?uuid_filter=" + nodeId);
- }
- catch (err) {
+ await this.__responsesQueue.waitUntilResponse(path);
+ } catch (err) {
console.error("Error: open node files", err);
throw (err);
}
}
async openNodeFilesAppMode(nodeId) {
- this.__responsesQueue.addResponseListener("storage/locations/0/files/metadata?uuid_filter=" + nodeId);
+ const pathFilter = `${this.__studyId}/${nodeId}`;
+ const path = "storage/locations/0/paths?file_filter=" + pathFilter;
+ this.__responsesQueue.addResponseListener(path);
await auto.openNodeFilesAppMode(this.__page);
try {
- await this.__responsesQueue.waitUntilResponse("storage/locations/0/files/metadata?uuid_filter=" + nodeId);
- }
- catch (err) {
+ await this.__responsesQueue.waitUntilResponse(path);
+ } catch (err) {
console.error("Error: open node files", err);
throw (err);
}
@@ -484,6 +483,7 @@ class TutorialBase {
async __checkNItemsInFolder(fileNames, openOutputsFolder = false) {
await this.takeScreenshot("checkNodeOutputs_before");
+ await this.waitAndClick("folderGridView");
console.log("N items in folder. Expected:", fileNames);
if (openOutputsFolder) {
const itemTexts = await this.__page.$$eval('[osparc-test-id="FolderViewerItem"]',
@@ -506,8 +506,7 @@ class TutorialBase {
}
if (outputsFound) {
await this.takeScreenshot("outputs_folder");
- }
- else {
+ } else {
throw ("outputs folder not found");
}
}
@@ -532,8 +531,7 @@ class TutorialBase {
const nodeId = await auto.openNode(this.__page, nodePos);
await this.openNodeFiles(nodeId);
await this.__checkNItemsInFolder(fileNames, openOutputsFolder);
- }
- catch (err) {
+ } catch (err) {
console.error("Error: Checking Node Outputs:", err);
throw (err)
}
@@ -543,8 +541,7 @@ class TutorialBase {
try {
await this.openNodeFilesAppMode(nodeId);
await this.__checkNItemsInFolder(fileNames, openOutputsFolder);
- }
- catch (err) {
+ } catch (err) {
console.error("Error: Checking Node Outputs:", err);
throw (err)
}
diff --git a/tests/e2e/utils/auto.js b/tests/e2e/utils/auto.js
index 4e999201736..f9a723059d5 100644
--- a/tests/e2e/utils/auto.js
+++ b/tests/e2e/utils/auto.js
@@ -107,14 +107,24 @@ async function __dashboardServicesBrowser(page) {
async function dashboardNewTIPlan(page) {
console.log("Creating New Plan");
- await utils.waitAndClick(page, '[osparc-test-id="newPlansBtn"]');
+ const uiConfig = await page.evaluate(async () => await osparc.store.Products.getInstance().fetchUiConfig());
+ if ("newStudies" in uiConfig) {
+ await utils.waitAndClick(page, '[osparc-test-id="newPlansBtn"]');
+ } else if ("plusButton" in uiConfig) {
+ await utils.waitAndClick(page, '[osparc-test-id="newPlusBtn"]');
+ }
+
await utils.waitAndClick(page, '[osparc-test-id="newTIPlanButton"]');
}
async function dashboardStartSim4LifeLite(page) {
- console.log("Start Sim4Lite from + button");
+ console.log("Start Sim4Life-Lite from + button");
+
+ const uiConfig = await page.evaluate(async () => await osparc.store.Products.getInstance().fetchUiConfig());
+ if ("plusButton" in uiConfig) {
+ await utils.waitAndClick(page, '[osparc-test-id="newPlusBtn"]');
+ }
- await utils.waitAndClick(page, '[osparc-test-id="newPlansBtn"]');
await utils.waitAndClick(page, '[osparc-test-id="startS4LButton"]');
}
@@ -363,6 +373,7 @@ async function openNodeFilesAppMode(page) {
async function checkDataProducedByNode(page, nFiles = 1) {
console.log("checking Data produced by Node. Expecting", nFiles, "file(s)");
+ await utils.waitAndClick(page, '[osparc-test-id="folderGridView"]');
const iconsContent = await page.waitForSelector('[osparc-test-id="FolderViewerIconsContent"]', {
timeout: 5000
});
diff --git a/tests/environment-setup/requirements/requirements.txt b/tests/environment-setup/requirements/requirements.txt
index db4a250991f..f4424462554 100644
--- a/tests/environment-setup/requirements/requirements.txt
+++ b/tests/environment-setup/requirements/requirements.txt
@@ -22,7 +22,7 @@ pydantic==2.10.6
# -r requirements/requirements.in
pydantic-core==2.27.2
# via pydantic
-pytest==8.3.4
+pytest==8.3.5
# via
# -r requirements/requirements.in
# pytest-asyncio
diff --git a/tests/public-api/requirements/_base.txt b/tests/public-api/requirements/_base.txt
index 7f83cf918c8..79d2ca83c91 100644
--- a/tests/public-api/requirements/_base.txt
+++ b/tests/public-api/requirements/_base.txt
@@ -2,7 +2,7 @@ annotated-types==0.7.0
# via pydantic
anyio==4.8.0
# via httpx
-certifi==2024.12.14
+certifi==2025.1.31
# via
# -c requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
@@ -54,8 +54,10 @@ pydantic-core==2.27.2
# via pydantic
pydantic-extra-types==2.10.2
# via -r requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in
-pydantic-settings==2.7.1
+pydantic-settings==2.7.0
# via
+ # -c requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
# -r requirements/../../../packages/settings-library/requirements/_base.in
# osparc
pygments==2.19.1
@@ -80,7 +82,7 @@ tenacity==9.0.0
# via osparc
tqdm==4.67.1
# via osparc
-typer==0.15.1
+typer==0.15.2
# via -r requirements/../../../packages/settings-library/requirements/_base.in
typing-extensions==4.12.2
# via
diff --git a/tests/public-api/requirements/_test.txt b/tests/public-api/requirements/_test.txt
index eb7c698c622..dec101da080 100644
--- a/tests/public-api/requirements/_test.txt
+++ b/tests/public-api/requirements/_test.txt
@@ -1,8 +1,8 @@
aiodocker==0.24.0
# via -r requirements/_test.in
-aiohappyeyeballs==2.4.4
+aiohappyeyeballs==2.4.6
# via aiohttp
-aiohttp==3.11.11
+aiohttp==3.11.13
# via
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_test.in
@@ -16,7 +16,7 @@ attrs==25.1.0
# aiohttp
# jsonschema
# referencing
-certifi==2024.12.14
+certifi==2025.1.31
# via
# -c requirements/../../../requirements/constraints.txt
# httpcore
@@ -26,7 +26,7 @@ charset-normalizer==3.4.1
# via requests
docker==7.1.0
# via -r requirements/_test.in
-faker==35.0.0
+faker==36.1.1
# via -r requirements/_test.in
frozenlist==1.5.0
# via
@@ -60,11 +60,11 @@ packaging==24.2
# via pytest
pluggy==1.5.0
# via pytest
-propcache==0.2.1
+propcache==0.3.0
# via
# aiohttp
# yarl
-pytest==8.3.4
+pytest==8.3.5
# via
# -r requirements/_test.in
# pytest-asyncio
@@ -72,8 +72,6 @@ pytest-asyncio==0.23.8
# via
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_test.in
-python-dateutil==2.9.0.post0
- # via faker
python-dotenv==1.0.1
# via -r requirements/_test.in
pyyaml==6.0.2
@@ -87,20 +85,18 @@ referencing==0.35.1
# jsonschema-specifications
requests==2.32.3
# via docker
-rpds-py==0.22.3
+rpds-py==0.23.1
# via
# jsonschema
# referencing
-six==1.17.0
- # via python-dateutil
sniffio==1.3.1
# via anyio
tenacity==9.0.0
# via -r requirements/_test.in
typing-extensions==4.12.2
- # via
- # anyio
- # faker
+ # via anyio
+tzdata==2025.1
+ # via faker
urllib3==2.3.0
# via
# -c requirements/../../../requirements/constraints.txt
diff --git a/tests/public-api/requirements/_tools.txt b/tests/public-api/requirements/_tools.txt
index 60e06fee8ab..0ce723bfa57 100644
--- a/tests/public-api/requirements/_tools.txt
+++ b/tests/public-api/requirements/_tools.txt
@@ -1,6 +1,6 @@
astroid==3.3.8
# via pylint
-black==24.10.0
+black==25.1.0
# via -r requirements/../../../requirements/devenv.txt
build==1.2.2.post1
# via pip-tools
@@ -19,15 +19,15 @@ distlib==0.3.9
# via virtualenv
filelock==3.17.0
# via virtualenv
-identify==2.6.6
+identify==2.6.8
# via pre-commit
-isort==5.13.2
+isort==6.0.1
# via
# -r requirements/../../../requirements/devenv.txt
# pylint
mccabe==0.7.0
# via pylint
-mypy==1.14.1
+mypy==1.15.0
# via -r requirements/../../../requirements/devenv.txt
mypy-extensions==1.0.0
# via
@@ -43,7 +43,7 @@ packaging==24.2
# build
pathspec==0.12.1
# via black
-pip==25.0
+pip==25.0.1
# via pip-tools
pip-tools==7.4.1
# via -r requirements/../../../requirements/devenv.txt
@@ -54,7 +54,7 @@ platformdirs==4.3.6
# virtualenv
pre-commit==4.1.0
# via -r requirements/../../../requirements/devenv.txt
-pylint==3.3.3
+pylint==3.3.4
# via -r requirements/../../../requirements/devenv.txt
pyproject-hooks==1.2.0
# via
@@ -65,9 +65,9 @@ pyyaml==6.0.2
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_test.txt
# pre-commit
-ruff==0.9.3
+ruff==0.9.9
# via -r requirements/../../../requirements/devenv.txt
-setuptools==75.8.0
+setuptools==75.8.2
# via pip-tools
tomlkit==0.13.2
# via pylint
@@ -76,7 +76,7 @@ typing-extensions==4.12.2
# -c requirements/_base.txt
# -c requirements/_test.txt
# mypy
-virtualenv==20.29.1
+virtualenv==20.29.2
# via pre-commit
wheel==0.45.1
# via pip-tools
diff --git a/tests/swarm-deploy/conftest.py b/tests/swarm-deploy/conftest.py
index b6f221c7c80..debb3529a2d 100644
--- a/tests/swarm-deploy/conftest.py
+++ b/tests/swarm-deploy/conftest.py
@@ -111,7 +111,7 @@ def simcore_stack_deployed_services(
# logs table like
# ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR
# xbrhmaygtb76 simcore_sidecar.1 itisfoundation/sidecar:latest crespo-wkstn Running Running 53 seconds ago
- # zde7p8qdwk4j simcore_rabbit.1 itisfoundation/rabbitmq:3.11.2-management crespo-wkstn Running Running 59 seconds ago
+ # zde7p8qdwk4j simcore_rabbit.1 itisfoundation/rabbitmq:3.13.7-management crespo-wkstn Running Running 59 seconds ago
# f2gxmhwq7hhk simcore_postgres.1 postgres:10.10 crespo-wkstn Running Running about a minute ago
# 1lh2hulxmc4q simcore_director.1 itisfoundation/director:latest crespo-wkstn Running Running 34 seconds ago
# ...
diff --git a/tests/swarm-deploy/requirements/_test.txt b/tests/swarm-deploy/requirements/_test.txt
index 43a246d3b3b..c1172331baf 100644
--- a/tests/swarm-deploy/requirements/_test.txt
+++ b/tests/swarm-deploy/requirements/_test.txt
@@ -1,4 +1,4 @@
-aio-pika==9.5.4
+aio-pika==9.5.5
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -22,9 +22,9 @@ aiofiles==24.1.0
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-aiohappyeyeballs==2.4.4
+aiohappyeyeballs==2.4.6
# via aiohttp
-aiohttp==3.11.11
+aiohttp==3.11.13
# via
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
@@ -87,7 +87,7 @@ attrs==25.1.0
# aiohttp
# jsonschema
# referencing
-certifi==2024.12.14
+certifi==2025.1.31
# via
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
@@ -125,7 +125,7 @@ click==8.1.8
# -r requirements/../../../packages/postgres-database/requirements/_migration.txt
# -r requirements/_test.in
# typer
-deprecated==1.2.17
+deprecated==1.2.18
# via
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
@@ -141,11 +141,11 @@ email-validator==2.2.0
# via pydantic
exceptiongroup==1.2.2
# via aio-pika
-faker==35.0.0
+faker==36.1.1
# via -r requirements/_test.in
fast-depends==2.4.12
# via faststream
-faststream==0.5.34
+faststream==0.5.35
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -157,7 +157,7 @@ frozenlist==1.5.0
# via
# aiohttp
# aiosignal
-googleapis-common-protos==1.66.0
+googleapis-common-protos==1.68.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
@@ -187,7 +187,7 @@ jsonschema==4.23.0
# -r requirements/_test.in
jsonschema-specifications==2024.10.1
# via jsonschema
-mako==1.3.8
+mako==1.3.9
# via
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
@@ -228,7 +228,7 @@ multidict==6.1.0
# via
# aiohttp
# yarl
-opentelemetry-api==1.29.0
+opentelemetry-api==1.30.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -243,19 +243,19 @@ opentelemetry-api==1.29.0
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
# opentelemetry-semantic-conventions
-opentelemetry-exporter-otlp==1.29.0
+opentelemetry-exporter-otlp==1.30.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-exporter-otlp-proto-common==1.29.0
+opentelemetry-exporter-otlp-proto-common==1.30.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-exporter-otlp-proto-grpc==1.29.0
+opentelemetry-exporter-otlp-proto-grpc==1.30.0
# via opentelemetry-exporter-otlp
-opentelemetry-exporter-otlp-proto-http==1.29.0
+opentelemetry-exporter-otlp-proto-http==1.30.0
# via opentelemetry-exporter-otlp
-opentelemetry-instrumentation==0.50b0
+opentelemetry-instrumentation==0.51b0
# via
# opentelemetry-instrumentation-aiopg
# opentelemetry-instrumentation-asyncpg
@@ -263,36 +263,36 @@ opentelemetry-instrumentation==0.50b0
# opentelemetry-instrumentation-logging
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
-opentelemetry-instrumentation-aiopg==0.50b0
+opentelemetry-instrumentation-aiopg==0.51b0
# via -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-opentelemetry-instrumentation-asyncpg==0.50b0
+opentelemetry-instrumentation-asyncpg==0.51b0
# via -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
-opentelemetry-instrumentation-dbapi==0.50b0
+opentelemetry-instrumentation-dbapi==0.51b0
# via opentelemetry-instrumentation-aiopg
-opentelemetry-instrumentation-logging==0.50b0
+opentelemetry-instrumentation-logging==0.51b0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-redis==0.50b0
+opentelemetry-instrumentation-redis==0.51b0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-requests==0.50b0
+opentelemetry-instrumentation-requests==0.51b0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-proto==1.29.0
+opentelemetry-proto==1.30.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-sdk==1.29.0
+opentelemetry-sdk==1.30.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-semantic-conventions==0.50b0
+opentelemetry-semantic-conventions==0.51b0
# via
# opentelemetry-instrumentation
# opentelemetry-instrumentation-asyncpg
@@ -300,7 +300,7 @@ opentelemetry-semantic-conventions==0.50b0
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
-opentelemetry-util-http==0.50b0
+opentelemetry-util-http==0.51b0
# via opentelemetry-instrumentation-requests
orjson==3.10.15
# via
@@ -359,7 +359,7 @@ platformdirs==4.3.6
# via pint
pluggy==1.5.0
# via pytest
-propcache==0.2.1
+propcache==0.3.0
# via
# aiohttp
# yarl
@@ -367,7 +367,7 @@ protobuf==5.29.3
# via
# googleapis-common-protos
# opentelemetry-proto
-psutil==6.1.1
+psutil==7.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -375,6 +375,8 @@ psycopg2-binary==2.9.10
# via
# aiopg
# sqlalchemy
+pycryptodome==3.21.0
+ # via stream-zip
pydantic==2.10.6
# via
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -449,8 +451,33 @@ pydantic-extra-types==2.10.2
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in
-pydantic-settings==2.7.1
+pydantic-settings==2.7.0
# via
+ # -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
+ # -c requirements/../../../requirements/constraints.txt
# -r requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
@@ -465,7 +492,7 @@ pyinstrument==5.0.1
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-pytest==8.3.4
+pytest==8.3.5
# via
# -r requirements/_test.in
# pytest-asyncio
@@ -509,9 +536,7 @@ pytest-runner==6.0.1
pytest-sugar==1.0.0
# via -r requirements/_test.in
python-dateutil==2.9.0.post0
- # via
- # arrow
- # faker
+ # via arrow
python-dotenv==1.0.1
# via
# -r requirements/_test.in
@@ -616,7 +641,7 @@ rich==13.9.4
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
# typer
-rpds-py==0.22.3
+rpds-py==0.23.1
# via
# jsonschema
# referencing
@@ -657,6 +682,10 @@ sqlalchemy==1.4.54
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
# aiopg
# alembic
+stream-zip==0.0.83
+ # via
+ # -r requirements/../../../packages/service-library/requirements/_base.in
+ # -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
tenacity==9.0.0
# via
# -r requirements/../../../packages/postgres-database/requirements/_migration.txt
@@ -675,7 +704,7 @@ tqdm==4.67.1
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-typer==0.15.1
+typer==0.15.2
# via
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/settings-library/requirements/_base.in
@@ -689,7 +718,6 @@ typing-extensions==4.12.2
# aiodebug
# alembic
# anyio
- # faker
# faststream
# flexcache
# flexparser
@@ -699,6 +727,8 @@ typing-extensions==4.12.2
# pydantic-core
# pydantic-extra-types
# typer
+tzdata==2025.1
+ # via faker
urllib3==2.3.0
# via
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
diff --git a/tests/swarm-deploy/requirements/_tools.txt b/tests/swarm-deploy/requirements/_tools.txt
index 20c4de73711..891fdf1892c 100644
--- a/tests/swarm-deploy/requirements/_tools.txt
+++ b/tests/swarm-deploy/requirements/_tools.txt
@@ -1,6 +1,6 @@
astroid==3.3.8
# via pylint
-black==24.10.0
+black==25.1.0
# via -r requirements/../../../requirements/devenv.txt
build==1.2.2.post1
# via pip-tools
@@ -19,15 +19,15 @@ distlib==0.3.9
# via virtualenv
filelock==3.17.0
# via virtualenv
-identify==2.6.6
+identify==2.6.8
# via pre-commit
-isort==5.13.2
+isort==6.0.1
# via
# -r requirements/../../../requirements/devenv.txt
# pylint
mccabe==0.7.0
# via pylint
-mypy==1.14.1
+mypy==1.15.0
# via -r requirements/../../../requirements/devenv.txt
mypy-extensions==1.0.0
# via
@@ -42,7 +42,7 @@ packaging==24.2
# build
pathspec==0.12.1
# via black
-pip==25.0
+pip==25.0.1
# via pip-tools
pip-tools==7.4.1
# via -r requirements/../../../requirements/devenv.txt
@@ -54,7 +54,7 @@ platformdirs==4.3.6
# virtualenv
pre-commit==4.1.0
# via -r requirements/../../../requirements/devenv.txt
-pylint==3.3.3
+pylint==3.3.4
# via -r requirements/../../../requirements/devenv.txt
pyproject-hooks==1.2.0
# via
@@ -66,9 +66,9 @@ pyyaml==6.0.2
# -c requirements/_test.txt
# pre-commit
# watchdog
-ruff==0.9.3
+ruff==0.9.9
# via -r requirements/../../../requirements/devenv.txt
-setuptools==75.8.0
+setuptools==75.8.2
# via pip-tools
tomlkit==0.13.2
# via pylint
@@ -76,7 +76,7 @@ typing-extensions==4.12.2
# via
# -c requirements/_test.txt
# mypy
-virtualenv==20.29.1
+virtualenv==20.29.2
# via pre-commit
watchdog==6.0.0
# via -r requirements/_tools.in