diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index df5d25c07260..dd06d9784e38 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -7316,6 +7316,8 @@ export interface components { device?: string | null; /** Name */ name?: string | null; + /** Object Expires After Days */ + object_expires_after_days?: number | null; /** Object Store Id */ object_store_id?: string | null; /** Private */ @@ -11385,6 +11387,11 @@ export interface components { * @description The name of the item. */ name?: string | null; + /** + * Object Store ID + * @description The ID of the object store that this dataset is stored in. + */ + object_store_id?: string | null; /** * Peek * @description A few lines of contents from the start of the file. @@ -11499,9 +11506,10 @@ export interface components { copied_from_ldda_id?: string | null; /** * Create Time + * Format: date-time * @description The time and date this item was created. */ - create_time: string | null; + create_time: string; /** * Created from basename * @description The basename of the output that produced this dataset. @@ -11637,6 +11645,11 @@ export interface components { * @description The name of the item. */ name: string | null; + /** + * Object Store ID + * @description The ID of the object store that this dataset is stored in. + */ + object_store_id?: string | null; /** * Peek * @description A few lines of contents from the start of the file. @@ -11732,9 +11745,10 @@ export interface components { copied_from_ldda_id?: string | null; /** * Create Time + * Format: date-time * @description The time and date this item was created. */ - create_time: string | null; + create_time: string; /** * Deleted * @description Whether this item is marked as deleted. @@ -11851,9 +11865,10 @@ export interface components { copied_from_ldda_id?: string | null; /** * Create Time + * Format: date-time * @description The time and date this item was created. */ - create_time: string | null; + create_time: string; /** * Dataset ID * @description The encoded ID of the dataset associated with this item. @@ -11902,6 +11917,11 @@ export interface components { * @description The name of the item. */ name: string | null; + /** + * Object Store ID + * @description The ID of the object store that this dataset is stored in. + */ + object_store_id?: string | null; /** * Purged * @description Whether this dataset has been removed from disk. @@ -12048,6 +12068,11 @@ export interface components { * @description Optional message with further information in case the population of the dataset collection failed. */ populated_state_message?: string | null; + /** + * Store Times Summary + * @description A list of objects containing the object store ID and the oldest creation time of the datasets stored in that object store for this collection.This is used to determine the age of the datasets in the collection when the object store is short-lived. + */ + store_times_summary?: components["schemas"]["OldestCreateTimeByObjectStoreId"][] | null; tags?: components["schemas"]["TagCollection"] | null; /** * Type @@ -12098,9 +12123,10 @@ export interface components { contents_url: string; /** * Create Time + * Format: date-time * @description The time and date this item was created. */ - create_time: string | null; + create_time: string; /** * Deleted * @description Whether this item is marked as deleted. @@ -12189,6 +12215,11 @@ export interface components { * @description Optional message with further information in case the population of the dataset collection failed. */ populated_state_message?: string | null; + /** + * Store Times Summary + * @description A list of objects containing the object store ID and the oldest creation time of the datasets stored in that object store for this collection.This is used to determine the age of the datasets in the collection when the object store is short-lived. + */ + store_times_summary?: components["schemas"]["OldestCreateTimeByObjectStoreId"][] | null; tags: components["schemas"]["TagCollection"]; /** * Type @@ -12241,9 +12272,10 @@ export interface components { contents_url: string; /** * Create Time + * Format: date-time * @description The time and date this item was created. */ - create_time: string | null; + create_time: string; /** * Deleted * @description Whether this item is marked as deleted. @@ -12316,6 +12348,11 @@ export interface components { * @description Optional message with further information in case the population of the dataset collection failed. */ populated_state_message?: string | null; + /** + * Store Times Summary + * @description A list of objects containing the object store ID and the oldest creation time of the datasets stored in that object store for this collection.This is used to determine the age of the datasets in the collection when the object store is short-lived. + */ + store_times_summary?: components["schemas"]["OldestCreateTimeByObjectStoreId"][] | null; tags: components["schemas"]["TagCollection"]; /** * Type @@ -16633,6 +16670,23 @@ export interface components { */ version: number; }; + /** + * OldestCreateTimeByObjectStoreId + * @description Represents the oldest creation time of a set of datasets stored in a specific object store. + */ + OldestCreateTimeByObjectStoreId: { + /** + * Object Store ID + * @description The ID of the object store. + */ + object_store_id: string; + /** + * Oldest Create Time + * Format: date-time + * @description The oldest creation time of a set of datasets stored in this object store. + */ + oldest_create_time: string; + }; /** OutputReferenceByLabel */ OutputReferenceByLabel: { /** @@ -20197,6 +20251,8 @@ export interface components { hidden: boolean; /** Name */ name?: string | null; + /** Object Expires After Days */ + object_expires_after_days?: number | null; /** Object Store Id */ object_store_id?: string | null; /** Private */ diff --git a/client/src/components/History/Content/Collection/CollectionDescription.test.js b/client/src/components/History/Content/Collection/CollectionDescription.test.js deleted file mode 100644 index 4e2712ac6f22..000000000000 --- a/client/src/components/History/Content/Collection/CollectionDescription.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import { mount } from "@vue/test-utils"; -import { getLocalVue } from "tests/jest/helpers"; - -import CollectionDescription from "./CollectionDescription"; -import { JobStateSummary } from "./JobStateSummary"; - -const localVue = getLocalVue(); - -describe("CollectionDescription", () => { - let wrapper; - - beforeEach(() => { - const jss = new JobStateSummary(); - wrapper = mount(CollectionDescription, { - propsData: { - collectionType: "list", - jobStateSummary: jss, - }, - localVue, - }); - }); - - it("should display expected heterogeneous descriptions", async () => { - const HETEROGENEOUS_DATATYPES = ["txt", "csv", "tabular"]; - await wrapper.setProps({ elementCount: 1, elementsDatatypes: HETEROGENEOUS_DATATYPES }); - expect(wrapper.text()).toBe("a list with 1 dataset"); - - await wrapper.setProps({ elementCount: 2, collectionType: "paired" }); - expect(wrapper.text()).toBe("a pair with 2 datasets"); - - await wrapper.setProps({ elementCount: 10, collectionType: "list" }); - expect(wrapper.text()).toBe("a list with 10 datasets"); - - await wrapper.setProps({ collectionType: "list:paired" }); - expect(wrapper.text()).toBe("a list with 10 pairs"); - - await wrapper.setProps({ collectionType: "list:list" }); - expect(wrapper.text()).toBe("a list with 10 lists"); - - await wrapper.setProps({ collectionType: "other" }); - expect(wrapper.text()).toBe("a nested list with 10 dataset collections"); - }); - - it("should display expected homogeneous descriptions", async () => { - const EXPECTED_HOMOGENEOUS_DATATYPE = "tabular"; - await wrapper.setProps({ elementCount: 1, elementsDatatypes: [EXPECTED_HOMOGENEOUS_DATATYPE] }); - expect(wrapper.text()).toBe(`a list with 1 ${EXPECTED_HOMOGENEOUS_DATATYPE} dataset`); - - await wrapper.setProps({ elementCount: 2, collectionType: "paired" }); - expect(wrapper.text()).toBe(`a pair with 2 ${EXPECTED_HOMOGENEOUS_DATATYPE} datasets`); - - await wrapper.setProps({ elementCount: 10, collectionType: "list" }); - expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} datasets`); - - await wrapper.setProps({ collectionType: "list:paired" }); - expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} pairs`); - - await wrapper.setProps({ collectionType: "list:list" }); - expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} lists`); - - await wrapper.setProps({ collectionType: "other" }); - expect(wrapper.text()).toBe(`a nested list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} dataset collections`); - }); -}); diff --git a/client/src/components/History/Content/Collection/CollectionDescription.test.ts b/client/src/components/History/Content/Collection/CollectionDescription.test.ts new file mode 100644 index 000000000000..5a8d3be87346 --- /dev/null +++ b/client/src/components/History/Content/Collection/CollectionDescription.test.ts @@ -0,0 +1,164 @@ +import { mount, type Wrapper } from "@vue/test-utils"; +import { getLocalVue } from "tests/jest/helpers"; +import type Vue from "vue"; + +import type { HDCASummary } from "@/api"; + +import CollectionDescription from "./CollectionDescription.vue"; + +const localVue = getLocalVue(); + +const defaultTestHDCA: HDCASummary = { + id: "test_id", + name: "Test Collection", + hid: 1, + collection_id: "test_collection_id", + type: "collection", + collection_type: "list", + history_content_type: "dataset_collection", + element_count: null, + create_time: "2023-10-01T00:00:00Z", + update_time: null, + deleted: false, + visible: true, + elements_datatypes: [], + history_id: "fake_history_id", + model_class: "HistoryDatasetCollectionAssociation", + populated_state: "ok", + tags: [], + url: "fake/url", + contents_url: "fake/contents/url", +}; + +describe("CollectionDescription", () => { + let wrapper: Wrapper; + + beforeEach(() => { + wrapper = mount(CollectionDescription as object, { + propsData: { + hdca: defaultTestHDCA, + }, + localVue, + }); + }); + + it("should display expected heterogeneous descriptions", async () => { + const HETEROGENEOUS_DATATYPES = ["txt", "csv", "tabular"]; + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + collection_type: "list", + element_count: 1, + elements_datatypes: HETEROGENEOUS_DATATYPES, + }, + }); + expect(wrapper.text()).toBe("a list with 1 dataset"); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 2, + collection_type: "paired", + }, + }); + expect(wrapper.text()).toBe("a pair with 2 datasets"); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list", + }, + }); + expect(wrapper.text()).toBe("a list with 10 datasets"); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list:paired", + }, + }); + expect(wrapper.text()).toBe("a list with 10 pairs"); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list:list", + }, + }); + expect(wrapper.text()).toBe("a list with 10 lists"); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "other", + }, + }); + expect(wrapper.text()).toBe("a nested list with 10 dataset collections"); + }); + + it("should display expected homogeneous descriptions", async () => { + const EXPECTED_HOMOGENEOUS_DATATYPE = "tabular"; + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 1, + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a list with 1 ${EXPECTED_HOMOGENEOUS_DATATYPE} dataset`); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 2, + collection_type: "paired", + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a pair with 2 ${EXPECTED_HOMOGENEOUS_DATATYPE} datasets`); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list", + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} datasets`); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list:paired", + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} pairs`); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "list:list", + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} lists`); + + await wrapper.setProps({ + hdca: { + ...defaultTestHDCA, + element_count: 10, + collection_type: "other", + elements_datatypes: [EXPECTED_HOMOGENEOUS_DATATYPE], + }, + }); + expect(wrapper.text()).toBe(`a nested list with 10 ${EXPECTED_HOMOGENEOUS_DATATYPE} dataset collections`); + }); +}); diff --git a/client/src/components/History/Content/Collection/CollectionDescription.vue b/client/src/components/History/Content/Collection/CollectionDescription.vue index 42096e4fe940..7cc6e0a40b00 100644 --- a/client/src/components/History/Content/Collection/CollectionDescription.vue +++ b/client/src/components/History/Content/Collection/CollectionDescription.vue @@ -1,21 +1,17 @@ + diff --git a/client/src/components/History/Content/ContentItem.vue b/client/src/components/History/Content/ContentItem.vue index ab6a5ac57731..4bfb4f62e009 100644 --- a/client/src/components/History/Content/ContentItem.vue +++ b/client/src/components/History/Content/ContentItem.vue @@ -19,10 +19,10 @@ import { useEntryPointStore } from "@/stores/entryPointStore"; import { useEventStore } from "@/stores/eventStore"; import { clearDrag } from "@/utils/setDrag"; -import { JobStateSummary } from "./Collection/JobStateSummary"; import { getContentItemState, type StateMap, STATES } from "./model/states"; import CollectionDescription from "./Collection/CollectionDescription.vue"; +import ContentExpirationIndicator from "./ContentExpirationIndicator.vue"; import ContentOptions from "./ContentOptions.vue"; import DatasetDetails from "./Dataset/DatasetDetails.vue"; import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue"; @@ -90,12 +90,8 @@ const eventStore = useEventStore(); const contentItem = ref(null); const subItemsVisible = ref(false); -const jobState = computed(() => { - return new JobStateSummary(props.item); -}); - const itemIsRunningInteractiveTool = computed(() => { - // If our datset id is in the entrypOintStore it's a running it + // If our dataset id is in the entrypOintStore it's a running it return !isCollection.value && entryPointStore.entryPointsForHda(props.item.id).length > 0; }); @@ -441,15 +437,10 @@ function unexpandedClick(event: Event) { + - + -import { computed } from "vue"; - import type { HDCASummary } from "@/api"; -import { JobStateSummary } from "@/components/History/Content/Collection/JobStateSummary.js"; import CollectionDescription from "@/components/History/Content/Collection/CollectionDescription.vue"; +import ContentExpirationIndicator from "@/components/History/Content/ContentExpirationIndicator.vue"; import DetailsLayout from "@/components/History/Layout/DetailsLayout.vue"; interface Props { @@ -12,11 +10,7 @@ interface Props { writeable: boolean; } -const props = defineProps(); - -const jobState = computed(() => { - return new JobStateSummary(props.dsc); -}); +defineProps(); diff --git a/client/src/stores/collectionElementsStore.test.ts b/client/src/stores/collectionElementsStore.test.ts index b065b6891fe3..010f7dc44eea 100644 --- a/client/src/stores/collectionElementsStore.test.ts +++ b/client/src/stores/collectionElementsStore.test.ts @@ -143,6 +143,7 @@ function mockCollection(id: string, numElements = 10): HDCASummary { type_id: "dataset_collection", url: "", type: "collection", + store_times_summary: null, }; } diff --git a/lib/galaxy/managers/hdcas.py b/lib/galaxy/managers/hdcas.py index a5c1530eda16..2900e49b0790 100644 --- a/lib/galaxy/managers/hdcas.py +++ b/lib/galaxy/managers/hdcas.py @@ -23,6 +23,7 @@ ) from galaxy.managers.collections_util import get_hda_and_element_identifiers from galaxy.model.tags import GalaxyTagHandler +from galaxy.schema.schema import OldestCreateTimeByObjectStoreId from galaxy.structured_app import ( MinimalManagerApp, StructuredApp, @@ -299,6 +300,7 @@ def __init__(self, app: StructuredApp): "update_time", "tags", "contents_url", + "store_times_summary", ], ) self.add_view( @@ -335,6 +337,7 @@ def add_serializers(self): "job_state_summary": self.serialize_job_state_summary, "elements_datatypes": self.serialize_elements_datatypes, "collection_id": self.serialize_id, + "store_times_summary": self.serialize_store_times_summary, } self.serializers.update(serializers) @@ -353,3 +356,9 @@ def serialize_job_state_summary(self, item, key, **context): def serialize_elements_datatypes(self, item, key, **context): extensions_set = item.dataset_dbkeys_and_extensions_summary[1] return list(extensions_set) + + def serialize_store_times_summary(self, item, key, **context): + store_times_summary = item.dataset_dbkeys_and_extensions_summary[2] + return [ + OldestCreateTimeByObjectStoreId(object_store_id=t[0], oldest_create_time=t[1]) for t in store_times_summary + ] diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index c063d8ec3677..3a55f09f1d91 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -7346,11 +7346,14 @@ def job_state_summary_dict(self): @property def dataset_dbkeys_and_extensions_summary(self): if not hasattr(self, "_dataset_dbkeys_and_extensions_summary"): - stmt = self.collection._build_nested_collection_attributes_stmt(hda_attributes=("_metadata", "extension")) + stmt = self.collection._build_nested_collection_attributes_stmt( + hda_attributes=("_metadata", "extension"), dataset_attributes=("object_store_id", "create_time") + ) tuples = required_object_session(self).execute(stmt) extensions = set() dbkeys = set() + store_times = {} for row in tuples: if row is not None: dbkey_field = row._metadata.get("dbkey") @@ -7361,7 +7364,13 @@ def dataset_dbkeys_and_extensions_summary(self): dbkeys.add(dbkey_field) if row.extension: extensions.add(row.extension) - self._dataset_dbkeys_and_extensions_summary = (dbkeys, extensions) + store_id = row.object_store_id + create_time = row.create_time + if store_id is not None and create_time is not None: + store_times[store_id] = min(create_time, store_times.get(store_id, create_time)) + # Convert to set of (object_store_id, oldest_create_time) pairs + store_times_summary = set(store_times.items()) + self._dataset_dbkeys_and_extensions_summary = (dbkeys, extensions, store_times_summary) return self._dataset_dbkeys_and_extensions_summary @property @@ -7435,7 +7444,7 @@ def _serialize(self, id_encoder, serialization_options): def to_dict(self, view="collection"): original_dict_value = super().to_dict(view=view) if view == "dbkeysandextensions": - (dbkeys, extensions) = self.dataset_dbkeys_and_extensions_summary + (dbkeys, extensions, object_store_ids) = self.dataset_dbkeys_and_extensions_summary dict_value = dict( dbkey=dbkeys.pop() if len(dbkeys) == 1 else "?", extension=extensions.pop() if len(extensions) == 1 else "auto", diff --git a/lib/galaxy/objectstore/__init__.py b/lib/galaxy/objectstore/__init__.py index 1b426ca0c072..2602465515d8 100644 --- a/lib/galaxy/objectstore/__init__.py +++ b/lib/galaxy/objectstore/__init__.py @@ -756,6 +756,7 @@ def __init__(self, config, config_dict=None, **kwargs): self.description = config_dict.get("description", None) # Annotate this as true to prevent sharing of data. self.private = config_dict.get("private", DEFAULT_PRIVATE) + self.object_expires_after_days = config_dict.get("object_expires_after_days", None) # short label describing the quota source or null to use default # quota source right on user object. quota_config = config_dict.get("quota", {}) @@ -776,6 +777,7 @@ def to_dict(self): } rval["badges"] = self._get_concrete_store_badges(None) rval["device"] = self.device_id + rval["object_expires_after_days"] = self.object_expires_after_days return rval def to_model(self, object_store_id: str) -> "ConcreteObjectStoreModel": @@ -787,6 +789,7 @@ def to_model(self, object_store_id: str) -> "ConcreteObjectStoreModel": quota=QuotaModel(source=self.quota_source, enabled=self.quota_enabled), badges=self._get_concrete_store_badges(None), device=self.device_id, + object_expires_after_days=self.object_expires_after_days, ) def _get_concrete_store_badges(self, obj) -> List[BadgeDict]: @@ -1774,6 +1777,7 @@ class ConcreteObjectStoreModel(BaseModel): quota: QuotaModel badges: List[BadgeDict] device: Optional[str] = None + object_expires_after_days: Optional[int] = None def type_to_object_store_class( diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 9067ad02a0ba..56fe1cc0ebb1 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -669,7 +669,7 @@ class HistoryItemCommon(HistoryItemBase): title="Type", description="The type of this item.", ) - create_time: Optional[datetime] = CreateTimeField + create_time: datetime = CreateTimeField update_time: Optional[datetime] = UpdateTimeField url: RelativeUrlField tags: TagCollection @@ -707,6 +707,11 @@ class HDASummary(HDACommon): description="Whether this dataset has been removed from disk.", ) genome_build: Optional[str] = GenomeBuildField + object_store_id: Optional[str] = Field( + None, + title="Object Store ID", + description="The ID of the object store that this dataset is stored in.", + ) class HDAInaccessible(HDACommon): @@ -1128,6 +1133,21 @@ class HDCACommon(HistoryItemCommon): ] +class OldestCreateTimeByObjectStoreId(Model): + """Represents the oldest creation time of a set of datasets stored in a specific object store.""" + + object_store_id: str = Field( + ..., + title="Object Store ID", + description="The ID of the object store.", + ) + oldest_create_time: datetime = Field( + ..., + title="Oldest Create Time", + description="The oldest creation time of a set of datasets stored in this object store.", + ) + + class HDCASummary(HDCACommon, WithModelClass): """History Dataset Collection Association summary information.""" @@ -1164,6 +1184,15 @@ class HDCASummary(HDCACommon, WithModelClass): ) contents_url: ContentsUrlField collection_id: DatasetCollectionId + store_times_summary: Optional[List[OldestCreateTimeByObjectStoreId]] = Field( + None, + title="Store Times Summary", + description=( + "A list of objects containing the object store ID and the oldest creation time of the datasets stored in that object store " + "for this collection." + "This is used to determine the age of the datasets in the collection when the object store is short-lived." + ), + ) class HDCADetailed(HDCASummary): diff --git a/test/unit/app/managers/test_markdown_export.py b/test/unit/app/managers/test_markdown_export.py index 06b3c66555a1..aa47ad09db19 100644 --- a/test/unit/app/managers/test_markdown_export.py +++ b/test/unit/app/managers/test_markdown_export.py @@ -379,7 +379,9 @@ def test_export_dataset_collection_paired(self): from galaxy.managers.hdcas import HDCASerializer with mock.patch.object(HDCASerializer, "url_for", return_value="http://google.com"): - export, extra_data = self._ready_export(example) + # Patch out serialize_store_times_summary because the datasets aren't real, just mock objects. + with mock.patch.object(HDCASerializer, "serialize_store_times_summary", return_value=[]): + export, extra_data = self._ready_export(example) assert "history_dataset_collections" in extra_data assert len(extra_data.get("history_dataset_collections")) == 1