Skip to content

Commit d1f879d

Browse files
🎨 Adds client session ID to ProjectDocument + Leave Project Room (#8176)
1 parent 5f20287 commit d1f879d

File tree

19 files changed

+480
-229
lines changed

19 files changed

+480
-229
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ class ProjectDocument(OutputSchema):
302302
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
303303

304304

305+
ProjectDocumentVersion: TypeAlias = int
306+
307+
305308
__all__: tuple[str, ...] = (
306309
"EmptyModel",
307310
"ProjectCopyOverride",

packages/service-library/src/servicelib/rest_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ class PydanticExportParametersDict(TypedDict):
2323

2424
# Headers keys
2525
X_PRODUCT_NAME_HEADER: Final[str] = "X-Simcore-Products-Name"
26+
X_CLIENT_SESSION_ID_HEADER: Final[str] = "X-Client-Session-Id"

services/web/server/src/simcore_service_webserver/db_listener/_db_comp_tasks_listening_task.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ async def _update_project_state(
5252
node_errors: list[ErrorDict] | None,
5353
) -> None:
5454
project = await _projects_service.update_project_node_state(
55-
app, user_id, project_uuid, node_uuid, new_state
55+
app,
56+
user_id,
57+
project_uuid,
58+
node_uuid,
59+
new_state,
60+
client_session_id=None, # <-- The trigger for this update is not from the UI (its db listener)
5661
)
5762

5863
await _projects_service.notify_project_node_update(
@@ -95,6 +100,7 @@ async def _handle_db_notification(
95100
changed_row.run_hash,
96101
node_errors=changed_row.errors,
97102
ui_changed_keys=None,
103+
client_session_id=None, # <-- The trigger for this update is not from the UI (its db listener)
98104
)
99105

100106
if "state" in payload.changes and (changed_row.state is not None):

services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from simcore_postgres_database.utils_repos import transaction_context
99

1010
from ..db.plugin import get_asyncpg_engine
11+
from ..models import ClientSessionID
1112
from ..projects import _folders_repository as projects_folders_repository
1213
from ..projects import _groups_repository as projects_groups_repository
1314
from ..projects._access_rights_service import check_user_project_permission
@@ -26,6 +27,7 @@ async def move_folder_into_workspace(
2627
folder_id: FolderID,
2728
workspace_id: WorkspaceID | None,
2829
product_name: ProductName,
30+
client_session_id: ClientSessionID | None = None,
2931
) -> None:
3032
# 1. User needs to have delete permission on source folder
3133
folder_db = await _folders_repository.get(
@@ -84,6 +86,7 @@ async def move_folder_into_workspace(
8486
project_uuid=project_id,
8587
patch_project_data={"workspace_id": workspace_id},
8688
user_primary_gid=user["primary_gid"],
89+
client_session_id=client_session_id,
8790
)
8891

8992
# 5. BATCH update of folders with workspace_id

services/web/server/src/simcore_service_webserver/folders/_workspaces_rest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22

33
from aiohttp import web
44
from servicelib.aiohttp import status
5-
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
5+
from servicelib.aiohttp.requests_validation import (
6+
parse_request_headers_as,
7+
parse_request_path_parameters_as,
8+
)
69

710
from .._meta import api_version_prefix as VTAG
811
from ..login.decorators import login_required
12+
from ..models import ClientSessionHeaderParams
913
from ..security.decorators import permission_required
1014
from . import _workspaces_repository
1115
from ._common.exceptions_handlers import handle_plugin_requests_exceptions
@@ -27,12 +31,14 @@
2731
async def move_folder_to_workspace(request: web.Request):
2832
req_ctx = FoldersRequestContext.model_validate(request)
2933
path_params = parse_request_path_parameters_as(FolderWorkspacesPathParams, request)
34+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
3035

3136
await _workspaces_repository.move_folder_into_workspace(
3237
app=request.app,
3338
user_id=req_ctx.user_id,
3439
folder_id=path_params.folder_id,
3540
workspace_id=path_params.workspace_id,
3641
product_name=req_ctx.product_name,
42+
client_session_id=header_params.client_session_id,
3743
)
3844
return web.json_response(status=status.HTTP_204_NO_CONTENT)

services/web/server/src/simcore_service_webserver/models.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from models_library.products import ProductName
44
from models_library.rest_base import RequestParameters
55
from models_library.users import UserID
6-
from pydantic import ConfigDict, Field
6+
from pydantic import ConfigDict, Field, StringConstraints
77
from pydantic_extra_types.phone_numbers import PhoneNumberValidator
88
from servicelib.request_keys import RQT_USERID_KEY
9+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
910

1011
from .constants import RQ_PRODUCT_KEY
1112

@@ -16,6 +17,18 @@
1617
]
1718

1819

20+
ClientSessionID: TypeAlias = Annotated[
21+
str,
22+
StringConstraints(
23+
strip_whitespace=True,
24+
min_length=36,
25+
max_length=36,
26+
pattern=r"^[0-9a-fA-F\-]{36}$", # UUID format
27+
strict=True,
28+
),
29+
]
30+
31+
1932
class AuthenticatedRequestContext(RequestParameters):
2033
"""Fields expected in the request context for authenticated endpoints"""
2134

@@ -25,3 +38,20 @@ class AuthenticatedRequestContext(RequestParameters):
2538
model_config = ConfigDict(
2639
frozen=True # prevents modifications after middlewares creates this model
2740
)
41+
42+
43+
assert X_CLIENT_SESSION_ID_HEADER
44+
45+
46+
class ClientSessionHeaderParams(RequestParameters):
47+
"""Header parameters for client session tracking in collaborative features."""
48+
49+
client_session_id: ClientSessionID | None = Field(
50+
default=None,
51+
alias="X-Client-Session-Id", # X_CLIENT_SESSION_ID_HEADER,
52+
description="Client session identifier for collaborative features (UUID string)",
53+
)
54+
55+
model_config = ConfigDict(
56+
validate_by_name=True,
57+
)

services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from servicelib.aiohttp.long_running_tasks.server import start_long_running_task
3636
from servicelib.aiohttp.requests_validation import (
3737
parse_request_body_as,
38+
parse_request_headers_as,
3839
parse_request_path_parameters_as,
3940
parse_request_query_parameters_as,
4041
)
@@ -59,6 +60,7 @@
5960
from ...groups.api import get_group_from_gid, list_all_user_groups_ids
6061
from ...groups.exceptions import GroupNotFoundError
6162
from ...login.decorators import login_required
63+
from ...models import ClientSessionHeaderParams
6264
from ...security.decorators import permission_required
6365
from ...users import users_service
6466
from ...utils_aiohttp import envelope_json_response, get_api_base_url
@@ -96,6 +98,7 @@ async def create_node(request: web.Request) -> web.Response:
9698
req_ctx = AuthenticatedRequestContext.model_validate(request)
9799
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
98100
body = await parse_request_body_as(NodeCreate, request)
101+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
99102

100103
if await _projects_service.is_service_deprecated(
101104
request.app,
@@ -124,6 +127,7 @@ async def create_node(request: web.Request) -> web.Response:
124127
body.service_key,
125128
body.service_version,
126129
body.service_id,
130+
client_session_id=header_params.client_session_id,
127131
)
128132
}
129133
assert NodeCreated.model_validate(data) is not None # nosec
@@ -178,6 +182,7 @@ async def patch_project_node(request: web.Request) -> web.Response:
178182
req_ctx = AuthenticatedRequestContext.model_validate(request)
179183
path_params = parse_request_path_parameters_as(NodePathParams, request)
180184
node_patch = await parse_request_body_as(NodePatch, request)
185+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
181186

182187
await _projects_service.patch_project_node(
183188
request.app,
@@ -187,6 +192,7 @@ async def patch_project_node(request: web.Request) -> web.Response:
187192
project_id=path_params.project_id,
188193
node_id=path_params.node_id,
189194
partial_node=node_patch.to_domain_model(),
195+
client_session_id=header_params.client_session_id,
190196
)
191197

192198
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -199,6 +205,7 @@ async def patch_project_node(request: web.Request) -> web.Response:
199205
async def delete_node(request: web.Request) -> web.Response:
200206
req_ctx = AuthenticatedRequestContext.model_validate(request)
201207
path_params = parse_request_path_parameters_as(NodePathParams, request)
208+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
202209

203210
# ensure the project exists
204211
await _projects_service.get_project_for_user(
@@ -213,6 +220,7 @@ async def delete_node(request: web.Request) -> web.Response:
213220
NodeIDStr(path_params.node_id),
214221
req_ctx.product_name,
215222
product_api_base_url=get_api_base_url(request),
223+
client_session_id=header_params.client_session_id,
216224
)
217225

218226
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -249,6 +257,7 @@ async def update_node_outputs(request: web.Request) -> web.Response:
249257
req_ctx = AuthenticatedRequestContext.model_validate(request)
250258
path_params = parse_request_path_parameters_as(NodePathParams, request)
251259
node_outputs = await parse_request_body_as(NodeOutputs, request)
260+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
252261

253262
ui_changed_keys = set()
254263
ui_changed_keys.add(f"{path_params.node_id}")
@@ -261,6 +270,7 @@ async def update_node_outputs(request: web.Request) -> web.Response:
261270
run_hash=None,
262271
node_errors=None,
263272
ui_changed_keys=ui_changed_keys,
273+
client_session_id=header_params.client_session_id,
264274
)
265275
return web.json_response(status=status.HTTP_204_NO_CONTENT)
266276

services/web/server/src/simcore_service_webserver/projects/_controller/ports_rest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
from pydantic import BaseModel, Field, TypeAdapter
1818
from servicelib.aiohttp.requests_validation import (
1919
parse_request_body_as,
20+
parse_request_headers_as,
2021
parse_request_path_parameters_as,
2122
)
2223

2324
from ..._meta import API_VTAG as VTAG
2425
from ...login.decorators import login_required
26+
from ...models import ClientSessionHeaderParams
2527
from ...security.decorators import permission_required
2628
from ...utils_aiohttp import envelope_json_response
2729
from .. import _ports_service, _projects_service
@@ -88,6 +90,7 @@ async def update_project_inputs(request: web.Request) -> web.Response:
8890
req_ctx = AuthenticatedRequestContext.model_validate(request)
8991
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
9092
inputs_updates = await parse_request_body_as(list[ProjectInputUpdate], request)
93+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
9194

9295
assert request.app # nosec
9396

@@ -123,6 +126,7 @@ async def update_project_inputs(request: web.Request) -> web.Response:
123126
project_uuid=path_params.project_id,
124127
product_name=req_ctx.product_name,
125128
partial_workbench_data=jsonable_encoder(partial_workbench_data),
129+
client_session_id=header_params.client_session_id,
126130
)
127131

128132
workbench = TypeAdapter(dict[NodeID, Node]).validate_python(

services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from ..._meta import API_VTAG as VTAG
3030
from ...login.decorators import login_required
31+
from ...models import ClientSessionHeaderParams
3132
from ...redis import get_redis_lock_manager_client_sdk
3233
from ...resource_manager.user_sessions import PROJECT_ID_KEY, managed_resource
3334
from ...security import security_web
@@ -312,13 +313,15 @@ async def patch_project(request: web.Request):
312313
req_ctx = AuthenticatedRequestContext.model_validate(request)
313314
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
314315
project_patch = await parse_request_body_as(ProjectPatch, request)
316+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
315317

316318
await _projects_service.patch_project_for_user(
317319
request.app,
318320
user_id=req_ctx.user_id,
319321
project_uuid=path_params.project_id,
320322
project_patch=project_patch,
321323
product_name=req_ctx.product_name,
324+
client_session_id=header_params.client_session_id,
322325
)
323326

324327
return web.json_response(status=status.HTTP_204_NO_CONTENT)

services/web/server/src/simcore_service_webserver/projects/_controller/workspaces_rest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
from models_library.workspaces import WorkspaceID
88
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
99
from servicelib.aiohttp import status
10-
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
10+
from servicelib.aiohttp.requests_validation import (
11+
parse_request_headers_as,
12+
parse_request_path_parameters_as,
13+
)
1114

1215
from ..._meta import api_version_prefix as VTAG
1316
from ...login.decorators import login_required
17+
from ...models import ClientSessionHeaderParams
1418
from ...security.decorators import permission_required
1519
from .. import _workspaces_service
1620
from ._rest_exceptions import handle_plugin_requests_exceptions
@@ -43,12 +47,14 @@ async def move_project_to_workspace(request: web.Request):
4347
path_params = parse_request_path_parameters_as(
4448
_ProjectWorkspacesPathParams, request
4549
)
50+
header_params = parse_request_headers_as(ClientSessionHeaderParams, request)
4651

4752
await _workspaces_service.move_project_into_workspace(
4853
app=request.app,
4954
user_id=req_ctx.user_id,
5055
project_id=path_params.project_id,
5156
workspace_id=path_params.workspace_id,
5257
product_name=req_ctx.product_name,
58+
client_session_id=header_params.client_session_id,
5359
)
5460
return web.json_response(status=status.HTTP_204_NO_CONTENT)

0 commit comments

Comments
 (0)