async def lock_project( app: web.Application, project_uuid: Union[str, ProjectID], status: ProjectStatus, user_id: int, user_name: UserNameDict, ) -> ProjectLock: """returns a distributed redis lock on the project defined by its UUID. NOTE: can be used as a context manager try: async with await lock_project(app, project_uuid, ProjectStatus.CLOSING, user_id, user_name): close_project(project_uuid) # do something with the project that requires the project to be locked except aioredlock.LockError: pass # the lock could not be acquired """ return await get_redis_lock_manager(app).lock( PROJECT_REDIS_LOCK_KEY.format(project_uuid), lock_timeout=None, lock_identifier=ProjectLocked( value=True, owner=Owner(user_id=user_id, **user_name), status=status, ).json(), )
async def add_project_states_for_user( user_id: int, project: Dict[str, Any], is_template: bool, app: web.Application, ) -> Dict[str, Any]: log.debug( "adding project states for %s with project %s", f"{user_id=}", f"{project['uuid']=}", ) # for templates: the project is never locked and never opened. also the running state is always unknown lock_state = ProjectLocked(value=False, status=ProjectStatus.CLOSED) running_state = RunningState.UNKNOWN if not is_template: lock_state = await _get_project_lock_state(user_id, project["uuid"], app) if computation_task := await director_v2_api.get_computation_task( app, user_id, project["uuid"]): # get the running state running_state = computation_task.state # get the nodes individual states for ( node_id, node_state, ) in computation_task.pipeline_details.node_states.items(): prj_node = project["workbench"].get(str(node_id)) if prj_node is None: continue node_state_dict = json.loads( node_state.json(by_alias=True, exclude_unset=True)) prj_node.setdefault("state", {}).update(node_state_dict)
def mocks_on_projects_api(mocker) -> None: """ All projects in this module are UNLOCKED """ mocker.patch( "simcore_service_webserver.projects.projects_api._get_project_lock_state", return_value=ProjectLocked(value=False, status=ProjectStatus.CLOSED), )
async def get_project_locked_state( app: web.Application, project_uuid: Union[str, ProjectID]) -> Optional[ProjectLocked]: """returns the ProjectLocked object if the project is locked""" if await is_project_locked(app, project_uuid): project_locked: Optional[str] = await get_redis_lock_manager_client( app).get(PROJECT_REDIS_LOCK_KEY.format(project_uuid)) if project_locked: return ProjectLocked.parse_raw(project_locked)
async def _get_project_lock_state(user_id: int, project_uuid: str, app: web.Application) -> ProjectLocked: with managed_resource(user_id, None, app) as rt: # checks who is using it users_of_project = await rt.find_users_of_resource( "project_id", project_uuid) usernames = [ await get_user_name(app, uid) for uid in set(users_of_project) ] assert (len(usernames) <= 1 ) # nosec # currently not possible to have more than 1 # based on usage, sets an state is_locked: bool = len(usernames) > 0 if is_locked: return ProjectLocked( value=is_locked, owner=Owner(user_id=users_of_project[0], **usernames[0]), ) return ProjectLocked(value=is_locked)
async def get_project_states_for_user(user_id: int, project_uuid: str, app: web.Application) -> ProjectState: # for templates: the project is never locked and never opened. also the running state is always unknown lock_state = ProjectLocked(value=False, status=ProjectStatus.CLOSED) running_state = RunningState.UNKNOWN lock_state, computation_task = await logged_gather( _get_project_lock_state(user_id, project_uuid, app), director_v2_api.get_computation_task(app, user_id, UUID(project_uuid)), ) if computation_task: # get the running state running_state = computation_task.state return ProjectState(locked=lock_state, state=ProjectRunningState(value=running_state))
def mocks_on_projects_api(mocker) -> Dict: """ All projects in this module are UNLOCKED """ state = ProjectState( locked=ProjectLocked( value=False, owner=Owner(user_id=2, first_name="Speedy", last_name="Gonzalez"), ), state=ProjectRunningState(value=RunningState.NOT_STARTED), ).dict(by_alias=True, exclude_unset=True) mocker.patch( "simcore_service_webserver.projects.projects_api.get_project_state_for_user", return_value=future_with_result(state), )
def mocks_on_projects_api(mocker, logged_user) -> None: """ All projects in this module are UNLOCKED Emulates that it found logged_user as the SOLE user of this project and returns the ProjectState indicating his as owner """ nameparts = logged_user["name"].split(".") + [""] state = ProjectState( locked=ProjectLocked( value=False, owner=Owner( user_id=logged_user["id"], first_name=nameparts[0], last_name=nameparts[1], ), status=ProjectStatus.CLOSED, ), state=ProjectRunningState(value=RunningState.NOT_STARTED), ) mocker.patch( "simcore_service_webserver.projects.projects_api._get_project_lock_state", return_value=state, )
async def test_open_shared_project_2_users_locked( client: TestClient, client_on_running_server_factory: Callable, logged_user: Dict, shared_project: Dict, socketio_client_factory: Callable, client_session_id_factory: Callable, user_role: UserRole, expected: ExpectedResponse, mocker, disable_gc_manual_guest_users, ): # Use-case: user 1 opens a shared project, user 2 tries to open it as well mock_project_state_updated_handler = mocker.Mock() client_1 = client client_id1 = client_session_id_factory() client_2 = client_on_running_server_factory() client_id2 = client_session_id_factory() # 1. user 1 opens project sio_1 = await _connect_websocket( socketio_client_factory, user_role != UserRole.ANONYMOUS, client_1, client_id1, {SOCKET_IO_PROJECT_UPDATED_EVENT: mock_project_state_updated_handler}, ) # expected is that the project is closed and unlocked expected_project_state_client_1 = ProjectState( locked=ProjectLocked(value=False, status=ProjectStatus.CLOSED), state=ProjectRunningState(value=RunningState.NOT_STARTED), ) for client_id in [client_id1, None]: await _state_project( client_1, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, expected_project_state_client_1, ) await _open_project( client_1, client_id1, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, ) # now the expected result is that the project is locked and opened by client 1 owner1 = Owner( user_id=logged_user["id"], first_name=(logged_user["name"].split(".") + [""])[0], last_name=(logged_user["name"].split(".") + [""])[1], ) expected_project_state_client_1.locked.value = True expected_project_state_client_1.locked.status = ProjectStatus.OPENED expected_project_state_client_1.locked.owner = owner1 # NOTE: there are 2 calls since we are part of the primary group and the all group await _assert_project_state_updated( mock_project_state_updated_handler, shared_project, [expected_project_state_client_1] * (0 if user_role == UserRole.ANONYMOUS else 2), ) await _state_project( client_1, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, expected_project_state_client_1, ) # 2. create a separate client now and log in user2, try to open the same shared project user_2 = await log_client_in( client_2, {"role": user_role.name}, enable_check=user_role != UserRole.ANONYMOUS ) sio_2 = await _connect_websocket( socketio_client_factory, user_role != UserRole.ANONYMOUS, client_2, client_id2, {SOCKET_IO_PROJECT_UPDATED_EVENT: mock_project_state_updated_handler}, ) await _open_project( client_2, client_id2, shared_project, expected.locked if user_role != UserRole.GUEST else HTTPLocked, ) expected_project_state_client_2 = deepcopy(expected_project_state_client_1) expected_project_state_client_2.locked.status = ProjectStatus.OPENED await _state_project( client_2, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, expected_project_state_client_2, ) # 3. user 1 closes the project await _close_project(client_1, client_id1, shared_project, expected.no_content) if not any(user_role == role for role in [UserRole.ANONYMOUS, UserRole.GUEST]): # Guests cannot close projects expected_project_state_client_1 = ProjectState( locked=ProjectLocked(value=False, status=ProjectStatus.CLOSED), state=ProjectRunningState(value=RunningState.NOT_STARTED), ) # we should receive an event that the project lock state changed # NOTE: there are 2x3 calls since we are part of the primary group and the all group and user 2 is part of the all group # first CLOSING, then CLOSED await _assert_project_state_updated( mock_project_state_updated_handler, shared_project, [ expected_project_state_client_1.copy( update={ "locked": ProjectLocked( value=True, status=ProjectStatus.CLOSING, owner=owner1 ) } ) ] * ( 0 if any(user_role == role for role in [UserRole.ANONYMOUS, UserRole.GUEST]) else 3 ) + [expected_project_state_client_1] * ( 0 if any(user_role == role for role in [UserRole.ANONYMOUS, UserRole.GUEST]) else 3 ), ) await _state_project( client_1, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, expected_project_state_client_1, ) # 4. user 2 now should be able to open the project await _open_project( client_2, client_id2, shared_project, expected.ok if user_role != UserRole.GUEST else HTTPLocked, ) if not any(user_role == role for role in [UserRole.ANONYMOUS, UserRole.GUEST]): expected_project_state_client_2.locked.value = True expected_project_state_client_2.locked.status = ProjectStatus.OPENED owner2 = Owner( user_id=user_2["id"], first_name=(user_2["name"].split(".") + [""])[0], last_name=(user_2["name"].split(".") + [""])[1], ) expected_project_state_client_2.locked.owner = owner2 expected_project_state_client_1.locked.value = True expected_project_state_client_1.locked.status = ProjectStatus.OPENED expected_project_state_client_1.locked.owner = owner2 # NOTE: there are 3 calls since we are part of the primary group and the all group await _assert_project_state_updated( mock_project_state_updated_handler, shared_project, [expected_project_state_client_1] * ( 0 if any(user_role == role for role in [UserRole.ANONYMOUS, UserRole.GUEST]) else 3 ), ) await _state_project( client_1, shared_project, expected.ok if user_role != UserRole.GUEST else web.HTTPOk, expected_project_state_client_1, )
async def _get_project_lock_state( user_id: int, project_uuid: str, app: web.Application, ) -> ProjectLocked: """returns the lock state of a project 1. If a project is locked for any reason, first return the project as locked and STATUS defined by lock 2. If a client_session_id is passed, then first check to see if the project is currently opened by this very user/tab combination, if yes returns the project as Locked and OPENED. 3. If any other user than user_id is using the project (even disconnected before the TTL is finished) then the project is Locked and OPENED. 4. If the same user is using the project with a valid socket id (meaning a tab is currently active) then the project is Locked and OPENED. 5. If the same user is using the project with NO socket id (meaning there is no current tab active) then the project is Unlocked and OPENED. which means the user can open it again. """ log.debug( "getting project [%s] lock state for user [%s]...", f"{project_uuid=}", f"{user_id=}", ) prj_locked_state: Optional[ProjectLocked] = await get_project_locked_state( app, project_uuid) if prj_locked_state: log.debug("project [%s] is locked: %s", f"{project_uuid=}", f"{prj_locked_state=}") return prj_locked_state # let's now check if anyone has the project in use somehow with managed_resource(user_id, None, app) as rt: user_session_id_list: List[ UserSessionID] = await rt.find_users_of_resource( PROJECT_ID_KEY, project_uuid) set_user_ids = { user_session.user_id for user_session in user_session_id_list } assert ( # nosec len(set_user_ids) <= 1 ) # nosec # NOTE: A project can only be opened by one user in one tab at the moment if not set_user_ids: # no one has the project, so it is unlocked and closed. log.debug("project [%s] is not in use", f"{project_uuid=}") return ProjectLocked(value=False, status=ProjectStatus.CLOSED) log.debug( "project [%s] might be used by the following users: [%s]", f"{project_uuid=}", f"{set_user_ids=}", ) usernames: List[UserNameDict] = [ await get_user_name(app, uid) for uid in set_user_ids ] # let's check if the project is opened by the same user, maybe already opened or closed in a orphaned session if set_user_ids.issubset({user_id}): if not await _user_has_another_client_open(user_session_id_list, app): # in this case the project is re-openable by the same user until it gets closed log.debug( "project [%s] is in use by the same user [%s] that is currently disconnected, so it is unlocked for this specific user and opened", f"{project_uuid=}", f"{set_user_ids=}", ) return ProjectLocked( value=False, owner=Owner(user_id=list(set_user_ids)[0], **usernames[0]), status=ProjectStatus.OPENED, ) # the project is opened in another tab or browser, or by another user, both case resolves to the project being locked, and opened log.debug( "project [%s] is in use by another user [%s], so it is locked", f"{project_uuid=}", f"{set_user_ids=}", ) return ProjectLocked( value=True, owner=Owner(user_id=list(set_user_ids)[0], **usernames[0]), status=ProjectStatus.OPENED, )
def test_project_locked_with_allowed_values(lock: bool, status: ProjectStatus): with pytest.raises(ValueError): ProjectLocked.parse_obj({"value": lock, "status": status})
def test_project_locked_with_missing_owner_raises(): with pytest.raises(ValueError): ProjectLocked(**{"value": True, "status": ProjectStatus.OPENED}) ProjectLocked.parse_obj({"value": False, "status": ProjectStatus.OPENED})