diff --git a/api/specs/web-server/_tags.py b/api/specs/web-server/_tags.py index d3c62fcde95..b4b1639dee4 100644 --- a/api/specs/web-server/_tags.py +++ b/api/specs/web-server/_tags.py @@ -4,10 +4,17 @@ # pylint: disable=too-many-arguments -from fastapi import APIRouter, status +from typing import Annotated + +from fastapi import APIRouter, Depends, status from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.tags._handlers import TagCreate, TagGet, TagUpdate +from simcore_service_webserver.tags._handlers import ( + TagCreate, + TagGet, + TagPathParams, + TagUpdate, +) router = APIRouter(prefix=f"/{API_VTAG}", tags=["tags"]) @@ -16,7 +23,7 @@ "/tags", response_model=Envelope[TagGet], ) -async def create_tag(create: TagCreate): +async def create_tag(_body: TagCreate): ... @@ -32,7 +39,9 @@ async def list_tags(): "/tags/{tag_id}", response_model=Envelope[TagGet], ) -async def update_tag(tag_id: int, update: TagUpdate): +async def update_tag( + _path_params: Annotated[TagPathParams, Depends()], _body: TagUpdate +): ... @@ -40,5 +49,5 @@ async def update_tag(tag_id: int, update: TagUpdate): "/tags/{tag_id}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_tag(tag_id: int): +async def delete_tag(_path_params: Annotated[TagPathParams, Depends()]): ... diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/7604e65e2f83_renamed_study_tags_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/7604e65e2f83_renamed_study_tags_table.py new file mode 100644 index 00000000000..69b44c3f40c --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/7604e65e2f83_renamed_study_tags_table.py @@ -0,0 +1,29 @@ +"""renamed study_tags table + +Revision ID: 7604e65e2f83 +Revises: 617e0ecaf602 +Create Date: 2024-08-23 12:03:59.328670+00:00 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "7604e65e2f83" +down_revision = "617e0ecaf602" +branch_labels = None +depends_on = None + + +def upgrade(): + op.rename_table("study_tags", "projects_tags") + + # Rename the column from study_id to project_id in the renamed table + op.alter_column("projects_tags", "study_id", new_column_name="project_id") + + +def downgrade(): + # Reverse the column rename from project_id to study_id + op.alter_column("projects_tags", "project_id", new_column_name="study_id") + + # Reverse the table rename from projects_tags to study_tags + op.rename_table("projects_tags", "study_tags") diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/e8057a4a7bb0_new_services_tags_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e8057a4a7bb0_new_services_tags_table.py new file mode 100644 index 00000000000..62998ed021f --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e8057a4a7bb0_new_services_tags_table.py @@ -0,0 +1,44 @@ +"""new services_tags table + +Revision ID: e8057a4a7bb0 +Revises: 7604e65e2f83 +Create Date: 2024-08-23 12:12:32.883771+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e8057a4a7bb0" +down_revision = "7604e65e2f83" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "services_tags", + sa.Column("service_key", sa.String(), nullable=False), + sa.Column("service_version", sa.String(), nullable=False), + sa.Column("tag_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["service_key", "service_version"], + ["services_meta_data.key", "services_meta_data.version"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["tag_id"], ["tags.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.UniqueConstraint( + "service_key", "service_version", "tag_id", name="services_tags_uc" + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("services_tags") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/feca36c8e18f_rename_tags_to_groups.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/feca36c8e18f_rename_tags_to_groups.py new file mode 100644 index 00000000000..a473492f632 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/feca36c8e18f_rename_tags_to_groups.py @@ -0,0 +1,23 @@ +"""rename tags_to_groups + +Revision ID: feca36c8e18f +Revises: e8057a4a7bb0 +Create Date: 2024-08-23 12:30:56.650085+00:00 + +""" +from alembic import op + +# revision identifiers, used by Alembic. +revision = "feca36c8e18f" +down_revision = "e8057a4a7bb0" +branch_labels = None +depends_on = None + + +def upgrade(): + op.rename_table("tags_to_groups", "tags_access_rights") + + +def downgrade(): + # Reverse the table rename from projects_tags to study_tags + op.rename_table("tags_access_rights", "tags_to_groups") diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py new file mode 100644 index 00000000000..4ac88510e2d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py @@ -0,0 +1,27 @@ +import sqlalchemy as sa + +from .base import metadata +from .projects import projects +from .tags import tags + +projects_tags = sa.Table( + # + # Tags associated to a project (many-to-many relation) + # + "projects_tags", + metadata, + sa.Column( + "project_id", + sa.BigInteger, + sa.ForeignKey(projects.c.id, onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + doc="NOTE that project.c.id != project.c.uuid", + ), + sa.Column( + "tag_id", + sa.BigInteger, + sa.ForeignKey(tags.c.id, onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ), + sa.UniqueConstraint("project_id", "tag_id"), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py b/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py new file mode 100644 index 00000000000..6a3ea828eea --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py @@ -0,0 +1,42 @@ +import sqlalchemy as sa + +from .base import metadata +from .tags import tags + +services_tags = sa.Table( + # + # Tags assigned to a service (many-to-many relation) + # + "services_tags", + metadata, + # Service + sa.Column( + "service_key", + sa.String, + nullable=False, + doc="Service Key Identifier", + ), + sa.Column( + "service_version", + sa.String, + nullable=False, + doc="Service version", + ), + # Tag + sa.Column( + "tag_id", + sa.BigInteger, + sa.ForeignKey(tags.c.id, onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ), + # Constraints + sa.ForeignKeyConstraint( + ["service_key", "service_version"], + ["services_meta_data.key", "services_meta_data.version"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "service_key", "service_version", "tag_id", name="services_tags_uc" + ), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/tags.py b/packages/postgres-database/src/simcore_postgres_database/models/tags.py index 141e5f89ee3..ce05e68f198 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/tags.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/tags.py @@ -1,6 +1,5 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime from .base import metadata # @@ -35,86 +34,3 @@ doc="Hex color (see https://www.color-hex.com/)", ), ) - - -# -# tags_to_groups: Maps tags with groups to define the level of access -# of a group (group_id) for the corresponding tag (tag_id) -# -tags_to_groups = sa.Table( - "tags_to_groups", - metadata, - sa.Column( - "tag_id", - sa.BigInteger(), - sa.ForeignKey( - tags.c.id, - onupdate="CASCADE", - ondelete="CASCADE", - name="fk_tag_to_group_tag_id", - ), - nullable=False, - doc="Tag unique ID", - ), - sa.Column( - "group_id", - sa.BigInteger, - sa.ForeignKey( - "groups.gid", - onupdate="CASCADE", - ondelete="CASCADE", - name="fk_tag_to_group_group_id", - ), - nullable=False, - doc="Group unique ID", - ), - # ACCESS RIGHTS --- - sa.Column( - "read", - sa.Boolean(), - nullable=False, - server_default=sa.sql.expression.true(), - doc="If true, group can *read* a tag." - "This column can be used to set the tag invisible", - ), - sa.Column( - "write", - sa.Boolean(), - nullable=False, - server_default=sa.sql.expression.false(), - doc="If true, group can *create* and *update* a tag", - ), - sa.Column( - "delete", - sa.Boolean(), - nullable=False, - server_default=sa.sql.expression.false(), - doc="If true, group can *delete* the tag", - ), - # TIME STAMPS ---- - column_created_datetime(timezone=False), - column_modified_datetime(timezone=False), - sa.UniqueConstraint("tag_id", "group_id"), -) - - -# -# study_tags: projects marked with tags -# -study_tags = sa.Table( - "study_tags", - metadata, - sa.Column( - "study_id", - sa.BigInteger, - sa.ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"), - nullable=False, - ), - sa.Column( - "tag_id", - sa.BigInteger, - sa.ForeignKey("tags.id", onupdate="CASCADE", ondelete="CASCADE"), - nullable=False, - ), - sa.UniqueConstraint("study_id", "tag_id"), -) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py b/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py new file mode 100644 index 00000000000..9efb4123f0d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py @@ -0,0 +1,66 @@ +import sqlalchemy as sa + +from ._common import column_created_datetime, column_modified_datetime +from .base import metadata +from .groups import groups +from .tags import tags + +tags_access_rights = sa.Table( + # + # Maps tags with groups to define the level of access rights + # of a group (group_id) for the corresponding tag (tag_id) + # + "tags_access_rights", + metadata, + sa.Column( + "tag_id", + sa.BigInteger(), + sa.ForeignKey( + tags.c.id, + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_tag_to_group_tag_id", + ), + nullable=False, + doc="Tag unique ID", + ), + sa.Column( + "group_id", + sa.BigInteger, + sa.ForeignKey( + groups.c.gid, + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_tag_to_group_group_id", + ), + nullable=False, + doc="Group unique ID", + ), + # ACCESS RIGHTS --- + sa.Column( + "read", + sa.Boolean(), + nullable=False, + server_default=sa.sql.expression.true(), + doc="If true, group can *read* a tag." + "This column can be used to set the tag invisible", + ), + sa.Column( + "write", + sa.Boolean(), + nullable=False, + server_default=sa.sql.expression.false(), + doc="If true, group can *create* and *update* a tag", + ), + sa.Column( + "delete", + sa.Boolean(), + nullable=False, + server_default=sa.sql.expression.false(), + doc="If true, group can *delete* the tag", + ), + # TIME STAMPS ---- + column_created_datetime(timezone=False), + column_modified_datetime(timezone=False), + sa.UniqueConstraint("tag_id", "group_id"), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_tags.py b/packages/postgres-database/src/simcore_postgres_database/utils_tags.py index eee39cc33bb..0a8b3e4ac28 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_tags.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_tags.py @@ -1,16 +1,21 @@ """ Repository pattern, errors and data structures for models.tags """ -import functools import itertools from dataclasses import dataclass from typing import TypedDict -import sqlalchemy as sa from aiopg.sa.connection import SAConnection -from simcore_postgres_database.models.groups import user_to_groups -from simcore_postgres_database.models.tags import tags, tags_to_groups -from simcore_postgres_database.models.users import users + +from .utils_tags_sql import ( + count_users_with_access_rights_stmt, + create_tag_stmt, + delete_tag_stmt, + get_tag_stmt, + list_tags_stmt, + set_tag_access_rights_stmt, + update_tag_stmt, +) # @@ -33,23 +38,6 @@ class TagOperationNotAllowedError(BaseTagError): # maps to AccessForbidden # -_TAG_COLUMNS = [ - tags.c.id, - tags.c.name, - tags.c.description, - tags.c.color, -] - -_ACCESS_COLUMNS = [ - tags_to_groups.c.read, - tags_to_groups.c.write, - tags_to_groups.c.delete, -] - - -_COLUMNS = _TAG_COLUMNS + _ACCESS_COLUMNS - - class TagDict(TypedDict, total=True): id: int name: str @@ -63,37 +51,7 @@ class TagDict(TypedDict, total=True): @dataclass(frozen=True) class TagsRepo: - user_id: int - - def _join_user_groups_tag( - self, - access_condition, - tag_id: int, - ): - return user_to_groups.join( - tags_to_groups, - (user_to_groups.c.uid == self.user_id) - & (user_to_groups.c.gid == tags_to_groups.c.group_id) - & (access_condition) - & (tags_to_groups.c.tag_id == tag_id), - ) - - def _join_user_to_given_tag(self, access_condition, tag_id: int): - return self._join_user_groups_tag( - access_condition=access_condition, - tag_id=tag_id, - ).join(tags) - - def _join_user_to_tags( - self, - access_condition, - ): - return user_to_groups.join( - tags_to_groups, - (user_to_groups.c.uid == self.user_id) - & (user_to_groups.c.gid == tags_to_groups.c.group_id) - & (access_condition), - ).join(tags) + user_id: int # Determines access-rights async def access_count( self, @@ -108,26 +66,10 @@ async def access_count( Returns 0 if tag does not match access Returns >0 if it does and represents the number of groups granting this access to the user """ - access = [] - if read is not None: - access.append(tags_to_groups.c.read == read) - if write is not None: - access.append(tags_to_groups.c.write == write) - if delete is not None: - access.append(tags_to_groups.c.delete == delete) - - if not access: - msg = "Undefined access" - raise ValueError(msg) - - j = self._join_user_groups_tag( - access_condition=functools.reduce(sa.and_, access), - tag_id=tag_id, + count_stmt = count_users_with_access_rights_stmt( + user_id=self.user_id, tag_id=tag_id, read=read, write=write, delete=delete ) - stmt = sa.select(sa.func.count(user_to_groups.c.uid)).select_from(j) - - # The number of occurrences of the user_id = how many groups are giving this access permission - permissions_count: int | None = await conn.scalar(stmt) + permissions_count: int | None = await conn.scalar(count_stmt) return permissions_count if permissions_count else 0 # @@ -145,54 +87,41 @@ async def create( write: bool = True, delete: bool = True, ) -> TagDict: - values = {"name": name, "color": color} + values = { + "name": name, + "color": color, + } if description: values["description"] = description async with conn.begin(): # insert new tag - insert_stmt = tags.insert().values(**values).returning(*_TAG_COLUMNS) + insert_stmt = create_tag_stmt(**values) result = await conn.execute(insert_stmt) tag = await result.first() assert tag # nosec # take tag ownership - scalar_subq = ( - sa.select(users.c.primary_gid) - .where(users.c.id == self.user_id) - .scalar_subquery() - ) - result = await conn.execute( - tags_to_groups.insert() - .values( - tag_id=tag.id, - group_id=scalar_subq, - read=read, - write=write, - delete=delete, - ) - .returning(*_ACCESS_COLUMNS) + access_stmt = set_tag_access_rights_stmt( + tag_id=tag.id, + user_id=self.user_id, + read=read, + write=write, + delete=delete, ) + result = await conn.execute(access_stmt) access = await result.first() assert access return TagDict(itertools.chain(tag.items(), access.items())) # type: ignore async def list_all(self, conn: SAConnection) -> list[TagDict]: - select_stmt = ( - sa.select(*_COLUMNS) - .select_from(self._join_user_to_tags(tags_to_groups.c.read.is_(True))) - .order_by(tags.c.id) - ) - - return [TagDict(row.items()) async for row in conn.execute(select_stmt)] # type: ignore + stmt_list = list_tags_stmt(user_id=self.user_id) + return [TagDict(row.items()) async for row in conn.execute(stmt_list)] # type: ignore async def get(self, conn: SAConnection, tag_id: int) -> TagDict: - select_stmt = sa.select(*_COLUMNS).select_from( - self._join_user_to_given_tag(tags_to_groups.c.read.is_(True), tag_id=tag_id) - ) - - result = await conn.execute(select_stmt) + stmt_get = get_tag_stmt(user_id=self.user_id, tag_id=tag_id) + result = await conn.execute(stmt_get) row = await result.first() if not row: msg = f"{tag_id=} not found: either no access or does not exists" @@ -215,21 +144,7 @@ async def update( # no updates == get return await self.get(conn, tag_id=tag_id) - update_stmt = ( - tags.update() - .where(tags.c.id == tag_id) - .where( - (tags.c.id == tags_to_groups.c.tag_id) - & (tags_to_groups.c.write.is_(True)) - ) - .where( - (tags_to_groups.c.group_id == user_to_groups.c.gid) - & (user_to_groups.c.uid == self.user_id) - ) - .values(**updates) - .returning(*_COLUMNS) - ) - + update_stmt = update_tag_stmt(user_id=self.user_id, tag_id=tag_id, **updates) result = await conn.execute(update_stmt) row = await result.first() if not row: @@ -239,21 +154,9 @@ async def update( return TagDict(row.items()) # type: ignore async def delete(self, conn: SAConnection, tag_id: int) -> None: - delete_stmt = ( - tags.delete() - .where(tags.c.id == tag_id) - .where( - (tags_to_groups.c.tag_id == tag_id) - & (tags_to_groups.c.delete.is_(True)) - ) - .where( - (tags_to_groups.c.group_id == user_to_groups.c.gid) - & (user_to_groups.c.uid == self.user_id) - ) - .returning(tags_to_groups.c.delete) - ) + stmt_delete = delete_tag_stmt(user_id=self.user_id, tag_id=tag_id) - deleted = await conn.scalar(delete_stmt) + deleted = await conn.scalar(stmt_delete) if not deleted: msg = f"Could not delete {tag_id=}. Not found or insuficient access." raise TagOperationNotAllowedError(msg) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py b/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py new file mode 100644 index 00000000000..05a1e93ca33 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/utils_tags_sql.py @@ -0,0 +1,202 @@ +import functools + +import sqlalchemy as sa +from simcore_postgres_database.models.groups import user_to_groups +from simcore_postgres_database.models.projects_tags import projects_tags +from simcore_postgres_database.models.services_tags import services_tags +from simcore_postgres_database.models.tags import tags +from simcore_postgres_database.models.tags_access_rights import tags_access_rights +from simcore_postgres_database.models.users import users +from sqlalchemy.dialects.postgresql import insert as pg_insert + +_TAG_COLUMNS = [ + tags.c.id, + tags.c.name, + tags.c.description, + tags.c.color, +] + +_ACCESS_RIGHTS_COLUMNS = [ + tags_access_rights.c.read, + tags_access_rights.c.write, + tags_access_rights.c.delete, +] + + +_COLUMNS = _TAG_COLUMNS + _ACCESS_RIGHTS_COLUMNS + + +def _join_user_groups_tag(*, access_condition, tag_id: int, user_id: int): + return user_to_groups.join( + tags_access_rights, + (user_to_groups.c.uid == user_id) + & (user_to_groups.c.gid == tags_access_rights.c.group_id) + & (access_condition) + & (tags_access_rights.c.tag_id == tag_id), + ) + + +def _join_user_to_given_tag(*, access_condition, tag_id: int, user_id: int): + return _join_user_groups_tag( + access_condition=access_condition, + tag_id=tag_id, + user_id=user_id, + ).join(tags) + + +def _join_user_to_tags(*, access_condition, user_id: int): + return user_to_groups.join( + tags_access_rights, + (user_to_groups.c.uid == user_id) + & (user_to_groups.c.gid == tags_access_rights.c.group_id) + & (access_condition), + ).join(tags) + + +def get_tag_stmt( + user_id: int, + tag_id: int, +): + return sa.select(*_COLUMNS).select_from( + _join_user_to_given_tag( + access_condition=tags_access_rights.c.read.is_(True), + tag_id=tag_id, + user_id=user_id, + ) + ) + + +def list_tags_stmt(*, user_id: int): + return ( + sa.select(*_COLUMNS) + .select_from( + _join_user_to_tags( + access_condition=tags_access_rights.c.read.is_(True), + user_id=user_id, + ) + ) + .order_by(tags.c.id) + ) + + +def create_tag_stmt(**values): + return tags.insert().values(**values).returning(*_TAG_COLUMNS) + + +def count_users_with_access_rights_stmt( + *, + user_id: int, + tag_id: int, + read: bool | None, + write: bool | None, + delete: bool | None +): + """ + How many users are given these access permissions + """ + access = [] + if read is not None: + access.append(tags_access_rights.c.read == read) + if write is not None: + access.append(tags_access_rights.c.write == write) + if delete is not None: + access.append(tags_access_rights.c.delete == delete) + + if not access: + msg = "Undefined access" + raise ValueError(msg) + + j = _join_user_groups_tag( + access_condition=functools.reduce(sa.and_, access), + user_id=user_id, + tag_id=tag_id, + ) + return sa.select(sa.func.count(user_to_groups.c.uid)).select_from(j) + + +def set_tag_access_rights_stmt( + *, tag_id: int, user_id: int, read: bool, write: bool, delete: bool +): + scalar_subq = ( + sa.select(users.c.primary_gid).where(users.c.id == user_id).scalar_subquery() + ) + return ( + tags_access_rights.insert() + .values( + tag_id=tag_id, + group_id=scalar_subq, + read=read, + write=write, + delete=delete, + ) + .returning(*_ACCESS_RIGHTS_COLUMNS) + ) + + +def update_tag_stmt(*, user_id: int, tag_id: int, **updates): + return ( + tags.update() + .where(tags.c.id == tag_id) + .where( + (tags.c.id == tags_access_rights.c.tag_id) + & (tags_access_rights.c.write.is_(True)) + ) + .where( + (tags_access_rights.c.group_id == user_to_groups.c.gid) + & (user_to_groups.c.uid == user_id) + ) + .values(**updates) + .returning(*_COLUMNS) + ) + + +def delete_tag_stmt(*, user_id: int, tag_id: int): + return ( + tags.delete() + .where(tags.c.id == tag_id) + .where( + (tags_access_rights.c.tag_id == tag_id) + & (tags_access_rights.c.delete.is_(True)) + ) + .where( + (tags_access_rights.c.group_id == user_to_groups.c.gid) + & (user_to_groups.c.uid == user_id) + ) + .returning(tags_access_rights.c.delete) + ) + + +def get_tags_for_project_stmt(*, project_index: int): + return sa.select(projects_tags.c.tag_id).where( + projects_tags.c.project_id == project_index + ) + + +def add_tag_to_project_stmt(*, project_index: int, tag_id: int): + return ( + pg_insert(projects_tags) + .values( + project_id=project_index, + tag_id=tag_id, + ) + .on_conflict_do_nothing() + ) + + +def get_tags_for_services_stmt(*, key: str, version: str): + return sa.select(services_tags.c.tag_id).where( + (services_tags.c.service_key == key) + & (services_tags.c.service_version == version) + ) + + +def add_tag_to_services_stmt(*, key: str, version: str, tag_id: int): + return ( + pg_insert(services_tags) + .values( + service_key=key, + service_version=version, + tag_id=tag_id, + ) + .on_conflict_do_nothing() + ) diff --git a/packages/postgres-database/src/simcore_postgres_database/webserver_models.py b/packages/postgres-database/src/simcore_postgres_database/webserver_models.py index 9b53794a629..571db047cfb 100644 --- a/packages/postgres-database/src/simcore_postgres_database/webserver_models.py +++ b/packages/postgres-database/src/simcore_postgres_database/webserver_models.py @@ -12,9 +12,10 @@ from .models.groups import GroupType, groups, user_to_groups from .models.products import products from .models.projects import ProjectType, projects +from .models.projects_tags import projects_tags from .models.projects_to_wallet import projects_to_wallet from .models.scicrunch_resources import scicrunch_resources -from .models.tags import study_tags, tags +from .models.tags import tags from .models.tokens import tokens from .models.users import UserRole, UserStatus, users @@ -34,7 +35,7 @@ "ProjectType", "scicrunch_resources", "StateType", - "study_tags", + "projects_tags", "tags", "tokens", "user_to_groups", diff --git a/packages/postgres-database/tests/conftest.py b/packages/postgres-database/tests/conftest.py index 7fc074e884e..0d2224e286e 100644 --- a/packages/postgres-database/tests/conftest.py +++ b/packages/postgres-database/tests/conftest.py @@ -5,6 +5,7 @@ import uuid from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from pathlib import Path import aiopg.sa import aiopg.sa.exc @@ -47,7 +48,7 @@ def postgres_service(docker_services, docker_ip, docker_compose_file) -> str: """Deploys postgres and service is responsive""" # container environment - with open(docker_compose_file) as fh: + with Path.open(docker_compose_file) as fh: config = yaml.safe_load(fh) environ = config["services"]["postgres"]["environment"] diff --git a/packages/postgres-database/tests/test_models_tags.py b/packages/postgres-database/tests/test_models_tags.py index 8a9caf9aa2d..71a7ba4702d 100644 --- a/packages/postgres-database/tests/test_models_tags.py +++ b/packages/postgres-database/tests/test_models_tags.py @@ -7,7 +7,7 @@ import pytest import sqlalchemy as sa from simcore_postgres_database.models.base import metadata -from simcore_postgres_database.models.tags import tags_to_groups +from simcore_postgres_database.models.tags_access_rights import tags_access_rights from simcore_postgres_database.models.users import users @@ -31,12 +31,14 @@ def test_migration_downgrade_script(): sa.Column("color", sa.String, nullable=False), ) - j = users.join(tags_to_groups, tags_to_groups.c.group_id == users.c.primary_gid) + j = users.join( + tags_access_rights, tags_access_rights.c.group_id == users.c.primary_gid + ) scalar_subq = ( sa.select(users.c.id) .select_from(j) - .where(old_tags.c.id == tags_to_groups.c.tag_id) + .where(old_tags.c.id == tags_access_rights.c.tag_id) .scalar_subquery() ) @@ -44,6 +46,6 @@ def test_migration_downgrade_script(): assert str(update_stmt).split("\n") == [ "UPDATE old_tags SET user_id=(SELECT users.id ", - "FROM users JOIN tags_to_groups ON tags_to_groups.group_id = users.primary_gid ", - "WHERE old_tags.id = tags_to_groups.tag_id)", + "FROM users JOIN tags_access_rights ON tags_access_rights.group_id = users.primary_gid ", + "WHERE old_tags.id = tags_access_rights.tag_id)", ] diff --git a/packages/postgres-database/tests/test_utils_tags.py b/packages/postgres-database/tests/test_utils_tags.py index 86fca7dfaef..2b99c1939fe 100644 --- a/packages/postgres-database/tests/test_utils_tags.py +++ b/packages/postgres-database/tests/test_utils_tags.py @@ -11,13 +11,25 @@ from aiopg.sa.connection import SAConnection from aiopg.sa.result import RowProxy from pytest_simcore.helpers.postgres_tags import create_tag, create_tag_access -from simcore_postgres_database.models.tags import tags_to_groups +from simcore_postgres_database.models.tags_access_rights import tags_access_rights from simcore_postgres_database.models.users import UserRole, UserStatus +from simcore_postgres_database.utils import as_postgres_sql_query_str from simcore_postgres_database.utils_tags import ( TagNotFoundError, TagOperationNotAllowedError, TagsRepo, ) +from simcore_postgres_database.utils_tags_sql import ( + add_tag_to_project_stmt, + add_tag_to_services_stmt, + create_tag_stmt, + delete_tag_stmt, + get_tag_stmt, + get_tags_for_project_stmt, + get_tags_for_services_stmt, + set_tag_access_rights_stmt, + update_tag_stmt, +) @pytest.fixture @@ -510,9 +522,85 @@ async def test_tags_repo_create( # assigned primary group assert ( await conn.scalar( - sa.select(tags_to_groups.c.group_id).where( - tags_to_groups.c.tag_id == tag_1["id"] + sa.select(tags_access_rights.c.group_id).where( + tags_access_rights.c.tag_id == tag_1["id"] ) ) == user.primary_gid ) + + +def test_building_tags_sql_statements(): + def _check(func_smt, **kwargs): + print(f"{func_smt.__name__:*^100}") + stmt = func_smt(**kwargs) + print() + print(as_postgres_sql_query_str(stmt)) + print() + + # some data + product_name = "osparc" + user_id = 425 # 4 + tag_id = 4 + project_index = 1 + service_key = "simcore/services/comp/isolve" + service_version = "2.0.85" + + _check( + get_tag_stmt, + user_id=user_id, + tag_id=tag_id, + ) + + _check( + create_tag_stmt, + name="foo", + description="description", + ) + + _check( + set_tag_access_rights_stmt, + tag_id=tag_id, + user_id=user_id, + read=True, + write=True, + delete=True, + ) + + _check( + update_tag_stmt, + user_id=user_id, + tag_id=tag_id, + # updates + name="foo", + ) + + _check( + delete_tag_stmt, + user_id=user_id, + tag_id=tag_id, + ) + + _check( + get_tags_for_project_stmt, + project_index=project_index, + ) + + _check( + get_tags_for_services_stmt, + key=service_key, + version=service_version, + ) + + _check( + add_tag_to_project_stmt, + project_index=project_index, + tag_id=tag_id, + ) + + _check( + add_tag_to_services_stmt, + key=service_key, + version=service_version, + tag_id=tag_id, + ) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tags.py b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tags.py index e83c6b03b05..0514369b50e 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tags.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tags.py @@ -5,7 +5,8 @@ from aiopg.sa.connection import SAConnection -from simcore_postgres_database.models.tags import tags, tags_to_groups +from simcore_postgres_database.models.tags import tags +from simcore_postgres_database.models.tags_access_rights import tags_access_rights async def create_tag_access( @@ -18,7 +19,7 @@ async def create_tag_access( delete, ) -> int: await conn.execute( - tags_to_groups.insert().values( + tags_access_rights.insert().values( tag_id=tag_id, group_id=group_id, read=read, write=write, delete=delete ) ) diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 7779de1ccdc..45f06c8653c 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -3182,6 +3182,8 @@ paths: schema: title: ' New' type: object + additionalProperties: + $ref: '#/components/schemas/ImageResources' required: true responses: '200': @@ -3415,7 +3417,7 @@ paths: '403': description: ProjectInvalidRightsError '404': - description: ProjectNotFoundError, UserDefaultWalletNotFoundError + description: UserDefaultWalletNotFoundError, ProjectNotFoundError '409': description: ProjectTooManyProjectOpenedError '422': @@ -4447,7 +4449,9 @@ paths: - required: true schema: title: Tag Id + exclusiveMinimum: true type: integer + minimum: 0 name: tag_id in: path responses: @@ -4462,7 +4466,9 @@ paths: - required: true schema: title: Tag Id + exclusiveMinimum: true type: integer + minimum: 0 name: tag_id in: path requestBody: @@ -5858,14 +5864,20 @@ components: inputs: title: Inputs type: object + additionalProperties: + $ref: '#/components/schemas/ServiceInputGet' description: inputs with extended information outputs: title: Outputs type: object + additionalProperties: + $ref: '#/components/schemas/ServiceOutputGet' description: outputs with extended information bootOptions: title: Bootoptions type: object + additionalProperties: + $ref: '#/components/schemas/BootOption' minVisibleInputs: title: Minvisibleinputs minimum: 0 @@ -7168,6 +7180,8 @@ components: data: title: Data type: object + additionalProperties: + $ref: '#/components/schemas/ImageResources' error: title: Error Envelope_dict_str__Any__: @@ -7596,14 +7610,36 @@ components: progress: title: Progress type: object + additionalProperties: + maximum: 100 + minimum: 0 + type: integer description: Progress in each computational node labels: title: Labels type: object + additionalProperties: + type: string description: Maps captured node with a label values: title: Values type: object + additionalProperties: + type: object + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + format: json-string + - type: string + - $ref: '#/components/schemas/SimCoreFileLink' + - $ref: '#/components/schemas/DatCoreFileLink' + - $ref: '#/components/schemas/DownloadLink' + - type: array + items: {} + - type: object description: Captured outputs per node example: progress: @@ -8506,6 +8542,21 @@ components: inputs: title: Inputs type: object + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + format: json-string + - type: string + - $ref: '#/components/schemas/PortLink' + - $ref: '#/components/schemas/SimCoreFileLink' + - $ref: '#/components/schemas/DatCoreFileLink' + - $ref: '#/components/schemas/DownloadLink' + - type: array + items: {} + - type: object description: values of input properties inputsRequired: title: Inputsrequired @@ -8517,10 +8568,14 @@ components: inputsUnits: title: Inputsunits type: object + additionalProperties: + type: string description: Overrides default unit (if any) defined in the service for each port inputAccess: type: object + additionalProperties: + $ref: '#/components/schemas/AccessEnum' description: map with key - access level pairs inputNodes: title: Inputnodes @@ -8532,6 +8587,20 @@ components: outputs: title: Outputs type: object + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + format: json-string + - type: string + - $ref: '#/components/schemas/SimCoreFileLink' + - $ref: '#/components/schemas/DatCoreFileLink' + - $ref: '#/components/schemas/DownloadLink' + - type: array + items: {} + - type: object description: values of output properties outputNode: title: Outputnode @@ -8564,6 +8633,8 @@ components: bootOptions: title: Bootoptions type: object + additionalProperties: + type: string description: Some services provide alternative parameters to be injected at boot time. The user selection should be stored here, and it will overwrite the services's defaults. @@ -8756,6 +8827,21 @@ components: inputs: title: Inputs type: object + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + format: json-string + - type: string + - $ref: '#/components/schemas/PortLink' + - $ref: '#/components/schemas/SimCoreFileLink' + - $ref: '#/components/schemas/DatCoreFileLink' + - $ref: '#/components/schemas/DownloadLink' + - type: array + items: {} + - type: object inputsRequired: title: Inputsrequired type: array @@ -9616,6 +9702,8 @@ components: workbench: title: Workbench type: object + additionalProperties: + $ref: '#/components/schemas/Node' accessRights: title: Accessrights type: object @@ -9679,6 +9767,8 @@ components: workbench: title: Workbench type: object + additionalProperties: + $ref: '#/components/schemas/Node' prjOwner: title: Prjowner type: string @@ -9907,6 +9997,8 @@ components: workbench: title: Workbench type: object + additionalProperties: + $ref: '#/components/schemas/Node' prjOwner: title: Prjowner type: string @@ -10131,6 +10223,8 @@ components: workbench: title: Workbench type: object + additionalProperties: + $ref: '#/components/schemas/Node' accessRights: title: Accessrights type: object @@ -10746,6 +10840,9 @@ components: fileToKeyMap: title: Filetokeymap type: object + additionalProperties: + pattern: ^[-_a-zA-Z0-9]+$ + type: string description: Place the data associated with the named keys in files unit: title: Unit @@ -10852,6 +10949,9 @@ components: fileToKeyMap: title: Filetokeymap type: object + additionalProperties: + pattern: ^[-_a-zA-Z0-9]+$ + type: string description: Place the data associated with the named keys in files unit: title: Unit @@ -11244,9 +11344,13 @@ components: workbench: title: Workbench type: object + additionalProperties: + $ref: '#/components/schemas/WorkbenchUI' slideshow: title: Slideshow type: object + additionalProperties: + $ref: '#/components/schemas/Slideshow' currentNodeId: title: Currentnodeid type: string @@ -11254,6 +11358,8 @@ components: annotations: title: Annotations type: object + additionalProperties: + $ref: '#/components/schemas/Annotation' TLSAuthentication: title: TLSAuthentication required: @@ -11332,7 +11438,6 @@ components: title: Color pattern: ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ type: string - additionalProperties: false TagGet: title: TagGet required: @@ -11372,7 +11477,6 @@ components: title: Color pattern: ^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$ type: string - additionalProperties: false TaskCounts: title: TaskCounts type: object @@ -11950,6 +12054,8 @@ components: minimum: 0 name: title: Name + maxLength: 100 + minLength: 1 type: string description: title: Description @@ -11991,6 +12097,8 @@ components: minimum: 0 name: title: Name + maxLength: 100 + minLength: 1 type: string description: title: Description diff --git a/services/web/server/src/simcore_service_webserver/db/models.py b/services/web/server/src/simcore_service_webserver/db/models.py index f1a39771bc6..0cbfaa7638c 100644 --- a/services/web/server/src/simcore_service_webserver/db/models.py +++ b/services/web/server/src/simcore_service_webserver/db/models.py @@ -13,9 +13,9 @@ groups, products, projects, + projects_tags, projects_to_wallet, scicrunch_resources, - study_tags, tags, tokens, user_to_groups, @@ -33,7 +33,7 @@ "products", "projects", "scicrunch_resources", - "study_tags", + "projects_tags", "tags", "tokens", "user_to_groups", diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py index 8cd7ba3deb4..7a8e2f9c064 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py @@ -22,7 +22,7 @@ from sqlalchemy.sql import select from sqlalchemy.sql.selectable import Select -from ..db.models import GroupType, groups, study_tags, user_to_groups, users +from ..db.models import GroupType, groups, projects_tags, user_to_groups, users from ..users.exceptions import UserNotFoundError from ..utils import format_datetime from .exceptions import ( @@ -238,8 +238,8 @@ async def _get_user_primary_group_gid(conn: SAConnection, user_id: int) -> int: @staticmethod async def _get_tags_by_project(conn: SAConnection, project_id: str) -> list: - query = sa.select(study_tags.c.tag_id).where( - study_tags.c.study_id == project_id + query = sa.select(projects_tags.c.tag_id).where( + projects_tags.c.project_id == project_id ) return [row.tag_id async for row in conn.execute(query)] @@ -249,9 +249,9 @@ async def _upsert_tags_in_project( ) -> None: for tag_id in project_tags: await conn.execute( - pg_insert(study_tags) + pg_insert(projects_tags) .values( - study_id=project_index_id, + project_id=project_index_id, tag_id=tag_id, ) .on_conflict_do_nothing() diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/db.py index ab49bb67036..4df9be4c3fe 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -59,7 +59,7 @@ from tenacity.retry import retry_if_exception_type from ..application_settings import ApplicationSettings -from ..db.models import projects_to_wallet, study_tags +from ..db.models import projects_tags, projects_to_wallet from ..utils import now_str from ._comments_db import ( create_project_comment, @@ -1035,8 +1035,8 @@ async def add_tag( # pylint: disable=no-value-for-parameter if tag_id not in project_tags: await conn.execute( - study_tags.insert().values( - study_id=project["id"], + projects_tags.insert().values( + project_id=project["id"], tag_id=tag_id, ) ) @@ -1051,10 +1051,10 @@ async def remove_tag( project = await self._get_project(conn, user_id, project_uuid) user_email = await self._get_user_email(conn, user_id) # pylint: disable=no-value-for-parameter - query = study_tags.delete().where( + query = projects_tags.delete().where( and_( - study_tags.c.study_id == project["id"], - study_tags.c.tag_id == tag_id, + projects_tags.c.project_id == project["id"], + projects_tags.c.tag_id == tag_id, ) ) async with conn.execute(query): diff --git a/services/web/server/src/simcore_service_webserver/tags/_handlers.py b/services/web/server/src/simcore_service_webserver/tags/_handlers.py index df95777ae80..b2f6537f5be 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/tags/_handlers.py @@ -3,8 +3,9 @@ from aiohttp import web from aiopg.sa.engine import Engine +from models_library.api_schemas_webserver._base import InputSchema, OutputSchema from models_library.users import UserID -from pydantic import BaseModel, ConstrainedStr, Extra, Field, PositiveInt +from pydantic import BaseModel, ConstrainedStr, Field, PositiveInt from servicelib.aiohttp.application_keys import APP_DB_ENGINE_KEY from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -50,48 +51,34 @@ class _RequestContext(BaseModel): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] -class _InputSchema(BaseModel): - class Config: - allow_population_by_field_name = False - extra = Extra.forbid - allow_mutations = False - - class ColorStr(ConstrainedStr): regex = re.compile(r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$") -class TagPathParams(_InputSchema): +class TagPathParams(BaseModel): tag_id: PositiveInt -class TagUpdate(_InputSchema): +class TagUpdate(InputSchema): name: str | None = None description: str | None = None color: ColorStr | None = None -class TagCreate(_InputSchema): +class TagCreate(InputSchema): name: str description: str | None = None color: ColorStr -class _OutputSchema(BaseModel): - class Config: - allow_population_by_field_name = True - extra = Extra.ignore - allow_mutations = False - - -class TagAccessRights(_OutputSchema): +class TagAccessRights(OutputSchema): # NOTE: analogous to GroupAccessRights read: bool write: bool delete: bool -class TagGet(_OutputSchema): +class TagGet(OutputSchema): id: PositiveInt name: str description: str | None = None @@ -130,7 +117,7 @@ def from_db(cls, tag: TagDict) -> "TagGet": async def create_tag(request: web.Request): engine: Engine = request.app[APP_DB_ENGINE_KEY] req_ctx = _RequestContext.parse_obj(request) - tag_data = await parse_request_body_as(TagCreate, request) + new_tag = await parse_request_body_as(TagCreate, request) repo = TagsRepo(user_id=req_ctx.user_id) async with engine.acquire() as conn: @@ -139,7 +126,7 @@ async def create_tag(request: web.Request): read=True, write=True, delete=True, - **tag_data.dict(exclude_unset=True), + **new_tag.dict(exclude_unset=True), ) model = TagGet.from_db(tag) return envelope_json_response(model) @@ -168,13 +155,13 @@ async def list_tags(request: web.Request): async def update_tag(request: web.Request): engine: Engine = request.app[APP_DB_ENGINE_KEY] req_ctx = _RequestContext.parse_obj(request) - query_params = parse_request_path_parameters_as(TagPathParams, request) - tag_data = await parse_request_body_as(TagUpdate, request) + path_params = parse_request_path_parameters_as(TagPathParams, request) + tag_updates = await parse_request_body_as(TagUpdate, request) repo = TagsRepo(user_id=req_ctx.user_id) async with engine.acquire() as conn: tag = await repo.update( - conn, query_params.tag_id, **tag_data.dict(exclude_unset=True) + conn, path_params.tag_id, **tag_updates.dict(exclude_unset=True) ) model = TagGet.from_db(tag) return envelope_json_response(model) @@ -187,10 +174,10 @@ async def update_tag(request: web.Request): async def delete_tag(request: web.Request): engine: Engine = request.app[APP_DB_ENGINE_KEY] req_ctx = _RequestContext.parse_obj(request) - query_params = parse_request_path_parameters_as(TagPathParams, request) + path_params = parse_request_path_parameters_as(TagPathParams, request) repo = TagsRepo(user_id=req_ctx.user_id) async with engine.acquire() as conn: - await repo.delete(conn, tag_id=query_params.tag_id) + await repo.delete(conn, tag_id=path_params.tag_id) raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON)