diff --git a/lib/tool_shed/managers/repositories.py b/lib/tool_shed/managers/repositories.py index 58f2158c5cd3..e7af6ff641bf 100644 --- a/lib/tool_shed/managers/repositories.py +++ b/lib/tool_shed/managers/repositories.py @@ -273,8 +273,7 @@ def get_repository_metadata_for_management( trans: ProvidesUserContext, encoded_repository_id: str, changeset_revision: str ) -> RepositoryMetadata: repository = get_repository_in_tool_shed(trans.app, encoded_repository_id) - if not can_manage_repo(trans, repository): - raise InsufficientPermissionsException("Cannot manage target repository") + ensure_can_manage(trans, repository, "Cannot manage target repository") revisions = [r for r in repository.metadata_revisions if r.changeset_revision == changeset_revision] if len(revisions) != 1: raise ObjectNotFound() @@ -582,6 +581,12 @@ def upload_tar_and_set_metadata( return message +def ensure_can_manage(trans: ProvidesUserContext, repository: Repository, error_message: Optional[str] = None) -> None: + if not can_manage_repo(trans, repository): + error_message = error_message or "You do not have permission to update this repository." + raise InsufficientPermissionsException(error_message) + + def _get_repository_by_name_and_owner(session: scoped_session, name: str, owner: str, user_model): stmt = ( select(Repository) diff --git a/lib/tool_shed/test/base/populators.py b/lib/tool_shed/test/base/populators.py index 01cc8eea6937..642c1b76b9ac 100644 --- a/lib/tool_shed/test/base/populators.py +++ b/lib/tool_shed/test/base/populators.py @@ -23,6 +23,7 @@ Category, CreateCategoryRequest, CreateRepositoryRequest, + DetailedRepository, from_legacy_install_info, GetInstallInfoRequest, GetOrderedInstallableRevisionsRequest, @@ -41,6 +42,7 @@ ResetMetadataOnRepositoryResponse, ToolSearchRequest, ToolSearchResults, + UpdateRepositoryRequest, Version, ) from .api_util import ( @@ -209,6 +211,17 @@ def upload_revision_raw( ) return response + def update_raw(self, repository: HasRepositoryId, request: UpdateRepositoryRequest) -> requests.Response: + repository_id = self._repository_id(repository) + body_json = request.model_dump(exclude_unset=True, by_alias=True) + put_response = self._api_interactor.put(f"repositories/{repository_id}", json=body_json) + return put_response + + def update(self, repository: HasRepositoryId, request: UpdateRepositoryRequest) -> Repository: + response = self.update_raw(repository, request) + api_asserts.assert_status_code_is_ok(response) + return Repository(**response.json()) + def upload_revision( self, repository: HasRepositoryId, path: Traversable, commit_message: str = DEFAULT_COMMIT_MESSAGE ) -> RepositoryUpdate: @@ -358,11 +371,14 @@ def unset_deprecated(self, repository: HasRepositoryId): delete_response = self._api_interactor.delete(f"repositories/{repository_id}/deprecated") delete_response.raise_for_status() - def is_deprecated(self, repository: HasRepositoryId) -> bool: + def get_repository(self, repository: HasRepositoryId) -> DetailedRepository: repository_id = self._repository_id(repository) repository_response = self._api_interactor.get(f"repositories/{repository_id}") repository_response.raise_for_status() - return Repository(**repository_response.json()).deprecated + return DetailedRepository(**repository_response.json()) + + def is_deprecated(self, repository: HasRepositoryId) -> bool: + return self.get_repository(repository).deprecated def get_metadata(self, repository: HasRepositoryId, downloadable_only=True) -> RepositoryMetadata: repository_id = self._repository_id(repository) diff --git a/lib/tool_shed/test/functional/test_shed_repositories.py b/lib/tool_shed/test/functional/test_shed_repositories.py index fe7c4d3bac24..bd4e88d26567 100644 --- a/lib/tool_shed/test/functional/test_shed_repositories.py +++ b/lib/tool_shed/test/functional/test_shed_repositories.py @@ -10,7 +10,10 @@ HasRepositoryId, repo_tars, ) -from tool_shed_client.schema import RepositoryRevisionMetadata +from tool_shed_client.schema import ( + RepositoryRevisionMetadata, + UpdateRepositoryRequest, +) from ..base.api import ( ShedApiTestCase, skip_if_api_v1, @@ -49,6 +52,40 @@ def test_update_repository(self): ) assert repository_update.is_ok + def test_update_repository_info(self): + populator = self.populator + prefix = "testupdateinfo" + category_id = populator.new_category(prefix=prefix).id + repository = populator.new_repository(category_id, prefix=prefix) + repository_id = repository.id + request = UpdateRepositoryRequest(homepage_url="https://www.google.com") + update = populator.update(repository_id, request) + assert update.homepage_url == "https://www.google.com" + assert populator.get_repository(repository_id).homepage_url == "https://www.google.com" + + def test_update_category(self): + populator = self.populator + prefix = "testupdatecategory" + + old_category_id = populator.new_category(prefix=prefix).id + new_category_id = populator.new_category(prefix=prefix).id + + populator.assert_category_has_n_repositories(old_category_id, 0) + populator.assert_category_has_n_repositories(new_category_id, 0) + + repository = populator.new_repository(old_category_id, prefix=prefix) + repository_id = repository.id + + populator.assert_category_has_n_repositories(old_category_id, 1) + populator.assert_category_has_n_repositories(new_category_id, 0) + + request = UpdateRepositoryRequest(category_ids=[new_category_id]) + populator.update(repository_id, request) + + # does this mean we cannot remove categories? + populator.assert_category_has_n_repositories(old_category_id, 0) + populator.assert_category_has_n_repositories(new_category_id, 1) + # used by getRepository in TS client. def test_metadata_simple(self): populator = self.populator diff --git a/lib/tool_shed/util/repository_util.py b/lib/tool_shed/util/repository_util.py index 32829078f661..51c9ae6d1155 100644 --- a/lib/tool_shed/util/repository_util.py +++ b/lib/tool_shed/util/repository_util.py @@ -432,17 +432,27 @@ def change_repository_name_in_hgrc_file(hgrc_file: str, new_name: str) -> None: def update_repository(trans: "ProvidesUserContext", id: str, **kwds) -> Tuple[Optional["Repository"], Optional[str]]: """Update an existing ToolShed repository""" app = trans.app - message = None - flush_needed = False sa_session = app.model.session repository = sa_session.get(app.model.Repository, app.security.decode_id(id)) if repository is None: return None, "Unknown repository ID" - if not (trans.user_is_admin or trans.app.security_agent.user_can_administer_repository(trans.user, repository)): + if not (trans.user_is_admin or app.security_agent.user_can_administer_repository(trans.user, repository)): message = "You are not the owner of this repository, so you cannot administer it." return None, message + return update_validated_repository(trans, repository, **kwds) + + +def update_validated_repository( + trans: "ProvidesUserContext", repository: "Repository", **kwds +) -> Tuple[Optional["Repository"], Optional[str]]: + """Update an existing ToolShed repository metadata once permissions have been checked.""" + app = trans.app + sa_session = app.model.session + message = None + flush_needed = False + # Allowlist properties that can be changed via this method for key in ("type", "description", "long_description", "remote_repository_url", "homepage_url"): # If that key is available, not None and different than what's in the model @@ -451,10 +461,9 @@ def update_repository(trans: "ProvidesUserContext", id: str, **kwds) -> Tuple[Op flush_needed = True if "category_ids" in kwds and isinstance(kwds["category_ids"], list): + # Remove existing category associations - delete_repository_category_associations( - sa_session, app.model.RepositoryCategoryAssociation, app.security.decode_id(id) - ) + delete_repository_category_associations(sa_session, app.model.RepositoryCategoryAssociation, repository.id) # Then (re)create category associations for category_id in kwds["category_ids"]: diff --git a/lib/tool_shed/webapp/api2/repositories.py b/lib/tool_shed/webapp/api2/repositories.py index ef510c088888..b20f3fc020d3 100644 --- a/lib/tool_shed/webapp/api2/repositories.py +++ b/lib/tool_shed/webapp/api2/repositories.py @@ -19,7 +19,10 @@ ) from starlette.datastructures import UploadFile as StarletteUploadFile -from galaxy.exceptions import InsufficientPermissionsException +from galaxy.exceptions import ( + ActionInputError, + InsufficientPermissionsException, +) from galaxy.model.base import transaction from galaxy.webapps.galaxy.api import as_form from tool_shed.context import SessionRequestContext @@ -28,6 +31,7 @@ can_update_repo, check_updates, create_repository, + ensure_can_manage, get_install_info, get_ordered_installable_revisions, get_repository_metadata_dict, @@ -42,7 +46,10 @@ upload_tar_and_set_metadata, ) from tool_shed.structured_app import ToolShedApp -from tool_shed.util.repository_util import get_repository_in_tool_shed +from tool_shed.util.repository_util import ( + get_repository_in_tool_shed, + update_validated_repository, +) from tool_shed_client.schema import ( CreateRepositoryRequest, DetailedRepository, @@ -57,6 +64,7 @@ RepositoryUpdateRequest, ResetMetadataOnRepositoryRequest, ResetMetadataOnRepositoryResponse, + UpdateRepositoryRequest, ValidRepostiroyUpdateMessage, ) from . import ( @@ -293,6 +301,28 @@ def show( repository = get_repository_in_tool_shed(self.app, encoded_repository_id) return to_detailed_model(self.app, repository) + @router.put( + "/api/repositories/{encoded_repository_id}", + operation_id="repositories__update_repository", + ) + def update_repository( + self, + trans: SessionRequestContext = DependsOnTrans, + encoded_repository_id: str = RepositoryIdPathParam, + request: UpdateRepositoryRequest = Body(...), + ) -> DetailedRepository: + repository = get_repository_in_tool_shed(self.app, encoded_repository_id) + ensure_can_manage(trans, repository) + + # may want to set some of these to null, so we're using the exclude_unset feature + # to just serialize the ones we want to use to a dictionary. + update_dictionary = request.model_dump(exclude_unset=True) + repo_result, message = update_validated_repository(trans, repository, **update_dictionary) + if repo_result is None: + raise ActionInputError(message) + + return to_detailed_model(self.app, repository) + @router.get( "/api/repositories/{encoded_repository_id}/permissions", operation_id="repositories__permissions", @@ -323,8 +353,7 @@ def show_allow_push( encoded_repository_id: str = RepositoryIdPathParam, ) -> List[str]: repository = get_repository_in_tool_shed(self.app, encoded_repository_id) - if not can_manage_repo(trans, repository): - raise InsufficientPermissionsException("You do not have permission to update this repository.") + ensure_can_manage(trans, repository) return trans.app.security_agent.usernames_that_can_push(repository) @router.post( @@ -390,8 +419,7 @@ def set_deprecated( encoded_repository_id: str = RepositoryIdPathParam, ): repository = get_repository_in_tool_shed(self.app, encoded_repository_id) - if not can_manage_repo(trans, repository): - raise InsufficientPermissionsException("You do not have permission to update this repository.") + ensure_can_manage(trans, repository) repository.deprecated = True trans.sa_session.add(repository) with transaction(trans.sa_session): @@ -409,8 +437,7 @@ def unset_deprecated( encoded_repository_id: str = RepositoryIdPathParam, ): repository = get_repository_in_tool_shed(self.app, encoded_repository_id) - if not can_manage_repo(trans, repository): - raise InsufficientPermissionsException("You do not have permission to update this repository.") + ensure_can_manage(trans, repository) repository.deprecated = False trans.sa_session.add(repository) with transaction(trans.sa_session): diff --git a/lib/tool_shed/webapp/frontend/src/schema/schema.ts b/lib/tool_shed/webapp/frontend/src/schema/schema.ts index 8122e6ae0647..9023188a434d 100644 --- a/lib/tool_shed/webapp/frontend/src/schema/schema.ts +++ b/lib/tool_shed/webapp/frontend/src/schema/schema.ts @@ -101,6 +101,8 @@ export interface paths { "/api/repositories/{encoded_repository_id}": { /** Show */ get: operations["repositories__show"] + /** Update Repository */ + put: operations["repositories__update_repository"] } "/api/repositories/{encoded_repository_id}/allow_push": { /** Show Allow Push */ @@ -2093,6 +2095,23 @@ export interface components { /** Email */ email: string } + /** UpdateRepositoryRequest */ + UpdateRepositoryRequest: { + /** Category IDs */ + category_ids?: string[] | null + /** Description */ + description?: string | null + /** Homepage Url */ + homepage_url?: string | null + /** Name */ + name?: string | null + /** Remote Repository Url */ + remote_repository_url?: string | null + /** Synopsis */ + synopsis?: string | null + /** Type */ + type?: ("repository_suite_definition" | "tool_dependency_definition" | "unrestricted") | null + } /** UserV2 */ UserV2: { /** Id */ @@ -2701,6 +2720,40 @@ export interface operations { } } } + /** Update Repository */ + repositories__update_repository: { + parameters: { + path: { + /** @description The encoded database identifier of the repository. */ + encoded_repository_id: string + } + } + requestBody: { + content: { + "application/json": components["schemas"]["UpdateRepositoryRequest"] + } + } + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["DetailedRepository"] + } + } + /** @description Request Error */ + "4XX": { + content: { + "application/json": components["schemas"]["MessageExceptionModel"] + } + } + /** @description Server Error */ + "5XX": { + content: { + "application/json": components["schemas"]["MessageExceptionModel"] + } + } + } + } /** Show Allow Push */ repositories__show_allow_push: { parameters: { diff --git a/lib/tool_shed_client/schema/__init__.py b/lib/tool_shed_client/schema/__init__.py index 42c890ad4afd..ebf5a0a850a0 100644 --- a/lib/tool_shed_client/schema/__init__.py +++ b/lib/tool_shed_client/schema/__init__.py @@ -123,6 +123,25 @@ class CreateRepositoryRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) +class UpdateRepositoryRequest(BaseModel): + name: Optional[str] = None + synopsis: Optional[str] = None + type_: Optional[RepositoryType] = Field( + None, + alias="type", + title="Type", + ) + description: Optional[str] = None + remote_repository_url: Optional[str] = None + homepage_url: Optional[str] = None + category_ids: Optional[List[str]] = Field( + None, + alias="category_ids", + title="Category IDs", + ) + model_config = ConfigDict(populate_by_name=True) + + class RepositoryUpdateRequest(BaseModel): commit_message: Optional[str] = None