mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-24 02:04:04 +08:00
Merge pull request #14839 from davelopez/history_export_tracking
Add task-based history export tracking
This commit is contained in:
@@ -35,4 +35,35 @@ describe("ExportForm.vue", () => {
|
||||
it("should localize button text", async () => {
|
||||
expect(wrapper.find(".export-button").text()).toBeLocalizationOf("Export");
|
||||
});
|
||||
|
||||
it("should emit 'export' event with correct inputs on export button click", async () => {
|
||||
await wrapper.setData({
|
||||
name: "export.tar.gz",
|
||||
directory: "gxfiles://",
|
||||
});
|
||||
expect(wrapper.emitted()).not.toHaveProperty("export");
|
||||
|
||||
await wrapper.find(".export-button").trigger("click");
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty("export");
|
||||
expect(wrapper.emitted()["export"][0][0]).toBe("gxfiles://");
|
||||
expect(wrapper.emitted()["export"][0][1]).toBe("export.tar.gz");
|
||||
});
|
||||
|
||||
it("should clear the inputs after export when clearInputAfterExport is enabled", async () => {
|
||||
await wrapper.setProps({
|
||||
clearInputAfterExport: true,
|
||||
});
|
||||
await wrapper.setData({
|
||||
name: "export.tar.gz",
|
||||
directory: "gxfiles://",
|
||||
});
|
||||
expect(wrapper.vm.directory).toEqual("gxfiles://");
|
||||
expect(wrapper.vm.name).toEqual("export.tar.gz");
|
||||
|
||||
await wrapper.find(".export-button").trigger("click");
|
||||
|
||||
expect(wrapper.vm.directory).toBe(null);
|
||||
expect(wrapper.vm.name).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="export-to-remote-file">
|
||||
<b-form-group
|
||||
id="fieldset-directory"
|
||||
label-for="directory"
|
||||
@@ -32,6 +32,10 @@ export default {
|
||||
type: String,
|
||||
default: "archive",
|
||||
},
|
||||
clearInputAfterExport: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -59,6 +63,10 @@ export default {
|
||||
methods: {
|
||||
doExport() {
|
||||
this.$emit("export", this.directory, this.name);
|
||||
if (this.clearInputAfterExport) {
|
||||
this.directory = null;
|
||||
this.name = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { BAlert, BCard, BCardTitle } from "bootstrap-vue";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faExclamationCircle, faExclamationTriangle, faCheckCircle, faClock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { ExportRecordModel } from "./models/exportRecordModel";
|
||||
|
||||
library.add(faExclamationCircle, faExclamationTriangle, faCheckCircle, faClock);
|
||||
|
||||
const props = defineProps({
|
||||
record: {
|
||||
type: ExportRecordModel,
|
||||
required: true,
|
||||
},
|
||||
objectType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
actionMessageVariant: {
|
||||
type: String,
|
||||
default: "info",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onReimport", "onDownload", "onActionMessageDismissed"]);
|
||||
|
||||
const title = computed(() => (props.record.isReady ? `Exported` : `Export started`));
|
||||
const preparingMessage = computed(
|
||||
() => `Preparing export. This may take some time depending on the size of your ${props.objectType}`
|
||||
);
|
||||
|
||||
async function reimportObject() {
|
||||
emit("onReimport", props.record);
|
||||
}
|
||||
|
||||
function downloadObject() {
|
||||
emit("onDownload", props.record);
|
||||
}
|
||||
|
||||
function onMessageDismissed() {
|
||||
emit("onActionMessageDismissed");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-card class="export-record-details">
|
||||
<b-card-title>
|
||||
<b>{{ title }}</b> {{ props.record.elapsedTime }}
|
||||
</b-card-title>
|
||||
<p v-if="!props.record.isPreparing">
|
||||
Format: <b class="record-archive-format">{{ props.record.modelStoreFormat }}</b>
|
||||
</p>
|
||||
<span v-if="props.record.isPreparing">
|
||||
<loading-span :message="preparingMessage" />
|
||||
</span>
|
||||
<div v-else>
|
||||
<div v-if="props.record.hasFailed">
|
||||
<font-awesome-icon
|
||||
icon="exclamation-circle"
|
||||
class="text-danger record-failed-icon"
|
||||
title="Export failed" />
|
||||
<span>
|
||||
Something failed during this export. Please try again and if the problem persist contact your
|
||||
administrator.
|
||||
</span>
|
||||
<b-alert show variant="danger">{{ props.record.errorMessage }}</b-alert>
|
||||
</div>
|
||||
<div v-else-if="props.record.isUpToDate" title="Up to date">
|
||||
<font-awesome-icon icon="check-circle" class="text-success record-up-to-date-icon" />
|
||||
<span> This export record contains the latest changes of the {{ props.objectType }}. </span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<font-awesome-icon icon="exclamation-triangle" class="text-warning record-outdated-icon" />
|
||||
<span>
|
||||
This export is outdated and contains the changes of this {{ props.objectType }} from
|
||||
{{ props.record.elapsedTime }}.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="props.record.canExpire" class="mt-3">
|
||||
<span v-if="props.record.hasExpired">
|
||||
<font-awesome-icon icon="clock" class="text-danger record-expired-icon" /> This download link has
|
||||
expired.
|
||||
</span>
|
||||
<span v-else>
|
||||
<font-awesome-icon icon="clock" class="text-warning record-expiration-warning-icon" /> This download
|
||||
link expires {{ props.record.expirationElapsedTime }}.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div v-if="props.record.isReady">
|
||||
<p class="mt-3">You can do the following actions with this {{ props.objectType }} export:</p>
|
||||
<b-alert
|
||||
v-if="props.actionMessage !== null"
|
||||
:variant="props.actionMessageVariant"
|
||||
show
|
||||
fade
|
||||
dismissible
|
||||
@dismissed="onMessageDismissed">
|
||||
{{ props.actionMessage }}
|
||||
</b-alert>
|
||||
<div v-else class="actions">
|
||||
<b-button
|
||||
v-if="props.record.canDownload"
|
||||
class="record-download-btn"
|
||||
variant="primary"
|
||||
@click="downloadObject">
|
||||
Download
|
||||
</b-button>
|
||||
<b-button
|
||||
v-if="props.record.canReimport"
|
||||
class="record-reimport-btn"
|
||||
variant="primary"
|
||||
@click="reimportObject">
|
||||
Reimport
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-card>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { BCard, BButton, BButtonGroup, BButtonToolbar, BCollapse, BTable, BLink } from "bootstrap-vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faExclamationCircle,
|
||||
faCheckCircle,
|
||||
faDownload,
|
||||
faFileImport,
|
||||
faSpinner,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(faExclamationCircle, faCheckCircle, faDownload, faFileImport, faSpinner);
|
||||
|
||||
const props = defineProps({
|
||||
records: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onReimport", "onDownload"]);
|
||||
|
||||
const fields = [
|
||||
{ key: "elapsedTime", label: "Exported" },
|
||||
{ key: "format", label: "Format" },
|
||||
{ key: "expires", label: "Expires" },
|
||||
{ key: "isUpToDate", label: "Up to date", class: "text-center" },
|
||||
{ key: "isReady", label: "Ready", class: "text-center" },
|
||||
{ key: "actions", label: "Actions" },
|
||||
];
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const title = computed(() => (isExpanded.value ? `Hide export records` : `Show export records`));
|
||||
|
||||
async function reimportObject(record) {
|
||||
emit("onReimport", record);
|
||||
}
|
||||
|
||||
function downloadObject(record) {
|
||||
emit("onDownload", record);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<b-link
|
||||
:class="isExpanded ? null : 'collapsed'"
|
||||
:aria-expanded="isExpanded ? 'true' : 'false'"
|
||||
aria-controls="collapse-previous"
|
||||
@click="isExpanded = !isExpanded">
|
||||
{{ title }}
|
||||
</b-link>
|
||||
<b-collapse id="collapse-previous" v-model="isExpanded">
|
||||
<b-card>
|
||||
<b-table :items="props.records" :fields="fields">
|
||||
<template v-slot:cell(elapsedTime)="row">
|
||||
<span :title="row.item.date">{{ row.value }}</span>
|
||||
</template>
|
||||
<template v-slot:cell(format)="row">
|
||||
<span>{{ row.item.modelStoreFormat }}</span>
|
||||
</template>
|
||||
<template v-slot:cell(expires)="row">
|
||||
<span v-if="row.item.hasExpired">Expired</span>
|
||||
<span v-else-if="row.item.expirationDate" :title="row.item.expirationDate">{{
|
||||
row.item.expirationElapsedTime
|
||||
}}</span>
|
||||
<span v-else>No</span>
|
||||
</template>
|
||||
<template v-slot:cell(isUpToDate)="row">
|
||||
<font-awesome-icon
|
||||
v-if="row.item.isUpToDate"
|
||||
icon="check-circle"
|
||||
class="text-success"
|
||||
title="This export record contains the latest changes." />
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
icon="exclamation-circle"
|
||||
class="text-danger"
|
||||
title="This export record is outdated. Please consider generating a new export if you need the latest changes." />
|
||||
</template>
|
||||
<template v-slot:cell(isReady)="row">
|
||||
<font-awesome-icon
|
||||
v-if="row.item.isReady"
|
||||
icon="check-circle"
|
||||
class="text-success"
|
||||
title="Ready to download or import." />
|
||||
<font-awesome-icon
|
||||
v-else-if="row.item.isPreparing"
|
||||
icon="spinner"
|
||||
spin
|
||||
class="text-info"
|
||||
title="Exporting in progress..." />
|
||||
<font-awesome-icon
|
||||
v-else-if="row.item.hasExpired"
|
||||
icon="exclamation-circle"
|
||||
class="text-danger"
|
||||
title="The export has expired." />
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
icon="exclamation-circle"
|
||||
class="text-danger"
|
||||
title="The export failed." />
|
||||
</template>
|
||||
<template v-slot:cell(actions)="row">
|
||||
<b-button-toolbar aria-label="Actions">
|
||||
<b-button-group>
|
||||
<b-button
|
||||
v-b-tooltip.hover.bottom
|
||||
:disabled="!row.item.canDownload"
|
||||
title="Download"
|
||||
@click="downloadObject(row.item)">
|
||||
<font-awesome-icon icon="download" />
|
||||
</b-button>
|
||||
<b-button
|
||||
v-b-tooltip.hover.bottom
|
||||
:disabled="!row.item.canReimport"
|
||||
title="Reimport"
|
||||
@click="reimportObject(row.item)">
|
||||
<font-awesome-icon icon="file-import" />
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</b-button-toolbar>
|
||||
</template>
|
||||
</b-table>
|
||||
</b-card>
|
||||
</b-collapse>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ExportRecordModel } from "./exportRecordModel";
|
||||
import {
|
||||
EXPECTED_EXPIRATION_DATE,
|
||||
EXPIRED_STS_DOWNLOAD_RESPONSE,
|
||||
FAILED_DOWNLOAD_RESPONSE,
|
||||
FILE_SOURCE_STORE_RESPONSE,
|
||||
RECENT_STS_DOWNLOAD_RESPONSE,
|
||||
} from "./testData/exportData";
|
||||
|
||||
describe("ExportRecordModel", () => {
|
||||
describe("STS Download Record", () => {
|
||||
const stsDownloadRecord = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE);
|
||||
|
||||
it("should be considered temporal (STS) when it has a short term storage ID defined", () => {
|
||||
expect(stsDownloadRecord.isStsDownload).toBe(true);
|
||||
expect(stsDownloadRecord.stsDownloadId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should allow download when ready and not yet expired", () => {
|
||||
expect(stsDownloadRecord.isReady).toBe(true);
|
||||
expect(stsDownloadRecord.hasExpired).toBe(false);
|
||||
expect(stsDownloadRecord.canDownload).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired STS Download Record", () => {
|
||||
const expiredDownloadRecord = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE);
|
||||
|
||||
it("should calculate the correct expiration date", () => {
|
||||
expect(expiredDownloadRecord.canExpire).toBe(true);
|
||||
expect(expiredDownloadRecord.expirationDate).toStrictEqual(EXPECTED_EXPIRATION_DATE());
|
||||
});
|
||||
|
||||
it("should not allow download when expired", () => {
|
||||
expect(expiredDownloadRecord.hasExpired).toBe(true);
|
||||
expect(expiredDownloadRecord.canDownload).toBe(false);
|
||||
expect(expiredDownloadRecord.isReady).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Failed STS Download Record", () => {
|
||||
const failedDownloadRecord = new ExportRecordModel(FAILED_DOWNLOAD_RESPONSE);
|
||||
|
||||
it("should not be downloadable", () => {
|
||||
expect(failedDownloadRecord.isReady).toBe(false);
|
||||
expect(failedDownloadRecord.isPreparing).toBe(false);
|
||||
expect(failedDownloadRecord.hasExpired).toBe(false);
|
||||
expect(failedDownloadRecord.canDownload).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("File Source Storage Record", () => {
|
||||
const failedDownloadRecord = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE);
|
||||
|
||||
it("should be importable", () => {
|
||||
expect(failedDownloadRecord.isReady).toBe(true);
|
||||
expect(failedDownloadRecord.canReimport).toBe(true);
|
||||
expect(failedDownloadRecord.importUri).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
import type { components } from "schema";
|
||||
|
||||
type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"];
|
||||
|
||||
export type StoreExportPayload = components["schemas"]["StoreExportPayload"];
|
||||
export type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"];
|
||||
|
||||
export class ExportParamsModel {
|
||||
private _params: StoreExportPayload;
|
||||
constructor(data: StoreExportPayload = {}) {
|
||||
this._params = data;
|
||||
}
|
||||
|
||||
get modelStoreFormat() {
|
||||
return this._params?.model_store_format;
|
||||
}
|
||||
|
||||
get includeFiles() {
|
||||
return this._params?.include_files;
|
||||
}
|
||||
|
||||
get includeDeleted() {
|
||||
return this._params?.include_deleted;
|
||||
}
|
||||
|
||||
get includeHidden() {
|
||||
return this._params?.include_hidden;
|
||||
}
|
||||
|
||||
public equals(otherExportParams?: ExportParamsModel) {
|
||||
if (!otherExportParams) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.modelStoreFormat === otherExportParams.modelStoreFormat &&
|
||||
this.includeFiles === otherExportParams.includeFiles &&
|
||||
this.includeDeleted === otherExportParams.includeDeleted &&
|
||||
this.includeHidden === otherExportParams.includeHidden
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExportRecordModel {
|
||||
private _data: ObjectExportTaskResponse;
|
||||
private _expirationDate?: Date | null;
|
||||
private _requestMetadata?: ExportObjectRequestMetadata;
|
||||
private _exportParameters?: ExportParamsModel;
|
||||
|
||||
constructor(data: ObjectExportTaskResponse) {
|
||||
this._data = data;
|
||||
this._expirationDate = undefined;
|
||||
this._requestMetadata = data.export_metadata?.request_data;
|
||||
this._exportParameters = this._requestMetadata?.payload
|
||||
? new ExportParamsModel(this._requestMetadata?.payload)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
get isReady() {
|
||||
return (this._data.ready && !this.hasExpired) ?? false;
|
||||
}
|
||||
|
||||
get isPreparing() {
|
||||
return this._data.preparing ?? false;
|
||||
}
|
||||
|
||||
get isUpToDate() {
|
||||
return this._data.up_to_date ?? false;
|
||||
}
|
||||
|
||||
get hasFailed() {
|
||||
return !this.isReady && !this.isPreparing && !this.hasExpired;
|
||||
}
|
||||
|
||||
get date() {
|
||||
return parseISO(`${this._data.create_time}Z`);
|
||||
}
|
||||
|
||||
get elapsedTime() {
|
||||
return formatDistanceToNow(this.date, { addSuffix: true });
|
||||
}
|
||||
|
||||
get taskUUID() {
|
||||
return this._data.task_uuid;
|
||||
}
|
||||
|
||||
get importUri() {
|
||||
const payload = this._requestMetadata?.payload;
|
||||
return payload && "target_uri" in payload ? payload.target_uri : undefined;
|
||||
}
|
||||
|
||||
get canReimport() {
|
||||
return this.isReady && Boolean(this.importUri);
|
||||
}
|
||||
|
||||
get stsDownloadId() {
|
||||
const payload = this._requestMetadata?.payload;
|
||||
return payload && "short_term_storage_request_id" in payload
|
||||
? payload.short_term_storage_request_id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
get isStsDownload() {
|
||||
return Boolean(this.stsDownloadId);
|
||||
}
|
||||
|
||||
get canDownload() {
|
||||
return this.isReady && this.isStsDownload && !this.hasExpired;
|
||||
}
|
||||
|
||||
get modelStoreFormat() {
|
||||
return this.exportParams?.modelStoreFormat;
|
||||
}
|
||||
|
||||
get exportParams() {
|
||||
return this._exportParameters;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
const payload = this._requestMetadata?.payload;
|
||||
return payload && "duration" in payload ? payload.duration : undefined;
|
||||
}
|
||||
|
||||
get canExpire() {
|
||||
return this.isStsDownload && Boolean(this.duration);
|
||||
}
|
||||
|
||||
get expirationDate() {
|
||||
if (this._expirationDate === undefined) {
|
||||
this._expirationDate = this.duration ? new Date(this.date.getTime() + this.duration * 1000) : null;
|
||||
}
|
||||
return this._expirationDate;
|
||||
}
|
||||
|
||||
get expirationElapsedTime() {
|
||||
return this.canExpire && this.expirationDate
|
||||
? formatDistanceToNow(this.expirationDate, { addSuffix: true })
|
||||
: null;
|
||||
}
|
||||
|
||||
get hasExpired() {
|
||||
return this.canExpire && this.expirationDate && Date.now() > this.expirationDate.getTime();
|
||||
}
|
||||
|
||||
get errorMessage() {
|
||||
return this._data?.export_metadata?.result_data?.error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ExportRecordModel } from "components/Common/models/exportRecordModel";
|
||||
import type { components } from "schema";
|
||||
|
||||
type ObjectExportTaskResponse = components["schemas"]["ObjectExportTaskResponse"];
|
||||
type ExportObjectRequestMetadata = components["schemas"]["ExportObjectRequestMetadata"];
|
||||
type ExportObjectResultMetadata = components["schemas"]["ExportObjectResultMetadata"];
|
||||
|
||||
const PAST_EXPORT_DATE = new Date("11 November 2022 14:48 UTC").toISOString();
|
||||
const RECENT_EXPORT_DATE = new Date().toISOString();
|
||||
const STS_EXPORT_DURATION_IN_SECONDS = 86400;
|
||||
export const EXPECTED_EXPIRATION_DATE = () => {
|
||||
const expectedDate = new Date(PAST_EXPORT_DATE);
|
||||
expectedDate.setSeconds(expectedDate.getSeconds() + STS_EXPORT_DURATION_IN_SECONDS);
|
||||
return expectedDate;
|
||||
};
|
||||
|
||||
const FAKE_STS_DOWNLOAD_REQUEST_DATA: ExportObjectRequestMetadata = {
|
||||
object_id: "3cc0effd29705aa3",
|
||||
object_type: "history",
|
||||
user_id: "f597429621d6eb2b",
|
||||
payload: {
|
||||
model_store_format: "rocrate.zip",
|
||||
include_files: true,
|
||||
include_deleted: false,
|
||||
include_hidden: false,
|
||||
short_term_storage_request_id: "08bf4cc3-758e-4a9d-9fe4-a89a0d0604c7",
|
||||
duration: STS_EXPORT_DURATION_IN_SECONDS,
|
||||
},
|
||||
};
|
||||
|
||||
const FAKE_FILE_SOURCE_REQUEST_DATA: ExportObjectRequestMetadata = {
|
||||
object_id: "3cc0effd29705aa3",
|
||||
object_type: "history",
|
||||
user_id: "f597429621d6eb2b",
|
||||
payload: {
|
||||
model_store_format: "tar.gz",
|
||||
include_files: true,
|
||||
include_deleted: false,
|
||||
include_hidden: false,
|
||||
target_uri: "gxfiles://fake-target-uri/test.tar.gz",
|
||||
},
|
||||
};
|
||||
|
||||
const SUCCESS_EXPORT_RESULT_DATA: ExportObjectResultMetadata = {
|
||||
success: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const FAILED_EXPORT_RESULT_DATA: ExportObjectResultMetadata = {
|
||||
success: false,
|
||||
error: "Fake Error Message",
|
||||
};
|
||||
|
||||
export const RECENT_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
|
||||
id: "FAKE_RECENT_DOWNLOAD_ID",
|
||||
ready: true,
|
||||
preparing: false,
|
||||
up_to_date: true,
|
||||
task_uuid: "35563335-e275-4520-80e8-885793279095",
|
||||
create_time: RECENT_EXPORT_DATE,
|
||||
export_metadata: {
|
||||
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
|
||||
result_data: SUCCESS_EXPORT_RESULT_DATA,
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPIRED_STS_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
|
||||
id: "FAKE_EXPIRED_DOWNLOAD_ID",
|
||||
ready: true,
|
||||
preparing: false,
|
||||
up_to_date: true,
|
||||
task_uuid: "35563335-e275-4520-80e8-885793279095",
|
||||
create_time: PAST_EXPORT_DATE,
|
||||
export_metadata: {
|
||||
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
|
||||
result_data: SUCCESS_EXPORT_RESULT_DATA,
|
||||
},
|
||||
};
|
||||
|
||||
export const FAILED_DOWNLOAD_RESPONSE: ObjectExportTaskResponse = {
|
||||
id: "FAKE_FAILED_DOWNLOAD_ID",
|
||||
ready: false,
|
||||
preparing: false,
|
||||
up_to_date: true,
|
||||
task_uuid: "35563335-e275-4520-80e8-885793279095",
|
||||
create_time: RECENT_EXPORT_DATE,
|
||||
export_metadata: {
|
||||
request_data: FAKE_STS_DOWNLOAD_REQUEST_DATA,
|
||||
result_data: FAILED_EXPORT_RESULT_DATA,
|
||||
},
|
||||
};
|
||||
|
||||
export const FILE_SOURCE_STORE_RESPONSE: ObjectExportTaskResponse = {
|
||||
id: "FAKE_RECENT_DOWNLOAD_ID",
|
||||
ready: true,
|
||||
preparing: false,
|
||||
up_to_date: true,
|
||||
task_uuid: "35563335-e275-4520-80e8-885793279095",
|
||||
create_time: RECENT_EXPORT_DATE,
|
||||
export_metadata: {
|
||||
request_data: FAKE_FILE_SOURCE_REQUEST_DATA,
|
||||
result_data: SUCCESS_EXPORT_RESULT_DATA,
|
||||
},
|
||||
};
|
||||
|
||||
export const EXPIRED_STS_DOWNLOAD_RECORD = new ExportRecordModel(EXPIRED_STS_DOWNLOAD_RESPONSE);
|
||||
export const FILE_SOURCE_STORE_RECORD = new ExportRecordModel(FILE_SOURCE_STORE_RESPONSE);
|
||||
export const RECENT_STS_DOWNLOAD_RECORD = new ExportRecordModel(RECENT_STS_DOWNLOAD_RESPONSE);
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { BCard, BFormSelect, BFormCheckbox, BFormGroup, BCollapse, BLink } from "bootstrap-vue";
|
||||
import { ExportParamsModel } from "components/Common/models/exportRecordModel";
|
||||
import { AVAILABLE_EXPORT_FORMATS } from "./services";
|
||||
|
||||
const props = defineProps({
|
||||
exportParams: {
|
||||
type: ExportParamsModel,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onValueChanged"]);
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const title = computed(() => (isExpanded.value ? `Hide advanced export options` : `Show advanced export options`));
|
||||
const localOptions = reactive({
|
||||
modelStoreFormat: props.exportParams.modelStoreFormat,
|
||||
includeFiles: props.exportParams.includeFiles,
|
||||
includeDeleted: props.exportParams.includeDeleted,
|
||||
includeHidden: props.exportParams.includeHidden,
|
||||
});
|
||||
|
||||
function onValueChanged() {
|
||||
emit("onValueChanged", localOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<b-link
|
||||
id="toggle-options-link"
|
||||
:class="isExpanded ? null : 'collapsed'"
|
||||
:aria-expanded="isExpanded ? 'true' : 'false'"
|
||||
aria-controls="collapse-options"
|
||||
@click="isExpanded = !isExpanded">
|
||||
{{ title }}
|
||||
</b-link>
|
||||
<b-collapse id="collapse-options" v-model="isExpanded">
|
||||
<b-card>
|
||||
<b-form-group label="Export Format:" label-for="format">
|
||||
<b-form-select
|
||||
id="format-selector"
|
||||
v-model="localOptions.modelStoreFormat"
|
||||
:options="AVAILABLE_EXPORT_FORMATS"
|
||||
value-field="id"
|
||||
text-field="name"
|
||||
@change="onValueChanged" />
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label="Dataset files included in the package:">
|
||||
<b-form-checkbox
|
||||
id="include-files-check"
|
||||
v-model="localOptions.includeFiles"
|
||||
switch
|
||||
@change="onValueChanged">
|
||||
Include Active
|
||||
</b-form-checkbox>
|
||||
|
||||
<b-form-checkbox
|
||||
id="include-deleted-check"
|
||||
v-model="localOptions.includeDeleted"
|
||||
switch
|
||||
@change="onValueChanged">
|
||||
Include Deleted (not purged)
|
||||
</b-form-checkbox>
|
||||
|
||||
<b-form-checkbox
|
||||
id="include-hidden-check"
|
||||
v-model="localOptions.includeHidden"
|
||||
switch
|
||||
@change="onValueChanged">
|
||||
Include Hidden
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</b-card>
|
||||
</b-collapse>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import axios from "axios";
|
||||
import MockAdapter from "axios-mock-adapter";
|
||||
import {
|
||||
EXPIRED_STS_DOWNLOAD_RECORD,
|
||||
FILE_SOURCE_STORE_RECORD,
|
||||
RECENT_STS_DOWNLOAD_RECORD,
|
||||
} from "components/Common/models/testData/exportData";
|
||||
import flushPromises from "flush-promises";
|
||||
import type { components } from "schema";
|
||||
import { getLocalVue } from "../../../../tests/jest/helpers";
|
||||
import HistoryExport from "./HistoryExport.vue";
|
||||
import { getExportRecords } from "./services";
|
||||
|
||||
const localVue = getLocalVue(true);
|
||||
|
||||
jest.mock("./services");
|
||||
const mockGetExportRecords = getExportRecords as jest.MockedFunction<typeof getExportRecords>;
|
||||
mockGetExportRecords.mockResolvedValue([]);
|
||||
|
||||
const FAKE_HISTORY_ID = "fake-history-id";
|
||||
const REMOTE_FILES_API_ENDPOINT = new RegExp("/api/remote_files/plugins");
|
||||
|
||||
type FilesSourcePluginList = components["schemas"]["FilesSourcePlugin"][];
|
||||
const REMOTE_FILES_API_RESPONSE: FilesSourcePluginList = [
|
||||
{
|
||||
id: "test-posix-source",
|
||||
type: "posix",
|
||||
uri_root: "gxfiles://test-posix-source",
|
||||
label: "TestSource",
|
||||
doc: "For testing",
|
||||
writable: true,
|
||||
requires_roles: undefined,
|
||||
requires_groups: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
async function mountHistoryExport() {
|
||||
const wrapper = shallowMount(HistoryExport, {
|
||||
propsData: { historyId: FAKE_HISTORY_ID },
|
||||
localVue,
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("HistoryExport.vue", () => {
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock = new MockAdapter(axios);
|
||||
axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it("should render export options", async () => {
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#history-export-options").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should display a message indicating there are no exports where there are none", async () => {
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#no-export-records-alert").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should render previous records when there is more than one record", async () => {
|
||||
mockGetExportRecords.mockResolvedValue([
|
||||
RECENT_STS_DOWNLOAD_RECORD,
|
||||
FILE_SOURCE_STORE_RECORD,
|
||||
EXPIRED_STS_DOWNLOAD_RECORD,
|
||||
]);
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#previous-export-records").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not render previous records when there is one or less records", async () => {
|
||||
mockGetExportRecords.mockResolvedValue([RECENT_STS_DOWNLOAD_RECORD]);
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#previous-export-records").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("should display file sources tab if there are available", async () => {
|
||||
axiosMock.onGet(REMOTE_FILES_API_ENDPOINT).reply(200, REMOTE_FILES_API_RESPONSE);
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
|
||||
expect(wrapper.find("#file-source-tab").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not display file sources tab if there are no file sources available", async () => {
|
||||
const wrapper = await mountHistoryExport();
|
||||
|
||||
expect(wrapper.find("#direct-download-tab").exists()).toBe(true);
|
||||
expect(wrapper.find("#file-source-tab").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { computed, ref, reactive, onMounted, watch } from "vue";
|
||||
import { BAlert, BCard, BButton, BTab, BTabs } from "bootstrap-vue";
|
||||
import LoadingSpan from "components/LoadingSpan";
|
||||
import ExportRecordDetails from "components/Common/ExportRecordDetails.vue";
|
||||
import ExportRecordTable from "components/Common/ExportRecordTable.vue";
|
||||
import ExportOptions from "./ExportOptions.vue";
|
||||
import ExportToFileSourceForm from "components/Common/ExportForm.vue";
|
||||
import { getExportRecords, exportToFileSource, reimportHistoryFromRecord } from "./services";
|
||||
import { useTaskMonitor } from "composables/taskMonitor";
|
||||
import { useFileSources } from "composables/fileSources";
|
||||
import { useShortTermStorage, DEFAULT_EXPORT_PARAMS } from "composables/shortTermStorage";
|
||||
import { useConfirmDialog } from "composables/confirmDialog";
|
||||
|
||||
const { isRunning: isExportTaskRunning, waitForTask } = useTaskMonitor();
|
||||
const { hasWritable: hasWritableFileSources } = useFileSources();
|
||||
const { isPreparing: isPreparingDownload, downloadHistory, downloadObjectByRequestId } = useShortTermStorage();
|
||||
const { confirm } = useConfirmDialog();
|
||||
|
||||
const props = defineProps({
|
||||
historyId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const exportParams = reactive(DEFAULT_EXPORT_PARAMS);
|
||||
const isLoadingRecords = ref(true);
|
||||
const exportRecords = ref(null);
|
||||
const latestExportRecord = computed(() => (exportRecords.value?.length ? exportRecords.value.at(0) : null));
|
||||
const previousExportRecords = computed(() => (exportRecords.value ? exportRecords.value.slice(1) : null));
|
||||
const hasPreviousExports = computed(() => previousExportRecords.value?.length > 0);
|
||||
const availableRecordsMessage = computed(() =>
|
||||
isLoadingRecords.value
|
||||
? "Loading export records..."
|
||||
: "This history has no export records yet. You can choose one of the export options above."
|
||||
);
|
||||
const errorMessage = ref(null);
|
||||
const actionMessage = ref(null);
|
||||
const actionMessageVariant = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
updateExports();
|
||||
});
|
||||
|
||||
watch(isExportTaskRunning, (newValue, oldValue) => {
|
||||
const hasFinished = oldValue && !newValue;
|
||||
if (hasFinished) {
|
||||
updateExports();
|
||||
}
|
||||
});
|
||||
|
||||
async function updateExports() {
|
||||
isLoadingRecords.value = true;
|
||||
try {
|
||||
errorMessage.value = null;
|
||||
exportRecords.value = await getExportRecords(props.historyId);
|
||||
const shouldWaitForTask = latestExportRecord.value?.isPreparing && !isExportTaskRunning.value;
|
||||
if (shouldWaitForTask) {
|
||||
waitForTask(latestExportRecord.value.taskUUID, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error;
|
||||
}
|
||||
isLoadingRecords.value = false;
|
||||
}
|
||||
|
||||
async function doExportToFileSource(exportDirectory, fileName) {
|
||||
await exportToFileSource(props.historyId, exportDirectory, fileName, exportParams);
|
||||
updateExports();
|
||||
}
|
||||
|
||||
async function prepareDownload() {
|
||||
const upToDateDownloadRecord = findValidUpToDateDownloadRecord();
|
||||
if (upToDateDownloadRecord) {
|
||||
downloadObjectByRequestId(upToDateDownloadRecord.stsDownloadId);
|
||||
return;
|
||||
}
|
||||
await downloadHistory(props.historyId, { pollDelayInMs: 3000, exportParams: exportParams });
|
||||
updateExports();
|
||||
}
|
||||
|
||||
function downloadFromRecord(record) {
|
||||
if (record.canDownload) {
|
||||
downloadObjectByRequestId(record.stsDownloadId);
|
||||
}
|
||||
}
|
||||
|
||||
function findValidUpToDateDownloadRecord() {
|
||||
return exportRecords.value
|
||||
? exportRecords.value.find(
|
||||
(record) => record.canDownload && record.isUpToDate && record.exportParams?.equals(exportParams)
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function reimportFromRecord(record) {
|
||||
const confirmed = await confirm(
|
||||
`Do you really want to import a new copy of this history exported ${record.elapsedTime}?`
|
||||
);
|
||||
if (confirmed) {
|
||||
reimportHistoryFromRecord(record)
|
||||
.then(() => {
|
||||
actionMessageVariant.value = "info";
|
||||
actionMessage.value =
|
||||
"The history is being imported in the background. Check your histories after a while to find it.";
|
||||
})
|
||||
.catch((reason) => {
|
||||
actionMessageVariant.value = "danger";
|
||||
actionMessage.value = reason;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onActionMessageDismissedFromRecord() {
|
||||
actionMessage.value = null;
|
||||
actionMessageVariant.value = null;
|
||||
}
|
||||
|
||||
function updateExportParams(newParams) {
|
||||
exportParams.modelStoreFormat = newParams.modelStoreFormat;
|
||||
exportParams.includeFiles = newParams.includeFiles;
|
||||
exportParams.includeDeleted = newParams.includeDeleted;
|
||||
exportParams.includeHidden = newParams.includeHidden;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<span class="history-export-component">
|
||||
<h1 class="h-lg">Export history {{ props.historyId }}</h1>
|
||||
|
||||
<export-options
|
||||
id="history-export-options"
|
||||
:export-params="exportParams"
|
||||
@onValueChanged="updateExportParams" />
|
||||
|
||||
<b-card no-body class="mt-3">
|
||||
<b-tabs pills card>
|
||||
<b-tab id="direct-download-tab" title="to direct download" title-link-class="tab-export-to-link" active>
|
||||
<p>
|
||||
Here you can generate a temporal download for your history. When your download link expires or
|
||||
your history changes you can re-generate it again.
|
||||
</p>
|
||||
<b-alert show variant="warning">
|
||||
History archive downloads can expire and are removed at regular intervals. For permanent
|
||||
storage, export to a <b>remote file</b> or download and then import the archive on another
|
||||
Galaxy server.
|
||||
</b-alert>
|
||||
<b-button
|
||||
class="direct-download-btn"
|
||||
:disabled="isPreparingDownload"
|
||||
variant="primary"
|
||||
@click="prepareDownload">
|
||||
Download
|
||||
</b-button>
|
||||
<span v-if="isPreparingDownload">
|
||||
<loading-span message="Galaxy is preparing your download, this will likely take a while" />
|
||||
</span>
|
||||
</b-tab>
|
||||
<b-tab
|
||||
v-if="hasWritableFileSources"
|
||||
id="file-source-tab"
|
||||
title="to remote file"
|
||||
title-link-class="tab-export-to-file">
|
||||
<p>
|
||||
If you need a "more permanent" way of storing your history archive you can export it directly to
|
||||
one of the available remote file sources here. You will be able to re-import it later as long as
|
||||
it remains available on the remote server.
|
||||
</p>
|
||||
<export-to-file-source-form
|
||||
what="history"
|
||||
:clear-input-after-export="true"
|
||||
@export="doExportToFileSource" />
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</b-card>
|
||||
|
||||
<export-record-details
|
||||
v-if="latestExportRecord"
|
||||
:record="latestExportRecord"
|
||||
object-type="history"
|
||||
class="mt-3"
|
||||
:action-message="actionMessage"
|
||||
:action-message-variant="actionMessageVariant"
|
||||
@onDownload="downloadFromRecord"
|
||||
@onReimport="reimportFromRecord"
|
||||
@onActionMessageDismissed="onActionMessageDismissedFromRecord" />
|
||||
<b-alert v-else-if="errorMessage" id="last-export-record-error-alert" variant="danger" class="mt-3" show>
|
||||
{{ errorMessage }}
|
||||
</b-alert>
|
||||
<b-alert v-else id="no-export-records-alert" variant="info" class="mt-3" show>
|
||||
{{ availableRecordsMessage }}
|
||||
</b-alert>
|
||||
|
||||
<export-record-table
|
||||
v-if="hasPreviousExports"
|
||||
id="previous-export-records"
|
||||
:records="previousExportRecords"
|
||||
class="mt-3"
|
||||
@onDownload="downloadFromRecord"
|
||||
@onReimport="reimportFromRecord" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ExportRecordModel } from "components/Common/models/exportRecordModel";
|
||||
import type { ObjectExportTaskResponse } from "components/Common/models/exportRecordModel";
|
||||
import { DEFAULT_EXPORT_PARAMS } from "composables/shortTermStorage";
|
||||
import type { components } from "schema";
|
||||
import { fetcher } from "schema";
|
||||
|
||||
type ModelStoreFormat = components["schemas"]["ModelStoreFormat"];
|
||||
|
||||
const _getExportRecords = fetcher.path("/api/histories/{history_id}/exports").method("get").create();
|
||||
const _exportToFileSource = fetcher.path("/api/histories/{history_id}/write_store").method("post").create();
|
||||
const _importFromStoreAsync = fetcher.path("/api/histories/from_store_async").method("post").create();
|
||||
|
||||
/**
|
||||
* A list of objects with the available export formats IDs and display names.
|
||||
*/
|
||||
export const AVAILABLE_EXPORT_FORMATS: { id: ModelStoreFormat; name: string }[] = [
|
||||
{ id: "rocrate.zip", name: "RO-Crate" },
|
||||
{ id: "tar.gz", name: "Compressed TGZ" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets a list of export records for the given history.
|
||||
* @param historyId the encoded ID of the history
|
||||
* @param params query and pagination params
|
||||
* @returns a promise with a list of export records associated with the given history.
|
||||
*/
|
||||
export async function getExportRecords(historyId: string) {
|
||||
const response = await _getExportRecords(
|
||||
{
|
||||
history_id: historyId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.galaxy.task.export+json",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data.map((item: unknown) => new ExportRecordModel(item as ObjectExportTaskResponse));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param historyId the encoded ID of the history
|
||||
* @param exportDirectory the output directory in the file source
|
||||
* @param fileName the name of the output archive
|
||||
* @param exportParams additional parameters to configure the export
|
||||
* @returns A promise with the request response
|
||||
*/
|
||||
export async function exportToFileSource(
|
||||
historyId: string,
|
||||
exportDirectory: string,
|
||||
fileName: string,
|
||||
exportParams = DEFAULT_EXPORT_PARAMS
|
||||
) {
|
||||
const exportDirectoryUri = `${exportDirectory}/${fileName}.${exportParams.modelStoreFormat}`;
|
||||
|
||||
return _exportToFileSource({
|
||||
history_id: historyId,
|
||||
target_uri: exportDirectoryUri,
|
||||
model_store_format: exportParams.modelStoreFormat as ModelStoreFormat,
|
||||
include_files: exportParams.includeFiles,
|
||||
include_deleted: exportParams.includeDeleted,
|
||||
include_hidden: exportParams.includeHidden,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a new history using the information stored in the given export record.
|
||||
* @param record The export record to be imported
|
||||
* @returns A promise with the request response
|
||||
*/
|
||||
export async function reimportHistoryFromRecord(record: ExportRecordModel) {
|
||||
return _importFromStoreAsync({
|
||||
store_content_uri: record.importUri,
|
||||
model_store_format: record.modelStoreFormat,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ref, readonly, onMounted } from "vue";
|
||||
import { Services } from "components/FilesDialog/services";
|
||||
|
||||
const fileSourcesService = new Services();
|
||||
|
||||
/**
|
||||
* Composable for accessing and working with file sources.
|
||||
*/
|
||||
export function useFileSources() {
|
||||
const isLoading = ref(true);
|
||||
const hasWritable = ref(false);
|
||||
const fileSources = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
fileSources.value = await fileSourcesService.getFileSources();
|
||||
hasWritable.value = fileSources.value.some((fs) => fs.writable);
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* The list of available file sources from the server.
|
||||
*/
|
||||
fileSources: readonly(fileSources),
|
||||
/**
|
||||
* Whether the file sources are being loaded from the server.
|
||||
*/
|
||||
isLoading: readonly(isLoading),
|
||||
/**
|
||||
* Whether the user can write files to any of the available file sources.
|
||||
*/
|
||||
hasWritable: readonly(hasWritable),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ref, readonly } from "vue";
|
||||
import axios from "axios";
|
||||
import { safePath } from "utils/redirect";
|
||||
import { rethrowSimple } from "utils/simple-error";
|
||||
|
||||
export const DEFAULT_EXPORT_PARAMS = {
|
||||
modelStoreFormat: "rocrate.zip",
|
||||
includeFiles: true,
|
||||
includeDeleted: false,
|
||||
includeHidden: false,
|
||||
};
|
||||
|
||||
const DEFAULT_POLL_DELAY = 1000;
|
||||
const DEFAULT_OPTIONS = { exportParams: DEFAULT_EXPORT_PARAMS, pollDelayInMs: DEFAULT_POLL_DELAY };
|
||||
|
||||
/**
|
||||
* Composable to simplify and reuse the logic for downloading objects using Galaxy's Short Term Storage system.
|
||||
*/
|
||||
export function useShortTermStorage() {
|
||||
let timeout = null;
|
||||
let pollDelay = DEFAULT_POLL_DELAY;
|
||||
|
||||
const isPreparing = ref(false);
|
||||
|
||||
async function downloadHistory(historyId, options = DEFAULT_OPTIONS) {
|
||||
return prepareObjectDownload(historyId, "histories", options);
|
||||
}
|
||||
|
||||
async function downloadWorkflowInvocation(invocationId, options = DEFAULT_OPTIONS) {
|
||||
return prepareObjectDownload(invocationId, "invocations", options);
|
||||
}
|
||||
|
||||
function downloadObjectByRequestId(storageRequestId) {
|
||||
const url = safePath(`/api/short_term_storage/${storageRequestId}`);
|
||||
window.location.assign(url);
|
||||
}
|
||||
|
||||
async function prepareObjectDownload(object_id, object_api, options = DEFAULT_OPTIONS) {
|
||||
const finalOptions = Object.assign(DEFAULT_OPTIONS, options);
|
||||
resetTimeout();
|
||||
isPreparing.value = true;
|
||||
pollDelay = finalOptions.pollDelayInMs;
|
||||
const url = safePath(`/api/${object_api}/${object_id}/prepare_store_download`);
|
||||
const exportParams = {
|
||||
model_store_format: finalOptions.exportParams.modelStoreFormat,
|
||||
include_files: finalOptions.exportParams.includeFiles,
|
||||
include_deleted: finalOptions.exportParams.includeDeleted,
|
||||
include_hidden: finalOptions.exportParams.includeHidden,
|
||||
};
|
||||
|
||||
const response = await axios.post(url, exportParams).catch(handleError);
|
||||
handleInitialize(response);
|
||||
}
|
||||
|
||||
function handleInitialize(response) {
|
||||
const storageRequestId = response.data.storage_request_id;
|
||||
pollStorageRequestId(storageRequestId);
|
||||
}
|
||||
|
||||
function pollStorageRequestId(storageRequestId) {
|
||||
const url = safePath(`/api/short_term_storage/${storageRequestId}/ready`);
|
||||
axios
|
||||
.get(url)
|
||||
.then((r) => {
|
||||
handlePollResponse(r, storageRequestId);
|
||||
})
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
function handlePollResponse(response, storageRequestId) {
|
||||
const ready = response.data;
|
||||
if (ready) {
|
||||
isPreparing.value = false;
|
||||
downloadObjectByRequestId(storageRequestId);
|
||||
} else {
|
||||
pollAfterDelay(storageRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
rethrowSimple(err);
|
||||
isPreparing.value = false;
|
||||
}
|
||||
|
||||
function pollAfterDelay(storageRequestId) {
|
||||
resetTimeout();
|
||||
timeout = setTimeout(() => {
|
||||
pollStorageRequestId(storageRequestId);
|
||||
}, pollDelay);
|
||||
}
|
||||
|
||||
function resetTimeout() {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Starts preparing a history download file. When `isPreparing` is false the download will start automatically.
|
||||
* @param {String} historyId The ID of the history to be downloaded
|
||||
* @param {Object} options Options for the download preparation
|
||||
*/
|
||||
downloadHistory,
|
||||
/**
|
||||
* Starts preparing a workflow invocation download file. When `isPreparing` is false the download will start automatically.
|
||||
* @param {String} invocationId The ID of the workflow invocation to be downloaded
|
||||
* @param {Object} options Options for the download preparation
|
||||
*/
|
||||
downloadWorkflowInvocation,
|
||||
/**
|
||||
* Starts a direct download of the object associated with the given `storageRequestId`.
|
||||
* For the download to succeed, the associated `storageRequestId` must be `ready` and not in failure state.
|
||||
* @param {String} storageRequestId The storage request ID associated to the object to be downloaded
|
||||
*/
|
||||
downloadObjectByRequestId,
|
||||
/**
|
||||
* Whether the download is still being prepared.
|
||||
*/
|
||||
isPreparing: readonly(isPreparing),
|
||||
};
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import axios from "axios";
|
||||
const SUCCESS_STATE = "SUCCESS";
|
||||
const FAILURE_STATE = "FAILURE";
|
||||
const TASK_READY_STATES = [SUCCESS_STATE, FAILURE_STATE];
|
||||
const POLL_DELAY = 1000;
|
||||
const DEFAULT_POLL_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* Composable for waiting on Galaxy background tasks.
|
||||
*/
|
||||
export function useTaskMonitor() {
|
||||
let timeout = null;
|
||||
let pollDelay = DEFAULT_POLL_DELAY;
|
||||
|
||||
const isRunning = ref(false);
|
||||
const status = ref(null);
|
||||
@@ -21,7 +22,8 @@ export function useTaskMonitor() {
|
||||
const hasFailed = computed(() => status.value === FAILURE_STATE);
|
||||
const queryStateUrl = computed(() => `/api/tasks/${currentTaskId.value}/state`);
|
||||
|
||||
function waitForTask(taskId) {
|
||||
function waitForTask(taskId, pollDelayInMs = DEFAULT_POLL_DELAY) {
|
||||
pollDelay = pollDelayInMs;
|
||||
resetState();
|
||||
currentTaskId.value = taskId;
|
||||
isRunning.value = true;
|
||||
@@ -46,7 +48,7 @@ export function useTaskMonitor() {
|
||||
resetTimeout();
|
||||
timeout = setTimeout(() => {
|
||||
fetchTaskStatus(taskId);
|
||||
}, POLL_DELAY);
|
||||
}, pollDelay);
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
@@ -69,8 +71,9 @@ export function useTaskMonitor() {
|
||||
return {
|
||||
/**
|
||||
* Waits for a particular task ID to be completed.
|
||||
* While the task is pending, the state will be updated every second by polling the server.
|
||||
* While the task is pending, the state will be updated every `pollDelayInMs` by polling the server.
|
||||
* @param {string} taskId The task ID
|
||||
* @param {Number} pollDelayInMs The time (milliseconds) between poll requests to update the task state.
|
||||
*/
|
||||
waitForTask,
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,7 @@ import { APIKey } from "components/User/APIKey";
|
||||
import { CloudAuth } from "components/User/CloudAuth";
|
||||
import { ExternalIdentities } from "components/User/ExternalIdentities";
|
||||
import { HistoryExport } from "components/HistoryExport/index";
|
||||
import HistoryExportTasks from "components/History/Export/HistoryExport";
|
||||
import { StorageDashboardRouter } from "components/User/DiskUsage";
|
||||
|
||||
Vue.use(VueRouter);
|
||||
@@ -235,7 +236,9 @@ export function getRouter(Galaxy) {
|
||||
},
|
||||
{
|
||||
path: "histories/:historyId/export",
|
||||
component: HistoryExport,
|
||||
get component() {
|
||||
return Galaxy.config.enable_celery_tasks ? HistoryExportTasks : HistoryExport;
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
|
||||
+589
-437
File diff suppressed because it is too large
Load Diff
@@ -420,6 +420,28 @@ history_export:
|
||||
export_button: '.export-button'
|
||||
success_message: '.history-export-component .alert-success'
|
||||
|
||||
history_export_tasks:
|
||||
selectors:
|
||||
direct_download: '.direct-download-btn'
|
||||
file_source_tab: '.tab-export-to-file'
|
||||
remote_file_name_input: '#file-source-tab #name'
|
||||
toggle_options_link: '#toggle-options-link'
|
||||
export_format_selector: '#format-selector'
|
||||
select_format:
|
||||
type: xpath
|
||||
selector: '//select[@id="format-selector"]/option[@value="${format}"]'
|
||||
|
||||
last_export_record:
|
||||
selectors:
|
||||
details: '.export-record-details'
|
||||
preparing_export: '.export-record-details .loading-icon'
|
||||
export_format: '.record-archive-format'
|
||||
up_to_date_icon: '.record-up-to-date-icon'
|
||||
outdated_icon: '.record-outdated-icon'
|
||||
expiration_warning_icon: '.record-expiration-warning-icon'
|
||||
download_btn: '.record-download-btn'
|
||||
reimport_btn: '.record-reimport-btn'
|
||||
|
||||
history_import:
|
||||
selectors:
|
||||
radio_button_remote_files: '.history-import-component .fa-folder-open'
|
||||
@@ -918,4 +940,3 @@ job_details:
|
||||
type: xpath
|
||||
selector: '//td[@id="galaxy-tool-id"][normalize-space(text()) = "${tool_id}"]'
|
||||
tool_exit_code: '#exit-code'
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ from galaxy.managers.libraries import LibraryManager
|
||||
from galaxy.managers.library_datasets import LibraryDatasetsManager
|
||||
from galaxy.managers.roles import RoleManager
|
||||
from galaxy.managers.session import GalaxySessionManager
|
||||
from galaxy.managers.tasks import (
|
||||
AsyncTasksManager,
|
||||
CeleryAsyncTasksManager,
|
||||
)
|
||||
from galaxy.managers.tools import DynamicToolManager
|
||||
from galaxy.managers.users import UserManager
|
||||
from galaxy.managers.workflows import (
|
||||
@@ -538,6 +542,10 @@ class GalaxyManagerApplication(MinimalManagerApp, MinimalGalaxyApplication):
|
||||
self.role_manager = self._register_singleton(RoleManager)
|
||||
self.job_manager = self._register_singleton(JobManager)
|
||||
|
||||
self.task_manager = self._register_abstract_singleton(
|
||||
AsyncTasksManager, CeleryAsyncTasksManager # type: ignore[type-abstract]
|
||||
) # Ignored because of https://github.com/python/mypy/issues/4717
|
||||
|
||||
# ConfiguredFileSources
|
||||
self.file_sources = self._register_singleton(
|
||||
ConfiguredFileSources, ConfiguredFileSources.from_app_config(self.config)
|
||||
|
||||
@@ -27,6 +27,12 @@ class Container(LagomContainer):
|
||||
self[dep_type] = instance
|
||||
return self[dep_type]
|
||||
|
||||
def _register_abstract_singleton(
|
||||
self, abstract_type: Type[T], concrete_type: Type[T], instance: Optional[T] = None
|
||||
) -> T:
|
||||
self[abstract_type] = instance if instance is not None else concrete_type
|
||||
return self[abstract_type]
|
||||
|
||||
def resolve_or_none(self, dep_type: Type[T]) -> Optional[T]:
|
||||
"""Resolve the dependent type or just return None.
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import (
|
||||
and_,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from sqlalchemy.orm.scoping import scoped_session
|
||||
|
||||
from galaxy.exceptions import ObjectNotFound
|
||||
from galaxy.model import StoreExportAssociation
|
||||
from galaxy.schema.schema import ExportObjectType
|
||||
from galaxy.structured_app import MinimalManagerApp
|
||||
|
||||
|
||||
class StoreExportTracker:
|
||||
"""
|
||||
Manages export-related metadata association of different types of exportable objects
|
||||
to remote file sources or repositories.
|
||||
"""
|
||||
|
||||
def __init__(self, app: MinimalManagerApp):
|
||||
self.app = app
|
||||
|
||||
@property
|
||||
def session(self) -> scoped_session:
|
||||
return self.app.model.context
|
||||
|
||||
def create_export_association(self, object_id: int, object_type: ExportObjectType) -> StoreExportAssociation:
|
||||
export_association = StoreExportAssociation(object_id=object_id, object_type=object_type)
|
||||
self.session.add(export_association)
|
||||
self.session.flush()
|
||||
return export_association
|
||||
|
||||
def set_export_association_metadata(self, export_association_id: int, export_metadata: BaseModel):
|
||||
try:
|
||||
stmt = select(StoreExportAssociation).where(StoreExportAssociation.id == export_association_id)
|
||||
export_association: StoreExportAssociation = self.session.execute(stmt).scalars().one()
|
||||
except NoResultFound:
|
||||
raise ObjectNotFound("Cannot set export metadata. Reason: Export association not found")
|
||||
export_association.export_metadata = export_metadata.json()
|
||||
self.session.flush()
|
||||
|
||||
def get_object_exports(
|
||||
self, object_id: int, object_type: ExportObjectType, limit: Optional[int] = None, offset: Optional[int] = None
|
||||
) -> List[StoreExportAssociation]:
|
||||
stmt = (
|
||||
select(
|
||||
StoreExportAssociation,
|
||||
)
|
||||
.where(
|
||||
and_(StoreExportAssociation.object_type == object_type, StoreExportAssociation.object_id == object_id)
|
||||
)
|
||||
.order_by(StoreExportAssociation.create_time.desc())
|
||||
)
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars()
|
||||
@@ -36,8 +36,11 @@ from galaxy.managers.base import (
|
||||
Serializer,
|
||||
SortableManager,
|
||||
)
|
||||
from galaxy.managers.export_tracker import StoreExportTracker
|
||||
from galaxy.schema.fields import DecodedDatabaseIdField
|
||||
from galaxy.schema.schema import (
|
||||
ExportObjectMetadata,
|
||||
ExportObjectType,
|
||||
HDABasicInfo,
|
||||
ShareHistoryExtra,
|
||||
)
|
||||
@@ -351,25 +354,63 @@ class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMix
|
||||
log.warning(f"User without permissions tried to make dataset with id: {dataset.id} public")
|
||||
|
||||
|
||||
class HistoryExportView:
|
||||
def __init__(self, app: MinimalManagerApp):
|
||||
class HistoryExportManager:
|
||||
export_object_type = ExportObjectType.HISTORY
|
||||
|
||||
def __init__(self, app: MinimalManagerApp, export_tracker: StoreExportTracker):
|
||||
self.app = app
|
||||
self.export_tracker = export_tracker
|
||||
|
||||
def get_task_exports(self, trans, history_id: int, limit: Optional[int] = None, offset: Optional[int] = None):
|
||||
"""Returns task-based exports associated with this history"""
|
||||
history = self._history(trans, history_id)
|
||||
export_associations = self.export_tracker.get_object_exports(
|
||||
object_id=history_id, object_type=self.export_object_type, limit=limit, offset=offset
|
||||
)
|
||||
return [self._serialize_task_export(export, history) for export in export_associations]
|
||||
|
||||
def create_export_association(self, history_id: int) -> model.StoreExportAssociation:
|
||||
return self.export_tracker.create_export_association(object_id=history_id, object_type=self.export_object_type)
|
||||
|
||||
def _serialize_task_export(self, export: model.StoreExportAssociation, history: model.History):
|
||||
task_uuid = export.task_uuid
|
||||
export_date = export.create_time
|
||||
history_has_changed = history.update_time > export_date
|
||||
json_metadata = export.export_metadata
|
||||
export_metadata = ExportObjectMetadata.parse_raw(json_metadata) if json_metadata else None
|
||||
is_ready = (
|
||||
export_metadata is not None
|
||||
and export_metadata.result_data is not None
|
||||
and export_metadata.result_data.success
|
||||
)
|
||||
is_export_up_to_date = is_ready and not history_has_changed
|
||||
return {
|
||||
"id": export.id,
|
||||
"ready": is_ready,
|
||||
"preparing": export_metadata is None or export_metadata.result_data is None,
|
||||
"up_to_date": is_export_up_to_date,
|
||||
"task_uuid": task_uuid,
|
||||
"create_time": export_date,
|
||||
"export_metadata": export_metadata,
|
||||
}
|
||||
|
||||
def get_exports(self, trans, history_id: int):
|
||||
"""Returns job-based exports associated with this history"""
|
||||
history = self._history(trans, history_id)
|
||||
matching_exports = history.exports
|
||||
return [self.serialize(trans, history_id, e) for e in matching_exports]
|
||||
|
||||
def serialize(self, trans, history_id: int, jeha):
|
||||
def serialize(self, trans, history_id: int, jeha: model.JobExportHistoryArchive) -> dict:
|
||||
rval = jeha.to_dict()
|
||||
rval["type"] = "job"
|
||||
encoded_jeha_id = DecodedDatabaseIdField.encode(jeha.id)
|
||||
encoded_history_id = DecodedDatabaseIdField.encode(history_id)
|
||||
api_url = trans.url_builder("history_archive_download", id=encoded_history_id, jeha_id=encoded_jeha_id)
|
||||
api_url = trans.url_builder("history_archive_download", history_id=encoded_history_id, jeha_id=encoded_jeha_id)
|
||||
external_url = trans.url_builder(
|
||||
"history_archive_download", id=encoded_history_id, jeha_id="latest", qualified=True
|
||||
"history_archive_download", history_id=encoded_history_id, jeha_id="latest", qualified=True
|
||||
)
|
||||
external_permanent_url = trans.url_builder(
|
||||
"history_archive_download", id=encoded_history_id, jeha_id=encoded_jeha_id, qualified=True
|
||||
"history_archive_download", history_id=encoded_history_id, jeha_id=encoded_jeha_id, qualified=True
|
||||
)
|
||||
rval["download_url"] = api_url
|
||||
rval["external_download_latest_url"] = external_url
|
||||
@@ -494,7 +535,7 @@ class HistorySerializer(sharable.SharableModelSerializer, deletable.PurgableSeri
|
||||
"nice_size": lambda item, key, **context: item.disk_nice_size,
|
||||
"state": self.serialize_history_state,
|
||||
"url": lambda item, key, **context: self.url_for(
|
||||
"history", id=self.app.security.encode_id(item.id), context=context
|
||||
"history", history_id=self.app.security.encode_id(item.id), context=context
|
||||
),
|
||||
"contents_url": lambda item, key, **context: self.url_for(
|
||||
"history_contents", history_id=self.app.security.encode_id(item.id), context=context
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from typing import Optional
|
||||
from typing import (
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
||||
from galaxy import model
|
||||
from galaxy.exceptions import RequestParameterInvalidException
|
||||
from galaxy.jobs.manager import JobManager
|
||||
from galaxy.managers.context import ProvidesUserContext
|
||||
from galaxy.managers.export_tracker import StoreExportTracker
|
||||
from galaxy.managers.histories import HistoryManager
|
||||
from galaxy.managers.users import UserManager
|
||||
from galaxy.model.scoped_session import galaxy_scoped_session
|
||||
@@ -14,7 +18,15 @@ from galaxy.model.store import (
|
||||
ObjectImportTracker,
|
||||
source_to_import_store,
|
||||
)
|
||||
from galaxy.schema.schema import HistoryContentType
|
||||
from galaxy.schema.schema import (
|
||||
ExportObjectMetadata,
|
||||
ExportObjectRequestMetadata,
|
||||
ExportObjectResultMetadata,
|
||||
ExportObjectType,
|
||||
HistoryContentType,
|
||||
ShortTermStoreExportPayload,
|
||||
WriteStoreToPayload,
|
||||
)
|
||||
from galaxy.schema.tasks import (
|
||||
BcoGenerationTaskParametersMixin,
|
||||
GenerateHistoryContentDownload,
|
||||
@@ -61,6 +73,7 @@ class ModelStoreManager:
|
||||
self,
|
||||
app: MinimalManagerApp,
|
||||
history_manager: HistoryManager,
|
||||
export_tracker: StoreExportTracker,
|
||||
sa_session: galaxy_scoped_session,
|
||||
job_manager: JobManager,
|
||||
short_term_storage_monitor: ShortTermStorageMonitor,
|
||||
@@ -70,6 +83,7 @@ class ModelStoreManager:
|
||||
self._sa_session = sa_session
|
||||
self._job_manager = job_manager
|
||||
self._history_manager = history_manager
|
||||
self._export_tracker = export_tracker
|
||||
self._short_term_storage_monitor = short_term_storage_monitor
|
||||
self._user_manager = user_manager
|
||||
|
||||
@@ -95,13 +109,21 @@ class ModelStoreManager:
|
||||
export_files = "symlink" if request.include_files else None
|
||||
include_hidden = request.include_hidden
|
||||
include_deleted = request.include_deleted
|
||||
with storage_context(
|
||||
request.short_term_storage_request_id, self._short_term_storage_monitor
|
||||
) as short_term_storage_target:
|
||||
with model.store.get_export_store_factory(self._app, model_store_format, export_files=export_files)(
|
||||
short_term_storage_target.path
|
||||
) as export_store:
|
||||
export_store.export_history(history, include_hidden=include_hidden, include_deleted=include_deleted)
|
||||
export_metadata = self.set_history_export_request_metadata(request)
|
||||
try:
|
||||
with storage_context(
|
||||
request.short_term_storage_request_id, self._short_term_storage_monitor
|
||||
) as short_term_storage_target:
|
||||
with model.store.get_export_store_factory(self._app, model_store_format, export_files=export_files)(
|
||||
short_term_storage_target.path
|
||||
) as export_store:
|
||||
export_store.export_history(history, include_hidden=include_hidden, include_deleted=include_deleted)
|
||||
self.set_history_export_result_metadata(request.export_association_id, export_metadata, success=True)
|
||||
except Exception as e:
|
||||
self.set_history_export_result_metadata(
|
||||
request.export_association_id, export_metadata, success=False, error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
def prepare_history_content_download(self, request: GenerateHistoryContentDownload):
|
||||
model_store_format = request.model_store_format
|
||||
@@ -188,13 +210,54 @@ class ModelStoreManager:
|
||||
export_files = "symlink" if request.include_files else None
|
||||
target_uri = request.target_uri
|
||||
user_context = self._build_user_context(request.user.user_id)
|
||||
with model.store.get_export_store_factory(
|
||||
self._app, model_store_format, export_files=export_files, user_context=user_context
|
||||
)(target_uri) as export_store:
|
||||
history = self._history_manager.by_id(request.history_id)
|
||||
export_store.export_history(
|
||||
history, include_hidden=request.include_hidden, include_deleted=request.include_deleted
|
||||
export_metadata = self.set_history_export_request_metadata(request)
|
||||
try:
|
||||
with model.store.get_export_store_factory(
|
||||
self._app, model_store_format, export_files=export_files, user_context=user_context
|
||||
)(target_uri) as export_store:
|
||||
history = self._history_manager.by_id(request.history_id)
|
||||
export_store.export_history(
|
||||
history, include_hidden=request.include_hidden, include_deleted=request.include_deleted
|
||||
)
|
||||
self.set_history_export_result_metadata(request.export_association_id, export_metadata, success=True)
|
||||
except Exception as e:
|
||||
self.set_history_export_result_metadata(
|
||||
request.export_association_id, export_metadata, success=False, error=str(e)
|
||||
)
|
||||
raise
|
||||
|
||||
def set_history_export_request_metadata(
|
||||
self, request: Union[WriteHistoryTo, GenerateHistoryDownload]
|
||||
) -> Optional[ExportObjectMetadata]:
|
||||
if request.export_association_id is None:
|
||||
return None
|
||||
request_dict = request.dict()
|
||||
request_payload = (
|
||||
WriteStoreToPayload(**request_dict)
|
||||
if isinstance(request, WriteHistoryTo)
|
||||
else ShortTermStoreExportPayload(**request_dict)
|
||||
)
|
||||
export_metadata = ExportObjectMetadata(
|
||||
request_data=ExportObjectRequestMetadata(
|
||||
object_id=request.history_id,
|
||||
object_type=ExportObjectType.HISTORY,
|
||||
user_id=request.user.user_id,
|
||||
payload=request_payload,
|
||||
),
|
||||
)
|
||||
self._export_tracker.set_export_association_metadata(request.export_association_id, export_metadata)
|
||||
return export_metadata
|
||||
|
||||
def set_history_export_result_metadata(
|
||||
self,
|
||||
export_association_id: Optional[int],
|
||||
export_metadata: Optional[ExportObjectMetadata],
|
||||
success: bool,
|
||||
error: Optional[str] = None,
|
||||
):
|
||||
if export_association_id is not None and export_metadata is not None:
|
||||
export_metadata.result_data = ExportObjectResultMetadata(success=success, error=error)
|
||||
self._export_tracker.set_export_association_metadata(export_association_id, export_metadata)
|
||||
|
||||
def import_model_store(self, request: ImportModelStoreTaskRequest):
|
||||
import_options = ImportOptions(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from abc import (
|
||||
ABCMeta,
|
||||
abstractmethod,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
from celery.result import AsyncResult
|
||||
|
||||
|
||||
class AsyncTasksManager(metaclass=ABCMeta):
|
||||
@abstractmethod
|
||||
def is_ready(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task has executed.
|
||||
|
||||
If the task is still running, pending, or is waiting for retry then `False` is returned.
|
||||
"""
|
||||
|
||||
def is_pending(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task is not done yet."""
|
||||
return not self.is_ready(task_uuid)
|
||||
|
||||
@abstractmethod
|
||||
def is_successful(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task executed successfully."""
|
||||
|
||||
@abstractmethod
|
||||
def has_failed(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task failed."""
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self, task_uuid: UUID) -> str:
|
||||
"""Returns the current state of the task as a string."""
|
||||
|
||||
|
||||
class CeleryAsyncTasksManager(AsyncTasksManager):
|
||||
"""Thin wrapper around Celery tasks AsyncResult queries."""
|
||||
|
||||
def is_ready(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task has executed.
|
||||
|
||||
If the task is still running, pending, or is waiting for retry then `False` is returned.
|
||||
"""
|
||||
return self._get_result(task_uuid).ready()
|
||||
|
||||
def is_successful(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task executed successfully."""
|
||||
return self._get_result(task_uuid).successful()
|
||||
|
||||
def has_failed(self, task_uuid: UUID) -> bool:
|
||||
"""Return `True` if the task failed."""
|
||||
return self._get_result(task_uuid).failed()
|
||||
|
||||
def get_state(self, task_uuid: UUID) -> str:
|
||||
"""Returns the tasks current state as a string."""
|
||||
return str(self._get_result(task_uuid).state)
|
||||
|
||||
def _get_result(self, task_uuid: UUID) -> AsyncResult:
|
||||
return AsyncResult(str(task_uuid))
|
||||
@@ -2308,6 +2308,18 @@ class JobImportHistoryArchive(Base, RepresentById):
|
||||
history = relationship("History")
|
||||
|
||||
|
||||
class StoreExportAssociation(Base, RepresentById):
|
||||
__tablename__ = "store_export_association"
|
||||
__table_args__ = (Index("ix_store_export_object", "object_id", "object_type"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
task_uuid = Column(UUIDType(), index=True, unique=True)
|
||||
create_time = Column(DateTime, default=now)
|
||||
object_type = Column(TrimmedString(32))
|
||||
object_id = Column(Integer)
|
||||
export_metadata = Column(JSONType)
|
||||
|
||||
|
||||
class JobContainerAssociation(Base, RepresentById):
|
||||
__tablename__ = "job_container_association"
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
"""add export association table
|
||||
|
||||
Revision ID: 59e024ceaca1
|
||||
Revises: e0e3bb173ee6
|
||||
Create Date: 2022-10-12 18:02:34.659770
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
)
|
||||
|
||||
from galaxy.model.custom_types import (
|
||||
JSONType,
|
||||
TrimmedString,
|
||||
UUIDType,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "59e024ceaca1"
|
||||
down_revision = "e0e3bb173ee6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
table_name = "store_export_association"
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
table_name,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("task_uuid", UUIDType(), index=True, unique=True),
|
||||
Column("create_time", DateTime),
|
||||
Column("object_type", TrimmedString(32)),
|
||||
Column("object_id", Integer),
|
||||
Column("export_metadata", JSONType),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table(table_name)
|
||||
+60
-12
@@ -61,6 +61,7 @@ JOB_MODEL_CLASS = Literal["Job"]
|
||||
STORED_WORKFLOW_MODEL_CLASS = Literal["StoredWorkflow"]
|
||||
PAGE_MODEL_CLASS = Literal["Page"]
|
||||
|
||||
OptionalNumberT = Optional[Union[int, float]]
|
||||
|
||||
# Generic and common Field annotations that can be reused across models
|
||||
|
||||
@@ -1348,6 +1349,11 @@ class StoreExportPayload(Model):
|
||||
)
|
||||
|
||||
|
||||
class ShortTermStoreExportPayload(StoreExportPayload):
|
||||
short_term_storage_request_id: UUID
|
||||
duration: OptionalNumberT
|
||||
|
||||
|
||||
class BcoGenerationParametersMixin(BaseModel):
|
||||
bco_merge_history_metadata: bool = Field(
|
||||
default=False, description="When reading tags/annotations to generate BCO object include history metadata."
|
||||
@@ -1378,31 +1384,34 @@ class WriteStoreToPayload(StoreExportPayload):
|
||||
)
|
||||
|
||||
|
||||
class JobExportHistoryArchiveModel(Model):
|
||||
id: DecodedDatabaseIdField = Field(
|
||||
class ObjectExportResponseBase(Model):
|
||||
id: EncodedDatabaseIdField = Field(
|
||||
...,
|
||||
title="ID",
|
||||
description="The encoded database ID of the job that is currently processing a particular request.",
|
||||
)
|
||||
job_id: DecodedDatabaseIdField = Field(
|
||||
...,
|
||||
title="Job ID",
|
||||
description="The encoded database ID of the job that generated this history export archive.",
|
||||
description="The encoded database ID of the export request.",
|
||||
)
|
||||
ready: bool = Field(
|
||||
...,
|
||||
title="Ready",
|
||||
description="Whether the export history job has completed successfully and the archive is ready to download",
|
||||
description="Whether the export has completed successfully and the archive is ready",
|
||||
)
|
||||
preparing: bool = Field(
|
||||
...,
|
||||
title="Preparing",
|
||||
description="Whether the history archive is currently being built or in preparation.",
|
||||
description="Whether the archive is currently being built or in preparation.",
|
||||
)
|
||||
up_to_date: bool = Field(
|
||||
...,
|
||||
title="Up to Date",
|
||||
description="False, if a new export archive should be generated for the corresponding history.",
|
||||
description="False, if a new export archive should be generated.",
|
||||
)
|
||||
|
||||
|
||||
class JobExportHistoryArchiveModel(ObjectExportResponseBase):
|
||||
job_id: EncodedDatabaseIdField = Field(
|
||||
...,
|
||||
title="Job ID",
|
||||
description="The encoded database ID of the job that generated this history export archive.",
|
||||
)
|
||||
download_url: RelativeUrl = Field(
|
||||
...,
|
||||
@@ -1421,10 +1430,49 @@ class JobExportHistoryArchiveModel(Model):
|
||||
)
|
||||
|
||||
|
||||
class JobExportHistoryArchiveCollection(Model):
|
||||
class ExportObjectType(str, Enum):
|
||||
"""Types of objects that can be exported."""
|
||||
|
||||
HISTORY = "history"
|
||||
INVOCATION = "invocation"
|
||||
|
||||
|
||||
class ExportObjectRequestMetadata(Model):
|
||||
object_id: EncodedDatabaseIdField
|
||||
object_type: ExportObjectType
|
||||
user_id: Optional[EncodedDatabaseIdField]
|
||||
payload: Union[WriteStoreToPayload, ShortTermStoreExportPayload]
|
||||
|
||||
|
||||
class ExportObjectResultMetadata(Model):
|
||||
success: bool
|
||||
error: Optional[str]
|
||||
|
||||
|
||||
class ExportObjectMetadata(Model):
|
||||
request_data: ExportObjectRequestMetadata
|
||||
result_data: Optional[ExportObjectResultMetadata]
|
||||
|
||||
|
||||
class ObjectExportTaskResponse(ObjectExportResponseBase):
|
||||
task_uuid: UUID4 = Field(
|
||||
...,
|
||||
title="Task ID",
|
||||
description="The identifier of the task processing the export.",
|
||||
)
|
||||
create_time: datetime = CreateTimeField
|
||||
export_metadata: Optional[ExportObjectMetadata]
|
||||
|
||||
|
||||
class JobExportHistoryArchiveListResponse(Model):
|
||||
__root__: List[JobExportHistoryArchiveModel]
|
||||
|
||||
|
||||
class ExportTaskListResponse(Model):
|
||||
__root__: List[ObjectExportTaskResponse]
|
||||
__accept_type__ = "application/vnd.galaxy.task.export+json"
|
||||
|
||||
|
||||
class LabelValuePair(Model):
|
||||
"""Generic Label/Value pair model."""
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from .schema import (
|
||||
DatasetSourceType,
|
||||
HistoryContentType,
|
||||
ModelStoreFormat,
|
||||
StoreExportPayload,
|
||||
ShortTermStoreExportPayload,
|
||||
WriteStoreToPayload,
|
||||
)
|
||||
from ..schema import PdfDocumentType
|
||||
@@ -46,16 +46,15 @@ class RequestUser(BaseModel):
|
||||
# session_id: Optional[str]
|
||||
|
||||
|
||||
class GenerateHistoryDownload(StoreExportPayload):
|
||||
class GenerateHistoryDownload(ShortTermStoreExportPayload):
|
||||
history_id: int
|
||||
short_term_storage_request_id: UUID
|
||||
user: RequestUser
|
||||
export_association_id: Optional[int]
|
||||
|
||||
|
||||
class GenerateHistoryContentDownload(StoreExportPayload):
|
||||
class GenerateHistoryContentDownload(ShortTermStoreExportPayload):
|
||||
content_type: HistoryContentType
|
||||
content_id: int
|
||||
short_term_storage_request_id: UUID
|
||||
user: RequestUser
|
||||
|
||||
|
||||
@@ -63,9 +62,8 @@ class BcoGenerationTaskParametersMixin(BcoGenerationParametersMixin):
|
||||
galaxy_url: str
|
||||
|
||||
|
||||
class GenerateInvocationDownload(StoreExportPayload, BcoGenerationTaskParametersMixin):
|
||||
class GenerateInvocationDownload(ShortTermStoreExportPayload, BcoGenerationTaskParametersMixin):
|
||||
invocation_id: int
|
||||
short_term_storage_request_id: UUID
|
||||
user: RequestUser
|
||||
|
||||
|
||||
@@ -83,6 +81,7 @@ class WriteHistoryContentTo(WriteStoreToPayload):
|
||||
class WriteHistoryTo(WriteStoreToPayload):
|
||||
history_id: int
|
||||
user: RequestUser
|
||||
export_association_id: Optional[int]
|
||||
|
||||
|
||||
class ImportModelStoreTaskRequest(BaseModel):
|
||||
|
||||
@@ -24,6 +24,7 @@ from galaxy.exceptions import (
|
||||
ObjectNotFound,
|
||||
)
|
||||
from galaxy.exceptions.error_codes import error_codes_by_int_code
|
||||
from galaxy.schema.schema import OptionalNumberT
|
||||
from galaxy.util import (
|
||||
directory_hash_id,
|
||||
is_uuid,
|
||||
@@ -35,9 +36,6 @@ now = datetime.utcnow
|
||||
DEFAULT_STORAGE_DURATION = 24 * 60 * 60 # store for a day by default
|
||||
|
||||
|
||||
OptionalNumberT = Optional[Union[int, float]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShortTermStorageConfiguration:
|
||||
short_term_storage_directory: str
|
||||
@@ -68,6 +66,7 @@ class ShortTermStorageTargetSecurity:
|
||||
class ShortTermStorageTarget:
|
||||
request_id: UUID
|
||||
raw_path: str
|
||||
duration: OptionalNumberT = None
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
@@ -167,12 +166,14 @@ class ShortTermStorageManager(ShortTermStorageAllocator, ShortTermStorageMonitor
|
||||
security = ShortTermStorageTargetSecurity()
|
||||
request_id = uuid4()
|
||||
target_directory = self._directory(request_id)
|
||||
target = ShortTermStorageTarget(request_id=request_id, raw_path=str(target_directory / "target"))
|
||||
safe_makedirs(target_directory)
|
||||
duration = duration or self._config.default_storage_duration or DEFAULT_STORAGE_DURATION
|
||||
maximum_storage_duration = self._config.maximum_storage_duration
|
||||
if duration and maximum_storage_duration and duration > maximum_storage_duration:
|
||||
duration = maximum_storage_duration
|
||||
target = ShortTermStorageTarget(
|
||||
request_id=request_id, raw_path=str(target_directory / "target"), duration=duration
|
||||
)
|
||||
safe_makedirs(target_directory)
|
||||
# optimize by placing the deletion time outside JSON as new file...
|
||||
request_info = {
|
||||
"filename": filename,
|
||||
|
||||
@@ -46,6 +46,20 @@ FilterValueQueryParam: Optional[List[str]] = Query(
|
||||
example="2015-01-29",
|
||||
)
|
||||
|
||||
OffsetQueryParam: Optional[int] = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
title="Offset",
|
||||
description="Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item",
|
||||
)
|
||||
|
||||
LimitQueryParam: Optional[int] = Query(
|
||||
default=None,
|
||||
ge=1,
|
||||
title="Limit",
|
||||
description="The maximum number of items to return.",
|
||||
)
|
||||
|
||||
|
||||
def parse_serialization_params(
|
||||
view: Optional[str] = None,
|
||||
@@ -83,18 +97,8 @@ def get_value_filter_query_params(
|
||||
def get_filter_query_params(
|
||||
q: Optional[List[str]] = FilterQueryQueryParam,
|
||||
qv: Optional[List[str]] = FilterValueQueryParam,
|
||||
offset: Optional[int] = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
title="Offset",
|
||||
description="Starts at the beginning skip the first ( offset - 1 ) items and begin returning at the Nth item",
|
||||
),
|
||||
limit: Optional[int] = Query(
|
||||
default=None,
|
||||
ge=1,
|
||||
title="Limit",
|
||||
description="The maximum number of items to return.",
|
||||
),
|
||||
offset: Optional[int] = OffsetQueryParam,
|
||||
limit: Optional[int] = LimitQueryParam,
|
||||
order: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Order",
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import (
|
||||
from fastapi import (
|
||||
Body,
|
||||
Depends,
|
||||
Header,
|
||||
Path,
|
||||
Query,
|
||||
Response,
|
||||
@@ -39,8 +40,9 @@ from galaxy.schema.schema import (
|
||||
CreateHistoryPayload,
|
||||
CustomBuildsMetadataResponse,
|
||||
ExportHistoryArchivePayload,
|
||||
ExportTaskListResponse,
|
||||
HistoryArchiveExportResult,
|
||||
JobExportHistoryArchiveCollection,
|
||||
JobExportHistoryArchiveListResponse,
|
||||
JobImportHistoryResponse,
|
||||
SetSlugPayload,
|
||||
ShareWithPayload,
|
||||
@@ -60,6 +62,8 @@ from galaxy.webapps.galaxy.api import (
|
||||
)
|
||||
from galaxy.webapps.galaxy.api.common import (
|
||||
get_filter_query_params,
|
||||
LimitQueryParam,
|
||||
OffsetQueryParam,
|
||||
query_serialization_params,
|
||||
)
|
||||
from galaxy.webapps.galaxy.services.histories import HistoriesService
|
||||
@@ -179,60 +183,60 @@ class FastAPIHistories:
|
||||
return self.service.show(trans, serialization_params)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}",
|
||||
"/api/histories/{history_id}",
|
||||
name="history",
|
||||
summary="Returns the history with the given ID.",
|
||||
)
|
||||
def show(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
serialization_params: SerializationParams = Depends(query_serialization_params),
|
||||
) -> AnyHistoryView:
|
||||
return self.service.show(trans, serialization_params, id)
|
||||
return self.service.show(trans, serialization_params, history_id)
|
||||
|
||||
@router.post(
|
||||
"/api/histories/{id}/prepare_store_download",
|
||||
"/api/histories/{history_id}/prepare_store_download",
|
||||
summary="Return a short term storage token to monitor download of the history.",
|
||||
)
|
||||
def prepare_store_download(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: StoreExportPayload = Body(...),
|
||||
) -> AsyncFile:
|
||||
return self.service.prepare_download(
|
||||
trans,
|
||||
id,
|
||||
history_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/api/histories/{id}/write_store",
|
||||
"/api/histories/{history_id}/write_store",
|
||||
summary="Prepare history for export-style download and write to supplied URI.",
|
||||
)
|
||||
def write_store(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: WriteStoreToPayload = Body(...),
|
||||
) -> AsyncTaskResultSummary:
|
||||
return self.service.write_store(
|
||||
trans,
|
||||
id,
|
||||
history_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}/citations",
|
||||
"/api/histories/{history_id}/citations",
|
||||
summary="Return all the citations for the tools used to produce the datasets in the history.",
|
||||
)
|
||||
def citations(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> List[Any]:
|
||||
return self.service.citations(trans, id)
|
||||
return self.service.citations(trans, history_id)
|
||||
|
||||
@router.post(
|
||||
"/api/histories",
|
||||
@@ -257,48 +261,48 @@ class FastAPIHistories:
|
||||
return self.service.create(trans, payload, serialization_params)
|
||||
|
||||
@router.delete(
|
||||
"/api/histories/{id}",
|
||||
"/api/histories/{history_id}",
|
||||
summary="Marks the history with the given ID as deleted.",
|
||||
)
|
||||
def delete(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
serialization_params: SerializationParams = Depends(query_serialization_params),
|
||||
purge: bool = Query(default=False),
|
||||
payload: Optional[DeleteHistoryPayload] = Body(default=None),
|
||||
) -> AnyHistoryView:
|
||||
if payload:
|
||||
purge = payload.purge
|
||||
return self.service.delete(trans, id, serialization_params, purge)
|
||||
return self.service.delete(trans, history_id, serialization_params, purge)
|
||||
|
||||
@router.post(
|
||||
"/api/histories/deleted/{id}/undelete",
|
||||
"/api/histories/deleted/{history_id}/undelete",
|
||||
summary="Restores a deleted history with the given ID (that hasn't been purged).",
|
||||
)
|
||||
def undelete(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
serialization_params: SerializationParams = Depends(query_serialization_params),
|
||||
) -> AnyHistoryView:
|
||||
return self.service.undelete(trans, id, serialization_params)
|
||||
return self.service.undelete(trans, history_id, serialization_params)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}",
|
||||
"/api/histories/{history_id}",
|
||||
summary="Updates the values for the history with the given ID.",
|
||||
)
|
||||
def update(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: Any = Body(
|
||||
...,
|
||||
description="Object containing any of the editable fields of the history.",
|
||||
),
|
||||
serialization_params: SerializationParams = Depends(query_serialization_params),
|
||||
) -> AnyHistoryView:
|
||||
return self.service.update(trans, id, payload, serialization_params)
|
||||
return self.service.update(trans, history_id, payload, serialization_params)
|
||||
|
||||
@router.post(
|
||||
"/api/histories/from_store",
|
||||
@@ -324,19 +328,44 @@ class FastAPIHistories:
|
||||
return self.service.create_from_store_async(trans, payload)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}/exports",
|
||||
summary=("Get previous history exports (to links). Effectively returns serialized JEHA objects."),
|
||||
"/api/histories/{history_id}/exports",
|
||||
name="get_history_exports",
|
||||
summary=("Get previous history exports."),
|
||||
responses={
|
||||
200: {
|
||||
"description": "A list of history exports",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"ref": "#/components/schemas/JobExportHistoryArchiveListResponse"},
|
||||
},
|
||||
ExportTaskListResponse.__accept_type__: {
|
||||
"schema": {"ref": "#/components/schemas/ExportTaskListResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def index_exports(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> JobExportHistoryArchiveCollection:
|
||||
exports = self.service.index_exports(trans, id)
|
||||
return JobExportHistoryArchiveCollection.construct(__root__=exports)
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
limit: Optional[int] = LimitQueryParam,
|
||||
offset: Optional[int] = OffsetQueryParam,
|
||||
accept: str = Header(default="application/json", include_in_schema=False),
|
||||
) -> Union[JobExportHistoryArchiveListResponse, ExportTaskListResponse]:
|
||||
"""
|
||||
By default the legacy job-based history exports (jeha) are returned.
|
||||
|
||||
Change the `accept` content type header to return the new task-based history exports.
|
||||
"""
|
||||
use_tasks = accept == ExportTaskListResponse.__accept_type__
|
||||
exports = self.service.index_exports(trans, history_id, use_tasks, limit, offset)
|
||||
if use_tasks:
|
||||
return ExportTaskListResponse(__root__=exports)
|
||||
return JobExportHistoryArchiveListResponse(__root__=exports)
|
||||
|
||||
@router.put( # PUT instead of POST because multiple requests should just result in one object being created.
|
||||
"/api/histories/{id}/exports",
|
||||
"/api/histories/{history_id}/exports",
|
||||
summary=("Start job (if needed) to create history export for corresponding history."),
|
||||
responses={
|
||||
200: {
|
||||
@@ -346,12 +375,13 @@ class FastAPIHistories:
|
||||
"description": "The exported archive file is not ready yet.",
|
||||
},
|
||||
},
|
||||
deprecated=True,
|
||||
)
|
||||
def archive_export(
|
||||
self,
|
||||
response: Response,
|
||||
trans=DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: Optional[ExportHistoryArchivePayload] = Body(None),
|
||||
) -> HistoryArchiveExportResult:
|
||||
"""This will start a job to create a history export archive.
|
||||
@@ -363,14 +393,17 @@ class FastAPIHistories:
|
||||
If the history will be exported to a `directory_uri`, instead of returning the download
|
||||
link information, the Job ID will be returned so it can be queried to determine when
|
||||
the file has been written.
|
||||
|
||||
**Deprecation notice**: Please use `/api/histories/{id}/prepare_store_download` or
|
||||
`/api/histories/{id}/write_store` instead.
|
||||
"""
|
||||
export_result, ready = self.service.archive_export(trans, id, payload)
|
||||
export_result, ready = self.service.archive_export(trans, history_id, payload)
|
||||
if not ready:
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return export_result
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}/exports/{jeha_id}",
|
||||
"/api/histories/{history_id}/exports/{jeha_id}",
|
||||
name="history_archive_download",
|
||||
summary=("If ready and available, return raw contents of exported history as a downloadable archive."),
|
||||
response_class=GalaxyFileResponse,
|
||||
@@ -379,19 +412,23 @@ class FastAPIHistories:
|
||||
"description": "The archive file containing the History.",
|
||||
}
|
||||
},
|
||||
deprecated=True,
|
||||
)
|
||||
def archive_download(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
jeha_id: Union[DecodedDatabaseIdField, LatestLiteral] = JehaIDPathParam,
|
||||
):
|
||||
"""
|
||||
See ``PUT /api/histories/{id}/exports`` to initiate the creation
|
||||
of the history export - when ready, that route will return 200 status
|
||||
code (instead of 202) and this route can be used to download the archive.
|
||||
|
||||
**Deprecation notice**: Please use `/api/histories/{id}/prepare_store_download` or
|
||||
`/api/histories/{id}/write_store` instead.
|
||||
"""
|
||||
jeha = self.service.get_ready_history_export(trans, id, jeha_id)
|
||||
jeha = self.service.get_ready_history_export(trans, history_id, jeha_id)
|
||||
media_type = self.service.get_archive_media_type(jeha)
|
||||
file_path = self.service.get_archive_download_path(trans, jeha)
|
||||
return GalaxyFileResponse(
|
||||
@@ -401,100 +438,100 @@ class FastAPIHistories:
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}/custom_builds_metadata",
|
||||
"/api/histories/{history_id}/custom_builds_metadata",
|
||||
summary="Returns meta data for custom builds.",
|
||||
)
|
||||
def get_custom_builds_metadata(
|
||||
self,
|
||||
trans: ProvidesHistoryContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> CustomBuildsMetadataResponse:
|
||||
return self.service.get_custom_builds_metadata(trans, id)
|
||||
return self.service.get_custom_builds_metadata(trans, history_id)
|
||||
|
||||
@router.get(
|
||||
"/api/histories/{id}/sharing",
|
||||
"/api/histories/{history_id}/sharing",
|
||||
summary="Get the current sharing status of the given item.",
|
||||
)
|
||||
def sharing(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> SharingStatus:
|
||||
"""Return the sharing status of the item."""
|
||||
return self.service.shareable_service.sharing(trans, id)
|
||||
return self.service.shareable_service.sharing(trans, history_id)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/enable_link_access",
|
||||
"/api/histories/{history_id}/enable_link_access",
|
||||
summary="Makes this item accessible by a URL link.",
|
||||
)
|
||||
def enable_link_access(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> SharingStatus:
|
||||
"""Makes this item accessible by a URL link and return the current sharing status."""
|
||||
return self.service.shareable_service.enable_link_access(trans, id)
|
||||
return self.service.shareable_service.enable_link_access(trans, history_id)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/disable_link_access",
|
||||
"/api/histories/{history_id}/disable_link_access",
|
||||
summary="Makes this item inaccessible by a URL link.",
|
||||
)
|
||||
def disable_link_access(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> SharingStatus:
|
||||
"""Makes this item inaccessible by a URL link and return the current sharing status."""
|
||||
return self.service.shareable_service.disable_link_access(trans, id)
|
||||
return self.service.shareable_service.disable_link_access(trans, history_id)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/publish",
|
||||
"/api/histories/{history_id}/publish",
|
||||
summary="Makes this item public and accessible by a URL link.",
|
||||
)
|
||||
def publish(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> SharingStatus:
|
||||
"""Makes this item publicly available by a URL link and return the current sharing status."""
|
||||
return self.service.shareable_service.publish(trans, id)
|
||||
return self.service.shareable_service.publish(trans, history_id)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/unpublish",
|
||||
"/api/histories/{history_id}/unpublish",
|
||||
summary="Removes this item from the published list.",
|
||||
)
|
||||
def unpublish(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
) -> SharingStatus:
|
||||
"""Removes this item from the published list and return the current sharing status."""
|
||||
return self.service.shareable_service.unpublish(trans, id)
|
||||
return self.service.shareable_service.unpublish(trans, history_id)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/share_with_users",
|
||||
"/api/histories/{history_id}/share_with_users",
|
||||
summary="Share this item with specific users.",
|
||||
)
|
||||
def share_with_users(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: ShareWithPayload = Body(...),
|
||||
) -> ShareWithStatus:
|
||||
"""Shares this item with specific users and return the current sharing status."""
|
||||
return self.service.shareable_service.share_with_users(trans, id, payload)
|
||||
return self.service.shareable_service.share_with_users(trans, history_id, payload)
|
||||
|
||||
@router.put(
|
||||
"/api/histories/{id}/slug",
|
||||
"/api/histories/{history_id}/slug",
|
||||
summary="Set a new slug for this shared item.",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def set_slug(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
history_id: DecodedDatabaseIdField = HistoryIDPathParam,
|
||||
payload: SetSlugPayload = Body(...),
|
||||
):
|
||||
"""Sets a new slug to access this item by URL. The new slug must be unique."""
|
||||
self.service.shareable_service.set_slug(trans, id, payload)
|
||||
self.service.shareable_service.set_slug(trans, history_id, payload)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
API Controller providing experimental access to Celery Task State.
|
||||
"""
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from . import Router
|
||||
from galaxy.managers.tasks import AsyncTasksManager
|
||||
from . import (
|
||||
depends,
|
||||
Router,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,11 +17,12 @@ router = Router(tags=["tasks"])
|
||||
|
||||
@router.cbv
|
||||
class FastAPITasks:
|
||||
manager: AsyncTasksManager = depends(AsyncTasksManager) # type: ignore[type-abstract]
|
||||
|
||||
@router.get(
|
||||
"/api/tasks/{task_id}/state",
|
||||
summary="Determine state of task ID",
|
||||
response_description="String indicating task state.",
|
||||
)
|
||||
def state(self, task_id: str) -> str:
|
||||
res = AsyncResult(str(task_id))
|
||||
return str(res.state)
|
||||
def state(self, task_id: UUID) -> str:
|
||||
return self.manager.get_state(task_id)
|
||||
|
||||
@@ -216,7 +216,6 @@ class SharedHistoryListGrid(grids.Grid):
|
||||
|
||||
class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesItemRatings):
|
||||
history_manager: histories.HistoryManager = depends(histories.HistoryManager)
|
||||
history_export_view: histories.HistoryExportView = depends(histories.HistoryExportView)
|
||||
history_serializer: histories.HistorySerializer = depends(histories.HistorySerializer)
|
||||
slug_builder: SlugBuilder = depends(SlugBuilder)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from galaxy.managers.citations import CitationsManager
|
||||
from galaxy.managers.context import ProvidesHistoryContext
|
||||
from galaxy.managers.histories import (
|
||||
HistoryDeserializer,
|
||||
HistoryExportView,
|
||||
HistoryExportManager,
|
||||
HistoryFilters,
|
||||
HistoryManager,
|
||||
HistorySerializer,
|
||||
@@ -100,7 +100,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
serializer: HistorySerializer,
|
||||
deserializer: HistoryDeserializer,
|
||||
citations_manager: CitationsManager,
|
||||
history_export_view: HistoryExportView,
|
||||
history_export_manager: HistoryExportManager,
|
||||
filters: HistoryFilters,
|
||||
short_term_storage_allocator: ShortTermStorageAllocator,
|
||||
):
|
||||
@@ -110,7 +110,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
self.serializer = serializer
|
||||
self.deserializer = deserializer
|
||||
self.citations_manager = citations_manager
|
||||
self.history_export_view = history_export_view
|
||||
self.history_export_manager = history_export_manager
|
||||
self.filters = filters
|
||||
self.shareable_service = ShareableService(self.manager, self.serializer)
|
||||
self.short_term_storage_allocator = short_term_storage_allocator
|
||||
@@ -312,13 +312,10 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
Returns detailed information about the history with the given encoded `id`. If no `id` is
|
||||
provided, then the most recently used history will be returned.
|
||||
|
||||
:type id: an optional encoded id string
|
||||
:param id: the encoded id of the history to query or None to use the most recently used
|
||||
:param history_id: the encoded id of the history to query or None to use the most recently used
|
||||
|
||||
:type serialization_params: dictionary
|
||||
:param serialization_params: contains the optional `view`, `keys` and `default_view` for serialization
|
||||
|
||||
:rtype: dictionary
|
||||
:returns: detailed history information
|
||||
"""
|
||||
if history_id is None: # By default display the most recent history
|
||||
@@ -338,49 +335,60 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
history.name,
|
||||
payload.model_store_format,
|
||||
)
|
||||
export_association = self.history_export_manager.create_export_association(history.id)
|
||||
request = GenerateHistoryDownload(
|
||||
history_id=history.id,
|
||||
short_term_storage_request_id=short_term_storage_target.request_id,
|
||||
duration=short_term_storage_target.duration,
|
||||
user=trans.async_request_user,
|
||||
export_association_id=export_association.id,
|
||||
**payload.dict(),
|
||||
)
|
||||
result = prepare_history_download.delay(request=request)
|
||||
return AsyncFile(storage_request_id=short_term_storage_target.request_id, task=async_task_summary(result))
|
||||
task_summary = async_task_summary(result)
|
||||
export_association.task_uuid = task_summary.id
|
||||
trans.sa_session.flush()
|
||||
return AsyncFile(storage_request_id=short_term_storage_target.request_id, task=task_summary)
|
||||
|
||||
def write_store(
|
||||
self, trans: ProvidesHistoryContext, history_id: DecodedDatabaseIdField, payload: WriteStoreToPayload
|
||||
) -> AsyncTaskResultSummary:
|
||||
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
|
||||
request = WriteHistoryTo(user=trans.async_request_user, history_id=history.id, **payload.dict())
|
||||
export_association = self.history_export_manager.create_export_association(history.id)
|
||||
request = WriteHistoryTo(
|
||||
user=trans.async_request_user,
|
||||
history_id=history.id,
|
||||
export_association_id=export_association.id,
|
||||
**payload.dict(),
|
||||
)
|
||||
result = write_history_to.delay(request=request)
|
||||
return async_task_summary(result)
|
||||
task_summary = async_task_summary(result)
|
||||
export_association.task_uuid = task_summary.id
|
||||
trans.sa_session.flush()
|
||||
return task_summary
|
||||
|
||||
def update(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
id: DecodedDatabaseIdField,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
payload,
|
||||
serialization_params: SerializationParams,
|
||||
):
|
||||
"""Updates the values for the history with the given ``id``
|
||||
|
||||
:type id: str
|
||||
:param id: the encoded id of the history to update
|
||||
:type payload: dict
|
||||
:param history_id: the encoded id of the history to update
|
||||
:param payload: a dictionary containing any or all the
|
||||
fields in :func:`galaxy.model.History.to_dict` and/or the following:
|
||||
|
||||
* annotation: an annotation for the history
|
||||
|
||||
:type serialization_params: dictionary
|
||||
:param serialization_params: contains the optional `view`, `keys` and `default_view` for serialization
|
||||
|
||||
:rtype: dict
|
||||
:returns: an error object if an error occurred or a dictionary containing
|
||||
any values that were different from the original and, therefore, updated
|
||||
"""
|
||||
# TODO: PUT /api/histories/{encoded_history_id} payload = { rating: rating } (w/ no security checks)
|
||||
history = self.manager.get_owned(id, trans.user, current_history=trans.history)
|
||||
history = self.manager.get_owned(history_id, trans.user, current_history=trans.history)
|
||||
self.deserializer.deserialize(history, payload, user=trans.user, trans=trans)
|
||||
return self._serialize_history(trans, history, serialization_params)
|
||||
|
||||
@@ -397,12 +405,6 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
|
||||
You can purge a history, removing all it's datasets from disk (if unshared),
|
||||
by passing in ``purge=True`` in the url.
|
||||
|
||||
:type serialization_params: dictionary
|
||||
:param serialization_params: contains the optional `view`, `keys` and `default_view` for serialization
|
||||
|
||||
:rtype: dict
|
||||
:returns: the deleted or purged history
|
||||
"""
|
||||
history = self.manager.get_owned(history_id, trans.user, current_history=trans.history)
|
||||
if purge:
|
||||
@@ -419,13 +421,10 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
):
|
||||
"""Undelete history (that hasn't been purged) with the given ``id``
|
||||
|
||||
:type id: str
|
||||
:param id: the encoded id of the history to undelete
|
||||
:param history_id: the encoded id of the history to undelete
|
||||
|
||||
:type serialization_params: dictionary
|
||||
:param serialization_params: contains the optional `view`, `keys` and `default_view` for serialization
|
||||
|
||||
:rtype: dict
|
||||
:returns: the undeleted history
|
||||
"""
|
||||
history = self.manager.get_owned(history_id, trans.user, current_history=trans.history)
|
||||
@@ -497,32 +496,35 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
tool_ids.add(tool_id)
|
||||
return [citation.to_dict("bibtex") for citation in self.citations_manager.citations_for_tool_ids(tool_ids)]
|
||||
|
||||
def index_exports(self, trans: ProvidesHistoryContext, id: DecodedDatabaseIdField):
|
||||
"""
|
||||
Get previous history exports (to links). Effectively returns serialized
|
||||
JEHA objects.
|
||||
"""
|
||||
return self.history_export_view.get_exports(trans, id)
|
||||
def index_exports(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
use_tasks: bool = False,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
):
|
||||
if use_tasks:
|
||||
return self.history_export_manager.get_task_exports(trans, history_id, limit, offset)
|
||||
return self.history_export_manager.get_exports(trans, history_id)
|
||||
|
||||
def archive_export(
|
||||
self,
|
||||
trans,
|
||||
id: DecodedDatabaseIdField,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
payload: Optional[ExportHistoryArchivePayload] = None,
|
||||
) -> Tuple[HistoryArchiveExportResult, bool]:
|
||||
"""
|
||||
start job (if needed) to create history export for corresponding
|
||||
history.
|
||||
|
||||
:type id: str
|
||||
:param id: the encoded id of the history to export
|
||||
:param history_id: the encoded id of the history to export
|
||||
|
||||
:rtype: dict
|
||||
:returns: object containing url to fetch export from.
|
||||
"""
|
||||
if payload is None:
|
||||
payload = ExportHistoryArchivePayload()
|
||||
history = self.manager.get_accessible(id, trans.user, current_history=trans.history)
|
||||
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
|
||||
jeha = history.latest_export
|
||||
exporting_to_uri = payload.directory_uri
|
||||
# always just issue a new export when exporting to a URI.
|
||||
@@ -551,12 +553,12 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
return (JobIdResponse.construct(job_id=job.id), ready)
|
||||
|
||||
if up_to_date and jeha.ready:
|
||||
serialized_jeha = self.history_export_view.serialize(trans, id, jeha)
|
||||
serialized_jeha = self.history_export_manager.serialize(trans, history_id, jeha)
|
||||
return (JobExportHistoryArchiveModel.construct(**serialized_jeha), ready)
|
||||
else:
|
||||
# Valid request, just resource is not ready yet.
|
||||
if jeha:
|
||||
serialized_jeha = self.history_export_view.serialize(trans, id, jeha)
|
||||
serialized_jeha = self.history_export_manager.serialize(trans, history_id, jeha)
|
||||
return (JobExportHistoryArchiveModel.construct(**serialized_jeha), ready)
|
||||
else:
|
||||
assert job is not None, "logic error, don't have a jeha or a job"
|
||||
@@ -565,12 +567,12 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
def get_ready_history_export(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
id: DecodedDatabaseIdField,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
jeha_id: Union[DecodedDatabaseIdField, LatestLiteral],
|
||||
) -> model.JobExportHistoryArchive:
|
||||
"""Returns the exported history archive information if it's ready
|
||||
or raises an exception if not."""
|
||||
return self.history_export_view.get_ready_jeha(trans, id, jeha_id)
|
||||
return self.history_export_manager.get_ready_jeha(trans, history_id, jeha_id)
|
||||
|
||||
def get_archive_download_path(
|
||||
self,
|
||||
@@ -594,24 +596,24 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
|
||||
def legacy_archive_download(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
id: DecodedDatabaseIdField,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
jeha_id: DecodedDatabaseIdField,
|
||||
):
|
||||
"""
|
||||
If ready and available, return raw contents of exported history.
|
||||
"""
|
||||
jeha = self.history_export_view.get_ready_jeha(trans, id, jeha_id)
|
||||
jeha = self.history_export_manager.get_ready_jeha(trans, history_id, jeha_id)
|
||||
return self.manager.legacy_serve_ready_history_export(trans, jeha)
|
||||
|
||||
def get_custom_builds_metadata(
|
||||
self,
|
||||
trans: ProvidesHistoryContext,
|
||||
id: DecodedDatabaseIdField,
|
||||
history_id: DecodedDatabaseIdField,
|
||||
) -> CustomBuildsMetadataResponse:
|
||||
"""
|
||||
Returns metadata for custom builds.
|
||||
"""
|
||||
history = self.manager.get_accessible(id, trans.user, current_history=trans.history)
|
||||
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
|
||||
installed_builds = []
|
||||
for build in glob.glob(os.path.join(trans.app.config.len_file_path, "*.len")):
|
||||
installed_builds.append(os.path.basename(build).split(".len")[0])
|
||||
|
||||
@@ -1378,6 +1378,20 @@ class BaseDatasetPopulator(BasePopulator):
|
||||
task_ok = self.wait_on_task_id(task_id)
|
||||
assert task_ok, f"Task: Import history from {target_uri} failed"
|
||||
|
||||
def download_history_to_store(self, history_id: str, extension: str = "tgz", serve_file: bool = False):
|
||||
url = f"histories/{history_id}/prepare_store_download"
|
||||
download_response = self._post(url, dict(include_files=False, model_store_format=extension), json=True)
|
||||
storage_request_id = self.assert_download_request_ok(download_response)
|
||||
self.wait_for_download_ready(storage_request_id)
|
||||
if serve_file:
|
||||
return self._get_to_tempfile(f"short_term_storage/{storage_request_id}")
|
||||
|
||||
def get_history_export_tasks(self, history_id: str):
|
||||
headers = {"accept": "application/vnd.galaxy.task.export+json"}
|
||||
response = self._get(f"histories/{history_id}/exports", headers=headers)
|
||||
api_asserts.assert_status_code_is_ok(response)
|
||||
return response.json()
|
||||
|
||||
|
||||
class GalaxyInteractorHttpMixin:
|
||||
galaxy_interactor: ApiTestInteractor
|
||||
|
||||
@@ -119,6 +119,63 @@ class TestImportExportHistoryViaTasksIntegration(
|
||||
assert imported_history_id != history_id
|
||||
assert imported_history["name"] == history_name
|
||||
|
||||
def test_export_tracking(self):
|
||||
history_name = f"for_export_tracking_{uuid4()}"
|
||||
history_id = self.dataset_populator.setup_history_for_export_testing(history_name)
|
||||
model_store_format = "rocrate.zip"
|
||||
target_uri = f"gxfiles://posix_test/history.{model_store_format}"
|
||||
|
||||
# Initially there are no export records
|
||||
export_records = self.dataset_populator.get_history_export_tasks(history_id)
|
||||
assert len(export_records) == 0
|
||||
|
||||
# Export to a remote file source
|
||||
self.dataset_populator.export_history_to_uri_async(history_id, target_uri, model_store_format)
|
||||
export_records = self.dataset_populator.get_history_export_tasks(history_id)
|
||||
assert len(export_records) == 1
|
||||
last_record = export_records[0]
|
||||
self._wait_for_export_task_on_record(last_record)
|
||||
assert last_record["ready"] is True
|
||||
|
||||
# Check metadata
|
||||
assert last_record["up_to_date"] is True
|
||||
assert last_record["export_metadata"]
|
||||
assert last_record["export_metadata"]["request_data"]
|
||||
assert last_record["export_metadata"]["request_data"]["payload"]
|
||||
assert last_record["export_metadata"]["request_data"]["payload"]["target_uri"] == target_uri
|
||||
assert last_record["export_metadata"]["result_data"]
|
||||
assert last_record["export_metadata"]["result_data"]["success"] is True
|
||||
|
||||
# Track temporal direct download
|
||||
self.dataset_populator.download_history_to_store(history_id)
|
||||
# We should have two records now
|
||||
export_records = self.dataset_populator.get_history_export_tasks(history_id)
|
||||
assert len(export_records) == 2
|
||||
last_record = export_records[0]
|
||||
self._wait_for_export_task_on_record(last_record)
|
||||
assert last_record["ready"] is True
|
||||
|
||||
# Check metadata
|
||||
assert last_record["up_to_date"] is True
|
||||
assert last_record["export_metadata"]
|
||||
assert last_record["export_metadata"]["request_data"]
|
||||
assert last_record["export_metadata"]["request_data"]["payload"]
|
||||
assert last_record["export_metadata"]["request_data"]["payload"]["short_term_storage_request_id"] is not None
|
||||
assert last_record["export_metadata"]["request_data"]["payload"]["duration"] is not None
|
||||
assert last_record["export_metadata"]["result_data"]
|
||||
assert last_record["export_metadata"]["result_data"]["success"] is True
|
||||
|
||||
# After modifying the history the records must be outdated
|
||||
self.dataset_populator.new_dataset(history_id)
|
||||
export_records = self.dataset_populator.get_history_export_tasks(history_id)
|
||||
for record in export_records:
|
||||
assert record["up_to_date"] is False
|
||||
|
||||
def _wait_for_export_task_on_record(self, record):
|
||||
if record["preparing"]:
|
||||
assert record["task_uuid"]
|
||||
self.dataset_populator.wait_on_task_id(record["task_uuid"])
|
||||
|
||||
|
||||
class TestImportExportHistoryContentsViaTasksIntegration(IntegrationTestCase, UsesCeleryTasks):
|
||||
dataset_populator: DatasetPopulator
|
||||
|
||||
+6
-2
@@ -1,12 +1,16 @@
|
||||
from .framework import (
|
||||
selenium_test,
|
||||
SeleniumTestCase,
|
||||
SeleniumIntegrationTestCase,
|
||||
)
|
||||
|
||||
|
||||
class TestHistoryExport(SeleniumTestCase):
|
||||
class TestHistoryExport(SeleniumIntegrationTestCase):
|
||||
ensure_registered = True
|
||||
|
||||
@classmethod
|
||||
def handle_galaxy_config_kwds(cls, config):
|
||||
config["enable_celery_tasks"] = False
|
||||
|
||||
@selenium_test
|
||||
def test_history_export(self):
|
||||
self.perform_upload_of_pasted_content("my cool content")
|
||||
@@ -6,12 +6,16 @@ from .framework import (
|
||||
)
|
||||
|
||||
|
||||
class TestHistoryImportExportFtpSeleniumIntegration(SeleniumIntegrationTestCase):
|
||||
class TestHistoryImportExportFtpSeleniumIntegrationBase(SeleniumIntegrationTestCase):
|
||||
ensure_registered = True
|
||||
|
||||
@classmethod
|
||||
def handle_galaxy_config_kwds(cls, config):
|
||||
super().handle_galaxy_config_kwds(config)
|
||||
cls.setup_ftp_config(config)
|
||||
|
||||
@classmethod
|
||||
def setup_ftp_config(cls, config):
|
||||
ftp_dir = cls.ftp_dir()
|
||||
os.makedirs(ftp_dir)
|
||||
config["ftp_upload_dir"] = ftp_dir
|
||||
@@ -21,12 +25,29 @@ class TestHistoryImportExportFtpSeleniumIntegration(SeleniumIntegrationTestCase)
|
||||
def ftp_dir(cls):
|
||||
return cls.temp_config_dir("ftp")
|
||||
|
||||
@selenium_test
|
||||
def test_history_import_export(self):
|
||||
def create_user_ftp_dir(self):
|
||||
email = self.get_logged_in_user()["email"]
|
||||
user_ftp_dir = os.path.join(self.ftp_dir(), email)
|
||||
os.makedirs(user_ftp_dir)
|
||||
|
||||
def _export_to_ftp_with_filename(self, filename: str):
|
||||
self.components.history_export.directory_input.wait_for_and_click()
|
||||
self.components.files_dialog.ftp_label.wait_for_and_click()
|
||||
self.components.upload.file_dialog_ok.wait_for_and_click()
|
||||
self.components.history_export.name_input.wait_for_and_send_keys(filename)
|
||||
self.components.history_export.export_button.wait_for_and_click()
|
||||
|
||||
|
||||
class TestHistoryImportExportFtpSeleniumIntegration(TestHistoryImportExportFtpSeleniumIntegrationBase):
|
||||
@classmethod
|
||||
def handle_galaxy_config_kwds(cls, config):
|
||||
# Setup only FTP without Celery
|
||||
cls.setup_ftp_config(config)
|
||||
|
||||
@selenium_test
|
||||
def test_history_import_export(self):
|
||||
self.create_user_ftp_dir()
|
||||
|
||||
gx_selenium_context = self
|
||||
gx_selenium_context.perform_upload_of_pasted_content("my cool content")
|
||||
gx_selenium_context.history_panel_wait_for_hid_ok(1)
|
||||
@@ -39,14 +60,8 @@ class TestHistoryImportExportFtpSeleniumIntegration(SeleniumIntegrationTestCase)
|
||||
history_export.export_link.wait_for_visible()
|
||||
history_export.tab_export_to_file.wait_for_and_click()
|
||||
history_export.export_link.wait_for_absent_or_hidden()
|
||||
history_export.directory_input.wait_for_and_click()
|
||||
|
||||
# open directory by clicking on its name
|
||||
files_dialog.ftp_label.wait_for_and_click()
|
||||
self.components.upload.file_dialog_ok.wait_for_and_click()
|
||||
|
||||
history_export.name_input.wait_for_and_send_keys("my_export.tar.gz")
|
||||
history_export.export_button.wait_for_and_click()
|
||||
self._export_to_ftp_with_filename("my_export.tar.gz")
|
||||
|
||||
history_export.running.wait_for_visible()
|
||||
history_export.running.wait_for_absent(wait_type=gx_selenium_context.wait_types.JOB_COMPLETION)
|
||||
@@ -68,3 +83,60 @@ class TestHistoryImportExportFtpSeleniumIntegration(SeleniumIntegrationTestCase)
|
||||
gx_selenium_context.navigate_to_histories_page()
|
||||
newest_history_name = gx_selenium_context.histories_get_history_names()[0]
|
||||
assert newest_history_name.startswith("imported from archive")
|
||||
|
||||
|
||||
class TestHistoryImportExportFtpSeleniumIntegrationWithTasks(TestHistoryImportExportFtpSeleniumIntegrationBase):
|
||||
@classmethod
|
||||
def handle_galaxy_config_kwds(cls, config):
|
||||
super().handle_galaxy_config_kwds(config)
|
||||
|
||||
@selenium_test
|
||||
def test_history_export_tracking(self):
|
||||
self.create_user_ftp_dir()
|
||||
|
||||
self.perform_upload_of_pasted_content("my cool content")
|
||||
self.history_panel_wait_for_hid_ok(1)
|
||||
|
||||
self.click_history_option_export_to_file()
|
||||
|
||||
# Export to direct download link
|
||||
export_format = "rocrate.zip"
|
||||
history_export_tasks = self.components.history_export_tasks
|
||||
history_export_tasks.direct_download.wait_for_and_click()
|
||||
|
||||
self._verify_last_export_record(expected_format=export_format, is_download=True)
|
||||
|
||||
# Change export format
|
||||
export_format = "tar.gz"
|
||||
history_export_tasks.toggle_options_link.wait_for_and_click()
|
||||
history_export_tasks.export_format_selector.wait_for_visible()
|
||||
history_export_tasks.select_format(format=export_format).wait_for_and_click()
|
||||
history_export_tasks.toggle_options_link.wait_for_and_click()
|
||||
|
||||
# Export to FTP file source
|
||||
history_export_tasks.file_source_tab.wait_for_and_click()
|
||||
self._export_to_ftp_with_filename("my_export.tar.gz")
|
||||
|
||||
self._verify_last_export_record(expected_format=export_format)
|
||||
|
||||
def _verify_last_export_record(
|
||||
self, expected_format: str, expect_up_to_date: bool = True, is_download: bool = False
|
||||
):
|
||||
last_export_record = self.components.last_export_record
|
||||
last_export_record.preparing_export.wait_for_visible()
|
||||
last_export_record.preparing_export.wait_for_absent(wait_type=self.wait_types.DATABASE_OPERATION)
|
||||
|
||||
last_export_record.details.wait_for_visible()
|
||||
format_element = last_export_record.export_format.wait_for_visible()
|
||||
assert format_element.text == expected_format
|
||||
|
||||
if expect_up_to_date:
|
||||
last_export_record.up_to_date_icon.wait_for_visible()
|
||||
else:
|
||||
last_export_record.outdated_icon.wait_for_visible()
|
||||
|
||||
if is_download:
|
||||
last_export_record.expiration_warning_icon.wait_for_visible()
|
||||
last_export_record.download_btn.wait_for_visible()
|
||||
else:
|
||||
last_export_record.reimport_btn.wait_for_visible()
|
||||
|
||||
Reference in New Issue
Block a user