Skip to content

Commit 633f3c1

Browse files
authored
✨Allow multiple user sessions (user+tab) to open the same project (ITISFoundation#8123)
1 parent a71239a commit 633f3c1

File tree

58 files changed

+4454
-3967
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+4454
-3967
lines changed

api/specs/web-server/_projects_states.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from typing import Annotated
88

99
from fastapi import APIRouter, Body, Depends
10-
from models_library.api_schemas_webserver.projects import ProjectGet
10+
from models_library.api_schemas_webserver.projects import (
11+
ProjectGet,
12+
ProjectStateOutputSchema,
13+
)
1114
from models_library.generics import Envelope
12-
from models_library.projects_state import ProjectState
1315
from pydantic import ValidationError
1416
from servicelib.aiohttp import status
1517
from simcore_service_webserver._meta import API_VTAG
@@ -80,7 +82,9 @@ def close_project(
8082
): ...
8183

8284

83-
@router.get("/projects/{project_id}/state", response_model=Envelope[ProjectState])
85+
@router.get(
86+
"/projects/{project_id}/state", response_model=Envelope[ProjectStateOutputSchema]
87+
)
8488
def get_project_state(
8589
_path_params: Annotated[ProjectPathParams, Depends()],
8690
): ...

packages/models-library/src/models_library/api_schemas_webserver/projects.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@
3535
ProjectType,
3636
)
3737
from ..projects_access import AccessRights, GroupIDStr
38-
from ..projects_state import ProjectState
38+
from ..projects_state import (
39+
ProjectShareCurrentUserGroupIDs,
40+
ProjectShareLocked,
41+
ProjectShareStatus,
42+
ProjectStateRunningState,
43+
)
3944
from ..utils._original_fastapi_encoders import jsonable_encoder
4045
from ..utils.common_validators import (
4146
empty_str_to_none_pre_validator,
@@ -105,6 +110,17 @@ def to_domain_model(self) -> dict[str, Any]:
105110
)
106111

107112

113+
class ProjectShareStateOutputSchema(OutputSchema):
114+
status: ProjectShareStatus
115+
locked: ProjectShareLocked
116+
current_user_groupids: ProjectShareCurrentUserGroupIDs
117+
118+
119+
class ProjectStateOutputSchema(OutputSchema):
120+
share_state: ProjectShareStateOutputSchema
121+
state: ProjectStateRunningState
122+
123+
108124
class ProjectGet(OutputSchema):
109125
uuid: ProjectID
110126

@@ -124,7 +140,7 @@ class ProjectGet(OutputSchema):
124140
# state
125141
creation_date: DateTimeStr
126142
last_change_date: DateTimeStr
127-
state: ProjectState | None = None
143+
state: ProjectStateOutputSchema | None = None
128144
trashed_at: datetime | None
129145
trashed_by: Annotated[
130146
GroupID | None, Field(description="The primary gid of the user who trashed")

packages/models-library/src/models_library/projects_state.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
from enum import Enum, unique
6-
from typing import Annotated
6+
from typing import Annotated, Self, TypeAlias
77

88
from pydantic import (
99
BaseModel,
@@ -13,7 +13,9 @@
1313
field_validator,
1414
model_validator,
1515
)
16+
from pydantic.config import JsonDict
1617

18+
from .groups import GroupID
1719
from .projects_access import Owner
1820

1921

@@ -77,7 +79,101 @@ class ProjectStatus(str, Enum):
7779
EXPORTING = "EXPORTING"
7880
OPENING = "OPENING"
7981
OPENED = "OPENED"
80-
MAINTAINING = "MAINTAINING"
82+
MAINTAINING = "MAINTAINING" # used for maintenance tasks, like removing EFS data
83+
84+
85+
ProjectShareStatus: TypeAlias = Annotated[
86+
ProjectStatus, Field(description="The status of the project")
87+
]
88+
ProjectShareLocked: TypeAlias = Annotated[
89+
bool, Field(description="True if the project is locked")
90+
]
91+
ProjectShareCurrentUserGroupIDs: TypeAlias = Annotated[
92+
list[GroupID],
93+
Field(
94+
description="Current users in the project (if the project is locked, the list contains only the lock owner)"
95+
),
96+
]
97+
98+
99+
class ProjectShareState(BaseModel):
100+
status: ProjectShareStatus
101+
locked: ProjectShareLocked
102+
current_user_groupids: ProjectShareCurrentUserGroupIDs
103+
104+
@staticmethod
105+
def _update_json_schema_extra(schema: JsonDict) -> None:
106+
schema.update(
107+
{
108+
"examples": [
109+
{
110+
"status": ProjectStatus.CLOSED,
111+
"locked": False,
112+
"current_user_groupids": [],
113+
},
114+
{
115+
"status": ProjectStatus.OPENING,
116+
"locked": False,
117+
"current_user_groupids": [
118+
"7",
119+
"15",
120+
"666",
121+
],
122+
},
123+
{
124+
"status": ProjectStatus.OPENED,
125+
"locked": False,
126+
"current_user_groupids": [
127+
"7",
128+
"15",
129+
"666",
130+
],
131+
},
132+
{
133+
"status": ProjectStatus.CLONING,
134+
"locked": True,
135+
"current_user_groupids": [
136+
"666",
137+
],
138+
},
139+
]
140+
}
141+
)
142+
143+
model_config = ConfigDict(
144+
extra="forbid", json_schema_extra=_update_json_schema_extra
145+
)
146+
147+
@model_validator(mode="after")
148+
def check_model_valid(self) -> Self:
149+
if (
150+
self.status
151+
in [
152+
ProjectStatus.CLONING,
153+
ProjectStatus.EXPORTING,
154+
ProjectStatus.MAINTAINING,
155+
]
156+
and not self.locked
157+
):
158+
msg = f"Project is {self.status=}, but it is not locked"
159+
raise ValueError(msg)
160+
if self.locked and not self.current_user_groupids:
161+
msg = "If the project is locked, the current_users list must contain at least the lock owner"
162+
raise ValueError(msg)
163+
if self.status is ProjectStatus.CLOSED:
164+
if self.locked:
165+
msg = "If the project is closed, it cannot be locked"
166+
raise ValueError(msg)
167+
if self.current_user_groupids:
168+
msg = "If the project is closed, the current_users list must be empty"
169+
raise ValueError(msg)
170+
elif not self.current_user_groupids and (
171+
self.status is not ProjectStatus.MAINTAINING
172+
):
173+
msg = f"If the project is {self.status=}, the current_users list must not be empty"
174+
raise ValueError(msg)
175+
176+
return self
81177

82178

83179
class ProjectLocked(BaseModel):
@@ -144,8 +240,16 @@ class ProjectRunningState(BaseModel):
144240
model_config = ConfigDict(extra="forbid")
145241

146242

243+
ProjectStateShareState: TypeAlias = Annotated[
244+
ProjectShareState, Field(description="The project share state")
245+
]
246+
ProjectStateRunningState: TypeAlias = Annotated[
247+
ProjectRunningState, Field(description="The project running state")
248+
]
249+
250+
147251
class ProjectState(BaseModel):
148-
locked: Annotated[ProjectLocked, Field(..., description="The project lock state")]
149-
state: ProjectRunningState = Field(..., description="The project running state")
252+
share_state: ProjectStateShareState
253+
state: ProjectStateRunningState
150254

151255
model_config = ConfigDict(extra="forbid")

packages/models-library/tests/test_projects_state.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import pytest
2-
from models_library.projects_state import ProjectLocked, ProjectStatus
2+
from models_library.projects_state import (
3+
ProjectLocked,
4+
ProjectShareState,
5+
ProjectStatus,
6+
)
37

48

59
def test_project_locked_with_missing_owner_raises():
6-
with pytest.raises(ValueError):
10+
with pytest.raises(ValueError, match=r"1 validation error for ProjectLocked"):
711
ProjectLocked(value=True, status=ProjectStatus.OPENED)
812
ProjectLocked.model_validate({"value": False, "status": ProjectStatus.OPENED})
913

@@ -22,5 +26,38 @@ def test_project_locked_with_missing_owner_ok_during_maintaining():
2226
+ [(True, ProjectStatus.CLOSED)],
2327
)
2428
def test_project_locked_with_allowed_values(lock: bool, status: ProjectStatus):
25-
with pytest.raises(ValueError):
29+
with pytest.raises(ValueError, match=r"1 validation error for ProjectLocked"):
2630
ProjectLocked.model_validate({"value": lock, "status": status})
31+
32+
33+
@pytest.mark.parametrize(
34+
"status,locked,current_users,should_raise",
35+
[
36+
(ProjectStatus.CLOSED, False, [], False),
37+
(ProjectStatus.OPENING, False, [1, 2], False),
38+
(ProjectStatus.OPENED, False, [1], False),
39+
(ProjectStatus.CLONING, True, [1], False),
40+
(ProjectStatus.EXPORTING, True, [1], False),
41+
(ProjectStatus.MAINTAINING, True, [1], False),
42+
# Invalid: locked but no users
43+
(ProjectStatus.CLONING, True, [], True),
44+
# Invalid: closed but has users
45+
(ProjectStatus.CLOSED, False, [1], True),
46+
# Invalid: not closed but no users
47+
(ProjectStatus.OPENED, False, [], True),
48+
],
49+
)
50+
def test_project_share_state_validations(status, locked, current_users, should_raise):
51+
data = {
52+
"status": status,
53+
"locked": locked,
54+
"current_user_groupids": current_users,
55+
}
56+
if should_raise:
57+
with pytest.raises(ValueError, match=r"If the project is "):
58+
ProjectShareState.model_validate(data)
59+
else:
60+
state = ProjectShareState.model_validate(data)
61+
assert state.status == status
62+
assert state.locked == locked
63+
assert state.current_user_groupids == current_users

packages/pytest-simcore/src/pytest_simcore/helpers/playwright.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ def __call__(self, message: str) -> bool:
274274
decoded_message = decode_socketio_42_message(message)
275275
if (
276276
(decoded_message.name == _OSparcMessages.PROJECT_STATE_UPDATED.value)
277-
and (decoded_message.obj["data"]["locked"]["status"] == "CLOSED")
278-
and (decoded_message.obj["data"]["locked"]["value"] is False)
277+
and (decoded_message.obj["data"]["shareState"]["status"] == "CLOSED")
278+
and (decoded_message.obj["data"]["shareState"]["locked"] is False)
279279
):
280280
self.logger.info("project successfully closed")
281281
return True

packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import json
66
import uuid as uuidlib
7-
from http import HTTPStatus
87
from pathlib import Path
98
from typing import Any
109

@@ -187,7 +186,7 @@ async def __aexit__(self, *args):
187186
async def assert_get_same_project(
188187
client: TestClient,
189188
project: ProjectDict,
190-
expected: HTTPStatus,
189+
expected: int,
191190
api_vtag="/v0",
192191
) -> dict:
193192
# GET /v0/projects/{project_id}

packages/pytest-simcore/src/pytest_simcore/simcore_webserver_projects_rest_api.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ def request_desc(self) -> str:
7070
"prjOwner": "[email protected]",
7171
"tags": [],
7272
"state": {
73-
"locked": {"value": False, "status": "CLOSED"},
73+
"shareState": {
74+
"status": "CLOSED",
75+
"locked": False,
76+
"currentUserGroupids": [],
77+
},
7478
"state": {"value": "NOT_STARTED"},
7579
},
7680
"dev": None,
@@ -114,7 +118,11 @@ def request_desc(self) -> str:
114118
"quality": {},
115119
"tags": [],
116120
"state": {
117-
"locked": {"value": False, "status": "CLOSED"},
121+
"shareState": {
122+
"status": "CLOSED",
123+
"locked": False,
124+
"currentUserGroupids": [],
125+
},
118126
"state": {"value": "NOT_STARTED"},
119127
},
120128
"workspace_id": None,
@@ -149,14 +157,10 @@ def request_desc(self) -> str:
149157
"quality": {},
150158
"tags": [],
151159
"state": {
152-
"locked": {
153-
"value": True,
154-
"owner": {
155-
"user_id": 1,
156-
"first_name": "crespo",
157-
"last_name": "",
158-
},
160+
"shareState": {
159161
"status": "OPENED",
162+
"locked": True,
163+
"currentUserGroupids": [1],
160164
},
161165
"state": {"value": "NOT_STARTED"},
162166
},
@@ -284,14 +288,10 @@ def request_desc(self) -> str:
284288
},
285289
"tags": [],
286290
"state": {
287-
"locked": {
288-
"value": True,
289-
"owner": {
290-
"user_id": 1,
291-
"first_name": "crespo",
292-
"last_name": "",
293-
},
291+
"shareState": {
294292
"status": "OPENED",
293+
"locked": True,
294+
"currentUserGroupids": [1],
295295
},
296296
"state": {"value": "NOT_STARTED"},
297297
},
@@ -547,14 +547,10 @@ def request_desc(self) -> str:
547547
},
548548
"tags": [],
549549
"state": {
550-
"locked": {
551-
"value": True,
552-
"owner": {
553-
"user_id": 1,
554-
"first_name": "crespo",
555-
"last_name": "",
556-
},
550+
"shareState": {
557551
"status": "OPENED",
552+
"locked": True,
553+
"currentUserGroupids": [1],
558554
},
559555
"state": {"value": "NOT_STARTED"},
560556
},
@@ -734,7 +730,11 @@ def request_desc(self) -> str:
734730
},
735731
"tags": [],
736732
"state": {
737-
"locked": {"value": False, "status": "CLOSED"},
733+
"shareState": {
734+
"status": "CLOSED",
735+
"locked": False,
736+
"currentUserGroupids": [],
737+
},
738738
"state": {"value": "NOT_STARTED"},
739739
},
740740
}
@@ -988,7 +988,11 @@ def request_desc(self) -> str:
988988
"prjOwner": "[email protected]",
989989
"tags": [22],
990990
"state": {
991-
"locked": {"value": False, "status": "CLOSED"},
991+
"shareState": {
992+
"status": "CLOSED",
993+
"locked": False,
994+
"currentUserGroupids": [],
995+
},
992996
"state": {"value": "NOT_STARTED"},
993997
},
994998
}

0 commit comments

Comments
 (0)