diff --git a/lib/galaxy/webapps/galaxy/api/folder_contents.py b/lib/galaxy/webapps/galaxy/api/folder_contents.py index bbdae1236d1d..0fff9bb3c79c 100644 --- a/lib/galaxy/webapps/galaxy/api/folder_contents.py +++ b/lib/galaxy/webapps/galaxy/api/folder_contents.py @@ -7,55 +7,62 @@ exceptions, util ) +from galaxy.managers import base as managers_base from galaxy.managers.folders import FolderManager from galaxy.managers.hdas import HDAManager from galaxy.model import tags +from galaxy.structured_app import StructuredApp from galaxy.web import ( expose_api, expose_api_anonymous ) -from galaxy.webapps.base.controller import UsesLibraryMixin, UsesLibraryMixinItems +from galaxy.webapps.base.controller import UsesLibraryMixinItems from . import BaseGalaxyAPIController, depends log = logging.getLogger(__name__) +TIME_FORMAT = "%Y-%m-%d %I:%M %p" +FOLDER_TYPE_NAME = "folder" +FILE_TYPE_NAME = "file" -class FolderContentsController(BaseGalaxyAPIController, UsesLibraryMixin, UsesLibraryMixinItems): + +class FolderContentsAPIView(UsesLibraryMixinItems): """ - Class controls retrieval, creation and updating of folder contents. + Interface/service object for interacting with folders contents providing a shared view for API controllers. """ - hda_manager: HDAManager = depends(HDAManager) - folder_manager: FolderManager = depends(FolderManager) - @expose_api_anonymous - def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **kwd): + def __init__(self, app: StructuredApp, hda_manager: HDAManager, folder_manager: FolderManager): + self.app = app + self.hda_manager = hda_manager + self.folder_manager = folder_manager + + def get_object(self, trans, id, class_name, check_ownership=False, check_accessible=False, deleted=None): """ - GET /api/folders/{encoded_folder_id}/contents?limit={limit}&offset={offset} + Convenience method to get a model object with the specified checks. + """ + return managers_base.get_object(trans, id, class_name, check_ownership=check_ownership, check_accessible=check_accessible, deleted=deleted) - Displays a collection (list) of a folder's contents - (files and folders). Encoded folder ID is prepended - with 'F' if it is a folder as opposed to a data set - which does not have it. Full path is provided in - response as a separate object providing data for - breadcrumb path building. + def index(self, trans, folder_id, limit=None, offset=None, search_text=None, include_deleted=False): + """ + Displays a collection (list) of a folder's contents (files and folders). Encoded folder ID is prepended + with 'F' if it is a folder as opposed to a data set which does not have it. Full path is provided in + response as a separate object providing data for breadcrumb path building. ..example: limit and offset can be combined. Skip the first two and return five: - '?limit=3&offset=5' + '?offset=2&limit=5' - :param folder_id: encoded ID of the folder which - contents should be library_dataset_dict + :param folder_id: encoded ID of the folder which contents should be library_dataset_dict :type folder_id: encoded string - :param offset: offset for returned library folder datasets - :type folder_id: encoded string + :param offset: number of folder contents to skip + :type offset: optional int - :param limit: limit for returned library folder datasets - contents should be library_dataset_dict - :type folder_id: encoded string + :param limit: maximum number of folder contents to return + :type limit: optional int - :param kwd: keyword dictionary with other params - :type kwd: dict + :param include_deleted: whether to include deleted items in the results + :type include_deleted: optional bool (default False) :returns: dictionary containing all items and metadata :type: dict @@ -64,12 +71,7 @@ def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **k InternalServerError """ is_admin = trans.user_is_admin - deleted = kwd.get('include_deleted', 'missing') current_user_roles = trans.get_current_user_roles() - try: - deleted = util.asbool(deleted) - except ValueError: - deleted = False decoded_folder_id = self.folder_manager.cut_and_decode(trans, folder_id) folder = self.folder_manager.get(trans, decoded_folder_id) @@ -81,46 +83,43 @@ def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **k if trans.user: log.warning(f"SECURITY: User (id: {trans.user.id}) without proper access rights is trying to load folder with ID of {decoded_folder_id}") else: - log.warning("SECURITY: Anonymous user is trying to load restricted folder with ID of %s" % (decoded_folder_id)) - raise exceptions.ObjectNotFound('Folder with the id provided ( %s ) was not found' % str(folder_id)) + log.warning(f"SECURITY: Anonymous user is trying to load restricted folder with ID of {decoded_folder_id}") + raise exceptions.ObjectNotFound(f'Folder with the id provided ( {folder_id} ) was not found') folder_contents = [] update_time = '' create_time = '' - folders, datasets = self.apply_preferences(folder, deleted, search_text) + folders, datasets = self._apply_preferences(folder, include_deleted, search_text) # Go through every accessible item (folders, datasets) in the folder and include its metadata. for content_item in self._load_folder_contents(trans, folders, datasets, offset, limit): return_item = {} encoded_id = trans.security.encode_id(content_item.id) - create_time = content_item.create_time.strftime("%Y-%m-%d %I:%M %p") + create_time = content_item.create_time.strftime(TIME_FORMAT) - if content_item.api_type == 'folder': - encoded_id = 'F' + encoded_id + if content_item.api_type == FOLDER_TYPE_NAME: + encoded_id = f'F{encoded_id}' can_modify = is_admin or (trans.user and trans.app.security_agent.can_modify_library_item(current_user_roles, folder)) can_manage = is_admin or (trans.user and trans.app.security_agent.can_manage_library_item(current_user_roles, folder)) - update_time = content_item.update_time.strftime("%Y-%m-%d %I:%M %p") + update_time = content_item.update_time.strftime(TIME_FORMAT) return_item.update(dict(can_modify=can_modify, can_manage=can_manage)) if content_item.description: return_item.update(dict(description=content_item.description)) - elif content_item.api_type == 'file': + elif content_item.api_type == FILE_TYPE_NAME: # Is the dataset public or private? # When both are False the dataset is 'restricted' # Access rights are checked on the dataset level, not on the ld or ldda level to maintain consistency dataset = content_item.library_dataset_dataset_association.dataset is_unrestricted = trans.app.security_agent.dataset_is_public(dataset) - if not is_unrestricted and trans.user and trans.app.security_agent.dataset_is_private_to_user(trans, dataset): - is_private = True - else: - is_private = False + is_private = not is_unrestricted and trans.user and trans.app.security_agent.dataset_is_private_to_user(trans, dataset) # Can user manage the permissions on the dataset? can_manage = is_admin or (trans.user and trans.app.security_agent.can_manage_dataset(current_user_roles, content_item.library_dataset_dataset_association.dataset)) raw_size = int(content_item.library_dataset_dataset_association.get_size()) nice_size = util.nice_size(raw_size) - update_time = content_item.library_dataset_dataset_association.update_time.strftime("%Y-%m-%d %I:%M %p") + update_time = content_item.library_dataset_dataset_association.update_time.strftime(TIME_FORMAT) library_dataset_dict = content_item.to_dict() encoded_ldda_id = trans.security.encode_id(content_item.library_dataset_dataset_association.id) @@ -155,13 +154,11 @@ def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **k folder_contents.append(return_item) # Return the reversed path so it starts with the library node. - full_path = self.build_path(trans, folder)[::-1] + full_path = self._build_path(trans, folder)[::-1] - # Check whether user can add items to the current folder - can_add_library_item = is_admin or trans.app.security_agent.can_add_library_item(current_user_roles, folder) + user_can_add_library_item = is_admin or trans.app.security_agent.can_add_library_item(current_user_roles, folder) - # Check whether user can modify the current folder - can_modify_folder = is_admin or trans.app.security_agent.can_modify_library_item(current_user_roles, folder) + user_can_modify_folder = is_admin or trans.app.security_agent.can_modify_library_item(current_user_roles, folder) parent_library_id = None if folder.parent_library is not None: @@ -171,15 +168,80 @@ def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **k metadata = dict(full_path=full_path, total_rows=total_rows, - can_add_library_item=can_add_library_item, - can_modify_folder=can_modify_folder, + can_add_library_item=user_can_add_library_item, + can_modify_folder=user_can_modify_folder, folder_name=folder.name, folder_description=folder.description, parent_library_id=parent_library_id) folder_container = dict(metadata=metadata, folder_contents=folder_contents) return folder_container - def build_path(self, trans, folder): + def create(self, trans, encoded_folder_id, payload, **kwd): + """ + Create a new library file from an HDA. + + :param encoded_folder_id: the encoded id of the folder to import dataset(s) to + :type encoded_folder_id: an encoded id string + :param payload: dictionary structure containing: + :param from_hda_id: (optional) the id of an accessible HDA to copy into the library + :type from_hda_id: encoded id + :param from_hdca_id: (optional) the id of an accessible HDCA to copy into the library + :type from_hdca_id: encoded id + :param ldda_message: (optional) the new message attribute of the LDDA created + :type ldda_message: str + :param extended_metadata: (optional) dub-dictionary containing any extended metadata to associate with the item + :type extended_metadata: dict + :type payload: dict + + :returns: a dictionary describing the new item if ``from_hda_id`` is supplied or a list of + such dictionaries describing the new items if ``from_hdca_id`` is supplied. + :rtype: object + + :raises: ObjectAttributeInvalidException, + InsufficientPermissionsException, ItemAccessibilityException, + InternalServerError + """ + encoded_folder_id_16 = self.__decode_library_content_id(trans, encoded_folder_id) + from_hda_id = payload.pop('from_hda_id', None) + from_hdca_id = payload.pop('from_hdca_id', None) + ldda_message = payload.pop('ldda_message', '') + try: + if from_hda_id: + decoded_hda_id = self._decode_id(from_hda_id) + return self._copy_hda_to_library_folder(trans, self.hda_manager, decoded_hda_id, encoded_folder_id_16, ldda_message) + if from_hdca_id: + decoded_hdca_id = self._decode_id(from_hdca_id) + return self._copy_hdca_to_library_folder(trans, self.hda_manager, decoded_hdca_id, encoded_folder_id_16, ldda_message) + except Exception as exc: + # TODO handle exceptions better within the mixins + exc_message = util.unicodify(exc) + if 'not accessible to the current user' in exc_message or 'You are not allowed to access this dataset' in exc_message: + raise exceptions.ItemAccessibilityException('You do not have access to the requested item') + else: + log.exception(exc) + raise exc + + def _decode_id(self, id): + return managers_base.decode_id(self.app, id) + + def __decode_library_content_id(self, trans, encoded_folder_id): + """ + Identify whether the id provided is properly encoded LibraryFolder. + + :param encoded_folder_id: encoded id of Galaxy LibraryFolder + :type encoded_folder_id: encoded string + + :returns: encoded id of Folder (had 'F' prepended) + :type: string + + :raises: MalformedId + """ + if ((len(encoded_folder_id) % 16 == 1) and encoded_folder_id.startswith('F')): + return encoded_folder_id[1:] + else: + raise exceptions.MalformedId('Malformed folder id ( %s ) specified, unable to decode.' % str(encoded_folder_id)) + + def _build_path(self, trans, folder): """ Search the path upwards recursively and load the whole route of names and ids for breadcrumb building purposes. @@ -192,13 +254,12 @@ def build_path(self, trans, folder): """ path_to_root = [] # We are almost in root - if folder.parent_id is None: - path_to_root.append(('F' + trans.security.encode_id(folder.id), folder.name)) - else: + encoded_id = trans.security.encode_id(folder.id) + path_to_root.append((f'F{encoded_id}', folder.name)) + if folder.parent_id is not None: # We add the current folder and traverse up one folder. - path_to_root.append(('F' + trans.security.encode_id(folder.id), folder.name)) upper_folder = trans.sa_session.query(trans.app.model.LibraryFolder).get(folder.parent_id) - path_to_root.extend(self.build_path(trans, upper_folder)) + path_to_root.extend(self._build_path(trans, upper_folder)) return path_to_root def _load_folder_contents(self, trans, folders, datasets, offset=None, limit=None): @@ -227,59 +288,29 @@ def _load_folder_contents(self, trans, folders, datasets, offset=None, limit=Non current_folders = self._calculate_pagination(folders, offset, limit) for subfolder in current_folders: - - if subfolder.deleted: - if is_admin: - # Admins can see all deleted folders. - subfolder.api_type = 'folder' - content_items.append(subfolder) - else: - # Users with MODIFY permissions can see deleted folders. - can_modify = trans.app.security_agent.can_modify_library_item(current_user_roles, subfolder) - if can_modify: - subfolder.api_type = 'folder' - content_items.append(subfolder) - else: - # Undeleted folders are non-restricted for now. The contents are not. - # TODO decide on restrictions - subfolder.api_type = 'folder' + if not subfolder.deleted or is_admin or trans.app.security_agent.can_modify_library_item(current_user_roles, subfolder): + # Undeleted folders are non-restricted for now. + # Admins or users with MODIFY permissions can see deleted folders. + subfolder.api_type = FOLDER_TYPE_NAME content_items.append(subfolder) - # if is_admin: - # subfolder.api_type = 'folder' - # content_items.append( subfolder ) - # else: - # can_access, folder_ids = trans.app.security_agent.check_folder_contents( trans.user, current_user_roles, subfolder ) - # if can_access: - # subfolder.api_type = 'folder' - # content_items.append( subfolder ) limit -= len(content_items) offset -= len(folders) - offset = 0 if offset < 0 else offset + offset = max(0, offset) current_datasets = self._calculate_pagination(datasets, offset, limit) for dataset in current_datasets: if dataset.deleted: - if is_admin: - # Admins can see all deleted datasets. - dataset.api_type = 'file' + if is_admin or trans.app.security_agent.can_modify_library_item(current_user_roles, dataset): + # Admins or users with MODIFY permissions can see deleted folders. + dataset.api_type = FILE_TYPE_NAME content_items.append(dataset) - else: - # Users with MODIFY permissions on the item can see the deleted item. - can_modify = trans.app.security_agent.can_modify_library_item(current_user_roles, dataset) - if can_modify: - dataset.api_type = 'file' - content_items.append(dataset) else: - if is_admin: - dataset.api_type = 'file' + if is_admin or trans.app.security_agent.can_access_dataset(current_user_roles, dataset.library_dataset_dataset_association.dataset): + # Admins or users with ACCESS permissions can see datasets. + dataset.api_type = FILE_TYPE_NAME content_items.append(dataset) - else: - can_access = trans.app.security_agent.can_access_dataset(current_user_roles, dataset.library_dataset_dataset_association.dataset) - if can_access: - dataset.api_type = 'file' - content_items.append(dataset) return content_items @@ -290,7 +321,7 @@ def _calculate_pagination(self, items, offset: int, limit: int): paginated_items = items[offset:] return paginated_items - def apply_preferences(self, folder, include_deleted, search_text): + def _apply_preferences(self, folder, include_deleted: bool, search_text: str): def check_deleted(array, include_deleted): if include_deleted: @@ -318,6 +349,52 @@ def filter_searched_datasets(dataset): return folders, datasets + +class FolderContentsController(BaseGalaxyAPIController): + """ + Class controls retrieval, creation and updating of folder contents. + """ + view: FolderContentsAPIView = depends(FolderContentsAPIView) + + @expose_api_anonymous + def index(self, trans, folder_id, limit=None, offset=None, search_text=None, **kwd): + """ + GET /api/folders/{encoded_folder_id}/contents?limit={limit}&offset={offset} + + Displays a collection (list) of a folder's contents + (files and folders). Encoded folder ID is prepended + with 'F' if it is a folder as opposed to a data set + which does not have it. Full path is provided in + response as a separate object providing data for + breadcrumb path building. + + ..example: + limit and offset can be combined. Skip the first two and return five: + '?limit=3&offset=5' + + :param folder_id: encoded ID of the folder which + contents should be library_dataset_dict + :type folder_id: encoded string + + :param offset: offset for returned library folder datasets + :type folder_id: encoded string + + :param limit: limit for returned library folder datasets + contents should be library_dataset_dict + :type folder_id: encoded string + + :param kwd: keyword dictionary with other params + :type kwd: dict + + :returns: dictionary containing all items and metadata + :type: dict + + :raises: MalformedId, InconsistentDatabase, ObjectNotFound, + InternalServerError + """ + include_deleted = util.asbool(kwd.get('include_deleted', False)) + return self.view.index(trans, folder_id, limit, offset, search_text, include_deleted) + @expose_api def create(self, trans, encoded_folder_id, payload, **kwd): """ @@ -346,56 +423,4 @@ def create(self, trans, encoded_folder_id, payload, **kwd): InsufficientPermissionsException, ItemAccessibilityException, InternalServerError """ - encoded_folder_id_16 = self.__decode_library_content_id(trans, encoded_folder_id) - from_hda_id = payload.pop('from_hda_id', None) - from_hdca_id = payload.pop('from_hdca_id', None) - ldda_message = payload.pop('ldda_message', '') - if ldda_message: - ldda_message = util.sanitize_html.sanitize_html(ldda_message) - try: - if from_hda_id: - decoded_hda_id = self.decode_id(from_hda_id) - return self._copy_hda_to_library_folder(trans, self.hda_manager, decoded_hda_id, encoded_folder_id_16, ldda_message) - if from_hdca_id: - decoded_hdca_id = self.decode_id(from_hdca_id) - return self._copy_hdca_to_library_folder(trans, self.hda_manager, decoded_hdca_id, encoded_folder_id_16, ldda_message) - except Exception as exc: - # TODO handle exceptions better within the mixins - exc_message = util.unicodify(exc) - if 'not accessible to the current user' in exc_message or 'You are not allowed to access this dataset' in exc_message: - raise exceptions.ItemAccessibilityException('You do not have access to the requested item') - else: - log.exception(exc) - raise exc - - def __decode_library_content_id(self, trans, encoded_folder_id): - """ - Identify whether the id provided is properly encoded - LibraryFolder. - - :param encoded_folder_id: encoded id of Galaxy LibraryFolder - :type encoded_folder_id: encoded string - - :returns: encoded id of Folder (had 'F' prepended) - :type: string - - :raises: MalformedId - """ - if ((len(encoded_folder_id) % 16 == 1) and encoded_folder_id.startswith('F')): - return encoded_folder_id[1:] - else: - raise exceptions.MalformedId('Malformed folder id ( %s ) specified, unable to decode.' % str(encoded_folder_id)) - - @expose_api - def show(self, trans, id, library_id, **kwd): - """ - GET /api/folders/{encoded_folder_id}/ - """ - raise exceptions.NotImplemented('Showing the library folder content is not implemented here.') - - @expose_api - def update(self, trans, id, library_id, payload, **kwd): - """ - PUT /api/folders/{encoded_folder_id}/contents - """ - raise exceptions.NotImplemented('Updating the library folder content is not implemented here.') + return self.view.create(trans, encoded_folder_id, payload) diff --git a/lib/galaxy_test/api/test_folder_contents.py b/lib/galaxy_test/api/test_folder_contents.py index b1c157b4aa08..8020d2ef4669 100644 --- a/lib/galaxy_test/api/test_folder_contents.py +++ b/lib/galaxy_test/api/test_folder_contents.py @@ -22,8 +22,7 @@ def setUp(self): self.history_id = self.dataset_populator.new_history() self.library = self.library_populator.new_private_library("FolderContentsTestsLibrary") - self.root_folder = self._create_folder_in_library("Test Folder Contents") - self.root_folder_id = self.root_folder["id"] + self.root_folder_id = self._create_folder_in_library("Test Folder Contents") def test_create_hda_with_ldda_message(self): hda_id = self._create_hda() @@ -47,8 +46,7 @@ def test_create_hdca_with_ldda_message(self): assert len(contents) == len(lddas) def test_index(self): - folder = self._create_folder_in_library("Test Folder Contents Index") - folder_id = folder["id"] + folder_id = self._create_folder_in_library("Test Folder Contents Index") self._create_dataset_in_folder(folder_id) @@ -59,8 +57,7 @@ def test_index(self): def test_index_include_deleted(self): folder_name = "Test Folder Contents Index include deleted" - folder = self._create_folder_in_library(folder_name) - folder_id = folder["id"] + folder_id = self._create_folder_in_library(folder_name) hda_id = self._create_dataset_in_folder(folder_id) self._delete_library_dataset(hda_id) @@ -79,8 +76,7 @@ def test_index_include_deleted(self): def test_index_limit_offset(self): folder_name = "Test Folder Contents Index limit" - folder = self._create_folder_in_library(folder_name) - folder_id = folder["id"] + folder_id = self._create_folder_in_library(folder_name) num_subfolders = 5 for index in range(num_subfolders): @@ -121,8 +117,7 @@ def test_index_limit_offset(self): def test_index_search_text(self): folder_name = "Test Folder Contents Index search text" - folder = self._create_folder_in_library(folder_name) - folder_id = folder["id"] + folder_id = self._create_folder_in_library(folder_name) dataset_names = ["AB", "BC", "ABC"] for name in dataset_names: @@ -142,11 +137,75 @@ def test_index_search_text(self): matching_names = [name for name in all_names if search_text in name] assert len(contents) == len(matching_names) + def test_index_permissions_include_deleted(self): + + folder_name = "Test Folder Contents Index permissions include deteleted" + folder_id = self._create_folder_in_library(folder_name) + + num_subfolders = 5 + subfolder_ids: List[str] = [] + deleted_subfolder_ids: List[str] = [] + for index in range(num_subfolders): + id = self._create_subfolder_in(folder_id, name=f"Folder_{index}") + subfolder_ids.append(id) + + for index, subfolder_id in enumerate(subfolder_ids): + if index % 2 == 0: + self._delete_subfolder(subfolder_id) + deleted_subfolder_ids.append(subfolder_id) + + num_datasets = 5 + datasets_ids: List[str] = [] + deleted_datasets_ids: List[str] = [] + for _ in range(num_datasets): + id = self._create_dataset_in_folder(folder_id) + datasets_ids.append(id) + + for index, ldda_id in enumerate(datasets_ids): + if index % 2 == 0: + self._delete_library_dataset(ldda_id) + deleted_datasets_ids.append(ldda_id) + + num_total_contents = num_subfolders + num_datasets + num_non_deleted = num_total_contents - len(deleted_subfolder_ids) - len(deleted_datasets_ids) + + # Verify deleted contents are not listed + include_deleted = False + response = self._get(f"folders/{folder_id}/contents?include_deleted={include_deleted}") + self._assert_status_code_is(response, 200) + contents = response.json()["folder_contents"] + assert len(contents) == num_non_deleted + + include_deleted = True + # Admins can see everything... + response = self._get(f"folders/{folder_id}/contents?include_deleted={include_deleted}", admin=True) + self._assert_status_code_is(response, 200) + contents = response.json()["folder_contents"] + assert len(contents) == num_total_contents + + # Owner can see everything too + response = self._get(f"folders/{folder_id}/contents?include_deleted={include_deleted}") + self._assert_status_code_is(response, 200) + contents = response.json()["folder_contents"] + assert len(contents) == num_total_contents + + # Users with access but no modify permission can't see deleted + with self._different_user(): + different_user_role_id = self.dataset_populator.user_private_role_id() + + self._allow_library_access_to_user_role(different_user_role_id) + + with self._different_user(): + response = self._get(f"folders/{folder_id}/contents?include_deleted={include_deleted}") + self._assert_status_code_is(response, 200) + contents = response.json()["folder_contents"] + assert len(contents) == num_non_deleted + def _create_folder_in_library(self, name: str) -> Any: root_folder_id = self.library["root_folder_id"] return self._create_subfolder_in(root_folder_id, name) - def _create_subfolder_in(self, folder_id: str, name: str) -> Any: + def _create_subfolder_in(self, folder_id: str, name: str) -> str: data = { "name": name, "description": f"The description of {name}", @@ -154,7 +213,7 @@ def _create_subfolder_in(self, folder_id: str, name: str) -> Any: create_response = self._post(f"folders/{folder_id}", data=data) self._assert_status_code_is(create_response, 200) folder = create_response.json() - return folder + return folder["id"] def _create_dataset_in_folder(self, folder_id: str, name: Optional[str] = None) -> str: hda_id = self._create_hda(name) @@ -180,5 +239,18 @@ def _create_hdca_with_contents(self, contents: List[str]) -> str: return hdca_id def _delete_library_dataset(self, ldda_id: str) -> None: - delete_response = self._delete(f"libraries/datasets/{ldda_id}", admin=True) + delete_response = self._delete(f"libraries/datasets/{ldda_id}") + self._assert_status_code_is(delete_response, 200) + + def _delete_subfolder(self, folder_id: str) -> None: + delete_response = self._delete(f"folders/{folder_id}") self._assert_status_code_is(delete_response, 200) + + def _allow_library_access_to_user_role(self, role_id: str): + library_id = self.library["id"] + action = "set_permissions" + data = { + "access_ids[]": role_id, + } + response = self._post(f"libraries/{library_id}/permissions?action={action}", data=data, admin=True) + self._assert_status_code_is(response, 200)