Merge pull request #18234 from davelopez/24.0_drop_restriction_switch_immutable_histories

[24.1] Drop restriction to switch to immutable histories
This commit is contained in:
Marius van den Beek
2024-05-29 11:22:29 +02:00
committed by GitHub
23 changed files with 364 additions and 89 deletions
+113
View File
@@ -0,0 +1,113 @@
import {
type AnonymousUser,
type AnyHistory,
type HistorySummary,
type HistorySummaryExtended,
isRegisteredUser,
type User,
userOwnsHistory,
} from ".";
const REGISTERED_USER_ID = "fake-user-id";
const ANOTHER_USER_ID = "another-fake-user-id";
const ANONYMOUS_USER_ID = null;
const REGISTERED_USER: User = {
id: REGISTERED_USER_ID,
email: "test@mail.test",
tags_used: [],
isAnonymous: false,
total_disk_usage: 0,
};
const ANONYMOUS_USER: AnonymousUser = {
isAnonymous: true,
};
const SESSIONLESS_USER = null;
function createFakeHistory<T>(historyId: string = "fake-id", user_id?: string | null): T {
const history: AnyHistory = {
id: historyId,
name: "test",
model_class: "History",
deleted: false,
archived: false,
purged: false,
published: false,
annotation: null,
update_time: "2021-09-01T00:00:00.000Z",
tags: [],
url: `/history/${historyId}`,
contents_active: { active: 0, deleted: 0, hidden: 0 },
count: 0,
size: 0,
};
if (user_id !== undefined) {
(history as HistorySummaryExtended).user_id = user_id;
}
return history as T;
}
const HISTORY_OWNED_BY_REGISTERED_USER = createFakeHistory<HistorySummaryExtended>("1234", REGISTERED_USER_ID);
const HISTORY_OWNED_BY_ANOTHER_USER = createFakeHistory<HistorySummaryExtended>("5678", ANOTHER_USER_ID);
const HISTORY_OWNED_BY_ANONYMOUS_USER = createFakeHistory<HistorySummaryExtended>("1234", ANONYMOUS_USER_ID);
const HISTORY_SUMMARY_WITHOUT_USER_ID = createFakeHistory<HistorySummary>("1234");
describe("API Types Helpers", () => {
describe("isRegisteredUser", () => {
it("should return true for a registered user", () => {
expect(isRegisteredUser(REGISTERED_USER)).toBe(true);
});
it("should return false for an anonymous user", () => {
expect(isRegisteredUser(ANONYMOUS_USER)).toBe(false);
});
it("should return false for sessionless users", () => {
expect(isRegisteredUser(SESSIONLESS_USER)).toBe(false);
});
});
describe("isAnonymousUser", () => {
it("should return true for an anonymous user", () => {
expect(isRegisteredUser(ANONYMOUS_USER)).toBe(false);
});
it("should return false for a registered user", () => {
expect(isRegisteredUser(REGISTERED_USER)).toBe(true);
});
it("should return false for sessionless users", () => {
expect(isRegisteredUser(SESSIONLESS_USER)).toBe(false);
});
});
describe("userOwnsHistory", () => {
it("should return true for a registered user owning the history", () => {
expect(userOwnsHistory(REGISTERED_USER, HISTORY_OWNED_BY_REGISTERED_USER)).toBe(true);
});
it("should return false for a registered user not owning the history", () => {
expect(userOwnsHistory(REGISTERED_USER, HISTORY_OWNED_BY_ANOTHER_USER)).toBe(false);
});
it("should return true for a registered user owning a history without user_id", () => {
expect(userOwnsHistory(REGISTERED_USER, HISTORY_SUMMARY_WITHOUT_USER_ID)).toBe(true);
});
it("should return true for an anonymous user owning a history with null user_id", () => {
expect(userOwnsHistory(ANONYMOUS_USER, HISTORY_OWNED_BY_ANONYMOUS_USER)).toBe(true);
});
it("should return false for an anonymous user not owning a history", () => {
expect(userOwnsHistory(ANONYMOUS_USER, HISTORY_OWNED_BY_REGISTERED_USER)).toBe(false);
});
it("should return false for sessionless users", () => {
expect(userOwnsHistory(SESSIONLESS_USER, HISTORY_OWNED_BY_REGISTERED_USER)).toBe(false);
expect(userOwnsHistory(SESSIONLESS_USER, HISTORY_SUMMARY_WITHOUT_USER_ID)).toBe(false);
expect(userOwnsHistory(SESSIONLESS_USER, HISTORY_OWNED_BY_ANONYMOUS_USER)).toBe(false);
});
});
});
+23 -5
View File
@@ -26,7 +26,8 @@ export interface HistoryContentsStats {
* Data returned by the API when requesting `?view=summary&keys=size,contents_active,user_id`.
*/
export interface HistorySummaryExtended extends HistorySummary, HistoryContentsStats {
user_id: string;
/** The ID of the user that owns the history. Null if the history is owned by an anonymous user. */
user_id: string | null;
}
type HistoryDetailedModel = components["schemas"]["HistoryDetailed"];
@@ -212,6 +213,7 @@ export function isHistorySummaryExtended(history: AnyHistory): history is Histor
type QuotaUsageResponse = components["schemas"]["UserQuotaUsage"];
/** Represents a registered user.**/
export interface User extends QuotaUsageResponse {
id: string;
email: string;
@@ -230,15 +232,23 @@ export interface AnonymousUser {
export type GenericUser = User | AnonymousUser;
export function isRegisteredUser(user: User | AnonymousUser | null): user is User {
return !user?.isAnonymous;
/** Represents any user, including anonymous users or session-less (null) users.**/
export type AnyUser = GenericUser | null;
export function isRegisteredUser(user: AnyUser): user is User {
return user !== null && !user?.isAnonymous;
}
export function userOwnsHistory(user: User | AnonymousUser | null, history: AnyHistory) {
export function isAnonymousUser(user: AnyUser): user is AnonymousUser {
return user !== null && user.isAnonymous;
}
export function userOwnsHistory(user: AnyUser, history: AnyHistory) {
return (
// Assuming histories without user_id are owned by the current user
(isRegisteredUser(user) && !hasOwner(history)) ||
(isRegisteredUser(user) && hasOwner(history) && user.id === history.user_id)
(isRegisteredUser(user) && hasOwner(history) && user.id === history.user_id) ||
(isAnonymousUser(user) && hasAnonymousOwner(history))
);
}
@@ -246,6 +256,14 @@ function hasOwner(history: AnyHistory): history is HistorySummaryExtended {
return "user_id" in history && history.user_id !== null;
}
function hasAnonymousOwner(history: AnyHistory): history is HistorySummaryExtended {
return "user_id" in history && history.user_id === null;
}
export function canMutateHistory(history: AnyHistory): boolean {
return !history.purged && !history.archived;
}
export type DatasetHash = components["schemas"]["DatasetHash"];
export type DatasetTransform = {
@@ -142,7 +142,6 @@ const fields: FieldArray = [
{
title: "Switch",
icon: faExchangeAlt,
condition: (data: HistoryEntry) => !data.deleted,
handler: (data: HistoryEntry) => {
const historyStore = useHistoryStore();
historyStore.setCurrentHistory(String(data.id));
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCopy, faEye, faUndo } from "@fortawesome/free-solid-svg-icons";
import { faCopy, faExchangeAlt, faEye, faUndo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge, BButton, BButtonGroup } from "bootstrap-vue";
import { computed } from "vue";
@@ -23,16 +23,21 @@ const canImportCopy = computed(() => props.history.export_record_data?.target_ur
const emit = defineEmits<{
(e: "onView", history: ArchivedHistorySummary): void;
(e: "onSwitch", history: ArchivedHistorySummary): void;
(e: "onRestore", history: ArchivedHistorySummary): void;
(e: "onImportCopy", history: ArchivedHistorySummary): void;
}>();
library.add(faUndo, faCopy, faEye);
library.add(faExchangeAlt, faUndo, faCopy, faEye);
function onViewHistoryInCenterPanel() {
emit("onView", props.history);
}
function onSetAsCurrentHistory() {
emit("onSwitch", props.history);
}
async function onRestoreHistory() {
emit("onRestore", props.history);
}
@@ -84,6 +89,15 @@ async function onImportCopy() {
<FontAwesomeIcon :icon="faEye" size="lg" />
View
</BButton>
<BButton
v-b-tooltip
:title="localize('Set as current history')"
variant="link"
class="p-0 px-1"
@click.stop="onSetAsCurrentHistory">
<FontAwesomeIcon :icon="faExchangeAlt" size="lg" />
Switch
</BButton>
<BButton
v-b-tooltip
:title="localize('Unarchive this history and move it back to your active histories')"
@@ -66,6 +66,10 @@ function onViewHistoryInCenterPanel(history: ArchivedHistorySummary) {
router.push(`/histories/view?id=${history.id}`);
}
function onSetAsCurrentHistory(history: ArchivedHistorySummary) {
historyStore.setCurrentHistory(history.id);
}
async function onRestoreHistory(history: ArchivedHistorySummary) {
const confirmTitle = localize(`Unarchive '${history.name}'?`);
const confirmMessage =
@@ -145,6 +149,7 @@ async function onImportCopy(history: ArchivedHistorySummary) {
<ArchivedHistoryCard
:history="history"
@onView="onViewHistoryInCenterPanel"
@onSwitch="onSetAsCurrentHistory"
@onRestore="onRestoreHistory"
@onImportCopy="onImportCopy" />
</BListGroupItem>
@@ -4,7 +4,7 @@
import { computed, ref, watch } from "vue";
import type { CollectionEntry, DCESummary, HistorySummary, SubCollection } from "@/api";
import { isCollectionElement, isHDCA } from "@/api";
import { canMutateHistory, isCollectionElement, isHDCA } from "@/api";
import ExpandedItems from "@/components/History/Content/ExpandedItems";
import { updateContentFields } from "@/components/History/model/queries";
import { useCollectionElementsStore } from "@/stores/collectionElementsStore";
@@ -66,6 +66,7 @@ const rootCollection = computed(() => {
}
});
const isRoot = computed(() => dsc.value == rootCollection.value);
const canEdit = computed(() => isRoot.value && canMutateHistory(props.history));
function updateDsc(collection: any, fields: Object | undefined) {
updateContentFields(collection, fields).then((response) => {
@@ -124,8 +125,8 @@ watch(
:history-name="history.name"
:selected-collections="selectedCollections"
v-on="$listeners" />
<CollectionDetails :dsc="dsc" :writeable="isRoot" @update:dsc="updateDsc(dsc, $event)" />
<CollectionOperations v-if="isRoot && showControls" :dsc="dsc" />
<CollectionDetails :dsc="dsc" :writeable="canEdit" @update:dsc="updateDsc(dsc, $event)" />
<CollectionOperations v-if="canEdit && showControls" :dsc="dsc" />
</section>
<section class="position-relative flex-grow-1 scroller">
<div>
@@ -9,7 +9,7 @@ import prettyBytes from "pretty-bytes";
import { computed, onMounted, ref, toRef } from "vue";
import { useRouter } from "vue-router/composables";
import type { HistorySummaryExtended } from "@/api";
import { type HistorySummaryExtended, userOwnsHistory } from "@/api";
import { HistoryFilters } from "@/components/History/HistoryFilters.js";
import { useConfig } from "@/composables/config";
import { useHistoryContentStats } from "@/composables/historyContentStats";
@@ -137,7 +137,7 @@ onMounted(() => {
variant="link"
size="sm"
class="rounded-0 text-decoration-none history-storage-overview-button"
:disabled="!showControls"
:disabled="!userOwnsHistory(currentUser, props.history)"
data-description="storage dashboard button"
@click="onDashboard">
<FontAwesomeIcon :icon="faDatabase" />
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faArchive, faBurn, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert } from "bootstrap-vue";
import { computed, ref } from "vue";
import type { HistorySummary } from "@/api";
import { type GenericUser, type HistorySummary, userOwnsHistory } from "@/api";
import localize from "@/utils/localization";
library.add(faArchive, faBurn, faTrash);
interface Props {
history: HistorySummary;
currentUser: GenericUser | null;
}
const props = defineProps<Props>();
@@ -14,14 +20,28 @@ const props = defineProps<Props>();
const userOverQuota = ref(false);
const hasMessages = computed(() => {
return userOverQuota.value || props.history.deleted;
return userOverQuota.value || props.history.deleted || props.history.archived;
});
const currentUserOwnsHistory = computed(() => {
return userOwnsHistory(props.currentUser, props.history);
});
</script>
<template>
<div v-if="hasMessages" class="mx-3 my-2">
<BAlert :show="history.deleted" variant="warning">
{{ localize("This history has been deleted") }}
<div v-if="hasMessages" class="mx-3 mt-2" data-description="history messages">
<BAlert v-if="history.purged" :show="history.purged" variant="warning">
<FontAwesomeIcon :icon="faBurn" fixed-width />
{{ localize("History has been purged") }}
</BAlert>
<BAlert v-else-if="history.deleted" :show="history.deleted" variant="warning">
<FontAwesomeIcon :icon="faTrash" fixed-width />
{{ localize("History has been deleted") }}
</BAlert>
<BAlert :show="history.archived && currentUserOwnsHistory" variant="warning">
<FontAwesomeIcon :icon="faArchive" fixed-width />
{{ localize("History has been archived") }}
</BAlert>
<BAlert :show="userOverQuota" variant="warning">
@@ -30,9 +30,9 @@ import {
BSpinner,
} from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { computed, ref } from "vue";
import type { HistorySummary } from "@/api";
import { canMutateHistory, type HistorySummary } from "@/api";
import { iframeRedirect } from "@/components/plugins/legacyNavigation";
import { useHistoryStore } from "@/stores/historyStore";
import { useUserStore } from "@/stores/userStore";
@@ -80,6 +80,22 @@ const historyStore = useHistoryStore();
const { isAnonymous } = storeToRefs(userStore);
const { totalHistoryCount } = storeToRefs(historyStore);
const canEditHistory = computed(() => {
return canMutateHistory(props.history);
});
const historyState = computed(() => {
if (props.history.purged) {
return "purged";
} else if (props.history.deleted) {
return "deleted";
} else if (props.history.archived) {
return "archived";
} else {
return "active";
}
});
function onDelete() {
if (purgeHistory.value) {
historyStore.deleteHistory(props.history.id, true);
@@ -160,7 +176,16 @@ function userTitle(title: string) {
<BDropdownDivider />
<BDropdownText v-if="!canEditHistory">
This history has been <span class="font-weight-bold">{{ historyState }}</span
>.
<span v-localize>Some actions might not be available.</span>
</BDropdownText>
<BDropdownDivider v-if="!canEditHistory" />
<BDropdownItem
:disabled="!canEditHistory"
:title="localize('Resume all Paused Jobs in this History')"
@click="iframeRedirect('/history/resume_paused_jobs?current=True')">
<FontAwesomeIcon fixed-width :icon="faPlay" class="mr-1" />
@@ -177,7 +202,10 @@ function userTitle(title: string) {
<span v-localize>Copy this History</span>
</BDropdownItem>
<BDropdownItem v-b-modal:delete-history-modal :title="localize('Permanently Delete History')">
<BDropdownItem
v-b-modal:delete-history-modal
:disabled="!canEditHistory"
:title="localize('Permanently Delete History')">
<FontAwesomeIcon fixed-width :icon="faTrash" class="mr-1" />
<span v-localize>Delete this History</span>
</BDropdownItem>
@@ -191,6 +219,7 @@ function userTitle(title: string) {
<BDropdownItem
data-description="export to file"
:disabled="history.purged"
:title="localize('Export and Download History as a File')"
@click="$router.push(`/histories/${history.id}/export`)">
<FontAwesomeIcon fixed-width :icon="faFileArchive" class="mr-1" />
@@ -198,7 +227,7 @@ function userTitle(title: string) {
</BDropdownItem>
<BDropdownItem
:disabled="isAnonymous"
:disabled="isAnonymous || history.archived || history.purged"
data-description="archive history"
:title="userTitle('Archive this History')"
@click="$router.push(`/histories/${history.id}/archive`)">
@@ -209,7 +238,7 @@ function userTitle(title: string) {
<BDropdownItem
:disabled="isAnonymous"
:title="userTitle('Convert History to Workflow')"
@click="iframeRedirect('/workflow/build_from_current_history')">
@click="iframeRedirect(`/workflow/build_from_current_history?history_id=${history.id}`)">
<FontAwesomeIcon fixed-width :icon="faFileExport" class="mr-1" />
<span v-localize>Extract Workflow</span>
</BDropdownItem>
@@ -225,7 +254,7 @@ function userTitle(title: string) {
<BDropdownDivider />
<BDropdownItem
:disabled="isAnonymous"
:disabled="isAnonymous || !canEditHistory"
:title="userTitle('Share or Publish this History')"
data-description="share or publish"
@click="$router.push(`/histories/sharing?id=${history.id}`)">
@@ -234,7 +263,7 @@ function userTitle(title: string) {
</BDropdownItem>
<BDropdownItem
:disabled="isAnonymous"
:disabled="isAnonymous || !canEditHistory"
:title="userTitle('Set who can View or Edit this History')"
@click="$router.push(`/histories/permissions?id=${history.id}`)">
<FontAwesomeIcon fixed-width :icon="faUserLock" class="mr-1" />
@@ -243,7 +272,7 @@ function userTitle(title: string) {
<BDropdownItem
v-b-modal:history-privacy-modal
:disabled="isAnonymous"
:disabled="isAnonymous || !canEditHistory"
:title="userTitle('Make this History Private')">
<FontAwesomeIcon fixed-width :icon="faLock" class="mr-1" />
<span v-localize>Make Private</span>
@@ -3,7 +3,7 @@ import { BAlert } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, onMounted, type Ref, ref, set as VueSet, unref, watch } from "vue";
import type { HistoryItemSummary, HistorySummaryExtended } from "@/api";
import { type HistoryItemSummary, type HistorySummaryExtended, userOwnsHistory } from "@/api";
import { copyDataset } from "@/api/datasets";
import ExpandedItems from "@/components/History/Content/ExpandedItems";
import SelectedItems from "@/components/History/Content/SelectedItems";
@@ -15,6 +15,7 @@ import { startWatchingHistory } from "@/store/historyStore/model/watchHistory";
import { useEventStore } from "@/stores/eventStore";
import { useHistoryItemsStore } from "@/stores/historyItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useUserStore } from "@/stores/userStore";
import { type Alias, getOperatorForAlias } from "@/utils/filtering";
import { setDrag } from "@/utils/setDrag";
@@ -49,7 +50,6 @@ interface Props {
listOffset?: number;
history: HistorySummaryExtended;
filter?: string;
canEditHistory?: boolean;
filterable?: boolean;
isMultiViewItem?: boolean;
}
@@ -59,7 +59,6 @@ type ContentItemRef = Record<string, Ref<InstanceType<typeof ContentItem> | null
const props = withDefaults(defineProps<Props>(), {
listOffset: 0,
filter: "",
canEditHistory: true,
filterable: false,
isMultiViewItem: false,
});
@@ -90,6 +89,14 @@ const { lastCheckedTime, totalMatchesCount, isWatching } = storeToRefs(useHistor
const historyStore = useHistoryStore();
const historyItemsStore = useHistoryItemsStore();
const { currentUser } = storeToRefs(useUserStore());
const currentUserOwnsHistory = computed(() => {
return userOwnsHistory(currentUser.value, props.history);
});
const canEditHistory = computed(() => {
return currentUserOwnsHistory.value && !props.history.deleted && !props.history.archived;
});
const historyUpdateTime = computed(() => {
return props.history.update_time;
@@ -535,13 +542,14 @@ function setItemDragstart(
:summarized="isMultiViewItem"
@update:history="historyStore.updateHistory($event)" />
<HistoryMessages :history="history" />
<HistoryMessages :history="history" :current-user="currentUser" />
<HistoryCounter
:history="history"
:is-watching="isWatching"
:last-checked="lastCheckedTime"
:show-controls="canEditHistory"
:owned-by-current-user="userOwnsHistory(currentUser, history)"
:filter-text.sync="filterText"
:hide-reload="isMultiViewItem"
@reloadContents="reloadContents" />
@@ -22,6 +22,7 @@ function create_history(historyId, userId, purged = false, archived = false) {
name: historyName,
purged: purged,
archived: archived,
deleted: purged,
count: 10,
annotation: "This is a history",
tags: ["tag_1", "tag_2"],
@@ -174,17 +175,17 @@ describe("History center panel View", () => {
const wrapper = await createWrapper(localVue, "user_1", history);
expect(wrapper.vm.history).toEqual(history);
// history purged, not switchable and not importable
// history purged, is switchable but not importable
const switchButton = wrapper.find("[data-description='switch to history button']");
const importButton = wrapper.find("[data-description='import history button']");
expect(switchButton.attributes("disabled")).toBeTruthy();
expect(switchButton.attributes("disabled")).toBeFalsy();
expect(importButton.exists()).toBe(false);
// storage dashboard button should be disabled
expect(storageDashboardButtonDisabled(wrapper)).toBeTruthy();
// storage dashboard button can be accessed
expect(storageDashboardButtonDisabled(wrapper)).toBeFalsy();
// instead we have an alert
expect(wrapper.find("[data-description='history state info']").text()).toBe("This history has been purged.");
expect(wrapper.find("[data-description='history messages']").text()).toBe("History has been purged");
});
it("should not display archived message and should be importable when user is not owner and history is archived", async () => {
@@ -202,7 +203,8 @@ describe("History center panel View", () => {
expect(storageDashboardButtonDisabled(wrapper)).toBeTruthy();
expectCorrectLayout(wrapper);
expect(wrapper.find("[data-description='history state info']").exists()).toBe(false);
// There is no message about the history status
expect(wrapper.find("[data-description='history messages']").text()).toBe("");
});
it("should display archived message and should not be importable when user is owner and history is archived", async () => {
@@ -213,13 +215,12 @@ describe("History center panel View", () => {
const switchButton = wrapper.find("[data-description='switch to history button']");
const importButton = wrapper.find("[data-description='import history button']");
expect(switchButton.exists()).toBe(true);
expect(switchButton.attributes("disabled")).toBeTruthy();
expect(importButton.exists()).toBe(false);
// storage dashboard button should be disabled
expect(storageDashboardButtonDisabled(wrapper)).toBeTruthy();
// storage dashboard button can be accessed
expect(storageDashboardButtonDisabled(wrapper)).toBeFalsy();
expectCorrectLayout(wrapper);
expect(wrapper.find("[data-description='history state info']").text()).toBe("This history has been archived.");
expect(wrapper.find("[data-description='history messages']").text()).toBe("History has been archived");
});
});
+2 -36
View File
@@ -1,9 +1,5 @@
<template>
<div v-if="currentUser && history" class="d-flex flex-column h-100">
<b-alert v-if="showHistoryStateInfo" variant="info" show data-description="history state info">
{{ historyStateInfoMessage }}
</b-alert>
<div class="flex-row flex-grow-0 pb-3">
<b-button
v-if="userOwnsHistory"
@@ -35,12 +31,7 @@
:selected-collections.sync="selectedCollections"
:show-controls="false"
@view-collection="onViewCollection" />
<HistoryPanel
v-else
:history="history"
:can-edit-history="canEditHistory"
filterable
@view-collection="onViewCollection" />
<HistoryPanel v-else :history="history" filterable @view-collection="onViewCollection" />
<CopyModal id="copy-history-modal" :history="history" @ok="copyOkay" />
</div>
@@ -87,39 +78,14 @@ export default {
return this.currentHistory?.id == this.history?.id;
},
isSetAsCurrentDisabled() {
return this.isCurrentHistory || this.history.archived || this.history.purged;
return this.isCurrentHistory;
},
setAsCurrentTitle() {
if (this.isCurrentHistory) {
return "This history is already your current history.";
}
if (this.history.archived) {
return "This history has been archived and cannot be set as your current history. Unarchive it first.";
}
if (this.history.purged) {
return "This history has been purged and cannot be set as your current history.";
}
return "Switch to this history";
},
canEditHistory() {
return this.userOwnsHistory && !this.history.archived && !this.history.purged;
},
showHistoryArchived() {
return this.history.archived && this.userOwnsHistory;
},
showHistoryStateInfo() {
return this.showHistoryArchived || this.history.purged;
},
historyStateInfoMessage() {
if (this.showHistoryArchived && this.history.purged) {
return "This history has been archived and purged.";
} else if (this.showHistoryArchived) {
return "This history has been archived.";
} else if (this.history.purged) {
return "This history has been purged.";
}
return "";
},
canImportHistory() {
return !this.userOwnsHistory && !this.history.purged;
},
@@ -7,6 +7,8 @@ import { createPinia } from "pinia";
import { useHistoryStore } from "stores/historyStore";
import { getLocalVue } from "tests/jest/helpers";
import { useUserStore } from "@/stores/userStore";
import SelectorModal from "./SelectorModal";
const localVue = getLocalVue();
@@ -35,6 +37,14 @@ const PROPS_FOR_MODAL_MULTIPLE_SELECT = {
const CURRENT_HISTORY_INDICATION_TEXT = "(Current)";
const CURRENT_USER = {
email: "email",
id: "user_id",
tags_used: [],
isAnonymous: false,
total_disk_usage: 0,
};
describe("History SelectorModal.vue", () => {
let wrapper;
let axiosMock;
@@ -60,6 +70,10 @@ describe("History SelectorModal.vue", () => {
icon: { template: "<div></div>" },
},
});
const userStore = useUserStore();
userStore.setCurrentUser(CURRENT_USER);
historyStore = useHistoryStore();
axiosMock = new MockAdapter(axios);
getUpdatedAxiosMock();
+15 -1
View File
@@ -3,6 +3,9 @@
<b-alert :show="messageShow" :variant="messageVariant">
{{ messageText }}
</b-alert>
<b-alert v-if="!showLoading && !canMutateHistory" show variant="warning">
{{ immutableHistoryMessage }}
</b-alert>
<LoadingSpan v-if="showLoading" message="Loading Tool" />
<div v-if="showEntryPoints">
<ToolEntryPoints v-for="job in entryPoints" :key="job.id" :job-id="job.id" />
@@ -87,6 +90,7 @@
<ButtonSpinner
id="execute"
title="Run Tool"
:disabled="!canMutateHistory"
class="btn-sm"
:wait="showExecuting"
:tooltip="tooltip"
@@ -96,6 +100,7 @@
<ButtonSpinner
title="Run Tool"
class="mt-3 mb-3"
:disabled="!canMutateHistory"
:wait="showExecuting"
:tooltip="tooltip"
@onClick="onExecute(config, currentHistoryId)" />
@@ -117,6 +122,7 @@ import { useHistoryItemsStore } from "stores/historyItemsStore";
import { useJobStore } from "stores/jobStore";
import { refreshContentsWrapper } from "utils/data";
import { canMutateHistory } from "@/api";
import { useConfigStore } from "@/stores/configurationStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useUserStore } from "@/stores/userStore";
@@ -196,11 +202,13 @@ export default {
{ label: "populate", value: "populate" },
{ label: "bundle", value: "bundle" },
],
immutableHistoryMessage:
"This history is immutable and you cannot run tools in it. Please switch to a different history.",
};
},
computed: {
...mapState(useUserStore, ["currentUser"]),
...mapState(useHistoryStore, ["currentHistoryId"]),
...mapState(useHistoryStore, ["currentHistoryId", "currentHistory"]),
...mapState(useHistoryItemsStore, ["lastUpdateTime"]),
toolName() {
return this.formConfig.name;
@@ -212,6 +220,9 @@ export default {
return id.endsWith(version) ? id : `${id}/${version}`;
},
tooltip() {
if (!this.canMutateHistory) {
return this.immutableHistoryMessage;
}
return `Run tool: ${this.formConfig.name} (${this.formConfig.version})`;
},
errorContentPretty() {
@@ -234,6 +245,9 @@ export default {
initialized() {
return this.formData !== undefined;
},
canMutateHistory() {
return this.currentHistory && canMutateHistory(this.currentHistory);
},
},
watch: {
currentHistoryId() {
@@ -1,5 +1,5 @@
<script setup>
import { BTab, BTabs } from "bootstrap-vue";
import { BAlert, BTab, BTabs } from "bootstrap-vue";
import { getDatatypesMapper } from "components/Datatypes";
import LoadingSpan from "components/LoadingSpan";
import {
@@ -12,6 +12,8 @@ import {
import { storeToRefs } from "pinia";
import { computed, onMounted, ref } from "vue";
import { canMutateHistory } from "@/api";
import { useHistoryStore } from "@/stores/historyStore";
import { useUploadStore } from "@/stores/uploadStore";
import { uploadPayload } from "@/utils/upload-payload.js";
@@ -82,6 +84,8 @@ const regular = ref(null);
const { percentage, status } = storeToRefs(useUploadStore());
const { currentHistory } = storeToRefs(useHistoryStore());
const effectiveExtensions = computed(() => {
if (props.formats === null || !datatypesMapperReady.value) {
return listExtensions.value;
@@ -106,6 +110,7 @@ const historyAvailable = computed(() => Boolean(props.currentHistoryId));
const ready = computed(
() => dbKeysSet.value && extensionsSet.value && historyAvailable.value && datatypesMapperReady.value
);
const canUploadToHistory = computed(() => currentHistory.value && canMutateHistory(currentHistory.value));
const showCollection = computed(() => !props.formats && props.multiple);
const showComposite = computed(() => !props.formats || hasCompositeExtension);
const showRegular = computed(() => !props.formats || hasRegularExtension);
@@ -160,7 +165,13 @@ defineExpose({
</script>
<template>
<BTabs v-if="ready">
<BAlert v-if="!canUploadToHistory" variant="warning" show>
<span v-localize>
The current history is immutable and you cannot upload data to it. Please select a different history or
create a new one.
</span>
</BAlert>
<BTabs v-else-if="ready">
<BTab v-if="showRegular" id="regular" title="Regular" button-id="tab-title-link-regular">
<DefaultBox
ref="regular"
@@ -196,6 +196,7 @@ async function onPermanentlyDeleteHistory(historyId: string) {
item-type="history"
:is-recoverable="isRecoverableDataPoint(data)"
:is-archived="isArchivedDataPoint(data)"
:can-edit="!isArchivedDataPoint(data)"
@set-current-history="onSetCurrentHistory"
@view-item="onViewHistory"
@undelete-item="onUndeleteHistory"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton, BForm, BFormGroup, BFormSelect, BInputGroup } from "bootstrap-vue";
import { ref } from "vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router/composables";
import { useSelectableObjectStores } from "@/composables/useObjectStores";
@@ -27,7 +27,7 @@ import FilterObjectStoreLink from "@/components/Common/FilterObjectStoreLink.vue
import LoadingSpan from "@/components/LoadingSpan.vue";
const router = useRouter();
const { getHistoryNameById } = useHistoryStore();
const { getHistoryNameById, getHistoryById } = useHistoryStore();
interface Props {
historyId: string;
@@ -53,6 +53,11 @@ const { selectableObjectStores, hasSelectableObjectStores } = useSelectableObjec
const objectStore = ref<string | null>(null);
const canEditHistory = computed(() => {
const history = getHistoryById(props.historyId);
return (history && !history.purged && !history.archived) ?? false;
});
function onChangeObjectStore(value: string | null) {
objectStore.value = value;
reloadDataFromServer();
@@ -209,6 +214,7 @@ function onUndelete(datasetId: string) {
:data="data"
item-type="dataset"
:is-recoverable="isRecoverableDataPoint(data)"
:can-edit="canEditHistory"
@view-item="onViewDataset"
@undelete-item="onUndelete"
@permanently-delete-item="onPermDelete" />
@@ -25,12 +25,14 @@ interface SelectedItemActionsProps {
isRecoverable: boolean;
itemType: ItemTypes;
isArchived?: boolean;
canEdit?: boolean;
}
const { currentHistoryId } = useHistoryStore();
const props = withDefaults(defineProps<SelectedItemActionsProps>(), {
isArchived: false,
canEdit: false,
});
library.add(faArchive, faChartBar, faInfoCircle, faLocationArrow, faTrash, faUndo);
@@ -38,9 +40,7 @@ library.add(faArchive, faChartBar, faInfoCircle, faLocationArrow, faTrash, faUnd
const label = computed(() => props.data?.label ?? "No data");
const prettySize = computed(() => bytesToString(props.data?.value ?? 0));
const viewDetailsIcon = computed(() => (props.itemType === "history" ? "chart-bar" : "info-circle"));
const canSetAsCurrent = computed(
() => props.itemType === "history" && !props.isArchived && props.data.id !== currentHistoryId
);
const canSetAsCurrent = computed(() => props.itemType === "history" && props.data.id !== currentHistoryId);
const emit = defineEmits<{
(e: "set-current-history", historyId: string): void;
@@ -99,7 +99,7 @@ function onPermanentlyDeleteItem() {
<FontAwesomeIcon :icon="viewDetailsIcon" />
</BButton>
<BButton
v-if="isRecoverable"
v-if="isRecoverable && canEdit"
variant="outline-primary"
size="sm"
class="mx-2"
@@ -108,6 +108,7 @@ function onPermanentlyDeleteItem() {
<FontAwesomeIcon icon="undo" />
</BButton>
<BButton
v-if="canEdit"
variant="outline-danger"
size="sm"
class="mx-2"
@@ -5,6 +5,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { useRouter } from "vue-router/composables";
import { canMutateHistory } from "@/api";
import { copyWorkflow } from "@/components/Workflow/workflows.services";
import { useHistoryItemsStore } from "@/stores/historyItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
@@ -52,6 +53,13 @@ const editorLink = computed(() => `/workflows/edit?id=${props.workflowId}`);
const historyStatusKey = computed(() => `${currentHistoryId.value}_${lastUpdateTime.value}`);
const isOwner = computed(() => currentUser.value?.username === workflowModel.value.runData.owner);
const lastUpdateTime = computed(() => historyItemsStore.lastUpdateTime);
const canRunOnHistory = computed(() => {
if (!currentHistoryId.value) {
return false;
}
const history = historyStore.getHistoryById(currentHistoryId.value);
return (history && canMutateHistory(history)) ?? false;
});
function handleInvocations(incomingInvocations: any) {
invocations.value = incomingInvocations;
@@ -177,12 +185,14 @@ defineExpose({
:model="workflowModel"
:target-history="simpleFormTargetHistory"
:use-job-cache="simpleFormUseJobCache"
:can-mutate-current-history="canRunOnHistory"
@submissionSuccess="handleInvocations"
@submissionError="handleSubmissionError"
@showAdvanced="showAdvanced" />
<WorkflowRunForm
v-else
:model="workflowModel"
:can-mutate-current-history="canRunOnHistory"
@submissionSuccess="handleInvocations"
@submissionError="handleSubmissionError" />
</div>
@@ -1,11 +1,18 @@
<template>
<div v-if="currentUser && currentHistoryId" class="workflow-expanded-form">
<BAlert v-if="!canRunOnHistory" variant="warning" show>
<span v-localize>
The workflow cannot run because the current history is immutable. Please select a different history or
send the results to a new one.
</span>
</BAlert>
<div class="h4 clearfix mb-3">
<b>Workflow: {{ model.name }}</b>
<ButtonSpinner
id="run-workflow"
class="float-right"
title="Run Workflow"
:disabled="!canRunOnHistory"
:wait="showExecuting"
@onClick="onExecute" />
</div>
@@ -53,6 +60,7 @@
</template>
<script>
import { BAlert } from "bootstrap-vue";
import ButtonSpinner from "components/Common/ButtonSpinner";
import FormCard from "components/Form/FormCard";
import FormDisplay from "components/Form/FormDisplay";
@@ -70,6 +78,7 @@ import WorkflowRunInputStep from "./WorkflowRunInputStep";
export default {
components: {
BAlert,
ButtonSpinner,
FormDisplay,
FormCard,
@@ -82,6 +91,10 @@ export default {
type: Object,
required: true,
},
canMutateCurrentHistory: {
type: Boolean,
required: true,
},
},
data() {
return {
@@ -140,6 +153,12 @@ export default {
wpInputs() {
return this.toArray(this.model.wpInputs);
},
shouldRunOnNewHistory() {
return Boolean(this.historyData["new_history|name"]);
},
canRunOnHistory() {
return this.shouldRunOnNewHistory || this.canMutateCurrentHistory;
},
},
methods: {
reuseAllowed(user) {
@@ -1,11 +1,17 @@
<template>
<div>
<div v-if="isConfigLoaded" class="h4 clearfix mb-3">
<BAlert v-if="!canRunOnHistory" variant="warning" show>
<span v-localize>
The workflow cannot run because the current history is immutable. Please select a different history
or send the results to a new one using the run settings
</span>
</BAlert>
<b>Workflow: {{ model.name }}</b>
<ButtonSpinner
id="run-workflow"
:wait="waitingForRequest"
:disabled="hasValidationErrors"
:disabled="hasValidationErrors || !canRunOnHistory"
class="float-right"
title="Run Workflow"
@onClick="onExecute" />
@@ -89,6 +95,10 @@ export default {
type: Boolean,
default: false,
},
canMutateCurrentHistory: {
type: Boolean,
required: true,
},
},
setup() {
const { config, isConfigLoaded } = useConfig(true);
@@ -143,6 +153,9 @@ export default {
hasValidationErrors() {
return Boolean(Object.values(this.stepValidations).find((value) => value !== null && value !== undefined));
},
canRunOnHistory() {
return this.canMutateCurrentHistory || this.sendToNewHistory;
},
},
methods: {
onValidation(validation) {
@@ -322,7 +322,7 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt
def set_as_current(self, trans, id, **kwargs):
"""Change the current user's current history to one with `id`."""
try:
history = self.history_manager.get_mutable(self.decode_id(id), trans.user, current_history=trans.history)
history = self.history_manager.get_owned(self.decode_id(id), trans.user, current_history=trans.history)
trans.set_history(history)
return self.history_data(trans, history)
except exceptions.MessageException as msg_exc:
@@ -9,8 +9,10 @@ from galaxy import (
util,
web,
)
from galaxy.managers.histories import HistoryManager
from galaxy.managers.sharable import SlugBuilder
from galaxy.model.item_attrs import UsesItemRatings
from galaxy.structured_app import StructuredApp
from galaxy.tools.parameters.workflow_utils import workflow_building_modes
from galaxy.util import FILENAME_VALID_CHARS
from galaxy.web import url_for
@@ -24,13 +26,18 @@ from galaxy.workflow.extract import (
summarize,
)
from galaxy.workflow.modules import load_module_sections
from ..api import depends
log = logging.getLogger(__name__)
class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixin, UsesItemRatings):
history_manager: HistoryManager = depends(HistoryManager)
slug_builder = SlugBuilder()
def __init__(self, app: StructuredApp):
super().__init__(app)
@web.expose
def display_by_username_and_slug(self, trans, username, slug, format="html", **kwargs):
"""
@@ -304,14 +311,18 @@ class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixi
workflow_name=None,
dataset_names=None,
dataset_collection_names=None,
history_id=None,
**kwargs,
):
user = trans.get_user()
history = trans.get_history()
history = trans.history
if history_id:
# Optionally target a different history than the current one.
history = self.history_manager.get_owned(self.decode_id(history_id), trans.user, current_history=history)
if not user:
return trans.show_error_message("Must be logged in to create workflows")
if (job_ids is None and dataset_ids is None) or workflow_name is None:
jobs, warnings = summarize(trans)
jobs, warnings = summarize(trans, history)
# Render
return trans.fill_template(
"workflow/build_from_current_history.mako", jobs=jobs, warnings=warnings, history=history
@@ -324,6 +335,7 @@ class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixi
stored_workflow = extract_workflow(
trans,
user=user,
history=history,
job_ids=job_ids,
dataset_ids=dataset_ids,
dataset_collection_ids=dataset_collection_ids,