From ba074bd4e012437758458fe4d175f7748c0a7e10 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:00:58 +0100 Subject: [PATCH 01/44] Preserve API behavior for "keys" Replace the `HistoryMinimal` model with a partial HistoryDetailed model where all fields are optional. --- client/src/api/schema/schema.ts | 233 +++++++++++++++++++++++++------- lib/galaxy/schema/__init__.py | 46 +++++++ lib/galaxy/schema/schema.py | 41 +++--- 3 files changed, 256 insertions(+), 64 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 6f7aac5f9e88..bac9afa9dcbd 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -3597,6 +3597,142 @@ export interface components { CustomHistoryItem: { [key: string]: unknown | undefined; }; + /** CustomHistoryView */ + CustomHistoryView: { + /** + * Annotation + * @description An annotation to provide details or to help understand the purpose and usage of this item. + */ + annotation?: string | null; + /** + * Archived + * @description Whether this item has been archived and is no longer active. + */ + archived?: boolean | null; + /** + * Contents Active + * @description Contains the number of active, deleted or hidden items in a History. + */ + contents_active?: components["schemas"]["HistoryActiveContentCounts"] | null; + /** + * Contents URL + * @description The relative URL to access the contents of this History. + */ + contents_url?: string | null; + /** + * Count + * @description The number of items in the history. + */ + count?: number | null; + /** + * Create Time + * @description The time and date this item was created. + */ + create_time?: string | null; + /** + * Deleted + * @description Whether this item is marked as deleted. + */ + deleted?: boolean | null; + /** + * Genome Build + * @description TODO + */ + genome_build?: string | null; + /** + * History ID + * @example 0123456789ABCDEF + */ + id?: string; + /** + * Importable + * @description Whether this History can be imported by other users with a shared link. + */ + importable?: boolean | null; + /** + * Model class + * @description The name of the database model class. + * @constant + */ + model_class?: "History"; + /** + * Name + * @description The name of the history. + */ + name?: string | null; + /** + * Preferred Object Store ID + * @description The ID of the object store that should be used to store new datasets in this history. + */ + preferred_object_store_id?: string | null; + /** + * Published + * @description Whether this resource is currently publicly available to all users. + */ + published?: boolean | null; + /** + * Purged + * @description Whether this item has been permanently removed. + */ + purged?: boolean | null; + /** + * Size + * @description The total size of the contents of this history in bytes. + */ + size?: number | null; + /** + * Slug + * @description Part of the URL to uniquely identify this History by link in a readable way. + */ + slug?: string | null; + /** + * State + * @description The current state of the History based on the states of the datasets it contains. + */ + state?: components["schemas"]["DatasetState"] | null; + /** + * State Counts + * @description A dictionary keyed to possible dataset states and valued with the number of datasets in this history that have those states. + */ + state_details?: { + [key: string]: number | undefined; + } | null; + /** + * State IDs + * @description A dictionary keyed to possible dataset states and valued with lists containing the ids of each HDA in that state. + */ + state_ids?: { + [key: string]: string[] | undefined; + } | null; + tags?: components["schemas"]["TagCollection"] | null; + /** + * Update Time + * @description The last time and date this item was updated. + */ + update_time?: string | null; + /** + * URL + * @deprecated + * @description The relative URL to access this item. + */ + url?: string | null; + /** + * User ID + * @description The encoded ID of the user that owns this History. + */ + user_id?: string | null; + /** + * Username + * @description Owner of the history + */ + username?: string | null; + /** + * Username and slug + * @description The relative URL in the form of /u/{username}/h/{slug} + */ + username_and_slug?: string | null; + [key: string]: unknown | undefined; + }; /** * DCESummary * @description Dataset Collection Element summary information. @@ -6378,6 +6514,27 @@ export interface components { HelpForumUser: { [key: string]: unknown | undefined; }; + /** + * HistoryActiveContentCounts + * @description Contains the number of active, deleted or hidden items in a History. + */ + HistoryActiveContentCounts: { + /** + * Active + * @description Number of active datasets. + */ + active: number; + /** + * Deleted + * @description Number of deleted datasets. + */ + deleted: number; + /** + * Hidden + * @description Number of hidden datasets. + */ + hidden: number; + }; /** HistoryContentBulkOperationPayload */ HistoryContentBulkOperationPayload: { /** Items */ @@ -6614,26 +6771,6 @@ export interface components { username_and_slug?: string | null; [key: string]: unknown | undefined; }; - /** - * HistoryMinimal - * @description Minimal History Response with optional fields - */ - HistoryMinimal: { - /** Id */ - id?: string | null; - /** - * Model class - * @description The name of the database model class. - * @constant - */ - model_class: "History"; - /** - * User ID - * @description The encoded ID of the user that owns this History. - */ - user_id?: string | null; - [key: string]: unknown | undefined; - }; /** * HistorySummary * @description History summary information. @@ -15139,9 +15276,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15181,9 +15318,9 @@ export interface operations { content: { "application/json": | components["schemas"]["JobImportHistoryResponse"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15267,9 +15404,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15305,9 +15442,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15373,9 +15510,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15410,9 +15547,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15447,9 +15584,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15507,9 +15644,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15549,9 +15686,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15592,9 +15729,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"] )[]; }; }; @@ -15629,9 +15766,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15670,9 +15807,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15712,9 +15849,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ @@ -15806,9 +15943,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistoryDetailed"] | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryMinimal"]; + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["CustomHistoryView"]; }; }; /** @description Validation Error */ diff --git a/lib/galaxy/schema/__init__.py b/lib/galaxy/schema/__init__.py index 0082c14ea5ea..b88ade573ef9 100644 --- a/lib/galaxy/schema/__init__.py +++ b/lib/galaxy/schema/__init__.py @@ -1,16 +1,23 @@ +import typing +from copy import deepcopy from datetime import datetime from enum import Enum from typing import ( + Any, + Callable, Dict, List, Optional, + TypeVar, Union, ) from pydantic import ( BaseModel, + create_model, Field, ) +from pydantic.fields import FieldInfo class BootstrapAdminUser(BaseModel): @@ -110,3 +117,42 @@ class PdfDocumentType(str, Enum): class APIKeyModel(BaseModel): key: str = Field(..., title="Key", description="API key to interact with the Galaxy API") create_time: datetime = Field(..., title="Create Time", description="The time and date this API key was created.") + + +T = TypeVar("T", bound="BaseModel") + + +def partial_model( + include: Optional[list[str]] = None, exclude: Optional[list[str]] = None +) -> Callable[[type[T]], type[T]]: + """Return a decorator to make model fields optional""" + + if exclude is None: + exclude = [] + + @typing.no_type_check # Mypy doesn't understand pydantic's create_model + def decorator(model: type[T]) -> type[T]: + def make_optional(field: FieldInfo, default: Any = None) -> tuple[Any, FieldInfo]: + new = deepcopy(field) + new.default = default + new.annotation = Optional[field.annotation or Any] + return new.annotation, new + + fields = model.model_fields + if include is None: + fields = fields.items() + else: + fields = ((k, v) for k, v in fields.items() if k in include) + + return create_model( + model.__name__, + __base__=model, + __module__=model.__module__, + **{ + field_name: make_optional(field_info) + for field_name, field_info in fields + if exclude is None or field_name not in exclude + }, + ) + + return decorator diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 40e076fd778c..d2efff6c78bd 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -33,6 +33,7 @@ Literal, ) +from galaxy.schema import partial_model from galaxy.schema.bco import XrefItem from galaxy.schema.fields import ( DecodedDatabaseIdField, @@ -1204,18 +1205,6 @@ class UpdateHistoryContentsPayload(HistoryBase): ) -class HistoryMinimal(HistoryBase, WithModelClass): - """Minimal History Response with optional fields""" - - model_class: HISTORY_MODEL_CLASS = ModelClassField(HISTORY_MODEL_CLASS) - id: Optional[HistoryID] = None - user_id: Optional[EncodedDatabaseIdField] = Field( - None, - title="User ID", - description="The encoded ID of the user that owns this History.", - ) - - class HistorySummary(HistoryBase, WithModelClass): """History summary information.""" @@ -1341,10 +1330,30 @@ class HistoryDetailed(HistorySummary): # Equivalent to 'dev-detailed' view, whi ) -AnyHistoryView = Union[ - HistoryDetailed, - HistorySummary, - HistoryMinimal, +@partial_model() +class CustomHistoryView(HistoryDetailed): + """History Response with all optional fields. + + It is used for serializing only specific attributes using the "keys" + query parameter. Unfortunately, we cannot know the exact fields that + will be requested, so we have to allow all fields to be optional. + """ + + # Define a few more useful fields to be optional that are not part of HistoryDetailed + contents_active: Optional[HistoryActiveContentCounts] = Field( + default=None, + title="Contents Active", + description=("Contains the number of active, deleted or hidden items in a History."), + ) + + +AnyHistoryView = Annotated[ + Union[ + HistorySummary, + HistoryDetailed, + CustomHistoryView, + ], + Field(union_mode="left_to_right"), ] From e2ad32d7cc0b811433c0011758a982af79372ba7 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 15 Mar 2024 13:24:37 +0100 Subject: [PATCH 02/44] Revert a7ea52841748437acfb172e11f513eed6f2fcceb Make sure only the requested keys are returned as the query "keys" used to return. --- lib/galaxy_test/api/test_histories.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/galaxy_test/api/test_histories.py b/lib/galaxy_test/api/test_histories.py index a3c8e28a9f89..f6beef0b597c 100644 --- a/lib/galaxy_test/api/test_histories.py +++ b/lib/galaxy_test/api/test_histories.py @@ -155,9 +155,10 @@ def test_index_views(self): # Expect only specific keys expected_keys = ["name"] - unexpected_keys = ["deleted", "state"] + unexpected_keys = ["id", "deleted", "state"] index_response = self._get(f"histories?keys={','.join(expected_keys)}").json() for history in index_response: + assert len(history) == len(expected_keys) for key in expected_keys: assert key in history for key in unexpected_keys: @@ -181,10 +182,11 @@ def test_index_search_mode_views(self): # Expect only specific keys expected_keys = ["name"] - unexpected_keys = ["deleted", "state"] + unexpected_keys = ["id", "deleted", "state"] data = dict(search=expected_name_contains, show_published=False, keys=",".join(expected_keys)) index_response = self._get("histories", data=data).json() for history in index_response: + assert len(history) == len(expected_keys) for key in expected_keys: assert key in history for key in unexpected_keys: From 947c6dade150b846920785567151df45deb9e8d1 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 10:21:20 +0100 Subject: [PATCH 03/44] Do not force serialize model_class when it is Optional --- lib/galaxy/schema/fields.py | 11 ++++++++++- lib/galaxy/schema/schema.py | 6 +++++- lib/galaxy/webapps/galaxy/api/histories.py | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/schema/fields.py b/lib/galaxy/schema/fields.py index 4fb6861b6b36..311a0cf105e5 100644 --- a/lib/galaxy/schema/fields.py +++ b/lib/galaxy/schema/fields.py @@ -1,5 +1,9 @@ import re -from typing import TYPE_CHECKING +from typing import ( + get_origin, + TYPE_CHECKING, + Union, +) from pydantic import ( BeforeValidator, @@ -112,6 +116,11 @@ def literal_to_value(arg): return val[0] +def is_optional(field): + args = get_args(field) + return get_origin(field) is Union and len(args) == 2 and type(None) in args + + def ModelClassField(default_value): """Represents a database model class name annotated as a constant pydantic Field. diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index d2efff6c78bd..2f37a486979b 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -39,6 +39,7 @@ DecodedDatabaseIdField, EncodedDatabaseIdField, EncodedLibraryFolderDatabaseIdField, + is_optional, LibraryFolderDatabaseIdField, literal_to_value, ModelClassField, @@ -313,8 +314,11 @@ class WithModelClass: def set_default(cls, data): if isinstance(data, dict): if "model_class" not in data and issubclass(cls, BaseModel): + model_class_annotation = cls.model_fields["model_class"].annotation + if is_optional(model_class_annotation): + return data data = data.copy() - data["model_class"] = literal_to_value(cls.model_fields["model_class"].annotation) + data["model_class"] = literal_to_value(model_class_annotation) return data diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index 743d31a48ccb..bad725b0424d 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -163,6 +163,7 @@ class FastAPIHistories: @router.get( "/api/histories", summary="Returns histories available to the current user.", + response_model_exclude_unset=True, ) def index( self, From c7f0fb0209fad9e96c3a401cc248989b62146dde Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:33:40 +0100 Subject: [PATCH 04/44] Use `response_model_exclude_unset` whenever we return `AnyHistoryView` This ensures we return just the fields specified in "keys" when returning a CustomHistory. --- client/src/api/schema/schema.ts | 9 ++++++--- lib/galaxy/schema/schema.py | 12 ++++++------ lib/galaxy/webapps/galaxy/api/histories.py | 13 +++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index bac9afa9dcbd..f108ec43e734 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -6522,18 +6522,21 @@ export interface components { /** * Active * @description Number of active datasets. + * @default 0 */ - active: number; + active?: number | null; /** * Deleted * @description Number of deleted datasets. + * @default 0 */ - deleted: number; + deleted?: number | null; /** * Hidden * @description Number of hidden datasets. + * @default 0 */ - hidden: number; + hidden?: number | null; }; /** HistoryContentBulkOperationPayload */ HistoryContentBulkOperationPayload: { diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 2f37a486979b..df1484839857 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1254,18 +1254,18 @@ class HistorySummary(HistoryBase, WithModelClass): class HistoryActiveContentCounts(Model): """Contains the number of active, deleted or hidden items in a History.""" - active: int = Field( - ..., + active: Optional[int] = Field( + default=0, title="Active", description="Number of active datasets.", ) - hidden: int = Field( - ..., + hidden: Optional[int] = Field( + default=0, title="Hidden", description="Number of hidden datasets.", ) - deleted: int = Field( - ..., + deleted: Optional[int] = Field( + default=0, title="Deleted", description="Number of deleted datasets.", ) diff --git a/lib/galaxy/webapps/galaxy/api/histories.py b/lib/galaxy/webapps/galaxy/api/histories.py index bad725b0424d..32e4c109f1c5 100644 --- a/lib/galaxy/webapps/galaxy/api/histories.py +++ b/lib/galaxy/webapps/galaxy/api/histories.py @@ -221,6 +221,7 @@ def count( @router.get( "/api/histories/deleted", summary="Returns deleted histories for the current user.", + response_model_exclude_unset=True, ) def index_deleted( self, @@ -236,6 +237,7 @@ def index_deleted( @router.get( "/api/histories/published", summary="Return all histories that are published.", + response_model_exclude_unset=True, ) def published( self, @@ -248,6 +250,7 @@ def published( @router.get( "/api/histories/shared_with_me", summary="Return all histories that are shared with the current user.", + response_model_exclude_unset=True, ) def shared_with_me( self, @@ -281,6 +284,7 @@ def get_archived_histories( @router.get( "/api/histories/most_recently_used", summary="Returns the most recently used history of the user.", + response_model_exclude_unset=True, ) def show_recent( self, @@ -293,6 +297,7 @@ def show_recent( "/api/histories/{history_id}", name="history", summary="Returns the history with the given ID.", + response_model_exclude_unset=True, ) def show( self, @@ -348,6 +353,7 @@ def citations( @router.post( "/api/histories", summary="Creates a new history.", + response_model_exclude_unset=True, ) def create( self, @@ -370,6 +376,7 @@ def create( @router.delete( "/api/histories/{history_id}", summary="Marks the history with the given ID as deleted.", + response_model_exclude_unset=True, ) def delete( self, @@ -386,6 +393,7 @@ def delete( @router.put( "/api/histories/batch/delete", summary="Marks several histories with the given IDs as deleted.", + response_model_exclude_unset=True, ) def batch_delete( self, @@ -405,6 +413,7 @@ def batch_delete( @router.post( "/api/histories/deleted/{history_id}/undelete", summary="Restores a deleted history with the given ID (that hasn't been purged).", + response_model_exclude_unset=True, ) def undelete( self, @@ -417,6 +426,7 @@ def undelete( @router.put( "/api/histories/batch/undelete", summary="Marks several histories with the given IDs as undeleted.", + response_model_exclude_unset=True, ) def batch_undelete( self, @@ -433,6 +443,7 @@ def batch_undelete( @router.put( "/api/histories/{history_id}", summary="Updates the values for the history with the given ID.", + response_model_exclude_unset=True, ) def update( self, @@ -449,6 +460,7 @@ def update( @router.post( "/api/histories/from_store", summary="Create histories from a model store.", + response_model_exclude_unset=True, ) def create_from_store( self, @@ -620,6 +632,7 @@ def archive_history( @router.put( "/api/histories/{history_id}/archive/restore", summary="Restore an archived history.", + response_model_exclude_unset=True, ) def restore_archived_history( self, From 9f11c07a8ff1a743ed2cb5d4676322f650131630 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:34:00 +0100 Subject: [PATCH 05/44] Add API test for show history views/keys --- lib/galaxy_test/api/test_histories.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/galaxy_test/api/test_histories.py b/lib/galaxy_test/api/test_histories.py index f6beef0b597c..7dc436e98f7d 100644 --- a/lib/galaxy_test/api/test_histories.py +++ b/lib/galaxy_test/api/test_histories.py @@ -106,6 +106,26 @@ def test_show_history_returns_expected_urls(self): assert show_response["url"] == f"/api/histories/{history_id}" assert show_response["contents_url"] == f"/api/histories/{history_id}/contents" + def test_show_respects_view(self): + history_id = self._create_history(f"TestHistoryForShowView_{uuid4()}")["id"] + # By default the view is "detailed" + show_response = self._get(f"histories/{history_id}").json() + assert "state" in show_response + + # Change the view to summary + show_response = self._get(f"histories/{history_id}", {"view": "summary"}).json() + assert "state" not in show_response + + # Expect only specific keys + expected_keys = ["name"] + unexpected_keys = ["id", "deleted", "state"] + show_response = self._get(f"histories/{history_id}", {"keys": ",".join(expected_keys)}).json() + assert len(show_response) == len(expected_keys) + for key in expected_keys: + assert key in show_response + for key in unexpected_keys: + assert key not in show_response + def test_show_most_recently_used(self): history_id = self._create_history("TestHistoryRecent")["id"] show_response = self._get("histories/most_recently_used").json() From 1683dfbb1e93e389940324256068c9575e6b5d4b Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:42:53 +0100 Subject: [PATCH 06/44] Do not mix `await` and `then` --- client/src/stores/historyStore.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/stores/historyStore.ts b/client/src/stores/historyStore.ts index fa82ee602e1b..16aec9d1d03d 100644 --- a/client/src/stores/historyStore.ts +++ b/client/src/stores/historyStore.ts @@ -241,12 +241,14 @@ export const useHistoryStore = defineStore("historyStore", () => { async function loadHistoryById(historyId: string) { if (!isLoadingHistory.has(historyId)) { isLoadingHistory.add(historyId); - await getHistoryByIdFromServer(historyId) - .then((history) => setHistory(history as HistorySummary)) - .catch((error: Error) => console.warn(error)) - .finally(() => { + try { + const history = await getHistoryByIdFromServer(historyId); + setHistory(history as HistorySummary); + } catch (error) { + console.error(error); + } finally { isLoadingHistory.delete(historyId); - }); + } } } From 6add6ccd54359939b5a4f33a9a2a90fb9182ff76 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:45:47 +0100 Subject: [PATCH 07/44] Remove global Vue import --- client/src/stores/historyStore.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/stores/historyStore.ts b/client/src/stores/historyStore.ts index 16aec9d1d03d..3bc1de807fd9 100644 --- a/client/src/stores/historyStore.ts +++ b/client/src/stores/historyStore.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import Vue, { computed, ref } from "vue"; +import { computed, del, ref, set } from "vue"; import type { HistorySummary } from "@/api"; import { archiveHistory, unarchiveHistory } from "@/api/histories.archived"; @@ -96,11 +96,11 @@ export const useHistoryStore = defineStore("historyStore", () => { } function setFilterText(historyId: string, filterText: string) { - Vue.set(storedFilterTexts.value, historyId, filterText); + set(storedFilterTexts.value, historyId, filterText); } function setHistory(history: HistorySummary) { - Vue.set(storedHistories.value, history.id, history); + set(storedHistories.value, history.id, history); } function setHistories(histories: HistorySummary[]) { @@ -179,7 +179,7 @@ export const useHistoryStore = defineStore("historyStore", () => { } else { await createNewHistory(); } - Vue.delete(storedHistories.value, deletedHistory.id); + del(storedHistories.value, deletedHistory.id); unpinHistories([deletedHistory.id]); await handleTotalCountChange(1, true); } @@ -247,7 +247,7 @@ export const useHistoryStore = defineStore("historyStore", () => { } catch (error) { console.error(error); } finally { - isLoadingHistory.delete(historyId); + isLoadingHistory.delete(historyId); } } } From 671b522d6e19329751b10b5c7d838112266b2b80 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:52:23 +0100 Subject: [PATCH 08/44] Fix side effect when updating parts of a history When requesting only some keys for a particular history, we don't want to replace the whole history with just a few values. --- client/src/stores/historyStore.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/stores/historyStore.ts b/client/src/stores/historyStore.ts index 3bc1de807fd9..0ceb16cbb053 100644 --- a/client/src/stores/historyStore.ts +++ b/client/src/stores/historyStore.ts @@ -100,7 +100,12 @@ export const useHistoryStore = defineStore("historyStore", () => { } function setHistory(history: HistorySummary) { - set(storedHistories.value, history.id, history); + if (storedHistories.value[history.id] !== undefined) { + // Merge the incoming history with existing one to keep additional information + Object.assign(storedHistories.value[history.id]!, history); + } else { + set(storedHistories.value, history.id, history); + } } function setHistories(histories: HistorySummary[]) { From fbc2d9eefa75b90d1770eab765d511db87dbed16 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Sun, 17 Mar 2024 11:55:21 +0100 Subject: [PATCH 09/44] Add TODO doc for partial_model --- lib/galaxy/schema/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/schema/__init__.py b/lib/galaxy/schema/__init__.py index b88ade573ef9..b35450b690da 100644 --- a/lib/galaxy/schema/__init__.py +++ b/lib/galaxy/schema/__init__.py @@ -122,10 +122,13 @@ class APIKeyModel(BaseModel): T = TypeVar("T", bound="BaseModel") +# TODO: This is a workaround to make all fields optional. +# It should be removed when Python/pydantic supports this feature natively. +# https://github.com/pydantic/pydantic/issues/1673 def partial_model( include: Optional[list[str]] = None, exclude: Optional[list[str]] = None ) -> Callable[[type[T]], type[T]]: - """Return a decorator to make model fields optional""" + """Decorator to make all model fields optional""" if exclude is None: exclude = [] From 95fb8daa78b1175a4e65cda41eadbb876a18b126 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:30:33 +0100 Subject: [PATCH 10/44] Remove config to allow all extra fields for history models --- lib/galaxy/schema/schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index df1484839857..bc104ef1d0ac 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1072,8 +1072,6 @@ class HDCADetailed(HDCASummary): class HistoryBase(Model): """Provides basic configuration for all the History models.""" - model_config = ConfigDict(extra="allow") - class HistoryContentItemBase(Model): """Identifies a dataset or collection contained in a History.""" @@ -1099,9 +1097,11 @@ class UpdateContentItem(HistoryContentItem): model_config = ConfigDict(use_enum_values=True, extra="allow") -class UpdateHistoryContentsBatchPayload(HistoryBase): +class UpdateHistoryContentsBatchPayload(Model): """Contains property values that will be updated for all the history `items` provided.""" + model_config = ConfigDict(extra="allow") + items: List[UpdateContentItem] = Field( ..., title="Items", From aba671cb85e5a210958dcf1744d9d8bd1da27adf Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:31:34 +0100 Subject: [PATCH 11/44] Increase test coverage for API queries with view and keys --- lib/galaxy_test/api/test_histories.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/galaxy_test/api/test_histories.py b/lib/galaxy_test/api/test_histories.py index 7dc436e98f7d..421e5a6d2e0a 100644 --- a/lib/galaxy_test/api/test_histories.py +++ b/lib/galaxy_test/api/test_histories.py @@ -174,7 +174,7 @@ def test_index_views(self): assert "state" in history # Expect only specific keys - expected_keys = ["name"] + expected_keys = ["nice_size", "contents_active", "contents_states"] unexpected_keys = ["id", "deleted", "state"] index_response = self._get(f"histories?keys={','.join(expected_keys)}").json() for history in index_response: @@ -184,6 +184,16 @@ def test_index_views(self): for key in unexpected_keys: assert key not in history + # Expect combination of view and keys + view = "summary" + expected_keys = ["create_time", "count"] + data = dict(view=view, keys=",".join(expected_keys)) + index_response = self._get("histories", data=data).json() + for history in index_response: + for key in expected_keys: + assert key in history + self._assert_has_keys(history, "id", "name", "url", "update_time", "deleted", "purged", "tags") + def test_index_search_mode_views(self): # Make sure there is at least one history expected_name_contains = "SearchMode" @@ -201,7 +211,7 @@ def test_index_search_mode_views(self): assert "state" in history # Expect only specific keys - expected_keys = ["name"] + expected_keys = ["nice_size", "contents_active", "contents_states"] unexpected_keys = ["id", "deleted", "state"] data = dict(search=expected_name_contains, show_published=False, keys=",".join(expected_keys)) index_response = self._get("histories", data=data).json() @@ -212,6 +222,16 @@ def test_index_search_mode_views(self): for key in unexpected_keys: assert key not in history + # Expect combination of view and keys + view = "summary" + expected_keys = ["create_time", "count"] + data = dict(search=expected_name_contains, show_published=False, view=view, keys=",".join(expected_keys)) + index_response = self._get("histories", data=data).json() + for history in index_response: + for key in expected_keys: + assert key in history + self._assert_has_keys(history, "id", "name", "url", "update_time", "deleted", "purged", "tags") + def test_index_case_insensitive_contains_query(self): # Create the histories with a different user to ensure the test # is not conflicted with the current user's histories. From df2e2025dade6c85ebd01ec14cb9ae900d869edc Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:38:56 +0100 Subject: [PATCH 12/44] Reorder union for proper model matching --- lib/galaxy/schema/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index bc104ef1d0ac..9b12a024751d 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1353,9 +1353,9 @@ class CustomHistoryView(HistoryDetailed): AnyHistoryView = Annotated[ Union[ - HistorySummary, - HistoryDetailed, CustomHistoryView, + HistoryDetailed, + HistorySummary, ], Field(union_mode="left_to_right"), ] From f92f14e3599a85cd4d1280da01343639e79bb2c2 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:47:01 +0100 Subject: [PATCH 13/44] Add missing optional fields to CustomHistoryView We now need to explicitly define any field that would be serialized --- lib/galaxy/schema/schema.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 9b12a024751d..c525c1616b41 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -1349,6 +1349,16 @@ class CustomHistoryView(HistoryDetailed): title="Contents Active", description=("Contains the number of active, deleted or hidden items in a History."), ) + contents_states: Optional[HistoryStateCounts] = Field( + default=None, + title="Contents States", + description="A dictionary keyed to possible dataset states and valued with the number of datasets in this history that have those states.", + ) + nice_size: Optional[str] = Field( + default=None, + title="Nice Size", + description="The total size of the contents of this history in a human-readable format.", + ) AnyHistoryView = Annotated[ From 4f97f7eafe43a3d7cbd5e73570c49616b54668fe Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:49:08 +0100 Subject: [PATCH 14/44] Update client API schema --- client/src/api/schema/schema.ts | 75 ++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index f108ec43e734..738685693143 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -2336,7 +2336,6 @@ export interface components { * @description The relative URL in the form of /u/{username}/h/{slug} */ username_and_slug?: string | null; - [key: string]: unknown | undefined; }; /** ArchivedHistorySummary */ ArchivedHistorySummary: { @@ -2409,7 +2408,6 @@ export interface components { * @description The relative URL to access this item. */ url: string; - [key: string]: unknown | undefined; }; /** AsyncFile */ AsyncFile: { @@ -3614,6 +3612,13 @@ export interface components { * @description Contains the number of active, deleted or hidden items in a History. */ contents_active?: components["schemas"]["HistoryActiveContentCounts"] | null; + /** + * Contents States + * @description A dictionary keyed to possible dataset states and valued with the number of datasets in this history that have those states. + */ + contents_states?: { + [key: string]: number | undefined; + } | null; /** * Contents URL * @description The relative URL to access the contents of this History. @@ -3660,6 +3665,11 @@ export interface components { * @description The name of the history. */ name?: string | null; + /** + * Nice Size + * @description The total size of the contents of this history in a human-readable format. + */ + nice_size?: string | null; /** * Preferred Object Store ID * @description The ID of the object store that should be used to store new datasets in this history. @@ -3731,7 +3741,6 @@ export interface components { * @description The relative URL in the form of /u/{username}/h/{slug} */ username_and_slug?: string | null; - [key: string]: unknown | undefined; }; /** * DCESummary @@ -6772,7 +6781,6 @@ export interface components { * @description The relative URL in the form of /u/{username}/h/{slug} */ username_and_slug?: string | null; - [key: string]: unknown | undefined; }; /** * HistorySummary @@ -6843,7 +6851,6 @@ export interface components { * @description The relative URL to access this item. */ url: string; - [key: string]: unknown | undefined; }; /** * Hyperlink @@ -11661,7 +11668,6 @@ export interface components { * @description A list of content items to update with the changes. */ items: components["schemas"]["UpdateContentItem"][]; - [key: string]: unknown | undefined; }; /** * UpdateHistoryContentsPayload @@ -11697,7 +11703,6 @@ export interface components { * @description Whether this item is visible in the history. */ visible?: boolean | null; - [key: string]: unknown | undefined; }; /** UpdateLibraryFolderPayload */ UpdateLibraryFolderPayload: { @@ -15279,9 +15284,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15321,9 +15326,9 @@ export interface operations { content: { "application/json": | components["schemas"]["JobImportHistoryResponse"] - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15407,9 +15412,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15445,9 +15450,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15513,9 +15518,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15550,9 +15555,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15587,9 +15592,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15647,9 +15652,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15689,9 +15694,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15732,9 +15737,9 @@ export interface operations { 200: { content: { "application/json": ( - | components["schemas"]["HistorySummary"] - | components["schemas"]["HistoryDetailed"] | components["schemas"]["CustomHistoryView"] + | components["schemas"]["HistoryDetailed"] + | components["schemas"]["HistorySummary"] )[]; }; }; @@ -15769,9 +15774,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15810,9 +15815,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15852,9 +15857,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ @@ -15946,9 +15951,9 @@ export interface operations { 200: { content: { "application/json": - | components["schemas"]["HistorySummary"] + | components["schemas"]["CustomHistoryView"] | components["schemas"]["HistoryDetailed"] - | components["schemas"]["CustomHistoryView"]; + | components["schemas"]["HistorySummary"]; }; }; /** @description Validation Error */ From d5d26b3cd7c9bc55b0b2fb204b46e3e1e41c43d9 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:59:56 +0100 Subject: [PATCH 15/44] Refactor move user-related types to api module --- client/src/api/index.ts | 23 +++++++++++++++++++ client/src/composables/hashedUserId.ts | 3 ++- client/src/composables/userLocalStorage.ts | 2 +- client/src/stores/userStore.ts | 26 +--------------------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/client/src/api/index.ts b/client/src/api/index.ts index 44704ca5fda5..0dbafc1a8b11 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -125,3 +125,26 @@ export function hasDetails(entry: DatasetEntry): entry is DatasetDetails { * Contains dataset metadata information. */ export type MetadataFiles = components["schemas"]["MetadataFile"][]; + +type QuotaUsageResponse = components["schemas"]["UserQuotaUsage"]; + +export interface User extends QuotaUsageResponse { + id: string; + email: string; + tags_used: string[]; + isAnonymous: false; + is_admin?: boolean; + username?: string; +} + +export interface AnonymousUser { + isAnonymous: true; + username?: string; + is_admin?: false; +} + +export type GenericUser = User | AnonymousUser; + +export function isRegisteredUser(user: User | AnonymousUser | null): user is User { + return !user?.isAnonymous; +} diff --git a/client/src/composables/hashedUserId.ts b/client/src/composables/hashedUserId.ts index fcda4cfde8f0..c19786b6ea7a 100644 --- a/client/src/composables/hashedUserId.ts +++ b/client/src/composables/hashedUserId.ts @@ -2,7 +2,8 @@ import { useLocalStorage } from "@vueuse/core"; import { storeToRefs } from "pinia"; import { computed, type Ref, ref, watch } from "vue"; -import { GenericUser, useUserStore } from "@/stores/userStore"; +import type { GenericUser } from "@/api"; +import { useUserStore } from "@/stores/userStore"; async function hash32(value: string): Promise { const valueUtf8 = new TextEncoder().encode(value); diff --git a/client/src/composables/userLocalStorage.ts b/client/src/composables/userLocalStorage.ts index b8a85b91845b..75375eb9d214 100644 --- a/client/src/composables/userLocalStorage.ts +++ b/client/src/composables/userLocalStorage.ts @@ -1,7 +1,7 @@ import { useLocalStorage } from "@vueuse/core"; import { computed, customRef, type Ref, ref } from "vue"; -import type { GenericUser } from "@/stores/userStore"; +import type { GenericUser } from "@/api"; import { useHashedUserId } from "./hashedUserId"; diff --git a/client/src/stores/userStore.ts b/client/src/stores/userStore.ts index e5296255d9ac..26783f6609fc 100644 --- a/client/src/stores/userStore.ts +++ b/client/src/stores/userStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; -import type { components } from "@/api/schema"; +import type { AnonymousUser, User } from "@/api"; import { useUserLocalStorage } from "@/composables/userLocalStorage"; import { useHistoryStore } from "@/stores/historyStore"; import { @@ -11,25 +11,6 @@ import { setCurrentThemeQuery, } from "@/stores/users/queries"; -type QuotaUsageResponse = components["schemas"]["UserQuotaUsage"]; - -export interface User extends QuotaUsageResponse { - id: string; - email: string; - tags_used: string[]; - isAnonymous: false; - is_admin?: boolean; - username?: string; -} - -export interface AnonymousUser { - isAnonymous: true; - username?: string; - is_admin?: false; -} - -export type GenericUser = User | AnonymousUser; - interface Preferences { theme: string; favorites: { tools: string[] }; @@ -143,10 +124,6 @@ export const useUserStore = defineStore("userStore", () => { toggledSideBar.value = toggledSideBar.value === currentOpen ? "" : currentOpen; } - function isRegisteredUser(user: User | AnonymousUser | null): user is User { - return !user?.isAnonymous; - } - return { currentUser, currentPreferences, @@ -164,7 +141,6 @@ export const useUserStore = defineStore("userStore", () => { removeFavoriteTool, toggleActivityBar, toggleSideBar, - isRegisteredUser, $reset, }; }); From b0bccaf0e0740e1eabfffd6fdd46c8fe727e93be Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:06:34 +0100 Subject: [PATCH 16/44] Fix typo --- .../src/components/History/CurrentHistory/HistoryMessages.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/History/CurrentHistory/HistoryMessages.vue b/client/src/components/History/CurrentHistory/HistoryMessages.vue index 22316dedf63f..91e06beb75fc 100644 --- a/client/src/components/History/CurrentHistory/HistoryMessages.vue +++ b/client/src/components/History/CurrentHistory/HistoryMessages.vue @@ -14,13 +14,13 @@ const props = defineProps(); const userOverQuota = ref(false); const hasMessages = computed(() => { - return userOverQuota.value || props.history.isDeleted; + return userOverQuota.value || props.history.deleted; });