Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ web-api: trashed resources include trashedBy with the primary GID of the user that trashed it #7052

Merged
merged 44 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
4d0245c
drafts tests
pcrespov Jan 15, 2025
41b94c4
back to uid
pcrespov Jan 15, 2025
47d5255
rm import
pcrespov Jan 16, 2025
10b5e79
fixes userid
pcrespov Jan 16, 2025
a900b67
fixes folders
pcrespov Jan 16, 2025
5e2e63c
doc
pcrespov Jan 17, 2025
b3f671b
updates OAS
pcrespov Jan 17, 2025
9384eef
services/webserver api version: 0.50.0 → 0.51.0
pcrespov Jan 17, 2025
f5357ad
updates workspaces to return groupid
pcrespov Jan 17, 2025
607c6a3
updates fakes in api-server
pcrespov Jan 17, 2025
a51dedf
fixes tests
pcrespov Jan 17, 2025
9582a27
folders trashed-by
pcrespov Jan 17, 2025
908e453
domain models in folders
pcrespov Jan 17, 2025
4abded4
doc
pcrespov Jan 17, 2025
93dbfda
fixes tests
pcrespov Jan 17, 2025
d9c57cd
projects schema, domain and data models
pcrespov Jan 17, 2025
92951c0
projects trashed by primary gid
pcrespov Jan 17, 2025
2f574a5
fix tests
pcrespov Jan 17, 2025
fa42231
fix tests
pcrespov Jan 17, 2025
afbbc44
fix mypy
pcrespov Jan 18, 2025
c069123
fix bad merge
pcrespov Jan 22, 2025
a77e643
split ProjectDB
pcrespov Jan 22, 2025
a0d8de7
read functions returning ProjectDict
pcrespov Jan 22, 2025
007a1e6
list of project dict gets trashed_by_primary_gid
pcrespov Jan 22, 2025
4d438e0
list of project dict gets trashed_by_primary_gid
pcrespov Jan 22, 2025
d5016e3
doc
pcrespov Jan 22, 2025
5905876
handlers return item
pcrespov Jan 22, 2025
ced0e98
by_alias since Page is strict
pcrespov Jan 22, 2025
b3cc85e
fixes test_proejcts_cancellations
pcrespov Jan 22, 2025
4e069ef
fixes tests
pcrespov Jan 22, 2025
294dc0b
adapts folders
pcrespov Jan 22, 2025
667daa8
testing folders
pcrespov Jan 22, 2025
f4c6886
minor
pcrespov Jan 23, 2025
23fca69
fixes test and refactor query
pcrespov Jan 23, 2025
1bf2942
fixes order
pcrespov Jan 23, 2025
347d565
@GitHK review: annotations
pcrespov Jan 23, 2025
8f0a626
@bisgaard-itis review: camelcase or not
pcrespov Jan 23, 2025
906a875
refactor to reduce complexity (sonarcloud)
pcrespov Jan 23, 2025
09644ab
fixes cancelation errors
pcrespov Jan 23, 2025
5b8a385
cleanup
pcrespov Jan 23, 2025
fadfb3a
sonarcloud complexity and pylint
pcrespov Jan 23, 2025
7a92b48
drop
pcrespov Jan 23, 2025
0a7b242
fixes tests
pcrespov Jan 24, 2025
1340862
fixes bug introduced in workspaces
pcrespov Jan 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from datetime import datetime
from typing import NamedTuple
from typing import Annotated, Self

from pydantic import ConfigDict, PositiveInt, field_validator
from pydantic import ConfigDict, Field, field_validator

from ..access_rights import AccessRights
from ..basic_types import IDStr
from ..folders import FolderID
from ..folders import FolderDB, FolderID
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
from ..groups import GroupID
from ..utils.common_validators import null_or_none_str_to_none_validator
from ..workspaces import WorkspaceID
Expand All @@ -16,17 +16,40 @@ class FolderGet(OutputSchema):
folder_id: FolderID
parent_folder_id: FolderID | None = None
name: str

created_at: datetime
modified_at: datetime
trashed_at: datetime | None
trashed_by: Annotated[
GroupID | None, Field(description="The primary gid of the user who trashed")
pcrespov marked this conversation as resolved.
Show resolved Hide resolved
]
owner: GroupID
workspace_id: WorkspaceID | None
my_access_rights: AccessRights

@classmethod
def from_domain_model(
cls,
folder_db: FolderDB,
trashed_by_primary_gid: GroupID | None,
user_folder_access_rights: AccessRights,
) -> Self:
if (folder_db.trashed_by is None) ^ (trashed_by_primary_gid is None):
msg = f"Incompatible inputs: {folder_db.trashed_by=} but not {trashed_by_primary_gid=}"
raise ValueError(msg)

class FolderGetPage(NamedTuple):
items: list[FolderGet]
total: PositiveInt
return cls(
folder_id=folder_db.folder_id,
parent_folder_id=folder_db.parent_folder_id,
name=folder_db.name,
created_at=folder_db.created,
modified_at=folder_db.modified,
trashed_at=folder_db.trashed,
trashed_by=trashed_by_primary_gid,
owner=folder_db.created_by_gid,
workspace_id=folder_db.workspace_id,
my_access_rights=user_folder_access_rights,
)


class FolderCreateBodyParams(InputSchema):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,11 @@

"""

import copy
from datetime import datetime
from typing import Annotated, Any, Literal, Self, TypeAlias

from common_library.dict_tools import remap_keys
from models_library.folders import FolderID
from models_library.utils._original_fastapi_encoders import jsonable_encoder
from models_library.workspaces import WorkspaceID
from pydantic import (
BeforeValidator,
ConfigDict,
Expand All @@ -25,10 +23,12 @@
from ..basic_types import LongTruncatedStr, ShortTruncatedStr
from ..emails import LowerCaseEmailStr
from ..folders import FolderID
from ..groups import GroupID
from ..projects import ClassifierID, DateTimeStr, NodesDict, ProjectID
from ..projects_access import AccessRights, GroupIDStr
from ..projects_state import ProjectState
from ..projects_ui import StudyUI
from ..utils._original_fastapi_encoders import jsonable_encoder
from ..utils.common_validators import (
empty_str_to_none_pre_validator,
none_to_empty_str_pre_validator,
Expand Down Expand Up @@ -98,6 +98,9 @@ class ProjectGet(OutputSchema):
folder_id: FolderID | None

trashed_at: datetime | None
trashed_by: Annotated[
GroupID | None, Field(description="The primary gid of the user who trashed")
]

_empty_description = field_validator("description", mode="before")(
none_to_empty_str_pre_validator
Expand All @@ -107,10 +110,20 @@ class ProjectGet(OutputSchema):

@classmethod
def from_domain_model(cls, project_data: dict[str, Any]) -> Self:
trimmed_data = copy.copy(project_data)
# project_data["trashed_by"] is a UserID
# project_data["trashed_by_primary_gid"] is a GroupID
Comment on lines +114 to +115
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe remove these if they are unsued?

trimmed_data.pop("trashed_by", None)
trimmed_data.pop("trashedBy", None)

return cls.model_validate(
remap_keys(
project_data,
rename={"trashed": "trashed_at"},
trimmed_data,
rename={
"trashed": "trashed_at",
"trashed_by_primary_gid": "trashed_by",
"trashedByPrimaryGid": "trashedBy",
},
)
)

Expand All @@ -127,7 +140,8 @@ class ProjectReplace(InputSchema):
name: ShortTruncatedStr
description: LongTruncatedStr
thumbnail: Annotated[
HttpUrl | None, BeforeValidator(empty_str_to_none_pre_validator)
HttpUrl | None,
BeforeValidator(empty_str_to_none_pre_validator),
] = Field(default=None)
creation_date: DateTimeStr
last_change_date: DateTimeStr
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from datetime import datetime
from typing import Self
from typing import Annotated, Self

from pydantic import ConfigDict
from pydantic import ConfigDict, Field

from ..access_rights import AccessRights
from ..basic_types import IDStr
from ..groups import GroupID
from ..users import UserID
from ..workspaces import UserWorkspaceWithAccessRights, WorkspaceID
from ._base import InputSchema, OutputSchema

Expand All @@ -19,7 +18,9 @@ class WorkspaceGet(OutputSchema):
created_at: datetime
modified_at: datetime
trashed_at: datetime | None
trashed_by: UserID | None
trashed_by: Annotated[
GroupID | None, Field(description="The primary gid of the user who trashed")
]
my_access_rights: AccessRights
access_rights: dict[GroupID, AccessRights]

Expand All @@ -33,7 +34,7 @@ def from_domain_model(cls, wks: UserWorkspaceWithAccessRights) -> Self:
created_at=wks.created,
modified_at=wks.modified,
trashed_at=wks.trashed,
trashed_by=wks.trashed_by if wks.trashed else None,
trashed_by=wks.trashed_by_primary_gid if wks.trashed else None,
my_access_rights=wks.my_access_rights,
access_rights=wks.access_rights,
)
Expand Down
52 changes: 19 additions & 33 deletions packages/models-library/src/models_library/folders.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
from datetime import datetime
from enum import auto
from typing import TypeAlias
from typing import NamedTuple, TypeAlias

from pydantic import (
BaseModel,
ConfigDict,
Field,
PositiveInt,
ValidationInfo,
field_validator,
)
from pydantic import BaseModel, ConfigDict, PositiveInt, ValidationInfo, field_validator

from .access_rights import AccessRights
from .groups import GroupID
Expand Down Expand Up @@ -43,38 +36,31 @@ def validate_folder_id(cls, value, info: ValidationInfo):
return value


#
# DB
#


class FolderDB(BaseModel):
folder_id: FolderID
name: str
parent_folder_id: FolderID | None
created_by_gid: GroupID = Field(
...,
description="GID of the group that owns this wallet",
)
created: datetime = Field(
...,
description="Timestamp on creation",
)
modified: datetime = Field(
...,
description="Timestamp of last modification",
)
trashed: datetime | None = Field(
...,
)

user_id: UserID | None
workspace_id: WorkspaceID | None

created_by_gid: GroupID
created: datetime
modified: datetime

trashed: datetime | None
trashed_by: UserID | None
trashed_explicitly: bool

user_id: UserID | None # owner?
workspace_id: WorkspaceID | None
model_config = ConfigDict(from_attributes=True)


class UserFolderAccessRightsDB(FolderDB):
class UserFolder(FolderDB):
my_access_rights: AccessRights

model_config = ConfigDict(from_attributes=True)


class FolderTuple(NamedTuple):
folder_db: FolderDB
trashed_by_primary_gid: GroupID | None
my_access_rights: AccessRights
7 changes: 6 additions & 1 deletion packages/models-library/src/models_library/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from .basic_regex import DATE_RE, UUID_RE_BASE
from .emails import LowerCaseEmailStr
from .groups import GroupID
from .projects_access import AccessRights, GroupIDStr
from .projects_nodes import Node
from .projects_nodes_io import NodeIDStr
Expand Down Expand Up @@ -106,7 +107,7 @@ class ProjectAtDB(BaseProjectModel):

@field_validator("project_type", mode="before")
@classmethod
def convert_sql_alchemy_enum(cls, v):
def _convert_sql_alchemy_enum(cls, v):
if isinstance(v, Enum):
return v.value
return v
Expand Down Expand Up @@ -185,8 +186,12 @@ class Project(BaseProjectModel):

trashed: datetime | None = None
trashed_by: Annotated[UserID | None, Field(alias="trashedBy")] = None
trashed_by_primary_gid: Annotated[
GroupID | None, Field(alias="trashedByPrimaryGid")
] = None
trashed_explicitly: Annotated[bool, Field(alias="trashedExplicitly")] = False

model_config = ConfigDict(
# NOTE: this is a security measure until we get rid of the ProjectDict variants
extra="forbid",
)
17 changes: 9 additions & 8 deletions packages/models-library/src/models_library/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class Workspace(BaseModel):
workspace_id: WorkspaceID
name: str
description: str | None
owner_primary_gid: PositiveInt = Field(
owner_primary_gid: GroupID = Field(
...,
description="GID of the group that owns this wallet",
)
Expand All @@ -62,6 +62,14 @@ class Workspace(BaseModel):
)
trashed: datetime | None
trashed_by: UserID | None
trashed_by_primary_gid: GroupID | None = None

model_config = ConfigDict(from_attributes=True)


class UserWorkspaceWithAccessRights(Workspace):
my_access_rights: AccessRights
access_rights: dict[GroupID, AccessRights]

model_config = ConfigDict(from_attributes=True)

Expand All @@ -72,10 +80,3 @@ class WorkspaceUpdates(BaseModel):
thumbnail: str | None = None
trashed: datetime | None = None
trashed_by: UserID | None = None


class UserWorkspaceWithAccessRights(Workspace):
my_access_rights: AccessRights
access_rights: dict[GroupID, AccessRights]

model_config = ConfigDict(from_attributes=True)
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

@pytest.mark.parametrize(
"api_call",
(NEW_PROJECT, CREATE_FROM_SERVICE, CREATE_FROM_TEMPLATE),
[NEW_PROJECT, CREATE_FROM_SERVICE, CREATE_FROM_TEMPLATE],
ids=lambda c: c.name,
)
def test_create_project_schemas(api_call: HttpApiCallCapture):
Expand All @@ -45,7 +45,7 @@ def test_create_project_schemas(api_call: HttpApiCallCapture):

@pytest.mark.parametrize(
"api_call",
(LIST_PROJECTS,),
[LIST_PROJECTS],
ids=lambda c: c.name,
)
def test_list_project_schemas(api_call: HttpApiCallCapture):
Expand All @@ -59,7 +59,7 @@ def test_list_project_schemas(api_call: HttpApiCallCapture):

@pytest.mark.parametrize(
"api_call",
(GET_PROJECT, CREATE_FROM_TEMPLATE__TASK_RESULT),
[GET_PROJECT, CREATE_FROM_TEMPLATE__TASK_RESULT],
ids=lambda c: c.name,
)
def test_get_project_schemas(api_call: HttpApiCallCapture):
Expand All @@ -74,7 +74,7 @@ def test_get_project_schemas(api_call: HttpApiCallCapture):

@pytest.mark.parametrize(
"api_call",
(REPLACE_PROJECT, REPLACE_PROJECT_ON_MODIFIED),
[REPLACE_PROJECT, REPLACE_PROJECT_ON_MODIFIED],
ids=lambda c: c.name,
)
def test_replace_project_schemas(api_call: HttpApiCallCapture):
Expand Down
1 change: 1 addition & 0 deletions packages/models-library/tests/test_project_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def test_create_minimal_node(minimal_node_data_sample: dict[str, Any]):
# a nice way to see how the simplest node looks like
assert node.inputs == {}
assert node.outputs == {}
assert node.state is not None
assert node.state.current_status == RunningState.NOT_STARTED
assert node.state.modified is True
assert node.state.dependencies == set()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class ProjectType(enum.Enum):
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
doc="Read/write/delete access rights of each group (gid) on this project",
doc="DEPRECATED: Read/write/delete access rights of each group (gid) on this project",
),
sa.Column(
"workbench",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TypeVar

import sqlalchemy as sa
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -56,3 +59,29 @@ async def transaction_context(
finally:
assert not conn.closed # nosec
assert not conn.in_transaction() # nosec


SQLModel = TypeVar(
# Towards using https://sqlmodel.tiangolo.com/#create-a-sqlmodel-model
"SQLModel",
bound=BaseModel,
)


def get_columns_from_db_model(
table: sa.Table, model_cls: type[SQLModel]
) -> list[sa.Column]:
"""
Usage example:

query = sa.select( get_columns_from_db_model(project, ProjectDB) )

or

query = (
project.insert().
# ...
.returning(*get_columns_from_db_model(project, ProjectDB))
)
"""
return [table.columns[field_name] for field_name in model_cls.model_fields]
Loading
Loading