Merge pull request #14839 from davelopez/history_export_tracking

Add task-based history export tracking
This commit is contained in:
Marius van den Beek
2022-12-12 09:42:44 +01:00
committed by GitHub
37 changed files with 2573 additions and 625 deletions
@@ -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);
});
});
+9 -1
View File
@@ -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,
});
}
+34
View File
@@ -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),
};
}
+123
View File
@@ -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),
};
}
+7 -4
View File
@@ -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,
/**
+4 -1
View File
@@ -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,
},
{
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -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'
+8
View File
@@ -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)
+6
View File
@@ -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.
+64
View File
@@ -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()
+48 -7
View File
@@ -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
+78 -15
View File
@@ -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(
+58
View File
@@ -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))
+12
View File
@@ -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"
@@ -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
View File
@@ -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."""
+6 -7
View File
@@ -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,
+16 -12
View File
@@ -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",
+95 -58
View File
@@ -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)
+10 -6
View File
@@ -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)
+48 -46
View File
@@ -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])
+14
View File
@@ -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
@@ -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()