diff --git a/client/src/components/History/CurrentHistory/HistoryCounter.vue b/client/src/components/History/CurrentHistory/HistoryCounter.vue index 30ef239cf236..0c41424f55f0 100644 --- a/client/src/components/History/CurrentHistory/HistoryCounter.vue +++ b/client/src/components/History/CurrentHistory/HistoryCounter.vue @@ -12,19 +12,25 @@ {{ historySize | niceFileSize }} - - - - - + + + + + + + + + { let axiosMock; beforeEach(async () => { axiosMock = new MockAdapter(axios); - axiosMock.onGet("/api/object_store?selectable=true").reply(200, OBJECT_STORES); }); afterEach(async () => { @@ -44,10 +44,10 @@ describe("SelectPreferredStore.vue", () => { it("updates object store to default on selection null", async () => { const wrapper = mountComponent(); await flushPromises(); - const els = wrapper.findAll(ROOT_COMPONENT.preferences.object_store_selection.option_buttons.selector); + const els = wrapper.findAll(PREFERENCES.object_store_selection.option_buttons.selector); expect(els.length).toBe(3); const galaxyDefaultOption = wrapper.find( - ROOT_COMPONENT.preferences.object_store_selection.option_button({ object_store_id: "__null__" }).selector + PREFERENCES.object_store_selection.option_button({ object_store_id: "__null__" }).selector ); expect(galaxyDefaultOption.exists()).toBeTruthy(); axiosMock diff --git a/client/src/components/History/CurrentHistory/SelectPreferredStore.vue b/client/src/components/History/CurrentHistory/SelectPreferredStore.vue index 6cf49f4f0801..0fd3195918f4 100644 --- a/client/src/components/History/CurrentHistory/SelectPreferredStore.vue +++ b/client/src/components/History/CurrentHistory/SelectPreferredStore.vue @@ -35,27 +35,27 @@ export default { selectedObjectStoreId: selectedObjectStoreId, newDatasetsDescription: "New dataset outputs from tools and workflows executed in this history", popoverPlacement: "left", - galaxySelectionDefalutTitle: "Use Galaxy Defaults", - galaxySelectionDefalutDescription: + galaxySelectionDefaultTitle: "Use Galaxy Defaults", + galaxySelectionDefaultDescription: "Selecting this will reset Galaxy to default behaviors configured by your Galaxy administrator.", - userSelectionDefalutTitle: "Use Your User Preference Defaults", - userSelectionDefalutDescription: + userSelectionDefaultTitle: "Use Your User Preference Defaults", + userSelectionDefaultDescription: "Selecting this will cause the history to not set a default and to fallback to your user preference defined default.", }; }, computed: { defaultOptionTitle() { if (this.userPreferredObjectStoreId) { - return this.userSelectionDefalutTitle; + return this.userSelectionDefaultTitle; } else { - return this.galaxySelectionDefalutTitle; + return this.galaxySelectionDefaultTitle; } }, defaultOptionDescription() { if (this.userPreferredObjectStoreId) { - return this.userSelectionDefalutDescription; + return this.userSelectionDefaultDescription; } else { - return this.galaxySelectionDefalutDescription; + return this.galaxySelectionDefaultDescription; } }, }, diff --git a/client/src/components/ObjectStore/SelectObjectStore.vue b/client/src/components/ObjectStore/SelectObjectStore.vue index 2d3096f4b04c..0ce3cb152923 100644 --- a/client/src/components/ObjectStore/SelectObjectStore.vue +++ b/client/src/components/ObjectStore/SelectObjectStore.vue @@ -1,3 +1,89 @@ + + - - diff --git a/client/src/components/ObjectStore/mockServices.ts b/client/src/components/ObjectStore/mockServices.ts new file mode 100644 index 000000000000..bd9e0f80c4e8 --- /dev/null +++ b/client/src/components/ObjectStore/mockServices.ts @@ -0,0 +1,12 @@ +import { getSelectableObjectStores } from "./services"; +jest.mock("./services"); + +const OBJECT_STORES = [ + { object_store_id: "object_store_1", badges: [], quota: { enabled: false }, private: false }, + { object_store_id: "object_store_2", badges: [], quota: { enabled: false }, private: false }, +]; + +export function setupSelectableMock() { + const mockGetObjectStores = getSelectableObjectStores as jest.MockedFunction; + mockGetObjectStores.mockResolvedValue(OBJECT_STORES); +} diff --git a/client/src/components/ObjectStore/services.ts b/client/src/components/ObjectStore/services.ts new file mode 100644 index 000000000000..418526f17818 --- /dev/null +++ b/client/src/components/ObjectStore/services.ts @@ -0,0 +1,8 @@ +import { fetcher } from "@/schema/fetcher"; + +const getObjectStores = fetcher.path("/api/object_store").method("get").create(); + +export async function getSelectableObjectStores() { + const { data } = await getObjectStores({ selectable: true }); + return data; +} diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index f877b14af41c..72100d6c6e84 100644 --- a/client/src/components/Tool/ToolCard.vue +++ b/client/src/components/Tool/ToolCard.vue @@ -130,7 +130,7 @@ function onUpdatePreferredObjectStoreId(selectedToolPreferredObjectStoreId) { size="sm" class="float-right" @click="onShowObjectStoreSelect"> - + { let axiosMock; beforeEach(async () => { axiosMock = new MockAdapter(axios); - axiosMock.onGet("/api/object_store?selectable=true").reply(200, OBJECT_STORES); }); afterEach(async () => { @@ -50,6 +47,7 @@ describe("UserPreferredObjectStore.vue", () => { const wrapper = mountComponent(); const el = await wrapper.find(ROOT_COMPONENT.preferences.object_store.selector); await el.trigger("click"); + await flushPromises(); const els = wrapper.findAll(ROOT_COMPONENT.preferences.object_store_selection.option_buttons.selector); expect(els.length).toBe(3); const galaxyDefaultOption = wrapper.find( @@ -63,7 +61,7 @@ describe("UserPreferredObjectStore.vue", () => { expect(errorEl.exists()).toBeFalsy(); }); - it("updates object store to default on selection null", async () => { + it("updates object store to default on actual selection", async () => { const wrapper = mountComponent(); const el = await wrapper.find(ROOT_COMPONENT.preferences.object_store.selector); await el.trigger("click"); diff --git a/client/src/components/Workflow/Run/WorkflowStorageConfiguration.vue b/client/src/components/Workflow/Run/WorkflowStorageConfiguration.vue index b5bc04252618..3d5a62022754 100644 --- a/client/src/components/Workflow/Run/WorkflowStorageConfiguration.vue +++ b/client/src/components/Workflow/Run/WorkflowStorageConfiguration.vue @@ -5,7 +5,7 @@ class="workflow-storage-indicator workflow-storage-indicator-primary" v-bind="buttonProps" @click="showPreferredObjectStoreModal = true"> - + - + []; + "application/json": components["schemas"]["ConcreteObjectStoreModel"][]; }; }; /** @description Validation Error */ @@ -12441,7 +12512,7 @@ export interface operations { }; }; show_info_api_object_store__object_store_id__get: { - /** Return boolean to indicate if Galaxy's default object store allows selection. */ + /** Get information about a concrete object store configured with Galaxy. */ parameters: { /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ header?: { @@ -12453,10 +12524,10 @@ export interface operations { }; }; responses: { - /** @description A list with details about the remote files available to the user. */ + /** @description Successful Response */ 200: { content: { - "application/json": Record; + "application/json": components["schemas"]["ConcreteObjectStoreModel"]; }; }; /** @description Validation Error */ @@ -13950,6 +14021,62 @@ export interface operations { }; }; }; + get_user_usage_api_users__user_id__usage_get: { + /** Return the user's quota usage summary broken down by quota source */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + /** @description The ID of the user to get or __current__. */ + path: { + user_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserQuotaUsage"][]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_usage_for_label_api_users__user_id__usage__label__get: { + /** Return the user's quota usage summary for a given quota source label */ + parameters: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + header?: { + "run-as"?: string; + }; + /** @description The ID of the user to get or __current__. */ + /** @description The label corresponding to the quota source to fetch usage information about. */ + path: { + user_id: string; + label: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UserQuotaUsage"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; version_api_version_get: { /** * Return Galaxy version information: major/minor version, optional extra info diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index 7e3c6c3c589d..2c58dd7e1052 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -34,6 +34,7 @@ base, deletable, ) +from galaxy.model import UserQuotaUsage from galaxy.security.validate_user_input import ( VALID_EMAIL_RE, validate_email, @@ -650,22 +651,38 @@ def add_serializers(self): } ) - def serialize_disk_usage(self, user: model.User) -> List[Dict[str, Any]]: - rval = user.dictify_usage(self.app.object_store) - for usage in rval: - quota_source_label = usage["quota_source_label"] - usage["quota_percent"] = self.user_manager.quota(user, quota_source_label=quota_source_label) - usage["quota"] = self.user_manager.quota(user, total=True, quota_source_label=quota_source_label) - usage["quota_bytes"] = self.user_manager.quota_bytes(user, quota_source_label=quota_source_label) + def serialize_disk_usage(self, user: model.User) -> List[UserQuotaUsage]: + usages = user.dictify_usage(self.app.object_store) + rval: List[UserQuotaUsage] = [] + for usage in usages: + quota_source_label = usage.quota_source_label + quota_percent = self.user_manager.quota(user, quota_source_label=quota_source_label) + quota = self.user_manager.quota(user, total=True, quota_source_label=quota_source_label) + quota_bytes = self.user_manager.quota_bytes(user, quota_source_label=quota_source_label) + rval.append( + UserQuotaUsage( + quota_source_label=quota_source_label, + total_disk_usage=usage.total_disk_usage, + quota_percent=quota_percent, + quota=quota, + quota_bytes=quota_bytes, + ) + ) return rval - def serialize_disk_usage_for(self, user: model.User, label: Optional[str]) -> Dict[str, Any]: + def serialize_disk_usage_for(self, user: model.User, label: Optional[str]) -> UserQuotaUsage: usage = user.dictify_usage_for(label) - quota_source_label = usage["quota_source_label"] - usage["quota_percent"] = self.user_manager.quota(user, quota_source_label=quota_source_label) - usage["quota"] = self.user_manager.quota(user, total=True, quota_source_label=quota_source_label) - usage["quota_bytes"] = self.user_manager.quota_bytes(user, quota_source_label=quota_source_label) - return usage + quota_source_label = usage.quota_source_label + quota_percent = self.user_manager.quota(user, quota_source_label=quota_source_label) + quota = self.user_manager.quota(user, total=True, quota_source_label=quota_source_label) + quota_bytes = self.user_manager.quota_bytes(user, quota_source_label=quota_source_label) + return UserQuotaUsage( + quota_source_label=quota_source_label, + total_disk_usage=usage.total_disk_usage, + quota_percent=quota_percent, + quota=quota, + quota_bytes=quota_bytes, + ) class UserDeserializer(base.ModelDeserializer): diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 723c2e0fadf0..32d7e01275cd 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -40,6 +40,7 @@ import sqlalchemy from boltons.iterutils import remap +from pydantic import BaseModel from social_core.storage import ( AssociationMixin, CodeMixin, @@ -634,6 +635,19 @@ def calculate_user_disk_usage_statements(user_id, quota_source_map, for_sqlite=F return statements +# move these to galaxy.schema.schema once galaxy-data depends on +# galaxy-schema. +class UserQuotaBasicUsage(BaseModel): + quota_source_label: Optional[str] + total_disk_usage: float + + +class UserQuotaUsage(UserQuotaBasicUsage): + quota_percent: Optional[float] + quota_bytes: Optional[int] + quota: Optional[str] + + class User(Base, Dictifiable, RepresentById): """ Data for a Galaxy user or admin and relations to their @@ -1025,23 +1039,23 @@ def attempt_create_private_role(self): session.add(assoc) session.flush() - def dictify_usage(self, object_store=None) -> List[Dict[str, Any]]: + def dictify_usage(self, object_store=None) -> List[UserQuotaBasicUsage]: """Include object_store to include empty/unused usage info.""" used_labels: Set[Union[str, None]] = set() - rval: List[Dict[str, Any]] = [ - { - "quota_source_label": None, - "total_disk_usage": float(self.disk_usage or 0), - } + rval: List[UserQuotaBasicUsage] = [ + UserQuotaBasicUsage( + quota_source_label=None, + total_disk_usage=float(self.disk_usage or 0), + ) ] used_labels.add(None) for quota_source_usage in self.quota_source_usages: label = quota_source_usage.quota_source_label rval.append( - { - "quota_source_label": label, - "total_disk_usage": float(quota_source_usage.disk_usage), - } + UserQuotaBasicUsage( + quota_source_label=label, + total_disk_usage=float(quota_source_usage.disk_usage), + ) ) used_labels.add(label) @@ -1049,33 +1063,33 @@ def dictify_usage(self, object_store=None) -> List[Dict[str, Any]]: for label in object_store.get_quota_source_map().ids_per_quota_source().keys(): if label not in used_labels: rval.append( - { - "quota_source_label": label, - "total_disk_usage": 0.0, - } + UserQuotaBasicUsage( + quota_source_label=label, + total_disk_usage=0.0, + ) ) return rval - def dictify_usage_for(self, quota_source_label: Optional[str]) -> Dict[str, Any]: - rval: Dict[str, Any] + def dictify_usage_for(self, quota_source_label: Optional[str]) -> UserQuotaBasicUsage: + rval: UserQuotaBasicUsage if quota_source_label is None: - rval = { - "quota_source_label": None, - "total_disk_usage": float(self.disk_usage or 0), - } + rval = UserQuotaBasicUsage( + quota_source_label=None, + total_disk_usage=float(self.disk_usage or 0), + ) else: quota_source_usage = self.quota_source_usage_for(quota_source_label) if quota_source_usage is None: - rval = { - "quota_source_label": quota_source_label, - "total_disk_usage": 0.0, - } + rval = UserQuotaBasicUsage( + quota_source_label=quota_source_label, + total_disk_usage=0.0, + ) else: - rval = { - "quota_source_label": quota_source_label, - "total_disk_usage": float(quota_source_usage.disk_usage), - } + rval = UserQuotaBasicUsage( + quota_source_label=quota_source_label, + total_disk_usage=float(quota_source_usage.disk_usage), + ) return rval diff --git a/lib/galaxy/objectstore/__init__.py b/lib/galaxy/objectstore/__init__.py index 45793c187f92..d0b0e35b4c05 100644 --- a/lib/galaxy/objectstore/__init__.py +++ b/lib/galaxy/objectstore/__init__.py @@ -24,6 +24,11 @@ ) import yaml +from pydantic import BaseModel +from typing_extensions import ( + Literal, + TypedDict, +) from galaxy.exceptions import ( ObjectInvalid, @@ -52,6 +57,22 @@ log = logging.getLogger(__name__) +BadgeSourceT = Literal["admin", "galaxy"] +BadgeT = Literal[ + "faster", + "slower", + "short_term", + "cloud", + "backed_up", + "not_backed_up", + "more_secure", + "less_secure", + "more_stable", + "less_stable", + "quota", + "no_quota", + "restricted", +] BADGE_SPECIFICATION = [ {"type": "faster", "conflicts": ["slower"]}, @@ -69,6 +90,12 @@ BADGE_SPECIFCATION_BY_TYPE = {s["type"]: s for s in BADGE_SPECIFICATION} +class BadgeDict(TypedDict): + type: BadgeT + message: Optional[str] + source: BadgeSourceT + + class ObjectStore(metaclass=abc.ABCMeta): """ObjectStore interface. @@ -270,7 +297,7 @@ def get_concrete_store_description_markdown(self, obj): """ @abc.abstractmethod - def get_concrete_store_badges(self, obj): + def get_concrete_store_badges(self, obj) -> List[BadgeDict]: """Return a list of dictified badges summarizing the object store configuration.""" @abc.abstractmethod @@ -443,7 +470,7 @@ def get_concrete_store_name(self, obj): def get_concrete_store_description_markdown(self, obj): return self._invoke("get_concrete_store_description_markdown", obj) - def get_concrete_store_badges(self, obj) -> List[Dict[str, Any]]: + def get_concrete_store_badges(self, obj) -> List[BadgeDict]: return self._invoke("get_concrete_store_badges", obj) def get_store_usage_percent(self): @@ -549,14 +576,27 @@ def to_dict(self): rval["badges"] = self._get_concrete_store_badges(None) return rval - def _get_concrete_store_badges(self, obj) -> List[Dict[str, Any]]: - badge_dicts: List[Dict[str, Any]] = [] + def to_model(self, object_store_id: str) -> "ConcreteObjectStoreModel": + return ConcreteObjectStoreModel( + object_store_id=object_store_id, + private=self.private, + name=self.name, + description=self.description, + quota=QuotaModel(source=self.quota_source, enabled=self.quota_enabled), + badges=self._get_concrete_store_badges(None), + ) + + def _get_concrete_store_badges(self, obj) -> List[BadgeDict]: + badge_dicts: List[BadgeDict] = [] for badge in self.badges: - badge_dict = badge.copy() - badge_dict["source"] = "admin" + badge_dict: BadgeDict = { + "source": "admin", + "type": badge["type"], + "message": badge["message"], + } badge_dicts.append(badge_dict) - quota_badge_dict: Dict[str, Any] + quota_badge_dict: BadgeDict if self.galaxy_enable_quotas and self.quota_enabled: quota_badge_dict = { "type": "quota", @@ -571,7 +611,7 @@ def _get_concrete_store_badges(self, obj) -> List[Dict[str, Any]]: } badge_dicts.append(quota_badge_dict) if self.private: - restricted_badge_dict = { + restricted_badge_dict: BadgeDict = { "type": "restricted", "message": None, "source": "galaxy", @@ -981,7 +1021,7 @@ def _get_concrete_store_name(self, obj): def _get_concrete_store_description_markdown(self, obj): return self._call_method("_get_concrete_store_description_markdown", obj, None, False) - def _get_concrete_store_badges(self, obj): + def _get_concrete_store_badges(self, obj) -> List[BadgeDict]: return self._call_method("_get_concrete_store_badges", obj, [], False) def _is_private(self, obj): @@ -1348,6 +1388,20 @@ def get_quota_source_map(self): return quota_source_map +class QuotaModel(BaseModel): + source: Optional[str] + enabled: bool + + +class ConcreteObjectStoreModel(BaseModel): + object_store_id: Optional[str] + private: bool + name: Optional[str] + description: Optional[str] + quota: QuotaModel + badges: List[BadgeDict] + + def type_to_object_store_class(store: str, fsmon: bool = False) -> Tuple[Type[BaseObjectStore], Dict[str, Any]]: objectstore_class: Type[BaseObjectStore] objectstore_constructor_kwds = {} diff --git a/lib/galaxy/quota/__init__.py b/lib/galaxy/quota/__init__.py index b01cf47d5ae6..5102f263cd95 100644 --- a/lib/galaxy/quota/__init__.py +++ b/lib/galaxy/quota/__init__.py @@ -1,5 +1,6 @@ """Galaxy Quotas""" import logging +from typing import Optional from sqlalchemy.sql import text @@ -23,10 +24,10 @@ class QuotaAgent: # metaclass=abc.ABCMeta """ # TODO: make abstractmethod after they work better with mypy - def get_quota(self, user, quota_source_label=None): + def get_quota(self, user, quota_source_label=None) -> Optional[int]: """Return quota in bytes or None if no quota is set.""" - def get_quota_nice_size(self, user, quota_source_label=None): + def get_quota_nice_size(self, user, quota_source_label=None) -> Optional[str]: """Return quota as a human-readable string or 'unlimited' if no quota is set.""" quota_bytes = self.get_quota(user, quota_source_label=quota_source_label) if quota_bytes is not None: @@ -36,10 +37,12 @@ def get_quota_nice_size(self, user, quota_source_label=None): return quota_str # TODO: make abstractmethod after they work better with mypy - def get_percent(self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None): + def get_percent( + self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None + ) -> Optional[int]: """Return the percentage of any storage quota applicable to the user/transaction.""" - def get_usage(self, trans=None, user=False, history=False, quota_source_label=None): + def get_usage(self, trans=None, user=False, history=False, quota_source_label=None) -> Optional[float]: if trans: user = trans.user history = trans.history @@ -73,14 +76,16 @@ class NoQuotaAgent(QuotaAgent): def __init__(self): pass - def get_quota(self, user, quota_source_label=None): + def get_quota(self, user, quota_source_label=None) -> Optional[int]: return None @property def default_quota(self): return None - def get_percent(self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None): + def get_percent( + self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None + ) -> Optional[int]: return None def is_over_quota(self, app, job, job_destination): @@ -94,7 +99,7 @@ def __init__(self, model): self.model = model self.sa_session = model.context - def get_quota(self, user, quota_source_label=None): + def get_quota(self, user, quota_source_label=None) -> Optional[int]: """ Calculated like so: @@ -220,7 +225,9 @@ def set_default_quota(self, default_type, quota): self.sa_session.add(target_default) self.sa_session.flush() - def get_percent(self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None): + def get_percent( + self, trans=None, user=False, history=False, usage=False, quota=False, quota_source_label=None + ) -> Optional[int]: """ Return the percentage of any storage quota applicable to the user/transaction. """ diff --git a/lib/galaxy/webapps/galaxy/api/object_store.py b/lib/galaxy/webapps/galaxy/api/object_store.py index f53e267339a2..3e3a81442d04 100644 --- a/lib/galaxy/webapps/galaxy/api/object_store.py +++ b/lib/galaxy/webapps/galaxy/api/object_store.py @@ -2,11 +2,7 @@ API operations on Galaxy's object store. """ import logging -from typing import ( - Any, - Dict, - List, -) +from typing import List from fastapi import ( Path, @@ -18,7 +14,10 @@ RequestParameterInvalidException, ) from galaxy.managers.context import ProvidesUserContext -from galaxy.objectstore import BaseObjectStore +from galaxy.objectstore import ( + BaseObjectStore, + ConcreteObjectStoreModel, +) from . import ( depends, DependsOnTrans, @@ -34,7 +33,9 @@ ) SelectableQueryParam: bool = Query( - default=False, title="Selectable", description="Restrict index query to user selectable object stores." + default=False, + title="Selectable", + description="Restrict index query to user selectable object stores, the current implementation requires this to be true.", ) @@ -44,37 +45,34 @@ class FastAPIObjectStore: @router.get( "/api/object_store", - summary="", - response_description="", + summary="Get a list of (currently only concrete) object stores configured with this Galaxy instance.", + response_description="A list of the configured object stores.", ) def index( self, trans: ProvidesUserContext = DependsOnTrans, selectable: bool = SelectableQueryParam, - ) -> List[Dict[str, Any]]: + ) -> List[ConcreteObjectStoreModel]: if not selectable: raise RequestParameterInvalidException( "The object store index query currently needs to be called with selectable=true" ) selectable_ids = self.object_store.object_store_ids_allowing_selection() - return [self._dict_for(selectable_id) for selectable_id in selectable_ids] + return [self._model_for(selectable_id) for selectable_id in selectable_ids] @router.get( "/api/object_store/{object_store_id}", - summary="Return boolean to indicate if Galaxy's default object store allows selection.", - response_description="A list with details about the remote files available to the user.", + summary="Get information about a concrete object store configured with Galaxy.", ) def show_info( self, trans: ProvidesUserContext = DependsOnTrans, object_store_id: str = ConcreteObjectStoreIdPathParam, - ) -> Dict[str, Any]: - return self._dict_for(object_store_id) + ) -> ConcreteObjectStoreModel: + return self._model_for(object_store_id) - def _dict_for(self, object_store_id: str) -> Dict[str, Any]: + def _model_for(self, object_store_id: str) -> ConcreteObjectStoreModel: concrete_object_store = self.object_store.get_concrete_store_by_object_store_id(object_store_id) if concrete_object_store is None: raise ObjectNotFound() - as_dict = concrete_object_store.to_dict() - as_dict["object_store_id"] = object_store_id - return as_dict + return concrete_object_store.to_model(object_store_id) diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 016faee817c9..82a1a75e5561 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -5,7 +5,10 @@ import json import logging import re -from typing import Optional +from typing import ( + List, + Optional, +) from fastapi import ( Body, @@ -34,6 +37,7 @@ from galaxy.model import ( User, UserAddress, + UserQuotaUsage, ) from galaxy.schema import APIKeyModel from galaxy.schema.fields import DecodedDatabaseIdField @@ -74,11 +78,18 @@ UserIdPathParam: DecodedDatabaseIdField = Path(..., title="User ID", description="The ID of the user to get.") APIKeyPathParam: str = Path(..., title="API Key", description="The API key of the user.") +FlexibleUserIdPathParam: str = Path(..., title="User ID", description="The ID of the user to get or __current__.") +QuotaSourceLabelPathParam: str = Path( + ..., + title="Quota Source Label", + description="The label corresponding to the quota source to fetch usage information about.", +) @router.cbv class FastAPIUsers: service: UsersService = depends(UsersService) + user_serializer: users.UserSerializer = depends(users.UserSerializer) @router.put( "/api/users/recalculate_disk_usage", @@ -141,6 +152,44 @@ def delete_api_key( self.service.delete_api_key(trans, user_id) return Response(status_code=status.HTTP_204_NO_CONTENT) + @router.get( + "/api/users/{user_id}/usage", + name="get_user_usage", + summary="Return the user's quota usage summary broken down by quota source", + ) + def usage( + self, + trans: ProvidesUserContext = DependsOnTrans, + user_id: str = FlexibleUserIdPathParam, + ) -> List[UserQuotaUsage]: + user = get_user_full(trans, user_id, False) + if user: + rval = self.user_serializer.serialize_disk_usage(user) + return rval + else: + return [] + + @router.get( + "/api/users/{user_id}/usage/{label}", + name="get_user_usage_for_label", + summary="Return the user's quota usage summary for a given quota source label", + ) + def usage_for( + self, + trans: ProvidesUserContext = DependsOnTrans, + user_id: str = FlexibleUserIdPathParam, + label: str = QuotaSourceLabelPathParam, + ) -> Optional[UserQuotaUsage]: + user = get_user_full(trans, user_id, False) + effective_label: Optional[str] = label + if label == "__null__": + effective_label = None + if user: + rval = self.user_serializer.serialize_disk_usage_for(user, effective_label) + return rval + else: + return None + @router.get( "/api/users/{user_id}/beacon", summary="Returns information about beacon share settings", @@ -287,65 +336,7 @@ def _get_user_full(self, trans, user_id, **kwd): """Return referenced user or None if anonymous user is referenced.""" deleted = kwd.get("deleted", "False") deleted = util.string_as_bool(deleted) - try: - # user is requesting data about themselves - if user_id == "current": - # ...and is anonymous - return usage and quota (if any) - if not trans.user: - return None - - # ...and is logged in - return full - else: - user = trans.user - else: - return managers_base.get_object( - trans, - user_id, - "User", - deleted=deleted, - ) - # check that the user is requesting themselves (and they aren't del'd) unless admin - if not trans.user_is_admin: - if trans.user != user or user.deleted: - raise exceptions.InsufficientPermissionsException( - "You are not allowed to perform action on that user", id=user_id - ) - return user - except exceptions.MessageException: - raise - except Exception: - raise exceptions.RequestParameterInvalidException("Invalid user id specified", id=user_id) - - @expose_api - def usage(self, trans, user_id: str, **kwd): - """ - GET /api/users/{user_id}/usage - - Get user's disk usage broken down by quota source. - """ - user = self._get_user_full(trans, user_id, **kwd) - if user: - rval = self.user_serializer.serialize_disk_usage(user) - return rval - else: - return [] - - @expose_api - def usage_for(self, trans, user_id: str, label: str, **kwd): - """ - GET /api/users/{user_id}/usage/{label} - - Get user's disk usage for supplied quota source label. - """ - user = self._get_user_full(trans, user_id, **kwd) - effective_label: Optional[str] = label - if label == "__null__": - effective_label = None - if user: - rval = self.user_serializer.serialize_disk_usage_for(user, effective_label) - return rval - else: - return None + return get_user_full(trans, user_id, deleted) @expose_api def create(self, trans: GalaxyWebTransaction, payload: dict, **kwd): @@ -1153,3 +1144,34 @@ def _get_user(self, trans, id): if user != trans.user and not trans.user_is_admin: raise exceptions.InsufficientPermissionsException("Access denied.") return user + + +def get_user_full(trans: ProvidesUserContext, user_id: str, deleted: bool) -> Optional[User]: + try: + # user is requesting data about themselves + if user_id == "current": + # ...and is anonymous - return usage and quota (if any) + if not trans.user: + return None + + # ...and is logged in - return full + else: + user = trans.user + else: + return managers_base.get_object( + trans, + user_id, + "User", + deleted=deleted, + ) + # check that the user is requesting themselves (and they aren't del'd) unless admin + if not trans.user_is_admin: + if trans.user != user or user.deleted: + raise exceptions.InsufficientPermissionsException( + "You are not allowed to perform action on that user", id=user_id + ) + return user + except exceptions.MessageException: + raise + except Exception: + raise exceptions.RequestParameterInvalidException("Invalid user id specified", id=user_id) diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 79c2e9098ba9..a9bcaba11314 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -583,12 +583,6 @@ def populate_api_routes(webapp, app): conditions=dict(method=["POST"]), ) - webapp.mapper.connect( - "/api/users/{user_id}/usage", action="usage", controller="users", conditions=dict(method=["GET"]) - ) - webapp.mapper.connect( - "/api/users/{user_id}/usage/{label}", action="usage_for", controller="users", conditions=dict(method=["GET"]) - ) webapp.mapper.resource_with_deleted("user", "users", path_prefix="/api") webapp.mapper.resource("visualization", "visualizations", path_prefix="/api") webapp.mapper.resource("plugins", "plugins", path_prefix="/api") diff --git a/test/unit/data/test_quota.py b/test/unit/data/test_quota.py index ebdc256c5b65..508476c47c12 100644 --- a/test/unit/data/test_quota.py +++ b/test/unit/data/test_quota.py @@ -60,8 +60,8 @@ def test_calculate_usage_per_source(self): usages = self.u.dictify_usage() assert len(usages) == 2 - assert usages[1]["quota_source_label"] == "myquotalabel" - assert usages[1]["total_disk_usage"] == 114 + assert usages[1].quota_source_label == "myquotalabel" + assert usages[1].total_disk_usage == 114 class TestCalculateUsage(BaseModelTestCase): @@ -144,23 +144,23 @@ def test_calculate_usage_alt_quota(self): model.context.refresh(u) usages = u.dictify_usage(object_store) assert len(usages) == 2 - assert usages[0]["quota_source_label"] is None - assert usages[0]["total_disk_usage"] == 10 + assert usages[0].quota_source_label is None + assert usages[0].total_disk_usage == 10 - assert usages[1]["quota_source_label"] == "alt_source" - assert usages[1]["total_disk_usage"] == 15 + assert usages[1].quota_source_label == "alt_source" + assert usages[1].total_disk_usage == 15 usage = u.dictify_usage_for(None) - assert usage["quota_source_label"] is None - assert usage["total_disk_usage"] == 10 + assert usage.quota_source_label is None + assert usage.total_disk_usage == 10 usage = u.dictify_usage_for("alt_source") - assert usage["quota_source_label"] == "alt_source" - assert usage["total_disk_usage"] == 15 + assert usage.quota_source_label == "alt_source" + assert usage.total_disk_usage == 15 usage = u.dictify_usage_for("unused_source") - assert usage["quota_source_label"] == "unused_source" - assert usage["total_disk_usage"] == 0 + assert usage.quota_source_label == "unused_source" + assert usage.total_disk_usage == 0 def test_calculate_usage_removes_unused_quota_labels(self): model = self.model @@ -180,22 +180,22 @@ def test_calculate_usage_removes_unused_quota_labels(self): model.context.refresh(u) usages = u.dictify_usage() assert len(usages) == 2 - assert usages[0]["quota_source_label"] is None - assert usages[0]["total_disk_usage"] == 10 + assert usages[0].quota_source_label is None + assert usages[0].total_disk_usage == 10 - assert usages[1]["quota_source_label"] == "alt_source" - assert usages[1]["total_disk_usage"] == 15 + assert usages[1].quota_source_label == "alt_source" + assert usages[1].total_disk_usage == 15 alt_source.default_quota_source = "new_alt_source" u.calculate_and_set_disk_usage(object_store) model.context.refresh(u) usages = u.dictify_usage() assert len(usages) == 2 - assert usages[0]["quota_source_label"] is None - assert usages[0]["total_disk_usage"] == 10 + assert usages[0].quota_source_label is None + assert usages[0].total_disk_usage == 10 - assert usages[1]["quota_source_label"] == "new_alt_source" - assert usages[1]["total_disk_usage"] == 15 + assert usages[1].quota_source_label == "new_alt_source" + assert usages[1].total_disk_usage == 15 def test_dictify_usage_unused_quota_labels(self): model = self.model @@ -236,11 +236,11 @@ def test_calculate_usage_default_storage_disabled(self): model.context.refresh(u) usages = u.dictify_usage(object_store) assert len(usages) == 2 - assert usages[0]["quota_source_label"] is None - assert usages[0]["total_disk_usage"] == 0 + assert usages[0].quota_source_label is None + assert usages[0].total_disk_usage == 0 - assert usages[1]["quota_source_label"] == "alt_source" - assert usages[1]["total_disk_usage"] == 15 + assert usages[1].quota_source_label == "alt_source" + assert usages[1].total_disk_usage == 15 class TestQuota(BaseModelTestCase): @@ -377,5 +377,5 @@ def test_labeled_usage(self): usages = u.dictify_usage() assert len(usages) == 2 - assert usages[1]["quota_source_label"] == "foobar" - assert usages[1]["total_disk_usage"] == 247 + assert usages[1].quota_source_label == "foobar" + assert usages[1].total_disk_usage == 247