Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
2c94b64
renamed
sanderegg Jul 17, 2025
44e9c2a
added argument
sanderegg Jul 17, 2025
7d6a902
keep it as now
sanderegg Jul 17, 2025
7363934
clean
sanderegg Jul 17, 2025
574931c
adjust closing
sanderegg Jul 17, 2025
f79ca4d
cleanup
sanderegg Jul 17, 2025
6505f05
preparing
sanderegg Jul 17, 2025
cbf64b9
add validators
sanderegg Jul 17, 2025
a087db8
ruff
sanderegg Jul 17, 2025
72068e7
minor
sanderegg Jul 17, 2025
c842b9f
rename
sanderegg Jul 17, 2025
9dbc647
new share state
sanderegg Jul 17, 2025
eba3686
modernize
sanderegg Jul 17, 2025
640b409
changed api
sanderegg Jul 17, 2025
c256c63
services/webserver api version: 0.72.0 → 0.73.0
sanderegg Jul 17, 2025
babebfd
minor
sanderegg Jul 17, 2025
8f511c4
trying to use camelcase
sanderegg Jul 17, 2025
e33a62d
updated schemas
sanderegg Jul 17, 2025
245aa12
output schema
sanderegg Jul 17, 2025
a09835b
make setting nullable
sanderegg Jul 17, 2025
1a8c364
connect with setting
sanderegg Jul 17, 2025
e8f29d7
camelcase in the websocket
sanderegg Jul 17, 2025
6c731bd
refactor
sanderegg Jul 17, 2025
5d10b30
fixed also get project state
sanderegg Jul 17, 2025
2c7d5bb
frontend changes
sanderegg Jul 17, 2025
d1e54f9
restore old behavior
sanderegg Jul 17, 2025
ecabfb2
model shall not silently do stuff
sanderegg Jul 17, 2025
1c1375a
fixed tests
sanderegg Jul 18, 2025
75d9876
change from locked to shareState
sanderegg Jul 18, 2025
761ee4f
changing from ProjectLocked to ProjectShareState
sanderegg Jul 18, 2025
16bded1
fix test
sanderegg Jul 18, 2025
cbc525a
fix example
sanderegg Jul 18, 2025
adae661
fix responses
sanderegg Jul 18, 2025
2cc135f
fix check of sharestate
sanderegg Jul 18, 2025
f37b922
fixing tests
sanderegg Jul 18, 2025
0798ece
services/webserver api version: 0.73.1 → 0.74.0
sanderegg Jul 20, 2025
b94bfe3
fixed test
sanderegg Jul 20, 2025
46e3574
fixed types
sanderegg Jul 20, 2025
c71e6cc
typing
sanderegg Jul 20, 2025
d5270e9
fixing tests
sanderegg Jul 21, 2025
713f762
remove unused stuff
sanderegg Jul 21, 2025
7e208ca
refactor
sanderegg Jul 21, 2025
b18060d
split test
sanderegg Jul 21, 2025
2edf8e6
clean
sanderegg Jul 21, 2025
c24de78
added exception to handle use sessions error
sanderegg Jul 21, 2025
8d4d9b3
fixing tests
sanderegg Jul 21, 2025
a460b39
minor
sanderegg Jul 21, 2025
8ed3cee
fixed test
sanderegg Jul 21, 2025
b8250dd
fixing tests
sanderegg Jul 21, 2025
2750930
removed dev feature from being always on
sanderegg Jul 21, 2025
0c9893c
fixed frontend
sanderegg Jul 21, 2025
f9bb570
add fixture for dev
sanderegg Jul 21, 2025
1eb8797
moved fixture
sanderegg Jul 21, 2025
8c73b42
disable gc in tests
sanderegg Jul 21, 2025
2ab9918
no need to disable anymore
sanderegg Jul 21, 2025
46dc72e
add fixture to enable collaboration
sanderegg Jul 21, 2025
c505e4e
refactor
sanderegg Jul 21, 2025
ebd1533
refactor
sanderegg Jul 21, 2025
d6040c7
wrong fix
sanderegg Jul 22, 2025
6c18654
enahnce
sanderegg Jul 22, 2025
0990ade
enahnce
sanderegg Jul 22, 2025
f288320
fix test
sanderegg Jul 22, 2025
6a1e026
use tenacity
sanderegg Jul 22, 2025
3ba396d
use tenacity
sanderegg Jul 22, 2025
3135c13
refactor
sanderegg Jul 22, 2025
da22a16
typos
sanderegg Jul 22, 2025
e2dcf82
more reliable
sanderegg Jul 22, 2025
3045d15
test with multiple users
sanderegg Jul 22, 2025
e0572e7
cleanup
sanderegg Jul 22, 2025
889d70b
rename
sanderegg Jul 22, 2025
f001bdf
added test for max number of user sessions
sanderegg Jul 22, 2025
2d48f50
typo
sanderegg Jul 22, 2025
4addeb1
@odeimaiz review: added suggestion
sanderegg Jul 23, 2025
f1f8749
@GitHK review
sanderegg Jul 23, 2025
a8d39a1
Merge branch 'i1962/let-multiple-users-open-projects' of github.com:s…
odeimaiz Jul 23, 2025
9486c6f
refactor
odeimaiz Jul 23, 2025
407d985
more refactor
odeimaiz Jul 23, 2025
8c469fa
show users on card!
odeimaiz Jul 23, 2025
2b0e524
show usernames in tooltip
odeimaiz Jul 23, 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
10 changes: 7 additions & 3 deletions api/specs/web-server/_projects_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
from typing import Annotated

from fastapi import APIRouter, Body, Depends
from models_library.api_schemas_webserver.projects import ProjectGet
from models_library.api_schemas_webserver.projects import (
ProjectGet,
ProjectStateOutputSchema,
)
from models_library.generics import Envelope
from models_library.projects_state import ProjectState
from pydantic import ValidationError
from servicelib.aiohttp import status
from simcore_service_webserver._meta import API_VTAG
Expand Down Expand Up @@ -80,7 +82,9 @@ def close_project(
): ...


@router.get("/projects/{project_id}/state", response_model=Envelope[ProjectState])
@router.get(
"/projects/{project_id}/state", response_model=Envelope[ProjectStateOutputSchema]
)
def get_project_state(
_path_params: Annotated[ProjectPathParams, Depends()],
): ...
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@
ProjectType,
)
from ..projects_access import AccessRights, GroupIDStr
from ..projects_state import ProjectState
from ..projects_state import (
ProjectShareCurrentUserGroupIDs,
ProjectShareLocked,
ProjectShareStatus,
ProjectStateRunningState,
)
from ..utils._original_fastapi_encoders import jsonable_encoder
from ..utils.common_validators import (
empty_str_to_none_pre_validator,
Expand Down Expand Up @@ -105,6 +110,17 @@ def to_domain_model(self) -> dict[str, Any]:
)


class ProjectShareStateOutputSchema(OutputSchema):
status: ProjectShareStatus
locked: ProjectShareLocked
current_user_groupids: ProjectShareCurrentUserGroupIDs


class ProjectStateOutputSchema(OutputSchema):
share_state: ProjectShareStateOutputSchema
state: ProjectStateRunningState


class ProjectGet(OutputSchema):
uuid: ProjectID

Expand All @@ -124,7 +140,7 @@ class ProjectGet(OutputSchema):
# state
creation_date: DateTimeStr
last_change_date: DateTimeStr
state: ProjectState | None = None
state: ProjectStateOutputSchema | None = None
trashed_at: datetime | None
trashed_by: Annotated[
GroupID | None, Field(description="The primary gid of the user who trashed")
Expand Down
112 changes: 108 additions & 4 deletions packages/models-library/src/models_library/projects_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

from enum import Enum, unique
from typing import Annotated
from typing import Annotated, Self, TypeAlias

from pydantic import (
BaseModel,
Expand All @@ -13,7 +13,9 @@
field_validator,
model_validator,
)
from pydantic.config import JsonDict

from .groups import GroupID
from .projects_access import Owner


Expand Down Expand Up @@ -77,7 +79,101 @@ class ProjectStatus(str, Enum):
EXPORTING = "EXPORTING"
OPENING = "OPENING"
OPENED = "OPENED"
MAINTAINING = "MAINTAINING"
MAINTAINING = "MAINTAINING" # used for maintenance tasks, like removing EFS data


ProjectShareStatus: TypeAlias = Annotated[
ProjectStatus, Field(description="The status of the project")
]
ProjectShareLocked: TypeAlias = Annotated[
bool, Field(description="True if the project is locked")
]
ProjectShareCurrentUserGroupIDs: TypeAlias = Annotated[
list[GroupID],
Field(
description="Current users in the project (if the project is locked, the list contains only the lock owner)"
),
]


class ProjectShareState(BaseModel):
status: ProjectShareStatus
locked: ProjectShareLocked
current_user_groupids: ProjectShareCurrentUserGroupIDs

@staticmethod
def _update_json_schema_extra(schema: JsonDict) -> None:
schema.update(
{
"examples": [
{
"status": ProjectStatus.CLOSED,
"locked": False,
"current_user_groupids": [],
},
{
"status": ProjectStatus.OPENING,
"locked": False,
"current_user_groupids": [
"7",
"15",
"666",
],
},
{
"status": ProjectStatus.OPENED,
"locked": False,
"current_user_groupids": [
"7",
"15",
"666",
],
},
{
"status": ProjectStatus.CLONING,
"locked": True,
"current_user_groupids": [
"666",
],
},
]
}
)

model_config = ConfigDict(
extra="forbid", json_schema_extra=_update_json_schema_extra
)

@model_validator(mode="after")
def check_model_valid(self) -> Self:
if (
self.status
in [
ProjectStatus.CLONING,
ProjectStatus.EXPORTING,
ProjectStatus.MAINTAINING,
]
and not self.locked
):
msg = f"Project is {self.status=}, but it is not locked"
raise ValueError(msg)
if self.locked and not self.current_user_groupids:
msg = "If the project is locked, the current_users list must contain at least the lock owner"
raise ValueError(msg)
if self.status is ProjectStatus.CLOSED:
if self.locked:
msg = "If the project is closed, it cannot be locked"
raise ValueError(msg)
if self.current_user_groupids:
msg = "If the project is closed, the current_users list must be empty"
raise ValueError(msg)
elif not self.current_user_groupids and (
self.status is not ProjectStatus.MAINTAINING
):
msg = f"If the project is {self.status=}, the current_users list must not be empty"
raise ValueError(msg)

return self


class ProjectLocked(BaseModel):
Expand Down Expand Up @@ -144,8 +240,16 @@ class ProjectRunningState(BaseModel):
model_config = ConfigDict(extra="forbid")


ProjectStateShareState: TypeAlias = Annotated[
ProjectShareState, Field(description="The project share state")
]
ProjectStateRunningState: TypeAlias = Annotated[
ProjectRunningState, Field(description="The project running state")
]


class ProjectState(BaseModel):
locked: Annotated[ProjectLocked, Field(..., description="The project lock state")]
state: ProjectRunningState = Field(..., description="The project running state")
share_state: ProjectStateShareState
state: ProjectStateRunningState

model_config = ConfigDict(extra="forbid")
43 changes: 40 additions & 3 deletions packages/models-library/tests/test_projects_state.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import pytest
from models_library.projects_state import ProjectLocked, ProjectStatus
from models_library.projects_state import (
ProjectLocked,
ProjectShareState,
ProjectStatus,
)


def test_project_locked_with_missing_owner_raises():
with pytest.raises(ValueError):
with pytest.raises(ValueError, match=r"1 validation error for ProjectLocked"):
ProjectLocked(value=True, status=ProjectStatus.OPENED)
ProjectLocked.model_validate({"value": False, "status": ProjectStatus.OPENED})

Expand All @@ -22,5 +26,38 @@ def test_project_locked_with_missing_owner_ok_during_maintaining():
+ [(True, ProjectStatus.CLOSED)],
)
def test_project_locked_with_allowed_values(lock: bool, status: ProjectStatus):
with pytest.raises(ValueError):
with pytest.raises(ValueError, match=r"1 validation error for ProjectLocked"):
ProjectLocked.model_validate({"value": lock, "status": status})


@pytest.mark.parametrize(
"status,locked,current_users,should_raise",
[
(ProjectStatus.CLOSED, False, [], False),
(ProjectStatus.OPENING, False, [1, 2], False),
(ProjectStatus.OPENED, False, [1], False),
(ProjectStatus.CLONING, True, [1], False),
(ProjectStatus.EXPORTING, True, [1], False),
(ProjectStatus.MAINTAINING, True, [1], False),
# Invalid: locked but no users
(ProjectStatus.CLONING, True, [], True),
# Invalid: closed but has users
(ProjectStatus.CLOSED, False, [1], True),
# Invalid: not closed but no users
(ProjectStatus.OPENED, False, [], True),
],
)
def test_project_share_state_validations(status, locked, current_users, should_raise):
data = {
"status": status,
"locked": locked,
"current_user_groupids": current_users,
}
if should_raise:
with pytest.raises(ValueError, match=r"If the project is "):
ProjectShareState.model_validate(data)
else:
state = ProjectShareState.model_validate(data)
assert state.status == status
assert state.locked == locked
assert state.current_user_groupids == current_users
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ def __call__(self, message: str) -> bool:
decoded_message = decode_socketio_42_message(message)
if (
(decoded_message.name == _OSparcMessages.PROJECT_STATE_UPDATED.value)
and (decoded_message.obj["data"]["locked"]["status"] == "CLOSED")
and (decoded_message.obj["data"]["locked"]["value"] is False)
and (decoded_message.obj["data"]["shareState"]["status"] == "CLOSED")
and (decoded_message.obj["data"]["shareState"]["locked"] is False)
):
self.logger.info("project successfully closed")
return True
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import json
import uuid as uuidlib
from http import HTTPStatus
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -187,7 +186,7 @@ async def __aexit__(self, *args):
async def assert_get_same_project(
client: TestClient,
project: ProjectDict,
expected: HTTPStatus,
expected: int,
api_vtag="/v0",
) -> dict:
# GET /v0/projects/{project_id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ def request_desc(self) -> str:
"prjOwner": "[email protected]",
"tags": [],
"state": {
"locked": {"value": False, "status": "CLOSED"},
"shareState": {
"status": "CLOSED",
"locked": False,
"currentUserGroupids": [],
},
"state": {"value": "NOT_STARTED"},
},
"dev": None,
Expand Down Expand Up @@ -114,7 +118,11 @@ def request_desc(self) -> str:
"quality": {},
"tags": [],
"state": {
"locked": {"value": False, "status": "CLOSED"},
"shareState": {
"status": "CLOSED",
"locked": False,
"currentUserGroupids": [],
},
"state": {"value": "NOT_STARTED"},
},
"workspace_id": None,
Expand Down Expand Up @@ -149,14 +157,10 @@ def request_desc(self) -> str:
"quality": {},
"tags": [],
"state": {
"locked": {
"value": True,
"owner": {
"user_id": 1,
"first_name": "crespo",
"last_name": "",
},
"shareState": {
"status": "OPENED",
"locked": True,
"currentUserGroupids": [1],
},
"state": {"value": "NOT_STARTED"},
},
Expand Down Expand Up @@ -284,14 +288,10 @@ def request_desc(self) -> str:
},
"tags": [],
"state": {
"locked": {
"value": True,
"owner": {
"user_id": 1,
"first_name": "crespo",
"last_name": "",
},
"shareState": {
"status": "OPENED",
"locked": True,
"currentUserGroupids": [1],
},
"state": {"value": "NOT_STARTED"},
},
Expand Down Expand Up @@ -547,14 +547,10 @@ def request_desc(self) -> str:
},
"tags": [],
"state": {
"locked": {
"value": True,
"owner": {
"user_id": 1,
"first_name": "crespo",
"last_name": "",
},
"shareState": {
"status": "OPENED",
"locked": True,
"currentUserGroupids": [1],
},
"state": {"value": "NOT_STARTED"},
},
Expand Down Expand Up @@ -734,7 +730,11 @@ def request_desc(self) -> str:
},
"tags": [],
"state": {
"locked": {"value": False, "status": "CLOSED"},
"shareState": {
"status": "CLOSED",
"locked": False,
"currentUserGroupids": [],
},
"state": {"value": "NOT_STARTED"},
},
}
Expand Down Expand Up @@ -988,7 +988,11 @@ def request_desc(self) -> str:
"prjOwner": "[email protected]",
"tags": [22],
"state": {
"locked": {"value": False, "status": "CLOSED"},
"shareState": {
"status": "CLOSED",
"locked": False,
"currentUserGroupids": [],
},
"state": {"value": "NOT_STARTED"},
},
}
Expand Down
Loading
Loading