Skip to content

Commit

Permalink
Merge pull request galaxyproject#18979 from mvdbeek/workflow_landing_…
Browse files Browse the repository at this point in the history
…improvements

Workflow landing improvements
  • Loading branch information
jmchilton authored Oct 22, 2024
2 parents aef57db + 3c7b0c1 commit 639d302
Show file tree
Hide file tree
Showing 21 changed files with 490 additions and 80 deletions.
10 changes: 8 additions & 2 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7175,6 +7175,12 @@ export interface components {
CreateWorkflowLandingRequestPayload: {
/** Client Secret */
client_secret?: string | null;
/**
* Public
* @description If workflow landing request is public anyone with the uuid can use the landing request. If not public the request must be claimed before use and additional verification might occur.
* @default false
*/
public: boolean;
/** Request State */
request_state?: Record<string, never> | null;
/** Workflow Id */
Expand All @@ -7183,7 +7189,7 @@ export interface components {
* Workflow Target Type
* @enum {string}
*/
workflow_target_type: "stored_workflow" | "workflow";
workflow_target_type: "stored_workflow" | "workflow" | "trs_url";
};
/** CreatedEntryResponse */
CreatedEntryResponse: {
Expand Down Expand Up @@ -18235,7 +18241,7 @@ export interface components {
* Workflow Target Type
* @enum {string}
*/
workflow_target_type: "stored_workflow" | "workflow";
workflow_target_type: "stored_workflow" | "workflow" | "trs_url";
};
/** WriteInvocationStoreToPayload */
WriteInvocationStoreToPayload: {
Expand Down
89 changes: 62 additions & 27 deletions client/src/components/Landing/WorkflowLanding.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { BAlert } from "bootstrap-vue";
import { onMounted, ref } from "vue";
import { storeToRefs } from "pinia";
import { ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { GalaxyApi } from "@/api";
import { type ClaimLandingPayload } from "@/api/landings";
import { useUserStore } from "@/stores/userStore";
import { errorMessageAsString } from "@/utils/simple-error";
import LoadingSpan from "@/components/LoadingSpan.vue";
Expand All @@ -12,50 +14,83 @@ import WorkflowRun from "@/components/Workflow/Run/WorkflowRun.vue";
interface Props {
uuid: string;
secret?: string;
public?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
secret: undefined,
public: false,
});
const workflowId = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const requestState = ref<Record<string, never> | null>(null);
const instance = ref<boolean>(false);
const userStore = useUserStore();
const router = useRouter();
onMounted(async () => {
const payload: ClaimLandingPayload = {};
if (props.secret) {
payload["client_secret"] = props.secret;
}
const { data, error } = await GalaxyApi().POST("/api/workflow_landings/{uuid}/claim", {
params: {
path: { uuid: props.uuid },
},
body: payload,
});
if (data) {
workflowId.value = data.workflow_id;
requestState.value = data.request_state;
} else {
errorMessage.value = errorMessageAsString(error);
}
console.log(data);
});
userStore.loadUser(false);
const { isAnonymous, currentUser } = storeToRefs(userStore);
watch(
currentUser,
async () => {
if (isAnonymous.value) {
router.push(
`/login/start?redirect=/workflow_landings/${props.uuid}?public=${props.public}&client_secret=${props.secret}`
);
} else if (currentUser.value) {
let claim;
let claimError;
if (props.public) {
const { data, error } = await GalaxyApi().GET("/api/workflow_landings/{uuid}", {
params: {
path: { uuid: props.uuid },
},
});
claim = data;
claimError = error;
} else {
const { data, error } = await GalaxyApi().POST("/api/workflow_landings/{uuid}/claim", {
params: {
path: { uuid: props.uuid },
},
body: {
client_secret: props.secret,
},
});
claim = data;
claimError = error;
}
if (claim) {
workflowId.value = claim.workflow_id;
instance.value = claim.workflow_target_type === "workflow";
requestState.value = claim.request_state;
} else {
errorMessage.value = errorMessageAsString(claimError);
}
}
},
{ immediate: true }
);
</script>

<template>
<div>
<div v-if="!workflowId">
<LoadingSpan message="Loading workflow parameters" />
</div>
<div v-else-if="errorMessage">
<div v-if="errorMessage">
<BAlert variant="danger" show>
{{ errorMessage }}
</BAlert>
</div>
<div v-else-if="!workflowId">
<LoadingSpan message="Loading workflow parameters" />
</div>
<div v-else>
<WorkflowRun :workflow-id="workflowId" :prefer-simple-form="true" :request-state="requestState" />
<WorkflowRun
:workflow-id="workflowId"
:prefer-simple-form="true"
:request-state="requestState"
:instance="instance" />
</div>
</div>
</template>
4 changes: 3 additions & 1 deletion client/src/components/Workflow/Run/WorkflowRun.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface Props {
simpleFormTargetHistory?: string;
simpleFormUseJobCache?: boolean;
requestState?: Record<string, never>;
instance?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
Expand All @@ -39,6 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
simpleFormTargetHistory: "current",
simpleFormUseJobCache: false,
requestState: undefined,
instance: false,
});
const loading = ref(true);
Expand Down Expand Up @@ -80,7 +82,7 @@ function handleSubmissionError(error: string) {
async function loadRun() {
try {
const runData = await getRunData(props.workflowId, props.version || undefined);
const runData = await getRunData(props.workflowId, props.version || undefined, props.instance);
const incomingModel = new WorkflowRunModel(runData);
simpleForm.value = props.preferSimpleForm;
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Workflow/Run/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { rethrowSimple } from "utils/simple-error";
* @param {String} workflowId - (Stored?) Workflow ID to fetch data for.
* @param {String} version - Version of the workflow to fetch.
*/
export async function getRunData(workflowId, version = null) {
let url = `${getAppRoot()}api/workflows/${workflowId}/download?style=run`;
export async function getRunData(workflowId, version = null, instance = false) {
let url = `${getAppRoot()}api/workflows/${workflowId}/download?style=run&instance=${instance}`;
if (version) {
url += `&version=${version}`;
}
Expand Down
6 changes: 5 additions & 1 deletion client/src/entry/analysis/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,11 @@ export function getRouter(Galaxy) {
{
path: "workflow_landings/:uuid",
component: WorkflowLanding,
props: true,
props: (route) => ({
uuid: route.params.uuid,
public: route.query.public.toLowerCase() === "true",
secret: route.query.client_secret,
}),
},
{
path: "user",
Expand Down
4 changes: 4 additions & 0 deletions lib/galaxy/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ class Conflict(MessageException):
err_code = error_codes_by_name["CONFLICT"]


class ItemMustBeClaimed(Conflict):
err_code = error_codes_by_name["MUST_CLAIM"]


class DeprecatedMethod(MessageException):
"""
Method (or a particular form/arg signature) has been removed and won't be available later
Expand Down
5 changes: 5 additions & 0 deletions lib/galaxy/exceptions/error_codes.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@
"code": 409001,
"message": "Database conflict prevented fulfilling the request."
},
{
"name": "MUST_CLAIM",
"code": 409010,
"message": "Private request must be claimed before use"
},
{
"name": "DEPRECATED_API_CALL",
"code": 410001,
Expand Down
69 changes: 58 additions & 11 deletions lib/galaxy/managers/landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@
)
from uuid import uuid4

import yaml
from pydantic import UUID4
from sqlalchemy import select

from galaxy.exceptions import (
InconsistentDatabase,
InsufficientPermissionsException,
ItemAlreadyClaimedException,
ItemMustBeClaimed,
ObjectNotFound,
RequestParameterMissingException,
)
from galaxy.managers.workflows import (
WorkflowContentsManager,
WorkflowCreateOptions,
)
from galaxy.model import (
ToolLandingRequest as ToolLandingRequestModel,
WorkflowLandingRequest as WorkflowLandingRequestModel,
Expand All @@ -29,6 +34,7 @@
WorkflowLandingRequest,
)
from galaxy.security.idencoding import IdEncodingHelper
from galaxy.structured_app import StructuredApp
from galaxy.util import safe_str_cmp
from .context import ProvidesUserContext

Expand All @@ -37,29 +43,43 @@

class LandingRequestManager:

def __init__(self, sa_session: galaxy_scoped_session, security: IdEncodingHelper):
def __init__(
self,
sa_session: galaxy_scoped_session,
security: IdEncodingHelper,
workflow_contents_manager: WorkflowContentsManager,
):
self.sa_session = sa_session
self.security = security
self.workflow_contents_manager = workflow_contents_manager

def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload) -> ToolLandingRequest:
def create_tool_landing_request(self, payload: CreateToolLandingRequestPayload, user_id=None) -> 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
model.public = payload.public
if user_id:
model.user_id = user_id
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:
elif payload.workflow_target_type == "workflow":
model.workflow_id = self.security.decode_id(payload.workflow_id)
model.request_state = payload.request_state
elif payload.workflow_target_type == "trs_url":
model.workflow_source_type = "trs_url"
# validate this ?
model.workflow_source = payload.workflow_id
model.uuid = uuid4()
model.client_secret = payload.client_secret
model.request_state = payload.request_state
model.public = payload.public
self._save(model)
return self._workflow_response(model)

Expand All @@ -77,16 +97,39 @@ def claim_workflow_landing_request(
) -> WorkflowLandingRequest:
request = self._get_workflow_landing_request(uuid)
self._check_can_claim(trans, request, claim)
self._ensure_workflow(trans, request)
request.user_id = trans.user.id
self._save(request)
return self._workflow_response(request)

def _ensure_workflow(self, trans: ProvidesUserContext, request: WorkflowLandingRequestModel):
if request.workflow_source_type == "trs_url" and isinstance(trans.app, StructuredApp):
# trans is always structured app except for unit test
assert request.workflow_source
trs_id, trs_version = request.workflow_source.rsplit("/", 1)
_, trs_id, trs_version = trans.app.trs_proxy.get_trs_id_and_version_from_trs_url(request.workflow_source)
workflow = self.workflow_contents_manager.get_workflow_by_trs_id_and_version(
self.sa_session, trs_id=trs_id, trs_version=trs_version, user_id=trans.user and trans.user.id
)
if not workflow:
data = trans.app.trs_proxy.get_version_from_trs_url(request.workflow_source)
as_dict = yaml.safe_load(data)
raw_workflow_description = self.workflow_contents_manager.normalize_workflow_format(trans, as_dict)
created_workflow = self.workflow_contents_manager.build_workflow_from_raw_description(
trans,
raw_workflow_description,
WorkflowCreateOptions(),
)
workflow = created_workflow.workflow
request.workflow_id = workflow.id

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)
self._ensure_workflow(trans, request)
return self._workflow_response(request)

def _check_can_claim(
Expand Down Expand Up @@ -139,18 +182,20 @@ def _tool_response(self, model: ToolLandingRequestModel) -> ToolLandingRequest:
return response_model

def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLandingRequest:
workflow_id: Optional[int]

workflow_id: Optional[Union[int, str]] = None
if model.stored_workflow_id is not None:
workflow_id = model.stored_workflow_id
target_type = "stored_workflow"
else:
elif model.workflow_id is not None:
workflow_id = model.workflow_id
target_type = "workflow"
if workflow_id is None:
raise InconsistentDatabase()
elif model.workflow_source_type == "trs_url":
target_type = model.workflow_source_type
workflow_id = model.workflow_source
assert workflow_id
response_model = WorkflowLandingRequest(
workflow_id=self.security.encode_id(workflow_id),
workflow_id=self.security.encode_id(workflow_id) if isinstance(workflow_id, int) else workflow_id,
workflow_target_type=target_type,
request_state=model.request_state,
uuid=model.uuid,
Expand All @@ -159,7 +204,9 @@ def _workflow_response(self, model: WorkflowLandingRequestModel) -> WorkflowLand
return response_model

def _check_ownership(self, trans: ProvidesUserContext, model: LandingRequestModel):
if model.user_id != trans.user.id:
if not model.public and self._state(model) == LandingRequestState.UNCLAIMED:
raise ItemMustBeClaimed
if model.user_id and model.user_id != trans.user.id:
raise InsufficientPermissionsException()

def _state(self, model: LandingRequestModel) -> LandingRequestState:
Expand Down
Loading

0 comments on commit 639d302

Please sign in to comment.