Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class ProjectGet(OutputSchema):

# display
name: str
description: str
description: Annotated[str, BeforeValidator(none_to_empty_str_pre_validator)]
thumbnail: HttpUrl | Literal[""]

type: ProjectType
Expand All @@ -144,7 +144,7 @@ class ProjectGet(OutputSchema):
trashed_at: datetime | None
trashed_by: Annotated[
GroupID | None, Field(description="The primary gid of the user who trashed")
]
] = None

# labeling
tags: list[int]
Expand All @@ -166,10 +166,6 @@ class ProjectGet(OutputSchema):
workspace_id: WorkspaceID | None
folder_id: FolderID | None

_empty_description = field_validator("description", mode="before")(
none_to_empty_str_pre_validator
)

@staticmethod
def _update_json_schema_extra(schema: JsonDict) -> None:
schema.update(
Expand Down
14 changes: 5 additions & 9 deletions packages/models-library/src/models_library/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from common_library.basic_types import DEFAULT_FACTORY
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
HttpUrl,
Expand Down Expand Up @@ -85,13 +86,17 @@ class BaseProjectModel(BaseModel):
]
description: Annotated[
str,
BeforeValidator(none_to_empty_str_pre_validator),
Field(
description="longer one-line description about the project",
examples=["Dabbling in temporal transitions ..."],
),
]
thumbnail: Annotated[
HttpUrl | None,
BeforeValidator(
empty_str_to_none_pre_validator,
),
Field(
description="url of the project thumbnail",
examples=["https://placeimg.com/171/96/tech/grayscale/?0.jpg"],
Expand All @@ -104,15 +109,6 @@ class BaseProjectModel(BaseModel):
# Pipeline of nodes (SEE projects_nodes.py)
workbench: Annotated[NodesDict, Field(description="Project's pipeline")]

# validators
_empty_thumbnail_is_none = field_validator("thumbnail", mode="before")(
empty_str_to_none_pre_validator
)

_none_description_is_empty = field_validator("description", mode="before")(
none_to_empty_str_pre_validator
)


class ProjectAtDB(BaseProjectModel):
# Model used to READ from database
Expand Down
38 changes: 29 additions & 9 deletions packages/models-library/src/models_library/projects_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,22 @@ class NodeState(BaseModel):

model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
validate_by_alias=True,
validate_by_name=True,
json_schema_extra={
"examples": [
# example with alias name
{
"modified": True,
"dependencies": [],
"currentStatus": "NOT_STARTED",
},
# example with field name
{
"modified": True,
"dependencies": [],
"current_status": "NOT_STARTED",
},
{
"modified": True,
"dependencies": ["42838344-03de-4ce2-8d93-589a5dcdfd05"],
Expand Down Expand Up @@ -230,7 +238,7 @@ class Node(BaseModel):
Field(description="The short name of the node", examples=["JupyterLab"]),
]
progress: Annotated[
float | None,
int | None,
Field(
ge=0,
le=100,
Expand Down Expand Up @@ -302,22 +310,28 @@ class Node(BaseModel):
Field(default_factory=dict, description="values of output properties"),
] = DEFAULT_FACTORY

output_node: Annotated[bool | None, Field(deprecated=True, alias="outputNode")] = (
None # <-- (DEPRECATED) Can be removed
)
output_node: Annotated[
bool | None,
Field(
deprecated=True,
alias="outputNode",
),
] = None # <-- (DEPRECATED) Can be removed

output_nodes: Annotated[ # <-- (DEPRECATED) Can be removed
list[NodeID] | None,
Field(
description="Used in group-nodes. Node IDs of those connected to the output",
alias="outputNodes",
deprecated=True,
),
] = None

parent: Annotated[ # <-- (DEPRECATED) Can be removed
NodeID | None,
Field(
description="Parent's (group-nodes') node ID s. Used to group",
deprecated=True,
),
] = None

Expand All @@ -334,6 +348,10 @@ class Node(BaseModel):
Field(default_factory=NodeState, description="The node's state object"),
] = DEFAULT_FACTORY

required_resources: Annotated[
dict[str, Any] | None, Field(default_factory=dict)
] = DEFAULT_FACTORY

boot_options: Annotated[
dict[EnvVarKey, str] | None,
Field(
Expand Down Expand Up @@ -453,12 +471,14 @@ def _update_json_schema_extra(schema: JsonDict) -> None:

model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
validate_by_name=True,
validate_by_alias=True,
json_schema_extra=_update_json_schema_extra,
)


class PartialNode(Node):
key: Annotated[ServiceKey, Field(default=None)]
version: Annotated[ServiceVersion, Field(default=None)]
label: Annotated[str, Field(default=None)]
# NOTE: `type: ignore[assignment]` is needed because mypy gets confused when overriding the types by adding the Union with None
key: ServiceKey | None = None # type: ignore[assignment]
version: ServiceVersion | None = None # type: ignore[assignment]
label: str | None = None # type: ignore[assignment]
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@
UUID_RE,
)

NodeID = UUID

UUIDStr: TypeAlias = Annotated[str, StringConstraints(pattern=UUID_RE)]

NodeID: TypeAlias = UUID
NodeIDStr: TypeAlias = UUIDStr

LocationID: TypeAlias = int
Expand Down
19 changes: 17 additions & 2 deletions packages/models-library/tests/test_services_types.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import pytest
from models_library.projects import ProjectID
from models_library.projects_nodes import NodeID
from models_library.services_types import ServiceRunID
from models_library.services_types import ServiceKey, ServiceRunID, ServiceVersion
from models_library.users import UserID
from pydantic import PositiveInt
from pydantic import PositiveInt, TypeAdapter
from pytest_simcore.helpers.faker_factories import (
random_service_key,
random_service_version,
)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -38,3 +42,14 @@ def test_get_resource_tracking_run_id_for_dynamic():
assert isinstance(
ServiceRunID.get_resource_tracking_run_id_for_dynamic(), ServiceRunID
)


@pytest.mark.parametrize(
"service_key, service_version",
[(random_service_key(), random_service_version()) for _ in range(10)],
)
def test_faker_factory_service_key_and_version_are_in_sync(
service_key: ServiceKey, service_version: ServiceVersion
):
TypeAdapter(ServiceKey).validate_python(service_key)
TypeAdapter(ServiceVersion).validate_python(service_version)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy.ext.asyncio import AsyncConnection

from .models.projects import projects
from .utils_repos import transaction_context
from .utils_repos import pass_or_acquire_connection, transaction_context


class DBBaseProjectError(OsparcErrorMixin, Exception):
Expand All @@ -22,6 +22,23 @@ class ProjectsRepo:
def __init__(self, engine):
self.engine = engine

async def exists(
self,
project_uuid: uuid.UUID,
*,
connection: AsyncConnection | None = None,
) -> bool:
async with pass_or_acquire_connection(self.engine, connection) as conn:
return (
await conn.scalar(
sa.select(1)
.select_from(projects)
.where(projects.c.uuid == f"{project_uuid}")
.limit(1)
)
is not None
)

async def get_project_last_change_date(
self,
project_uuid: uuid.UUID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@
from typing import Annotated, Any

import asyncpg.exceptions # type: ignore[import-untyped]
import sqlalchemy
import sqlalchemy.exc
from common_library.async_tools import maybe_await
from common_library.basic_types import DEFAULT_FACTORY
from common_library.errors_classes import OsparcErrorMixin
from pydantic import BaseModel, ConfigDict, Field
from simcore_postgres_database.utils_aiosqlalchemy import map_db_exception
from sqlalchemy.dialects.postgresql import insert as pg_insert

from ._protocols import DBConnection
from .aiopg_errors import ForeignKeyViolation, UniqueViolation
from .models.projects_node_to_pricing_unit import projects_node_to_pricing_unit
from .models.projects_nodes import projects_nodes
from .utils_aiosqlalchemy import map_db_exception


#
Expand Down Expand Up @@ -59,6 +58,7 @@ class ProjectNodeCreate(BaseModel):
input_access: dict[str, Any] | None = None
input_nodes: list[str] | None = None
inputs: dict[str, Any] | None = None
inputs_required: list[str] | None = None
inputs_units: dict[str, Any] | None = None
output_nodes: list[str] | None = None
outputs: dict[str, Any] | None = None
Expand All @@ -71,6 +71,24 @@ class ProjectNodeCreate(BaseModel):
def get_field_names(cls, *, exclude: set[str]) -> set[str]:
return cls.model_fields.keys() - exclude

def model_dump_as_node(self) -> dict[str, Any]:
"""Converts a ProjectNode from the database to a Node model for the API.

Handles field mapping and excludes database-specific fields that are not
part of the Node model.

NOTE: tested in services/web/server/tests/unit/isolated/test_models.py
"""
# Get all ProjectNode fields except those that don't belong in Node
exclude_fields = {"node_id", "required_resources"}
return self.model_dump(
# NOTE: this setup ensures using the defaults provided in Node model when the db does not
# provide them, e.g. `state`
exclude=exclude_fields,
exclude_none=True,
exclude_unset=True,
)

model_config = ConfigDict(frozen=True)


Expand All @@ -80,6 +98,24 @@ class ProjectNode(ProjectNodeCreate):

model_config = ConfigDict(from_attributes=True)

def model_dump_as_node(self) -> dict[str, Any]:
"""Converts a ProjectNode from the database to a Node model for the API.

Handles field mapping and excludes database-specific fields that are not
part of the Node model.

NOTE: tested in services/web/server/tests/unit/isolated/test_models.py
"""
# Get all ProjectNode fields except those that don't belong in Node
exclude_fields = {"node_id", "required_resources", "created", "modified"}
return self.model_dump(
# NOTE: this setup ensures using the defaults provided in Node model when the db does not
# provide them, e.g. `state`
exclude=exclude_fields,
exclude_none=True,
exclude_unset=True,
)


@dataclass(frozen=True, kw_only=True)
class ProjectNodesRepo:
Expand All @@ -103,17 +139,18 @@ async def add(
"""
if not nodes:
return []

values = [
{
"project_uuid": f"{self.project_uuid}",
**node.model_dump(mode="json"),
}
for node in nodes
]

insert_stmt = (
projects_nodes.insert()
.values(
[
{
"project_uuid": f"{self.project_uuid}",
**node.model_dump(exclude_unset=True, mode="json"),
}
for node in nodes
]
)
.values(values)
.returning(
*[
c
Expand All @@ -129,14 +166,17 @@ async def add(
rows = await maybe_await(result.fetchall())
assert isinstance(rows, list) # nosec
return [ProjectNode.model_validate(r) for r in rows]

except ForeignKeyViolation as exc:
# this happens when the project does not exist, as we first check the node exists
raise ProjectNodesProjectNotFoundError(
project_uuid=self.project_uuid
) from exc

except UniqueViolation as exc:
# this happens if the node already exists on creation
raise ProjectNodesDuplicateNodeError from exc

except sqlalchemy.exc.IntegrityError as exc:
raise map_db_exception(
exc,
Expand Down
Loading
Loading