diff --git a/client/src/api/landings.ts b/client/src/api/landings.ts new file mode 100644 index 000000000000..03bbfc01d840 --- /dev/null +++ b/client/src/api/landings.ts @@ -0,0 +1,4 @@ +import { type components } from "@/api/schema"; + +export type ClaimLandingPayload = components["schemas"]["ClaimLandingPayload"]; +export type WorkflowLandingRequest = components["schemas"]["WorkflowLandingRequest"]; diff --git a/client/src/components/Form/Elements/FormData/FormDataUri.vue b/client/src/components/Form/Elements/FormData/FormDataUri.vue new file mode 100644 index 000000000000..0c888fbc4fce --- /dev/null +++ b/client/src/components/Form/Elements/FormData/FormDataUri.vue @@ -0,0 +1,66 @@ + + + + + + + + + + diff --git a/client/src/components/Form/FormElement.vue b/client/src/components/Form/FormElement.vue index 72af07dd7f8d..ed81622799e1 100644 --- a/client/src/components/Form/FormElement.vue +++ b/client/src/components/Form/FormElement.vue @@ -14,6 +14,7 @@ import type { FormParameterAttributes, FormParameterTypes, FormParameterValue } import FormBoolean from "./Elements/FormBoolean.vue"; import FormColor from "./Elements/FormColor.vue"; import FormData from "./Elements/FormData/FormData.vue"; +import FormDataUri from "./Elements/FormData/FormDataUri.vue"; import FormDataDialog from "./Elements/FormDataDialog.vue"; import FormDirectory from "./Elements/FormDirectory.vue"; import FormDrilldown from "./Elements/FormDrilldown/FormDrilldown.vue"; @@ -130,6 +131,14 @@ const elementId = computed(() => `form-element-${props.id}`); const hasAlert = computed(() => alerts.value.length > 0); const showPreview = computed(() => (collapsed.value && attrs.value["collapsible_preview"]) || props.disabled); const showField = computed(() => !collapsed.value && !props.disabled); +const isUriDataField = computed(() => { + const dataField = props.type == "data"; + if (dataField && props.value && "src" in props.value) { + const src = props.value.src; + return src == "url"; + } + return false; +}); const previewText = computed(() => attrs.value["text_value"]); const helpText = computed(() => { @@ -285,6 +294,12 @@ function onAlert(value: string | undefined) { :options="attrs.options" :optional="attrs.optional" :multiple="attrs.multiple" /> + + + + This a place holder for a feature that is not currently implemented. + diff --git a/client/src/components/Landing/WorkflowLanding.vue b/client/src/components/Landing/WorkflowLanding.vue new file mode 100644 index 000000000000..b5f7e2126809 --- /dev/null +++ b/client/src/components/Landing/WorkflowLanding.vue @@ -0,0 +1,61 @@ + + + + + + + + + + {{ errorMessage }} + + + + + + + diff --git a/client/src/components/Workflow/Run/WorkflowRun.vue b/client/src/components/Workflow/Run/WorkflowRun.vue index 4190257e3cdd..5e8d9dea8bb6 100644 --- a/client/src/components/Workflow/Run/WorkflowRun.vue +++ b/client/src/components/Workflow/Run/WorkflowRun.vue @@ -30,6 +30,7 @@ interface Props { preferSimpleForm?: boolean; simpleFormTargetHistory?: string; simpleFormUseJobCache?: boolean; + requestState?: Record; } const props = withDefaults(defineProps(), { @@ -37,6 +38,7 @@ const props = withDefaults(defineProps(), { preferSimpleForm: false, simpleFormTargetHistory: "current", simpleFormUseJobCache: false, + requestState: undefined, }); const loading = ref(true); @@ -200,6 +202,7 @@ defineExpose({ :target-history="simpleFormTargetHistory" :use-job-cache="simpleFormUseJobCache" :can-mutate-current-history="canRunOnHistory" + :request-state="requestState" @submissionSuccess="handleInvocations" @submissionError="handleSubmissionError" @showAdvanced="showAdvanced" /> diff --git a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue index e7e45723ff16..feb1bb1b2ac2 100644 --- a/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue +++ b/client/src/components/Workflow/Run/WorkflowRunFormSimple.vue @@ -99,6 +99,10 @@ export default { type: Boolean, required: true, }, + requestState: { + type: Object, + required: false, + }, }, setup() { const { config, isConfigLoaded } = useConfig(true); @@ -135,6 +139,7 @@ export default { if (isWorkflowInput(step.step_type)) { const stepName = new String(step.step_index); const stepLabel = step.step_label || new String(step.step_index + 1); + const stepType = step.step_type; const help = step.annotation; const longFormInput = step.inputs[0]; const stepAsInput = Object.assign({}, longFormInput, { @@ -142,10 +147,14 @@ export default { help: help, label: stepLabel, }); + if (this.requestState && this.requestState[stepLabel]) { + const value = this.requestState[stepLabel]; + stepAsInput.value = value; + } // disable collection mapping... stepAsInput.flavor = "module"; inputs.push(stepAsInput); - this.inputTypes[stepName] = step.step_type; + this.inputTypes[stepName] = stepType; } }); return inputs; diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index 0128829043b9..18024224e9d3 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -17,6 +17,8 @@ import HistoryImport from "components/HistoryImport"; import InteractiveTools from "components/InteractiveTools/InteractiveTools"; import JobDetails from "components/JobInformation/JobDetails"; import CarbonEmissionsCalculations from "components/JobMetrics/CarbonEmissions/CarbonEmissionsCalculations"; +import ToolLanding from "components/Landing/ToolLanding"; +import WorkflowLanding from "components/Landing/WorkflowLanding"; import PageDisplay from "components/PageDisplay/PageDisplay"; import PageEditor from "components/PageEditor/PageEditor"; import ToolSuccess from "components/Tool/ToolSuccess"; @@ -494,6 +496,16 @@ export function getRouter(Galaxy) { path: "tools/json", component: ToolsJson, }, + { + path: "tool_landings/:uuid", + component: ToolLanding, + props: true, + }, + { + path: "workflow_landings/:uuid", + component: WorkflowLanding, + props: true, + }, { path: "user", component: UserPreferences, diff --git a/lib/galaxy/exceptions/__init__.py b/lib/galaxy/exceptions/__init__.py index f8e8c956ac98..749b80a57433 100644 --- a/lib/galaxy/exceptions/__init__.py +++ b/lib/galaxy/exceptions/__init__.py @@ -224,6 +224,11 @@ class UserActivationRequiredException(MessageException): err_code = error_codes_by_name["USER_ACTIVATION_REQUIRED"] +class ItemAlreadyClaimedException(MessageException): + status_code = 403 + err_code = error_codes_by_name["ITEM_IS_CLAIMED"] + + class ObjectNotFound(MessageException): """Accessed object was not found""" diff --git a/lib/galaxy/exceptions/error_codes.json b/lib/galaxy/exceptions/error_codes.json index 1c57fbc7bbad..cfaca436a65c 100644 --- a/lib/galaxy/exceptions/error_codes.json +++ b/lib/galaxy/exceptions/error_codes.json @@ -144,6 +144,11 @@ "code": 403007, "message": "Action requires account activation." }, + { + "name": "ITEM_IS_CLAIMED", + "code": 403008, + "message": "This item has already been claimed and cannot be re-claimed." + }, { "name": "USER_REQUIRED", "code": 403008, diff --git a/lib/galaxy/managers/landing.py b/lib/galaxy/managers/landing.py new file mode 100644 index 000000000000..a1355bd2e0fc --- /dev/null +++ b/lib/galaxy/managers/landing.py @@ -0,0 +1,172 @@ +from typing import ( + Optional, + Union, +) +from uuid import uuid4 + +from pydantic import UUID4 +from sqlalchemy import select + +from galaxy.exceptions import ( + InconsistentDatabase, + InsufficientPermissionsException, + ItemAlreadyClaimedException, + ObjectNotFound, + RequestParameterMissingException, +) +from galaxy.model import ( + ToolLandingRequest as ToolLandingRequestModel, + WorkflowLandingRequest as WorkflowLandingRequestModel, +) +from galaxy.model.base import transaction +from galaxy.model.scoped_session import galaxy_scoped_session +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + LandingRequestState, + ToolLandingRequest, + WorkflowLandingRequest, +) +from galaxy.security.idencoding import IdEncodingHelper +from galaxy.util import safe_str_cmp +from .context import ProvidesUserContext + +LandingRequestModel = Union[ToolLandingRequestModel, WorkflowLandingRequestModel] + + +class LandingRequestManager: + + def __init__(self, sa_session: galaxy_scoped_session, security: IdEncodingHelper): + self.sa_session = sa_session + self.security = security + + def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: + model = ToolLandingRequestModel() + model.tool_id = payload.tool_id + model.tool_version = payload.tool_version + model.request_state = payload.request_state + model.uuid = uuid4() + model.client_secret = payload.client_secret + self._save(model) + return self._tool_response(model) + + def create_workflow_landing_request(self, payload: CreateWorkflowLandingRequestPayload) -> WorkflowLandingRequest: + model = WorkflowLandingRequestModel() + if payload.workflow_target_type == "stored_workflow": + model.stored_workflow_id = self.security.decode_id(payload.workflow_id) + else: + model.workflow_id = self.security.decode_id(payload.workflow_id) + model.request_state = payload.request_state + model.uuid = uuid4() + model.client_secret = payload.client_secret + self._save(model) + return self._workflow_response(model) + + def claim_tool_landing_request( + self, trans: ProvidesUserContext, uuid: UUID4, claim: Optional[ClaimLandingPayload] + ) -> ToolLandingRequest: + request: ToolLandingRequestModel = self._get_tool_landing_request(uuid) + self._check_can_claim(trans, request, claim) + request.user_id = trans.user.id + self._save(request) + return self._tool_response(request) + + def claim_workflow_landing_request( + self, trans: ProvidesUserContext, uuid: UUID4, claim: Optional[ClaimLandingPayload] + ) -> WorkflowLandingRequest: + request: WorkflowLandingRequestModel = self._get_workflow_landing_request(uuid) + self._check_can_claim(trans, request, claim) + request.user_id = trans.user.id + self._save(request) + return self._workflow_response(request) + + def get_tool_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> ToolLandingRequest: + request = self._get_claimed_tool_landing_request(trans, uuid) + return self._tool_response(request) + + def get_workflow_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> WorkflowLandingRequest: + request = self._get_claimed_workflow_landing_request(trans, uuid) + return self._workflow_response(request) + + def _check_can_claim( + self, trans: ProvidesUserContext, request: LandingRequestModel, claim: Optional[ClaimLandingPayload] + ): + if request.client_secret is not None: + if claim is None or not claim.client_secret: + raise RequestParameterMissingException() + if not safe_str_cmp(request.client_secret, claim.client_secret): + raise InsufficientPermissionsException() + if request.user_id is not None: + raise ItemAlreadyClaimedException() + + def _get_tool_landing_request(self, uuid: UUID4) -> ToolLandingRequestModel: + request = self.sa_session.scalars( + select(ToolLandingRequestModel).where(ToolLandingRequestModel.uuid == str(uuid)) + ).one_or_none() + if request is None: + raise ObjectNotFound() + return request + + def _get_workflow_landing_request(self, uuid: UUID4) -> WorkflowLandingRequestModel: + request = self.sa_session.scalars( + select(WorkflowLandingRequestModel).where(WorkflowLandingRequestModel.uuid == str(uuid)) + ).one_or_none() + if request is None: + raise ObjectNotFound() + return request + + def _get_claimed_tool_landing_request(self, trans: ProvidesUserContext, uuid: UUID4) -> ToolLandingRequestModel: + request = self._get_tool_landing_request(uuid) + self._check_ownership(trans, request) + return request + + def _get_claimed_workflow_landing_request( + self, trans: ProvidesUserContext, uuid: UUID4 + ) -> WorkflowLandingRequestModel: + request = self._get_workflow_landing_request(uuid) + self._check_ownership(trans, request) + return request + + def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest: + response_model = ToolLandingRequest( + tool_id=model.tool_id, + tool_version=model.tool_version, + request_state=model.request_state, + uuid=model.uuid, + state=self._state(model), + ) + return response_model + + def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLandingRequest: + workflow_id: Optional[int] + if model.stored_workflow_id is not None: + workflow_id = model.stored_workflow_id + target_type = "stored_workflow" + else: + workflow_id = model.workflow_id + target_type = "workflow" + if workflow_id is None: + raise InconsistentDatabase() + assert workflow_id + response_model = WorkflowLandingRequest( + workflow_id=self.security.encode_id(workflow_id), + workflow_target_type=target_type, + request_state=model.request_state, + uuid=model.uuid, + state=self._state(model), + ) + return response_model + + def _check_ownership(self, trans: ProvidesUserContext, model: LandingRequestModel): + if model.user_id != trans.user.id: + raise InsufficientPermissionsException() + + def _state(self, model: LandingRequestModel) -> LandingRequestState: + return LandingRequestState.UNCLAIMED if model.user_id is None else LandingRequestState.CLAIMED + + def _save(self, model: LandingRequestModel): + sa_session = self.sa_session + sa_session.add(model) + with transaction(sa_session): + sa_session.commit() diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index fe414f14a435..5ee06dbbb627 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -8883,8 +8883,10 @@ def inputs_requiring_materialization(self): hdas_to_materialize = [] for input_dataset_assoc in self.input_datasets: request = input_dataset_assoc.request - if request and not request.get("deferred", False): - hdas_to_materialize.append(input_dataset_assoc.dataset) + if request: + deferred = request.get("deferred", False) + if not deferred: + hdas_to_materialize.append(input_dataset_assoc.dataset) return hdas_to_materialize def _serialize(self, id_encoder, serialization_options): @@ -11219,6 +11221,43 @@ def file_source_configuration( raise ValueError("No template sent to file_source_configuration") +# TODO: add link from tool_request to this +class ToolLandingRequest(Base): + __tablename__ = "tool_landing_request" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("galaxy_user.id"), index=True) + create_time: Mapped[datetime] = mapped_column(default=now, nullable=True) + update_time: Mapped[Optional[datetime]] = mapped_column(index=True, default=now, onupdate=now, nullable=True) + uuid: Mapped[Union[UUID, str]] = mapped_column(UUIDType(), index=True) + tool_id: Mapped[str] = mapped_column(String(255)) + tool_version: Mapped[Optional[str]] = mapped_column(String(255), default=None) + request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) + client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + + user: Mapped[Optional["User"]] = relationship() + + +# TODO: add link from workflow_invocation to this +class WorkflowLandingRequest(Base): + __tablename__ = "workflow_landing_request" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("galaxy_user.id"), index=True) + workflow_id: Mapped[Optional[int]] = mapped_column(ForeignKey("stored_workflow.id"), nullable=True) + stored_workflow_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow.id"), nullable=True) + + create_time: Mapped[datetime] = mapped_column(default=now, nullable=True) + update_time: Mapped[Optional[datetime]] = mapped_column(index=True, default=now, onupdate=now, nullable=True) + uuid: Mapped[Union[UUID, str]] = mapped_column(UUIDType(), index=True) + request_state: Mapped[Optional[Dict]] = mapped_column(JSONType) + client_secret: Mapped[Optional[str]] = mapped_column(String(255), default=None) + + user: Mapped[Optional["User"]] = relationship() + stored_workflow: Mapped[Optional["StoredWorkflow"]] = relationship() + workflow: Mapped[Optional["Workflow"]] = relationship() + + class UserAction(Base, RepresentById): __tablename__ = "user_action" diff --git a/lib/galaxy/model/deferred.py b/lib/galaxy/model/deferred.py index 042b6879cd2a..32ee9f954dda 100644 --- a/lib/galaxy/model/deferred.py +++ b/lib/galaxy/model/deferred.py @@ -165,9 +165,10 @@ def ensure_materialized( sa_session = object_session(dataset_instance) assert sa_session sa_session.add(materialized_dataset_instance) - materialized_dataset_instance.copy_from( - dataset_instance, new_dataset=materialized_dataset, include_tags=attached, include_metadata=True - ) + if not in_place: + materialized_dataset_instance.copy_from( + dataset_instance, new_dataset=materialized_dataset, include_tags=attached, include_metadata=True + ) require_metadata_regeneration = ( materialized_dataset_instance.has_metadata_files or materialized_dataset_instance.metadata_deferred ) diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index b2a3a9ec963e..c0418ef3ab2d 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -3832,6 +3832,49 @@ class PageSummaryList(RootModel): ) +class LandingRequestState(str, Enum): + UNCLAIMED = "unclaimed" + CLAIMED = "claimed" + + +ToolLandingRequestIdField = Field(title="ID", description="Encoded ID of the tool landing request") +WorkflowLandingRequestIdField = Field(title="ID", description="Encoded ID of the workflow landing request") + + +class CreateToolLandingRequestPayload(Model): + tool_id: str + tool_version: Optional[str] = None + request_state: Optional[Dict[str, Any]] = None + client_secret: Optional[str] = None + + +class CreateWorkflowLandingRequestPayload(Model): + workflow_id: str + workflow_target_type: Literal["stored_workflow", "workflow"] + request_state: Optional[Dict[str, Any]] = None + client_secret: Optional[str] = None + + +class ClaimLandingPayload(Model): + client_secret: Optional[str] = None + + +class ToolLandingRequest(Model): + uuid: UuidField + tool_id: str + tool_version: Optional[str] = None + request_state: Optional[Dict[str, Any]] = None + state: LandingRequestState + + +class WorkflowLandingRequest(Model): + uuid: UuidField + workflow_id: str + workflow_target_type: Literal["stored_workflow", "workflow"] + request_state: Dict[str, Any] + state: LandingRequestState + + class MessageExceptionModel(BaseModel): err_msg: str err_code: int diff --git a/lib/galaxy/tool_util/client/landing.py b/lib/galaxy/tool_util/client/landing.py new file mode 100644 index 000000000000..9519686ad59a --- /dev/null +++ b/lib/galaxy/tool_util/client/landing.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python + +# . .venv/bin/activate; PYTHONPATH=lib python lib/galaxy/tool_util/client/landing.py -g http://localhost:8081 simple_workflow + +import argparse +import random +import string +import sys +from dataclasses import dataclass +from typing import Optional + +import requests +import yaml + +from galaxy.util.resources import resource_string + +DESCRIPTION = """ +A small utility for demoing creation of tool and workflow landing endpoints. + +This allows an external developer to create tool and workflow forms with +pre-populated parameters and handing them off with URLs to their clients/users. +""" +RANDOM_SECRET_LENGTH = 10 + + +def load_default_library(): + library_yaml = resource_string("galaxy.tool_util.client", "landing_library.sample.yml") + return yaml.safe_load(library_yaml) + + +@dataclass +class Request: + template_id: str + library: str + client_secret: Optional[str] + galaxy_url: str + + +@dataclass +class Response: + landing_url: str + + +def generate_claim_url(request: Request) -> Response: + template_id = request.template_id + library_path = request.library + galaxy_url = request.galaxy_url + client_secret = request.client_secret + if client_secret == "__GEN__": + client_secret = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(RANDOM_SECRET_LENGTH) + ) + if library_path: + with open(library_path) as f: + library = yaml.safe_load(f) + else: + library = load_default_library() + template = library[template_id] + template_type = "tool" if "tool_id" in template else "workflow" + if client_secret: + template["client_secret"] = client_secret + + landing_request_url = f"{galaxy_url}/api/{template_type}_landings" + raw_response = requests.post( + landing_request_url, + json=template, + ) + raw_response.raise_for_status() + response = raw_response.json() + url = f"{galaxy_url}/{template_type}_landings/{response['uuid']}" + if client_secret: + url = url + f"?secret={client_secret}" + return Response(url) + + +def arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument("template_id") + parser.add_argument( + "-g", + "--galaxy-url", + dest="galaxy_url", + default="https://usegalaxy.org/", + help="Galxy target for the landing request", + ) + parser.add_argument( + "-l", + "--library", + dest="library", + default=None, + help="YAML library to load landing request templates from.", + ) + parser.add_argument( + "-s", + "--secret", + dest="secret", + default=None, + help="An optional client secret to verify the request against, set to __GEN__ to generate one at random for this request.", + ) + return parser + + +def main(argv=None) -> None: + if argv is None: + argv = sys.argv[1:] + + args = arg_parser().parse_args(argv) + request = Request( + args.template_id, + args.library, + args.secret, + args.galaxy_url, + ) + response = generate_claim_url(request) + print(f"Your customized form is located at {response.landing_url}") + + +if __name__ == "__main__": + main() diff --git a/lib/galaxy/tool_util/client/landing_library.sample.yml b/lib/galaxy/tool_util/client/landing_library.sample.yml new file mode 100644 index 000000000000..d5d505a8f102 --- /dev/null +++ b/lib/galaxy/tool_util/client/landing_library.sample.yml @@ -0,0 +1,13 @@ +simple_workflow: + workflow_id: f2db41e1fa331b3e + workflow_target_type: stored_workflow + request_state: + myinput: + src: url + url: https://raw.githubusercontent.com/galaxyproject/galaxy/dev/test-data/1.bed + ext: txt +int_workflow: + workflow_id: f597429621d6eb2b + workflow_target_type: stored_workflow + request_state: + int_input: 8 diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index 3b96292f955d..260f90cebced 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -26,6 +26,7 @@ APIRouter, Form, Header, + Path, Query, Request, Response, @@ -41,7 +42,10 @@ HTTPAuthorizationCredentials, HTTPBearer, ) -from pydantic import ValidationError +from pydantic import ( + UUID4, + ValidationError, +) from pydantic.main import BaseModel from routes import ( Mapper, @@ -618,3 +622,10 @@ def search_query_param(model_name: str, tags: list, free_text_fields: list) -> O title="Search query.", description=description, ) + + +LandingUuidPathParam: UUID4 = Path( + ..., + title="Landing UUID", + description="The UUID used to identify a persisted landing request.", +) diff --git a/lib/galaxy/webapps/galaxy/api/workflows.py b/lib/galaxy/webapps/galaxy/api/workflows.py index 05a475d430e6..002e705c8d98 100644 --- a/lib/galaxy/webapps/galaxy/api/workflows.py +++ b/lib/galaxy/webapps/galaxy/api/workflows.py @@ -42,6 +42,7 @@ ProvidesHistoryContext, ProvidesUserContext, ) +from galaxy.managers.landing import LandingRequestManager from galaxy.managers.workflows import ( MissingToolsException, RefactorRequest, @@ -68,12 +69,15 @@ from galaxy.schema.schema import ( AsyncFile, AsyncTaskResultSummary, + ClaimLandingPayload, + CreateWorkflowLandingRequestPayload, InvocationSortByEnum, InvocationsStateCounts, SetSlugPayload, ShareWithPayload, ShareWithStatus, SharingStatus, + WorkflowLandingRequest, WorkflowSortByEnum, ) from galaxy.schema.workflows import ( @@ -102,6 +106,7 @@ depends, DependsOnTrans, IndexQueryTag, + LandingUuidPathParam, Router, search_query_param, ) @@ -909,6 +914,7 @@ def __get_stored_workflow(self, trans, workflow_id, **kwd): @router.cbv class FastAPIWorkflows: service: WorkflowsService = depends(WorkflowsService) + landing_manager: LandingRequestManager = depends(LandingRequestManager) @router.get( "/api/workflows", @@ -1159,6 +1165,39 @@ def show_workflow( ) -> StoredWorkflowDetailed: return self.service.show_workflow(trans, workflow_id, instance, legacy, version) + @router.post("/api/workflow_landings", public=True) + def create_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + workflow_landing_request: CreateWorkflowLandingRequestPayload = Body(...), + ) -> WorkflowLandingRequest: + try: + return self.landing_manager.create_workflow_landing_request(workflow_landing_request) + except Exception: + log.exception("Problem...") + raise + + @router.post("/api/workflow_landings/{uuid}/claim") + def claim_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + payload: Optional[ClaimLandingPayload] = Body(...), + ) -> WorkflowLandingRequest: + try: + return self.landing_manager.claim_workflow_landing_request(trans, uuid, payload) + except Exception: + log.exception("claiim problem...") + raise + + @router.get("/api/workflow_landings/{uuid}") + def get_landing( + self, + trans: ProvidesUserContext = DependsOnTrans, + uuid: UUID4 = LandingUuidPathParam, + ) -> WorkflowLandingRequest: + return self.landing_manager.get_workflow_landing_request(trans, uuid) + StepDetailQueryParam = Annotated[ bool, diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 951926dcc35b..37780b8f0f4e 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -227,6 +227,8 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs): webapp.add_client_route("/login/start") webapp.add_client_route("/tools/list") webapp.add_client_route("/tools/json") + webapp.add_client_route("/tool_landings/{uuid}") + webapp.add_client_route("/workflow_landings/{uuid}") webapp.add_client_route("/tours") webapp.add_client_route("/tours/{tour_id}") webapp.add_client_route("/user") diff --git a/lib/galaxy_test/api/test_landing.py b/lib/galaxy_test/api/test_landing.py new file mode 100644 index 000000000000..84057f92097e --- /dev/null +++ b/lib/galaxy_test/api/test_landing.py @@ -0,0 +1,52 @@ +from base64 import b64encode +from typing import ( + Any, + Dict, +) + +from galaxy.schema.schema import CreateWorkflowLandingRequestPayload +from galaxy_test.base.populators import ( + DatasetPopulator, + skip_without_tool, + WorkflowPopulator, +) +from ._framework import ApiTestCase + + +class TestLandingApi(ApiTestCase): + dataset_populator: DatasetPopulator + workflow_populator: WorkflowPopulator + + def setUp(self): + super().setUp() + self.dataset_populator = DatasetPopulator(self.galaxy_interactor) + self.workflow_populator = WorkflowPopulator(self.galaxy_interactor) + + @skip_without_tool("cat1") + def test_workflow_landing(self): + workflow_id = self.workflow_populator.simple_workflow("test_landing") + workflow_target_type = "stored_workflow" + request_state = _workflow_request_state() + request = CreateWorkflowLandingRequestPayload( + workflow_id=workflow_id, + workflow_target_type=workflow_target_type, + request_state=request_state, + ) + response = self.dataset_populator.create_workflow_landing(request) + assert response.workflow_id == workflow_id + assert response.workflow_target_type == workflow_target_type + + response = self.dataset_populator.claim_workflow_landing(response.uuid) + assert response.workflow_id == workflow_id + assert response.workflow_target_type == workflow_target_type + + +def _workflow_request_state() -> Dict[str, Any]: + deferred = False + input_b64_1 = b64encode(b"1 2 3").decode("utf-8") + input_b64_2 = b64encode(b"4 5 6").decode("utf-8") + inputs = { + "WorkflowInput1": {"src": "url", "url": f"base64://{input_b64_1}", "ext": "txt", "deferred": deferred}, + "WorkflowInput2": {"src": "url", "url": f"base64://{input_b64_2}", "ext": "txt", "deferred": deferred}, + } + return inputs diff --git a/lib/galaxy_test/api/test_workflows.py b/lib/galaxy_test/api/test_workflows.py index b93b848a8695..2838e49e92d4 100644 --- a/lib/galaxy_test/api/test_workflows.py +++ b/lib/galaxy_test/api/test_workflows.py @@ -1537,14 +1537,18 @@ def test_run_workflow(self): def test_run_workflow_by_deferred_url(self): with self.dataset_populator.test_history() as history_id: self.__run_cat_workflow(inputs_by="deferred_url", history_id=history_id) - input_dataset_details = self.dataset_populator.get_history_dataset_details(history_id, hid=1) + # it did an upload of the inputs anyway - so this is a 3 is a bit of a hack... + # TODO fix this. + input_dataset_details = self.dataset_populator.get_history_dataset_details(history_id, hid=3) assert input_dataset_details["state"] == "deferred" @skip_without_tool("cat1") def test_run_workflow_by_url(self): with self.dataset_populator.test_history() as history_id: self.__run_cat_workflow(inputs_by="url", history_id=history_id) - input_dataset_details = self.dataset_populator.get_history_dataset_details(history_id, hid=1) + input_dataset_details = self.dataset_populator.get_history_dataset_details( + history_id, hid=3, assert_ok=False + ) assert input_dataset_details["state"] == "ok" def __run_cat_workflow(self, inputs_by, history_id: Optional[str] = None): diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index 2020685943a5..52006c898b19 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -77,10 +77,17 @@ ImporterGalaxyInterface, ) from gxformat2.yaml import ordered_load +from pydantic import UUID4 from requests import Response from rocrate.rocrate import ROCrate from typing_extensions import Literal +from galaxy.schema.schema import ( + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + ToolLandingRequest, + WorkflowLandingRequest, +) from galaxy.tool_util.client.staging import InteractorStaging from galaxy.tool_util.cwl.util import ( download_output, @@ -369,7 +376,9 @@ class BasePopulator(metaclass=ABCMeta): galaxy_interactor: ApiTestInteractor @abstractmethod - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: """POST data to target Galaxy instance on specified route.""" @abstractmethod @@ -758,6 +767,34 @@ def _wait_for_purge(): wait_on(_wait_for_purge, "dataset to become purged", timeout=2) return self._get(dataset_url) + def create_tool_landing(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest: + create_url = "tool_landings" + json = payload.model_dump(mode="json") + create_response = self._post(create_url, json, json=True, anon=True) + api_asserts.assert_status_code_is(create_response, 200) + create_response.raise_for_status() + return ToolLandingRequest.model_validate(create_response.json()) + + def create_workflow_landing(self, payload: CreateWorkflowLandingRequestPayload) -> WorkflowLandingRequest: + create_url = "workflow_landings" + json = payload.model_dump(mode="json") + create_response = self._post(create_url, json, json=True, anon=True) + api_asserts.assert_status_code_is(create_response, 200) + create_response.raise_for_status() + return WorkflowLandingRequest.model_validate(create_response.json()) + + def claim_tool_landing(self, uuid: UUID4) -> ToolLandingRequest: + url = f"tool_landings/{uuid}/claim" + claim_response = self._post(url, {"client_secret": "foobar"}, json=True) + api_asserts.assert_status_code_is(claim_response, 200) + return ToolLandingRequest.model_validate(claim_response.json()) + + def claim_workflow_landing(self, uuid: UUID4) -> WorkflowLandingRequest: + url = f"workflow_landings/{uuid}/claim" + claim_response = self._post(url, {"client_secret": "foobar"}, json=True) + api_asserts.assert_status_code_is(claim_response, 200) + return WorkflowLandingRequest.model_validate(claim_response.json()) + def create_tool_from_path(self, tool_path: str) -> Dict[str, Any]: tool_directory = os.path.dirname(os.path.abspath(tool_path)) payload = dict( @@ -1664,8 +1701,10 @@ class GalaxyInteractorHttpMixin: def _api_key(self): return self.galaxy_interactor.api_key - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: - return self.galaxy_interactor.post(route, data, files=files, admin=admin, headers=headers, json=json) + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: + return self.galaxy_interactor.post(route, data, files=files, admin=admin, headers=headers, json=json, anon=anon) def _put(self, route, data=None, headers=None, admin=False, json: bool = False): return self.galaxy_interactor.put(route, data, headers=headers, admin=admin, json=json) @@ -3309,11 +3348,14 @@ def _api_url(self): def _get(self, route, data=None, headers=None, admin=False) -> Response: return self._gi.make_get_request(self._url(route), params=data) - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: if headers is None: headers = {} headers = headers.copy() - headers["x-api-key"] = self._gi.key + if not anon: + headers["x-api-key"] = self._gi.key return requests.post(self._url(route), data=data, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT) def _put(self, route, data=None, headers=None, admin=False, json: bool = False): diff --git a/lib/galaxy_test/selenium/framework.py b/lib/galaxy_test/selenium/framework.py index 310c88c88053..9c300dc7b0de 100644 --- a/lib/galaxy_test/selenium/framework.py +++ b/lib/galaxy_test/selenium/framework.py @@ -744,12 +744,14 @@ def _get(self, route, data=None, headers=None, admin=False) -> Response: response = requests.get(full_url, params=data, cookies=cookies, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT) return response - def _post(self, route, data=None, files=None, headers=None, admin=False, json: bool = False) -> Response: + def _post( + self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False + ) -> Response: full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False) cookies = None if admin: full_url = f"{full_url}?key={self._mixin_admin_api_key}" - else: + elif not anon: cookies = self.selenium_context.selenium_to_requests_cookies() request_kwd = prepare_request_params(data=data, files=files, as_json=json, headers=headers, cookies=cookies) response = requests.post(full_url, timeout=DEFAULT_SOCKET_TIMEOUT, **request_kwd) diff --git a/test/unit/app/managers/test_landing.py b/test/unit/app/managers/test_landing.py new file mode 100644 index 000000000000..211e1f8010e5 --- /dev/null +++ b/test/unit/app/managers/test_landing.py @@ -0,0 +1,176 @@ +from uuid import uuid4 + +from galaxy.exceptions import ( + InsufficientPermissionsException, + ItemAlreadyClaimedException, + ObjectNotFound, +) +from galaxy.managers.landing import LandingRequestManager +from galaxy.model import ( + StoredWorkflow, + Workflow, +) +from galaxy.model.base import transaction +from galaxy.schema.schema import ( + ClaimLandingPayload, + CreateToolLandingRequestPayload, + CreateWorkflowLandingRequestPayload, + LandingRequestState, + ToolLandingRequest, + WorkflowLandingRequest, +) +from .base import BaseTestCase + +TEST_TOOL_ID = "cat1" +TEST_TOOL_VERSION = "1.0.0" +TEST_STATE = { + "input1": { + "src": "url", + "url": "https://raw.githubusercontent.com/galaxyproject/planemo/7be1bf5b3971a43eaa73f483125bfb8cabf1c440/tests/data/hello.txt", + "ext": "txt", + }, +} +CLIENT_SECRET = "mycoolsecret" + + +class TestLanding(BaseTestCase): + + def setUp(self): + super().setUp() + self.landing_manager = LandingRequestManager(self.trans.sa_session, self.app.security) + + def test_tool_landing_requests_typical_flow(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + assert landing_request.state == LandingRequestState.UNCLAIMED + assert landing_request.uuid is not None + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + landing_request = self.landing_manager.get_tool_landing_request(self.trans, uuid) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + + def test_tool_landing_requests_requires_matching_client_secret(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret="wrongsecret") + exception = None + try: + self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + except InsufficientPermissionsException as e: + exception = e + assert exception is not None + + def test_tool_landing_requests_get_requires_claim(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + uuid = landing_request.uuid + exception = None + try: + self.landing_manager.get_tool_landing_request(self.trans, uuid) + except InsufficientPermissionsException as e: + exception = e + assert exception is not None + + def test_cannot_reclaim_tool_landing(self): + landing_request: ToolLandingRequest = self.landing_manager.create_tool_landing_request(self._tool_request) + assert landing_request.state == LandingRequestState.UNCLAIMED + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + exception = None + try: + self.landing_manager.claim_tool_landing_request(self.trans, uuid, claim_payload) + except ItemAlreadyClaimedException as e: + exception = e + assert exception + + def test_get_tool_unknown_claim(self): + exception = None + try: + self.landing_manager.get_tool_landing_request(self.trans, uuid4()) + except ObjectNotFound as e: + exception = e + assert exception + + def test_stored_workflow_landing_requests_typical_flow(self): + landing_request: WorkflowLandingRequest = self.landing_manager.create_workflow_landing_request( + self._stored_workflow_request + ) + assert landing_request.state == LandingRequestState.UNCLAIMED + assert landing_request.uuid is not None + assert landing_request.workflow_target_type == "stored_workflow" + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_workflow_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + assert landing_request.workflow_target_type == "stored_workflow" + landing_request = self.landing_manager.get_workflow_landing_request(self.trans, uuid) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + assert landing_request.workflow_target_type == "stored_workflow" + + def test_workflow_landing_requests_typical_flow(self): + landing_request: WorkflowLandingRequest = self.landing_manager.create_workflow_landing_request( + self._workflow_request + ) + assert landing_request.state == LandingRequestState.UNCLAIMED + assert landing_request.uuid is not None + assert landing_request.workflow_target_type == "workflow" + uuid = landing_request.uuid + claim_payload = ClaimLandingPayload(client_secret=CLIENT_SECRET) + landing_request = self.landing_manager.claim_workflow_landing_request(self.trans, uuid, claim_payload) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + assert landing_request.workflow_target_type == "workflow" + landing_request = self.landing_manager.get_workflow_landing_request(self.trans, uuid) + assert landing_request.state == LandingRequestState.CLAIMED + assert landing_request.uuid == uuid + assert landing_request.workflow_target_type == "workflow" + + @property + def _tool_request(self) -> CreateToolLandingRequestPayload: + return CreateToolLandingRequestPayload( + tool_id=TEST_TOOL_ID, + tool_version=TEST_TOOL_VERSION, + request_state=TEST_STATE.copy(), + client_secret=CLIENT_SECRET, + ) + + @property + def _stored_workflow_request(self) -> CreateWorkflowLandingRequestPayload: + sa_session = self.app.model.context + stored_workflow = StoredWorkflow() + stored_workflow.user = self.trans.user + sa_session.add(stored_workflow) + with transaction(sa_session): + sa_session.commit() + + return CreateWorkflowLandingRequestPayload( + workflow_id=self.app.security.encode_id(stored_workflow.id), + workflow_target_type="stored_workflow", + request_state=TEST_STATE.copy(), + client_secret=CLIENT_SECRET, + ) + + @property + def _workflow_request(self) -> CreateWorkflowLandingRequestPayload: + sa_session = self.app.model.context + stored_workflow = StoredWorkflow() + stored_workflow.user = self.trans.user + workflow = Workflow() + workflow.stored_workflow = stored_workflow + sa_session.add(stored_workflow) + sa_session.add(workflow) + with transaction(sa_session): + sa_session.commit() + + return CreateWorkflowLandingRequestPayload( + workflow_id=self.app.security.encode_id(workflow.id), + workflow_target_type="workflow", + request_state=TEST_STATE.copy(), + client_secret=CLIENT_SECRET, + )