From 2796c1ac8944cbcbcdecffee1570ced224bbcbd4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 3 Feb 2021 18:38:33 +0100 Subject: [PATCH 01/23] Add integration tests for quotas API --- test/integration/test_quota.py | 175 +++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 10 deletions(-) diff --git a/test/integration/test_quota.py b/test/integration/test_quota.py index 0dd39c720ea..5cf7771aae3 100644 --- a/test/integration/test_quota.py +++ b/test/integration/test_quota.py @@ -1,3 +1,8 @@ + +import json + +from requests import delete + from galaxy_test.base.populators import ( DatasetPopulator, ) @@ -15,18 +20,168 @@ class QuotaIntegrationTestCase(integration_util.IntegrationTestCase): super().setUp() self.dataset_populator = DatasetPopulator(self.galaxy_interactor) - def test_quota_crud(self): - quotas = self.dataset_populator.get_quotas() - assert len(quotas) == 0 + def test_create(self): + self._create_quota_with_name("test-create-quota") - payload = { - 'name': 'defaultquota1', - 'description': 'first default quota', + def test_index(self): + self._create_quota_with_name("test-index-quota") + index_response = self._get("quotas") + index_response.raise_for_status() + json_response = index_response.json() + assert len(json_response) > 0 + + def test_index_deleted(self): + quota = self._create_quota_with_name("test-index-deleted-quota") + quota_id = quota["id"] + delete_response = self._delete(f"quotas/{quota_id}") + delete_response.raise_for_status() + index_response = self._get("quotas/deleted") + index_response.raise_for_status() + json_response = index_response.json() + assert len(json_response) > 0 + + def test_show(self): + quota_name = "test-show-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + show_response = self._get(f"quotas/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert json_response["name"] == quota["name"] + + def test_show_deleted(self): + quota_name = "test-show-deleted-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + delete_response = self._delete(f"quotas/{quota_id}") + delete_response.raise_for_status() + show_response = self._get(f"quotas/deleted/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert json_response["name"] == quota["name"] + + def test_update(self): + quota_name = "test-update-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + + new_quota_name = "updated-quota-name" + update_payload = { + 'name': new_quota_name, + } + put_response = self._put(f"quotas/{quota_id}", data=update_payload) + put_response.raise_for_status() + assert "has been renamed to" in put_response.text + + show_response = self._get(f"quotas/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert json_response["name"] == new_quota_name + + def test_delete(self): + quota_name = "test-delete-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + delete_response = self._delete(f"quotas/{quota_id}") + delete_response.raise_for_status() + self._assert_quota_is_deleted(quota_id) + + def test_delete_and_purge(self): + quota_name = "test-delete-purge-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + delete_response = self._delete_and_purge(f"quotas/{quota_id}") + delete_response.raise_for_status() + self._assert_quota_is_deleted(quota_id) + + def test_delete_and_purge_with_user(self): + user_email = "test@galaxy.test" + self.galaxy_interactor.ensure_user_with_email(user_email) + + quota_name = "test-delete-purge-quota-user" + payload = self._build_quota_payload_with_name(quota_name) + payload["in_users"].append(user_email) + create_response = self._post("quotas", data=payload, json=True) + create_response.raise_for_status() + quota = create_response.json() + quota_id = quota["id"] + + show_response = self._get(f"quotas/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert user_email in str(json_response["users"]) + + delete_response = self._delete_and_purge(f"quotas/{quota_id}") + delete_response.raise_for_status() + show_response = self._get(f"quotas/deleted/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert user_email not in str(json_response["users"]) + + def test_undelete(self): + quota_name = "test-undelete-quota" + quota = self._create_quota_with_name(quota_name) + quota_id = quota["id"] + delete_response = self._delete(f"quotas/{quota_id}") + delete_response.raise_for_status() + self._assert_quota_is_deleted(quota_id) + + undelete_response = self._post(f"quotas/deleted/{quota_id}/undelete") + undelete_response.raise_for_status() + + show_response = self._get(f"quotas/{quota_id}") + show_response.raise_for_status() + + show_response = self._get(f"quotas/deleted/{quota_id}") + self._assert_status_code_is(show_response, 400) + + def test_400_when_delete_default(self): + quota_name = "test-delete-default-quota" + quota = self._create_quota_with_name(quota_name, is_default=True) + quota_id = quota["id"] + delete_response = self._delete(f"quotas/{quota_id}") + self._assert_status_code_is(delete_response, 400) + + def test_400_when_quota_name_already_exists(self): + quota_name = "test-duplicated-quota" + self._create_quota_with_name(quota_name) + payload = self._build_quota_payload_with_name(quota_name) + create_response = self._post("quotas", data=payload) + self._assert_status_code_is(create_response, 400) + + def test_400_when_show_unknown_quota(self): + quota_id = "unknown-id" + show_response = self._get(f"quotas/{quota_id}") + self._assert_status_code_is(show_response, 400) + + def _create_quota_with_name(self, quota_name: str, is_default: bool = False): + payload = self._build_quota_payload_with_name(quota_name, is_default) + create_response = self._post("quotas", data=payload) + create_response.raise_for_status() + return create_response.json() + + def _build_quota_payload_with_name(self, quota_name: str, is_default: bool = False): + default = "registered" if is_default else "no" + return { + 'name': quota_name, + 'description': f'Quota {quota_name} description', 'amount': '100MB', 'operation': '=', - 'default': 'registered', + 'default': default, + 'in_users': [], + 'in_groups': [] } - self.dataset_populator.create_quota(payload) - quotas = self.dataset_populator.get_quotas() - assert len(quotas) == 1 + def _delete_and_purge(self, url): + headers = self.galaxy_interactor.api_key_header(self.galaxy_interactor.api_key, admin=True, anon=False) + json_data = json.dumps({ + 'purge': 'true' + }) + api_url = self._api_url(url) + return delete(api_url, data=json_data, headers=headers) + + def _assert_quota_is_deleted(self, quota_id: str): + show_response = self._get(f"quotas/deleted/{quota_id}") + show_response.raise_for_status() + json_response = show_response.json() + assert json_response["id"] == quota_id From 1bc4dce7a886e02836bbe73cd6cd7747882dd661 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 5 Feb 2021 20:01:36 +0100 Subject: [PATCH 02/23] Add pydantic models --- lib/galaxy/quota/_schema.py | 232 ++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 lib/galaxy/quota/_schema.py diff --git a/lib/galaxy/quota/_schema.py b/lib/galaxy/quota/_schema.py new file mode 100644 index 00000000000..789db13020d --- /dev/null +++ b/lib/galaxy/quota/_schema.py @@ -0,0 +1,232 @@ +from enum import Enum +from typing import ( + List, + Optional, +) + +from pydantic import ( + BaseModel, + Field, +) + +from galaxy.schema.fields import ( + EncodedDatabaseIdField, + ModelClassField, +) +from galaxy.schema.schema import ( + GroupModel, + UserModel, +) + +QUOTA_MODEL_CLASS_NAME = "Quota" +USER_QUOTA_ASSOCIATION_MODEL_CLASS_NAME = "UserQuotaAssociation" +GROUP_QUOTA_ASSOCIATION_MODEL_CLASS_NAME = "GroupQuotaAssociation" +DEFAULT_QUOTA_ASSOCIATION_MODEL_CLASS_NAME = "DefaultQuotaAssociation" + + +class QuotaOperation(str, Enum): + EXACT = "=" + ADD = "+" + SUBTRACT = "-" + + +class DefaultQuotaTypes(str, Enum): # TODO: should this replace lib.galaxy.model.DefaultQuotaAssociation.types at some point? + UNREGISTERED = 'unregistered' + REGISTERED = 'registered' + + +class DefaultQuotaValues(DefaultQuotaTypes): + NO = "no" + + +QuotaNameField = Field( + ..., + title="Name", + description="The name of the quota. This must be unique within a Galaxy instance.", +) + +QuotaDescriptionField = Field( + "", + title="Description", + description="Detailed text description for this Quota.", +) + +QuotaOperationField = Field( + QuotaOperation.EXACT, + title="Operation", + description=( + "Quotas can have one of three `operations`:" + "- `=` : The quota is exactly the amount specified" + "- `+` : The amount specified will be added to the amounts of the user's other associated quota definitions" + "- `-` : The amount specified will be subtracted from the amounts of the user's other associated quota definitions" + ) +) + + +class DefaultQuota(BaseModel): # TODO: should this replace lib.galaxy.model.DefaultQuotaAssociation at some point? + model_class: str = ModelClassField(DEFAULT_QUOTA_ASSOCIATION_MODEL_CLASS_NAME) + type: DefaultQuotaTypes = Field( + ..., + title="Type", + description=( + "The type of the default quota. Either one of:\n" + " - `registered`: the associated quota will affect registered users.\n" + " - `unregistered`: the associated quota will affect unregistered users.\n" + ) + ) + + +class UserQuota(BaseModel): + model_class: str = ModelClassField(USER_QUOTA_ASSOCIATION_MODEL_CLASS_NAME) + user: UserModel = Field( + ..., + title="User", + description="Information about a user associated with a quota.", + ) + + +class GroupQuota(BaseModel): + model_class: str = ModelClassField(GROUP_QUOTA_ASSOCIATION_MODEL_CLASS_NAME) + group: GroupModel = Field( + ..., + title="Group", + description="Information about a user group associated with a quota.", + ) + + +class QuotaBase(BaseModel): + """Base model containing common fields for Quotas.""" + model_class: str = ModelClassField(QUOTA_MODEL_CLASS_NAME) + id: EncodedDatabaseIdField = Field( + ..., + title="ID", + description="The `encoded identifier` of the quota.", + ) + name: str = QuotaNameField + + +class QuotaSummary(QuotaBase): + """Contains basic information about a Quota""" + url: str = Field( + ..., + title="URL", + description="The relative URL to get this particular Quota details from the rest API.", + ) + + +class QuotaSummaryList(BaseModel): + __root__: List[QuotaSummary] = Field( + default=[], + title='List with summary information of Quotas.', + ) + + +class QuotaDetails(QuotaBase): + description: str = QuotaDescriptionField + bytes: str = Field( + ..., + title="Bytes", + description="The amount, expressed in bytes, of this Quota.", + ) + operation: QuotaOperation = QuotaOperationField + display_amount: str = Field( + ..., + title="Display Amount", + description="Human-readable representation of the `amount` field.", + ) + default: List[DefaultQuota] = Field( + [], + title="Default", + description="A list indicating which types of default user quotas, if any, are associated with this quota.", + ) + users: List[UserQuota] = Field( + [], + title="Users", + description="A list of specific users associated with this quota.", + ) + groups: List[GroupQuota] = Field( + [], + title="Groups", + description="A list of specific groups of users associated with this quota.", + ) + + +class CreateQuotaResult(QuotaSummary): + message: str = Field( + ..., + title="Message", + description="Text message describing the result of the operation.", + ) + + +class CreateQuotaPayload(BaseModel): + name: str = QuotaNameField + description: Optional[str] = QuotaDescriptionField + amount: str = Field( + ..., + title="Amount", + description="Quota size (E.g. ``10000MB``, ``99 gb``, ``0.2T``, ``unlimited``)", + ) + operation: QuotaOperation = QuotaOperationField + default: DefaultQuotaValues = Field( + default="no", + title="Default", + description=( + "Whether or not this is a default quota. Valid values" + " are ``no``, ``unregistered``, ``registered``. None is" + " equivalent to ``no``." + ), + ) + in_users: List[str] = Field( + default=[], + title="Users", + description="A list of user IDs or user emails to associate with this quota.", + ) + in_groups: List[str] = Field( + default=[], + title="Groups", + description="A list of group IDs or names to associate with this quota.", + ) + + +class UpdateQuotaPayload(BaseModel): + name: Optional[str] = Field( + default=None, + title="Name", + description="The new name of the quota. This must be unique within a Galaxy instance.", + ) + description: Optional[str] = QuotaDescriptionField + amount: Optional[str] = Field( + ..., + title="Amount", + description="Quota size (E.g. ``10000MB``, ``99 gb``, ``0.2T``, ``unlimited``)", + ) + operation: QuotaOperation = Field( + QuotaOperation.EXACT, + title="Operation", + description=( + "One of (``+``, ``-``, ``=``). If you wish to change this value," + " you must also provide the ``amount``, otherwise it will not take effect." + ) + ) + default: Optional[DefaultQuotaValues] = Field( + default=DefaultQuotaValues.NO, + title="Default", + description=( + "Whether or not this is a default quota. Valid values" + " are ``no``, ``unregistered``, ``registered``." + ' Calling this method with ``default="no"`` on a' + " non-default quota will throw an error. Not" + " passing this parameter is equivalent to passing ``no``." + ), + ) + in_users: List[str] = Field( + default=[], + title="Users", + description="A list of user IDs or user emails to associate with this quota.", + ) + in_groups: List[str] = Field( + default=[], + title="Groups", + description="A list of group IDs or names to associate with this quota.", + ) From f28ea8d4c0e2ccbf74ac6c19fb23c83e231187ea Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 5 Feb 2021 20:02:10 +0100 Subject: [PATCH 03/23] Prepare QuotasManager interface --- lib/galaxy/managers/quotas.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 lib/galaxy/managers/quotas.py diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py new file mode 100644 index 00000000000..70b184fc176 --- /dev/null +++ b/lib/galaxy/managers/quotas.py @@ -0,0 +1,45 @@ +""" +Manager and Serializers for Quotas. + +For more information about quotas: https://galaxyproject.org/admin/disk-quotas/ +""" + +from typing import List + +from galaxy.managers.context import ProvidesUserContext +from galaxy.quota._schema import ( + CreateQuotaPayload, + CreateQuotaResult, + QuotaDetails, + QuotaSummaryList, + UpdateQuotaPayload, +) +from galaxy.schema.fields import EncodedDatabaseIdField + + +class QuotasManager: + """Interface/service object shared by controllers for interacting with quotas.""" + + def index(self, trans: ProvidesUserContext, deleted: bool = False) -> QuotaSummaryList: + """Displays a collection (list) of quotas.""" + pass + + def show(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, deleted: bool = False) -> QuotaDetails: + """Displays information about a quota.""" + pass + + def create(self, trans: ProvidesUserContext, payload: CreateQuotaPayload) -> CreateQuotaResult: + """Creates a new quota.""" + pass + + def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: UpdateQuotaPayload) -> List[str]: + """Modifies a quota.""" + pass + + def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: + """Marks a quota as deleted.""" + pass + + def undelete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: + """Restores a previously deleted quota.""" + pass From bff6450d70c32ceddee9a6b66439283b927ce7f5 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 8 Feb 2021 12:03:36 +0100 Subject: [PATCH 04/23] Add FastAPI route (disabled) --- lib/galaxy/managers/quotas.py | 48 +++++++-- lib/galaxy/quota/_schema.py | 4 +- lib/galaxy/webapps/galaxy/api/quotas.py | 138 +++++++++++++++++++++++- 3 files changed, 181 insertions(+), 9 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 70b184fc176..74501d55c0d 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -20,26 +20,62 @@ from galaxy.schema.fields import EncodedDatabaseIdField class QuotasManager: """Interface/service object shared by controllers for interacting with quotas.""" + # TODO refactor move here code from lib/galaxy/webapps/galaxy/api/quotas.py::QuotaAPIController def index(self, trans: ProvidesUserContext, deleted: bool = False) -> QuotaSummaryList: """Displays a collection (list) of quotas.""" - pass + raise NotImplementedError def show(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, deleted: bool = False) -> QuotaDetails: """Displays information about a quota.""" - pass + raise NotImplementedError def create(self, trans: ProvidesUserContext, payload: CreateQuotaPayload) -> CreateQuotaResult: """Creates a new quota.""" - pass + raise NotImplementedError def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: UpdateQuotaPayload) -> List[str]: """Modifies a quota.""" - pass + raise NotImplementedError def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: """Marks a quota as deleted.""" - pass + raise NotImplementedError def undelete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: """Restores a previously deleted quota.""" - pass + raise NotImplementedError + + +class QuotaManager: + + # TODO: refactor move here code from lib/galaxy/actions/admin.py::AdminActions + def create_quota(self, params, decode_id=None): + raise NotImplementedError + + def rename_quota(self, quota, params): + raise NotImplementedError + + def manage_users_and_groups_for_quota(self, quota, params, decode_id=None): + raise NotImplementedError + + def edit_quota(self, quota, params): + raise NotImplementedError + + def set_quota_default(self, quota, params): + raise NotImplementedError + + def unset_quota_default(self, quota, params=None): + raise NotImplementedError + + def delete_quota(self, quota, params=None): + raise NotImplementedError + + def undelete_quota(self, quota, params=None): + raise NotImplementedError + + def purge_quota(self, quota, params=None): + raise NotImplementedError + + # TODO: refactor move here code from lib/galaxy/webapps/base/controller.py::UsesQuotaMixin + def get_quota(self, trans, id, check_ownership=False, check_accessible=False, deleted=None): + raise NotImplementedError diff --git a/lib/galaxy/quota/_schema.py b/lib/galaxy/quota/_schema.py index 789db13020d..b5f07f21412 100644 --- a/lib/galaxy/quota/_schema.py +++ b/lib/galaxy/quota/_schema.py @@ -35,7 +35,9 @@ class DefaultQuotaTypes(str, Enum): # TODO: should this replace lib.galaxy.mode REGISTERED = 'registered' -class DefaultQuotaValues(DefaultQuotaTypes): +class DefaultQuotaValues(str, Enum): + UNREGISTERED = 'unregistered' + REGISTERED = 'registered' NO = "no" diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index a7f4800cd9d..74425ed87ea 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -3,6 +3,10 @@ API operations on Quota objects. """ import logging +from fastapi import Path +# TODO: replace with Router after merging #11219 +from fastapi_utils.cbv import cbv +from fastapi_utils.inferring_router import InferringRouter as APIRouter from paste.httpexceptions import HTTPBadRequest from sqlalchemy import ( false, @@ -15,18 +19,148 @@ from galaxy import ( ) from galaxy.actions.admin import AdminActions from galaxy.exceptions import ActionInputError +from galaxy.managers.context import ProvidesUserContext +from galaxy.managers.quotas import QuotasManager +from galaxy.quota._schema import ( + CreateQuotaPayload, + UpdateQuotaPayload, +) +from galaxy.schema.fields import EncodedDatabaseIdField from galaxy.web.params import QuotaParamParser from galaxy.webapps.base.controller import ( url_for, UsesQuotaMixin ) -from . import BaseGalaxyAPIController +from . import ( + AdminUserRequired, + BaseGalaxyAPIController, + depends, + DependsOnTrans, +) log = logging.getLogger(__name__) -class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, QuotaParamParser): +# TODO: This FastAPI router is disabled. Please rename it to `router` when the database session issues are fixed. +_router = APIRouter(tags=['quotas']) + +QuotaIdPathParam: EncodedDatabaseIdField = Path( + ..., # Required + title="Quota ID", + description="The encoded indentifier of the Quota." +) + + +@cbv(_router) +class FastAPITags: + manager: QuotasManager = depends(QuotasManager) + + @_router.get( + '/api/quotas', + summary="Displays a list with information of quotas that are currently active.", + dependencies=[AdminUserRequired], + ) + def index( + self, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Displays a list with information of quotas that are currently active.""" + self.manager.index(trans) + + @_router.get( + '/api/quotas/deleted', + summary="Displays a list with information of quotas that have been deleted.", + dependencies=[AdminUserRequired], + ) + def index_deleted( + self, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Displays a list with information of quotas that have been deleted.""" + self.manager.index(trans, deleted=True) + + @_router.get( + '/api/quotas/{id}', + summary="Displays details on a particular active quota.", + dependencies=[AdminUserRequired], + ) + def show( + self, + trans: ProvidesUserContext = DependsOnTrans, + id: EncodedDatabaseIdField = QuotaIdPathParam + ): + """Displays details on a particular active quota.""" + self.manager.show(trans, id) + + @_router.get( + '/api/quotas/deleted/{id}', + summary="Displays details on a particular quota that has been deleted.", + dependencies=[AdminUserRequired], + ) + def show_deleted( + self, + trans: ProvidesUserContext = DependsOnTrans, + id: EncodedDatabaseIdField = QuotaIdPathParam, + ): + """Displays details on a particular quota that has been deleted.""" + self.manager.show(trans, id, deleted=True) + + @_router.post( + '/api/quotas', + summary="Creates a new quota.", + dependencies=[AdminUserRequired], + ) + def create( + self, + payload: CreateQuotaPayload, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Creates a new quota.""" + self.manager.create(trans, payload) + + @_router.put( + '/api/quotas/{id}', + summary="Updates an existing quota.", + dependencies=[AdminUserRequired], + ) + def update( + self, + payload: UpdateQuotaPayload, + id: EncodedDatabaseIdField = QuotaIdPathParam, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Updates an existing quota.""" + self.manager.update(trans, id, payload) + + @_router.delete( + '/api/quotas/{id}', + summary="Deletes an existing quota.", + dependencies=[AdminUserRequired], + ) + def delete( + self, + id: EncodedDatabaseIdField = QuotaIdPathParam, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Deletes an existing quota.""" + self.manager.delete(trans, id) + + @_router.post( + '/api/quotas/deleted/{id}/undelete', + summary="Restores a previously deleted quota.", + dependencies=[AdminUserRequired], + ) + def undelete( + self, + id: EncodedDatabaseIdField = QuotaIdPathParam, + trans: ProvidesUserContext = DependsOnTrans, + ): + """Restores a previously deleted quota.""" + self.manager.undelete(trans, id) + + +class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, QuotaParamParser): @web.require_admin @web.legacy_expose_api def index(self, trans, deleted='False', **kwd): From 1a7a09206d1d2f602faed338868ce5f5c3ce7b09 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 10 Feb 2021 12:26:17 +0100 Subject: [PATCH 05/23] Add lib/galaxy/schema to packages/data/galaxy/schema --- packages/data/galaxy/schema | 1 + 1 file changed, 1 insertion(+) create mode 120000 packages/data/galaxy/schema diff --git a/packages/data/galaxy/schema b/packages/data/galaxy/schema new file mode 120000 index 00000000000..66f70ac00dc --- /dev/null +++ b/packages/data/galaxy/schema @@ -0,0 +1 @@ +/home/davelopez/dev/galaxy/lib/galaxy/schema/ \ No newline at end of file From b246bf3af4a4ce6f937b7d187a46afd042a32ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B3pez?= <46503462+davelopez@users.noreply.github.com> Date: Wed, 10 Feb 2021 13:09:20 +0100 Subject: [PATCH 06/23] Fix symlink Make it relative --- packages/data/galaxy/schema | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data/galaxy/schema b/packages/data/galaxy/schema index 66f70ac00dc..12228d1b993 120000 --- a/packages/data/galaxy/schema +++ b/packages/data/galaxy/schema @@ -1 +1 @@ -/home/davelopez/dev/galaxy/lib/galaxy/schema/ \ No newline at end of file +../../../lib/galaxy/schema/ From 50d7d2e339d16ddf8cf5d9e68ed7e7c90fe34e63 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 10 Feb 2021 13:25:34 +0100 Subject: [PATCH 07/23] Fix symlink --- packages/data/galaxy/schema | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data/galaxy/schema b/packages/data/galaxy/schema index 12228d1b993..14df7cabc56 120000 --- a/packages/data/galaxy/schema +++ b/packages/data/galaxy/schema @@ -1 +1 @@ -../../../lib/galaxy/schema/ +../../../lib/galaxy/schema \ No newline at end of file From 147802654aa5a5ab7cef1bd557250e07752cea09 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 1 Mar 2021 12:43:47 +0100 Subject: [PATCH 08/23] Fix quotas api delete tests --- test/integration/test_quota.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/integration/test_quota.py b/test/integration/test_quota.py index 5cf7771aae3..1aee9be235c 100644 --- a/test/integration/test_quota.py +++ b/test/integration/test_quota.py @@ -173,7 +173,12 @@ class QuotaIntegrationTestCase(integration_util.IntegrationTestCase): } def _delete_and_purge(self, url): - headers = self.galaxy_interactor.api_key_header(self.galaxy_interactor.api_key, admin=True, anon=False) + headers = self.galaxy_interactor.api_key_header( + key=self.galaxy_interactor.api_key, + admin=True, + anon=False, + headers=None, + ) json_data = json.dumps({ 'purge': 'true' }) From 5fe3454c1b59a549e3c39a1255e578b9232281f3 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 1 Mar 2021 15:24:32 +0100 Subject: [PATCH 09/23] Refactor Mixins into Manager - AdminActions, UsesQuotaMixin and QuotaParamParser logic moved to QuotaManager --- lib/galaxy/actions/admin.py | 211 ---------------- lib/galaxy/managers/quotas.py | 230 +++++++++++++++++- lib/galaxy/web/params.py | 31 --- lib/galaxy/webapps/base/controller.py | 6 - lib/galaxy/webapps/galaxy/api/quotas.py | 56 ++--- .../webapps/galaxy/controllers/admin.py | 28 ++- 6 files changed, 260 insertions(+), 302 deletions(-) delete mode 100644 lib/galaxy/actions/admin.py delete mode 100644 lib/galaxy/web/params.py diff --git a/lib/galaxy/actions/admin.py b/lib/galaxy/actions/admin.py deleted file mode 100644 index 0c3adc95ffd..00000000000 --- a/lib/galaxy/actions/admin.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Contains administrative functions -""" -import logging - -from galaxy import util -from galaxy.exceptions import ActionInputError - -log = logging.getLogger(__name__) - - -class AdminActions: - """ - Mixin for controllers that provide administrative functionality. - """ - - def _create_quota(self, params, decode_id=None): - if params.amount.lower() in ('unlimited', 'none', 'no limit'): - create_amount = None - else: - try: - create_amount = util.size_to_bytes(params.amount) - except AssertionError: - create_amount = False - if not params.name or not params.description: - raise ActionInputError("Enter a valid name and a description.") - elif self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): - raise ActionInputError("Quota names must be unique and a quota with that name already exists, so choose another name.") - elif not params.get('amount', None): - raise ActionInputError("Enter a valid quota amount.") - elif create_amount is False: - raise ActionInputError("Unable to parse the provided amount.") - elif params.operation not in self.app.model.Quota.valid_operations: - raise ActionInputError("Enter a valid operation.") - elif params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__members__.values(): - raise ActionInputError("Enter a valid default type.") - elif params.default != 'no' and params.operation != '=': - raise ActionInputError("Operation for a default quota must be '='.") - elif create_amount is None and params.operation != '=': - raise ActionInputError("Operation for an unlimited quota must be '='.") - else: - # Create the quota - quota = self.app.model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) - self.sa_session.add(quota) - # If this is a default quota, create the DefaultQuotaAssociation - if params.default != 'no': - self.app.quota_agent.set_default_quota(params.default, quota) - message = f"Default quota '{quota.name}' has been created." - else: - # Create the UserQuotaAssociations - in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] - in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] - if None in in_users: - raise ActionInputError("One or more invalid user id has been provided.") - for user in in_users: - uqa = self.app.model.UserQuotaAssociation(user, quota) - self.sa_session.add(uqa) - # Create the GroupQuotaAssociations - if None in in_groups: - raise ActionInputError("One or more invalid group id has been provided.") - for group in in_groups: - gqa = self.app.model.GroupQuotaAssociation(group, quota) - self.sa_session.add(gqa) - message = "Quota '%s' has been created with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) - self.sa_session.flush() - return quota, message - - def _rename_quota(self, quota, params): - if not params.name: - raise ActionInputError('Enter a valid name.') - elif params.name != quota.name and self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): - raise ActionInputError('A quota with that name already exists.') - else: - old_name = quota.name - quota.name = params.name - quota.description = params.description - self.sa_session.add(quota) - self.sa_session.flush() - message = f"Quota '{old_name}' has been renamed to '{params.name}'." - return message - - def _manage_users_and_groups_for_quota(self, quota, params, decode_id=None): - if quota.default: - raise ActionInputError('Default quotas cannot be associated with specific users and groups.') - else: - in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] - if None in in_users: - raise ActionInputError("One or more invalid user id has been provided.") - in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] - if None in in_groups: - raise ActionInputError("One or more invalid group id has been provided.") - self.app.quota_agent.set_entity_quota_associations(quotas=[quota], users=in_users, groups=in_groups) - self.sa_session.refresh(quota) - message = "Quota '%s' has been updated with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) - return message - - def _edit_quota(self, quota, params): - if params.amount.lower() in ('unlimited', 'none', 'no limit'): - new_amount = None - else: - try: - new_amount = util.size_to_bytes(params.amount) - except (AssertionError, ValueError): - new_amount = False - if not params.amount: - raise ActionInputError('Enter a valid amount.') - elif new_amount is False: - raise ActionInputError('Unable to parse the provided amount.') - elif params.operation not in self.app.model.Quota.valid_operations: - raise ActionInputError('Enter a valid operation.') - else: - quota.amount = new_amount - quota.operation = params.operation - self.sa_session.add(quota) - self.sa_session.flush() - message = f"Quota '{quota.name}' is now '{quota.operation + quota.display_amount}'." - return message - - def _set_quota_default(self, quota, params): - if params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__members__.values(): - raise ActionInputError('Enter a valid default type.') - else: - if params.default != 'no': - self.app.quota_agent.set_default_quota(params.default, quota) - message = f"Quota '{quota.name}' is now the default for {params.default} users." - else: - if quota.default: - message = f"Quota '{quota.name}' is no longer the default for {quota.default[0].type} users." - for dqa in quota.default: - self.sa_session.delete(dqa) - self.sa_session.flush() - else: - message = f"Quota '{quota.name}' is not a default." - return message - - def _unset_quota_default(self, quota, params=None): - if not quota.default: - raise ActionInputError(f"Quota '{quota.name}' is not a default.") - else: - message = f"Quota '{quota.name}' is no longer the default for {quota.default[0].type} users." - for dqa in quota.default: - self.sa_session.delete(dqa) - self.sa_session.flush() - return message - - def _delete_quota(self, quota, params=None): - quotas = util.listify(quota) - names = [] - for q in quotas: - if q.default: - names.append(q.name) - if len(names) == 1: - raise ActionInputError(f"Quota '{names[0]}' is a default, please unset it as a default before deleting it.") - elif len(names) > 1: - raise ActionInputError(f"Quotas are defaults, please unset them as defaults before deleting them: {', '.join(names)}") - message = "Deleted %d quotas: " % len(quotas) - for q in quotas: - q.deleted = True - self.sa_session.add(q) - names.append(q.name) - self.sa_session.flush() - message += ', '.join(names) - return message - - def _undelete_quota(self, quota, params=None): - quotas = util.listify(quota) - names = [] - for q in quotas: - if not q.deleted: - names.append(q.name) - if len(names) == 1: - raise ActionInputError(f"Quota '{names[0]}' has not been deleted, so it cannot be undeleted.") - elif len(names) > 1: - raise ActionInputError(f"Quotas have not been deleted so they cannot be undeleted: {', '.join(names)}") - message = "Undeleted %d quotas: " % len(quotas) - for q in quotas: - q.deleted = False - self.sa_session.add(q) - names.append(q.name) - self.sa_session.flush() - message += ', '.join(names) - return message - - def _purge_quota(self, quota, params=None): - """ - This method should only be called for a Quota that has previously been deleted. - Purging a deleted Quota deletes all of the following from the database: - - UserQuotaAssociations where quota_id == Quota.id - - GroupQuotaAssociations where quota_id == Quota.id - """ - quotas = util.listify(quota) - names = [] - for q in quotas: - if not q.deleted: - names.append(q.name) - if len(names) == 1: - raise ActionInputError(f"Quota '{names[0]}' has not been deleted, so it cannot be purged.") - elif len(names) > 1: - raise ActionInputError(f"Quotas have not been deleted so they cannot be undeleted: {', '.join(names)}") - message = "Purged %d quotas: " % len(quotas) - for q in quotas: - # Delete UserQuotaAssociations - for uqa in q.users: - self.sa_session.delete(uqa) - # Delete GroupQuotaAssociations - for gqa in q.groups: - self.sa_session.delete(gqa) - names.append(q.name) - self.sa_session.flush() - message += ', '.join(names) - return message diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 74501d55c0d..71777b88d5b 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -3,9 +3,13 @@ Manager and Serializers for Quotas. For more information about quotas: https://galaxyproject.org/admin/disk-quotas/ """ - +import logging from typing import List +from galaxy import util +from galaxy.app import StructuredApp +from galaxy.exceptions import ActionInputError +from galaxy.managers import base from galaxy.managers.context import ProvidesUserContext from galaxy.quota._schema import ( CreateQuotaPayload, @@ -16,6 +20,8 @@ from galaxy.quota._schema import ( ) from galaxy.schema.fields import EncodedDatabaseIdField +log = logging.getLogger(__name__) + class QuotasManager: """Interface/service object shared by controllers for interacting with quotas.""" @@ -47,35 +53,233 @@ class QuotasManager: class QuotaManager: + """Interface/service object to interact with Quotas.""" + + def __init__(self, app: StructuredApp): + self.app = app + + @property + def sa_session(self): + return self.app.model.context - # TODO: refactor move here code from lib/galaxy/actions/admin.py::AdminActions def create_quota(self, params, decode_id=None): - raise NotImplementedError + if params.amount.lower() in ('unlimited', 'none', 'no limit'): + create_amount = None + else: + try: + create_amount = util.size_to_bytes(params.amount) + except AssertionError: + create_amount = False + if not params.name or not params.description: + raise ActionInputError("Enter a valid name and a description.") + elif self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): + raise ActionInputError("Quota names must be unique and a quota with that name already exists, so choose another name.") + elif not params.get('amount', None): + raise ActionInputError("Enter a valid quota amount.") + elif create_amount is False: + raise ActionInputError("Unable to parse the provided amount.") + elif params.operation not in self.app.model.Quota.valid_operations: + raise ActionInputError("Enter a valid operation.") + elif params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__dict__.values(): + raise ActionInputError("Enter a valid default type.") + elif params.default != 'no' and params.operation != '=': + raise ActionInputError("Operation for a default quota must be '='.") + elif create_amount is None and params.operation != '=': + raise ActionInputError("Operation for an unlimited quota must be '='.") + else: + # Create the quota + quota = self.app.model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) + self.sa_session.add(quota) + # If this is a default quota, create the DefaultQuotaAssociation + if params.default != 'no': + self.app.quota_agent.set_default_quota(params.default, quota) + message = f"Default quota '{quota.name}' has been created." + else: + # Create the UserQuotaAssociations + in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] + in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] + if None in in_users: + raise ActionInputError("One or more invalid user id has been provided.") + for user in in_users: + uqa = self.app.model.UserQuotaAssociation(user, quota) + self.sa_session.add(uqa) + # Create the GroupQuotaAssociations + if None in in_groups: + raise ActionInputError("One or more invalid group id has been provided.") + for group in in_groups: + gqa = self.app.model.GroupQuotaAssociation(group, quota) + self.sa_session.add(gqa) + message = "Quota '%s' has been created with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) + self.sa_session.flush() + return quota, message def rename_quota(self, quota, params): - raise NotImplementedError + if not params.name: + raise ActionInputError('Enter a valid name.') + elif params.name != quota.name and self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): + raise ActionInputError('A quota with that name already exists.') + else: + old_name = quota.name + quota.name = params.name + quota.description = params.description + self.sa_session.add(quota) + self.sa_session.flush() + message = f"Quota '{old_name}' has been renamed to '{params.name}'." + return message def manage_users_and_groups_for_quota(self, quota, params, decode_id=None): - raise NotImplementedError + if quota.default: + raise ActionInputError('Default quotas cannot be associated with specific users and groups.') + else: + in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] + if None in in_users: + raise ActionInputError("One or more invalid user id has been provided.") + in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] + if None in in_groups: + raise ActionInputError("One or more invalid group id has been provided.") + self.app.quota_agent.set_entity_quota_associations(quotas=[quota], users=in_users, groups=in_groups) + self.sa_session.refresh(quota) + message = "Quota '%s' has been updated with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) + return message def edit_quota(self, quota, params): - raise NotImplementedError + if params.amount.lower() in ('unlimited', 'none', 'no limit'): + new_amount = None + else: + try: + new_amount = util.size_to_bytes(params.amount) + except (AssertionError, ValueError): + new_amount = False + if not params.amount: + raise ActionInputError('Enter a valid amount.') + elif new_amount is False: + raise ActionInputError('Unable to parse the provided amount.') + elif params.operation not in self.app.model.Quota.valid_operations: + raise ActionInputError('Enter a valid operation.') + else: + quota.amount = new_amount + quota.operation = params.operation + self.sa_session.add(quota) + self.sa_session.flush() + message = f"Quota '{quota.name}' is now '{quota.operation}{quota.display_amount}'." + return message def set_quota_default(self, quota, params): - raise NotImplementedError + if params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__dict__.values(): + raise ActionInputError('Enter a valid default type.') + else: + if params.default != 'no': + self.app.quota_agent.set_default_quota(params.default, quota) + message = f"Quota '{quota.name}' is now the default for {params.default} users." + else: + if quota.default: + message = f"Quota '{quota.name}' is no longer the default for {quota.default[0].type} users." + for dqa in quota.default: + self.sa_session.delete(dqa) + self.sa_session.flush() + else: + message = f"Quota '{quota.name}' is not a default." + return message def unset_quota_default(self, quota, params=None): - raise NotImplementedError + if not quota.default: + raise ActionInputError(f"Quota '{quota.name}' is not a default.") + else: + message = f"Quota '{quota.name}' is no longer the default for {quota.default[0].type} users." + for dqa in quota.default: + self.sa_session.delete(dqa) + self.sa_session.flush() + return message def delete_quota(self, quota, params=None): - raise NotImplementedError + quotas = util.listify(quota) + names = [] + for q in quotas: + if q.default: + names.append(q.name) + if len(names) == 1: + raise ActionInputError(f"Quota '{names[0]}' is a default, please unset it as a default before deleting it.") + elif len(names) > 1: + raise ActionInputError(f"Quotas are defaults, please unset them as defaults before deleting them: {', '.join(names)}") + message = "Deleted %d quotas: " % len(quotas) + for q in quotas: + q.deleted = True + self.sa_session.add(q) + names.append(q.name) + self.sa_session.flush() + message += ', '.join(names) + return message def undelete_quota(self, quota, params=None): - raise NotImplementedError + quotas = util.listify(quota) + names = [] + for q in quotas: + if not q.deleted: + names.append(q.name) + if len(names) == 1: + raise ActionInputError(f"Quota '{names[0]}' has not been deleted, so it cannot be undeleted.") + elif len(names) > 1: + raise ActionInputError(f"Quotas have not been deleted so they cannot be undeleted: {', '.join(names)}") + message = f"Undeleted {len(quotas)} quotas: " + for q in quotas: + q.deleted = False + self.sa_session.add(q) + names.append(q.name) + self.sa_session.flush() + message += ', '.join(names) + return message def purge_quota(self, quota, params=None): - raise NotImplementedError + """ + This method should only be called for a Quota that has previously been deleted. + Purging a deleted Quota deletes all of the following from the database: + - UserQuotaAssociations where quota_id == Quota.id + - GroupQuotaAssociations where quota_id == Quota.id + """ + quotas = util.listify(quota) + names = [] + for q in quotas: + if not q.deleted: + names.append(q.name) + if len(names) == 1: + raise ActionInputError(f"Quota '{names[0]}' has not been deleted, so it cannot be purged.") + elif len(names) > 1: + raise ActionInputError(f"Quotas have not been deleted so they cannot be undeleted: {', '.join(names)}") + message = f"Purged {len(quotas)} quotas: " + for q in quotas: + # Delete UserQuotaAssociations + for uqa in q.users: + self.sa_session.delete(uqa) + # Delete GroupQuotaAssociations + for gqa in q.groups: + self.sa_session.delete(gqa) + names.append(q.name) + self.sa_session.flush() + message += ', '.join(names) + return message - # TODO: refactor move here code from lib/galaxy/webapps/base/controller.py::UsesQuotaMixin def get_quota(self, trans, id, check_ownership=False, check_accessible=False, deleted=None): - raise NotImplementedError + return base.get_object(trans, id, 'Quota', check_ownership=False, check_accessible=False, deleted=deleted) + + def get_params(self, kwargs): + params = util.Params(kwargs) + # set defaults if unset + updates = dict(webapp=params.get('webapp', 'galaxy'), + message=util.restore_text(params.get('message', '')), + status=util.restore_text(params.get('status', 'done'))) + params.update(updates) + return params + + def get_quota_params(self, kwargs): + params = self.get_params(kwargs) + updates = dict(name=util.restore_text(params.get('name', '')), + description=util.restore_text(params.get('description', '')), + amount=util.restore_text(params.get('amount', '').strip()), + operation=params.get('operation', ''), + default=params.get('default', ''), + in_users=util.listify(params.get('in_users', [])), + out_users=util.listify(params.get('out_users', [])), + in_groups=util.listify(params.get('in_groups', [])), + out_groups=util.listify(params.get('out_groups', []))) + params.update(updates) + return params diff --git a/lib/galaxy/web/params.py b/lib/galaxy/web/params.py deleted file mode 100644 index 0f1fdf0f74e..00000000000 --- a/lib/galaxy/web/params.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Mixins for parsing web form and API parameters -""" -from galaxy import util - - -class BaseParamParser: - def get_params(self, kwargs): - params = util.Params(kwargs) - # set defaults if unset - updates = dict(webapp=params.get('webapp', 'galaxy'), - message=util.restore_text(params.get('message', '')), - status=util.restore_text(params.get('status', 'done'))) - params.update(updates) - return params - - -class QuotaParamParser(BaseParamParser): - def get_quota_params(self, kwargs): - params = self.get_params(kwargs) - updates = dict(name=util.restore_text(params.get('name', '')), - description=util.restore_text(params.get('description', '')), - amount=util.restore_text(params.get('amount', '').strip()), - operation=params.get('operation', ''), - default=params.get('default', ''), - in_users=util.listify(params.get('in_users', [])), - out_users=util.listify(params.get('out_users', [])), - in_groups=util.listify(params.get('in_groups', [])), - out_groups=util.listify(params.get('out_groups', []))) - params.update(updates) - return params diff --git a/lib/galaxy/webapps/base/controller.py b/lib/galaxy/webapps/base/controller.py index 33149b1ca67..2ed05dbf854 100644 --- a/lib/galaxy/webapps/base/controller.py +++ b/lib/galaxy/webapps/base/controller.py @@ -1398,12 +1398,6 @@ class SharableMixin: raise NotImplementedError() -class UsesQuotaMixin: - - def get_quota(self, trans, id, check_ownership=False, check_accessible=False, deleted=None): - return self.get_object(trans, id, 'Quota', check_ownership=False, check_accessible=False, deleted=deleted) - - class UsesTagsMixin(SharableItemSecurityMixin): def get_tag_handler(self, trans) -> tags.GalaxyTagHandler: diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index 74425ed87ea..8295f4a10c1 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -17,19 +17,16 @@ from galaxy import ( util, web ) -from galaxy.actions.admin import AdminActions from galaxy.exceptions import ActionInputError from galaxy.managers.context import ProvidesUserContext -from galaxy.managers.quotas import QuotasManager +from galaxy.managers.quotas import QuotaManager, QuotasManager from galaxy.quota._schema import ( CreateQuotaPayload, UpdateQuotaPayload, ) from galaxy.schema.fields import EncodedDatabaseIdField -from galaxy.web.params import QuotaParamParser from galaxy.webapps.base.controller import ( url_for, - UsesQuotaMixin ) from . import ( AdminUserRequired, @@ -41,7 +38,7 @@ from . import ( log = logging.getLogger(__name__) -# TODO: This FastAPI router is disabled. Please rename it to `router` when the database session issues are fixed. +# TODO: This FastAPI router is disabled. _router = APIRouter(tags=['quotas']) @@ -160,9 +157,12 @@ class FastAPITags: self.manager.undelete(trans, id) -class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, QuotaParamParser): +class QuotaAPIController(BaseGalaxyAPIController): + + quota_manager: QuotaManager = depends(QuotaManager) + @web.require_admin - @web.legacy_expose_api + @web.expose_api def index(self, trans, deleted='False', **kwd): """ GET /api/quotas @@ -186,18 +186,18 @@ class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, return rval @web.require_admin - @web.legacy_expose_api + @web.expose_api def show(self, trans, id, deleted='False', **kwd): """ GET /api/quotas/{encoded_quota_id} GET /api/quotas/deleted/{encoded_quota_id} Displays information about a quota. """ - quota = self.get_quota(trans, id, deleted=util.string_as_bool(deleted)) + quota = self.quota_manager.get_quota(trans, id, deleted=util.string_as_bool(deleted)) return quota.to_dict(view='element', value_mapper={'id': trans.security.encode_id, 'total_disk_usage': float}) @web.require_admin - @web.legacy_expose_api + @web.expose_api def create(self, trans, payload, **kwd): """ POST /api/quotas @@ -207,9 +207,9 @@ class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, self.validate_in_users_and_groups(trans, payload) except Exception as e: raise HTTPBadRequest(detail=util.unicodify(e)) - params = self.get_quota_params(payload) + params = self.quota_manager.get_quota_params(payload) try: - quota, message = self._create_quota(params) + quota, message = self.quota_manager.create_quota(params) except ActionInputError as e: raise HTTPBadRequest(detail=util.unicodify(e)) item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) @@ -218,7 +218,7 @@ class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, return item @web.require_admin - @web.legacy_expose_api + @web.expose_api def update(self, trans, id, payload, **kwd): """ PUT /api/quotas/{encoded_quota_id} @@ -229,22 +229,22 @@ class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, except Exception as e: raise HTTPBadRequest(detail=util.unicodify(e)) - quota = self.get_quota(trans, id, deleted=False) + quota = self.quota_manager.get_quota(trans, id, deleted=False) # FIXME: Doing it this way makes the update non-atomic if a method fails after an earlier one has succeeded. payload['id'] = id - params = self.get_quota_params(payload) + params = self.quota_manager.get_quota_params(payload) methods = [] if payload.get('name', None) or payload.get('description', None): - methods.append(self._rename_quota) + methods.append(self.quota_manager.rename_quota) if payload.get('amount', None): - methods.append(self._edit_quota) + methods.append(self.quota_manager.edit_quota) if payload.get('default', None) == 'no': - methods.append(self._unset_quota_default) + methods.append(self.quota_manager.unset_quota_default) elif payload.get('default', None): - methods.append(self._set_quota_default) + methods.append(self.quota_manager.set_quota_default) if payload.get('in_users', None) or payload.get('in_groups', None): - methods.append(self._manage_users_and_groups_for_quota) + methods.append(self.quota_manager.manage_users_and_groups_for_quota) messages = [] for method in methods: @@ -256,36 +256,36 @@ class QuotaAPIController(BaseGalaxyAPIController, AdminActions, UsesQuotaMixin, return '; '.join(messages) @web.require_admin - @web.legacy_expose_api + @web.expose_api def delete(self, trans, id, **kwd): """ DELETE /api/quotas/{encoded_quota_id} Deletes a quota """ - quota = self.get_quota(trans, id, deleted=False) # deleted quotas are not technically members of this collection + quota = self.quota_manager.get_quota(trans, id, deleted=False) # deleted quotas are not technically members of this collection # a request body is optional here payload = kwd.get('payload', {}) payload['id'] = id - params = self.get_quota_params(payload) + params = self.quota_manager.get_quota_params(payload) try: - message = self._delete_quota(quota, params) + message = self.quota_manager.delete_quota(quota, params) if util.string_as_bool(payload.get('purge', False)): - message += self._purge_quota(quota, params) + message += self.quota_manager.purge_quota(quota, params) except ActionInputError as e: raise HTTPBadRequest(detail=util.unicodify(e)) return message @web.require_admin - @web.legacy_expose_api + @web.expose_api def undelete(self, trans, id, **kwd): """ POST /api/quotas/deleted/{encoded_quota_id}/undelete Undeletes a quota """ - quota = self.get_quota(trans, id, deleted=True) + quota = self.quota_manager.get_quota(trans, id, deleted=True) try: - return self._undelete_quota(quota) + return self.quota_manager.undelete_quota(quota) except ActionInputError as e: raise HTTPBadRequest(detail=util.unicodify(e)) diff --git a/lib/galaxy/webapps/galaxy/controllers/admin.py b/lib/galaxy/webapps/galaxy/controllers/admin.py index 53545bd9446..df55acb7457 100644 --- a/lib/galaxy/webapps/galaxy/controllers/admin.py +++ b/lib/galaxy/webapps/galaxy/controllers/admin.py @@ -9,8 +9,8 @@ from galaxy import ( util, web ) -from galaxy.actions.admin import AdminActions from galaxy.exceptions import ActionInputError, MessageException +from galaxy.managers.quotas import QuotaManager from galaxy.model import tool_shed_install as install_model from galaxy.security.validate_user_input import validate_password from galaxy.tool_shed.util.repository_util import get_ids_of_tool_shed_repositories_being_installed @@ -22,9 +22,7 @@ from galaxy.util import ( from galaxy.util.tool_shed import common_util, encoding_util from galaxy.web import url_for from galaxy.web.framework.helpers import grids, time_ago -from galaxy.web.params import QuotaParamParser from galaxy.webapps.base import controller -from galaxy.webapps.base.controller import UsesQuotaMixin from tool_shed.util.web_util import escape @@ -503,7 +501,7 @@ class ToolVersionListGrid(grids.Grid): return trans.install_model.context.query(self.model_class) -class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaParamParser): +class AdminGalaxy(controller.JSAppLauncher): user_list_grid = UserListGrid() role_list_grid = RoleListGrid() @@ -522,6 +520,10 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP activate_operation = grids.GridOperation("Activate User", condition=(lambda item: not item.active), allow_multiple=False) resend_activation_email = grids.GridOperation("Resend Activation Email", condition=(lambda item: not item.active), allow_multiple=False) + def __init__(self, app): + super().__init__(app) + self.quota_manager: QuotaManager = QuotaManager(app) + @web.expose @web.require_admin def index(self, trans, **kwd): @@ -646,13 +648,13 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP operation = kwargs.pop('operation').lower() try: if operation == 'delete': - message = self._delete_quota(quotas) + message = self.quota_manager.delete_quota(quotas) elif operation == 'undelete': - message = self._undelete_quota(quotas) + message = self.quota_manager.undelete_quota(quotas) elif operation == 'purge': - message = self._purge_quota(quotas) + message = self.quota_manager.purge_quota(quotas) elif operation == 'unset as default': - message = self._unset_quota_default(quotas[0]) + message = self.quota_manager.unset_quota_default(quotas[0]) except ActionInputError as e: message, status = (e.err_msg, 'error') if message: @@ -703,7 +705,7 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP build_select_input('in_users', 'Users', all_users, [])]} else: try: - quota, message = self._create_quota(util.Params(payload), decode_id=trans.security.decode_id) + quota, message = self.quota_manager.create_quota(util.Params(payload), decode_id=trans.security.decode_id) return {'message': message} except ActionInputError as e: return self.message_exception(trans, e.err_msg) @@ -730,7 +732,7 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP } else: try: - return {'message': self._rename_quota(quota, util.Params(payload))} + return {'message': self.quota_manager.rename_quota(quota, util.Params(payload))} except ActionInputError as e: return self.message_exception(trans, e.err_msg) @@ -766,7 +768,7 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP build_select_input('in_users', 'Users', all_users, in_users)]} else: try: - return {'message': self._manage_users_and_groups_for_quota(quota, util.Params(payload), decode_id=trans.security.decode_id)} + return {'message': self.quota_manager.manage_users_and_groups_for_quota(quota, util.Params(payload), decode_id=trans.security.decode_id)} except ActionInputError as e: return self.message_exception(trans, e.err_msg) @@ -794,7 +796,7 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP } else: try: - return {'message': self._edit_quota(quota, util.Params(payload))} + return {'message': self.quota_manager.edit_quota(quota, util.Params(payload))} except ActionInputError as e: return self.message_exception(trans, e.err_msg) @@ -822,7 +824,7 @@ class AdminGalaxy(controller.JSAppLauncher, AdminActions, UsesQuotaMixin, QuotaP } else: try: - return {'message': self._set_quota_default(quota, util.Params(payload))} + return {'message': self.quota_manager.set_quota_default(quota, util.Params(payload))} except ActionInputError as e: return self.message_exception(trans, e.err_msg) From c80f773b74169830a61efa7af6f2ef658072f887 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Mon, 1 Mar 2021 18:48:52 +0100 Subject: [PATCH 10/23] Cleanup QuotaManager a bit - Add some type hints - Use fstrings - Remove unused parameters --- lib/galaxy/managers/quotas.py | 68 ++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 71777b88d5b..6b66394036a 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -4,9 +4,9 @@ Manager and Serializers for Quotas. For more information about quotas: https://galaxyproject.org/admin/disk-quotas/ """ import logging -from typing import List +from typing import List, Tuple -from galaxy import util +from galaxy import model, util from galaxy.app import StructuredApp from galaxy.exceptions import ActionInputError from galaxy.managers import base @@ -62,7 +62,11 @@ class QuotaManager: def sa_session(self): return self.app.model.context - def create_quota(self, params, decode_id=None): + @property + def quota_agent(self): + return self.app.quota_agent + + def create_quota(self, params, decode_id=None) -> Tuple[model.Quota, str]: if params.amount.lower() in ('unlimited', 'none', 'no limit'): create_amount = None else: @@ -72,15 +76,15 @@ class QuotaManager: create_amount = False if not params.name or not params.description: raise ActionInputError("Enter a valid name and a description.") - elif self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): + elif self.sa_session.query(model.Quota).filter(model.Quota.name == params.name).first(): raise ActionInputError("Quota names must be unique and a quota with that name already exists, so choose another name.") elif not params.get('amount', None): raise ActionInputError("Enter a valid quota amount.") elif create_amount is False: raise ActionInputError("Unable to parse the provided amount.") - elif params.operation not in self.app.model.Quota.valid_operations: + elif params.operation not in model.Quota.valid_operations: raise ActionInputError("Enter a valid operation.") - elif params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__dict__.values(): + elif params.default != 'no' and params.default not in model.DefaultQuotaAssociation.types.__members__.values(): raise ActionInputError("Enter a valid default type.") elif params.default != 'no' and params.operation != '=': raise ActionInputError("Operation for a default quota must be '='.") @@ -88,35 +92,35 @@ class QuotaManager: raise ActionInputError("Operation for an unlimited quota must be '='.") else: # Create the quota - quota = self.app.model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) + quota = model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) self.sa_session.add(quota) # If this is a default quota, create the DefaultQuotaAssociation if params.default != 'no': - self.app.quota_agent.set_default_quota(params.default, quota) + self.quota_agent.set_default_quota(params.default, quota) message = f"Default quota '{quota.name}' has been created." else: # Create the UserQuotaAssociations - in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] - in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] + in_users = [self.sa_session.query(model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] + in_groups = [self.sa_session.query(model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] if None in in_users: raise ActionInputError("One or more invalid user id has been provided.") for user in in_users: - uqa = self.app.model.UserQuotaAssociation(user, quota) + uqa = model.UserQuotaAssociation(user, quota) self.sa_session.add(uqa) # Create the GroupQuotaAssociations if None in in_groups: raise ActionInputError("One or more invalid group id has been provided.") for group in in_groups: - gqa = self.app.model.GroupQuotaAssociation(group, quota) + gqa = model.GroupQuotaAssociation(group, quota) self.sa_session.add(gqa) - message = "Quota '%s' has been created with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) + message = f"Quota '{quota.name}' has been created with {len(in_users)} associated users and {len(in_groups)} associated groups." self.sa_session.flush() return quota, message - def rename_quota(self, quota, params): + def rename_quota(self, quota, params) -> str: if not params.name: raise ActionInputError('Enter a valid name.') - elif params.name != quota.name and self.sa_session.query(self.app.model.Quota).filter(self.app.model.Quota.name == params.name).first(): + elif params.name != quota.name and self.sa_session.query(model.Quota).filter(model.Quota.name == params.name).first(): raise ActionInputError('A quota with that name already exists.') else: old_name = quota.name @@ -127,22 +131,22 @@ class QuotaManager: message = f"Quota '{old_name}' has been renamed to '{params.name}'." return message - def manage_users_and_groups_for_quota(self, quota, params, decode_id=None): + def manage_users_and_groups_for_quota(self, quota, params, decode_id=None) -> str: if quota.default: raise ActionInputError('Default quotas cannot be associated with specific users and groups.') else: - in_users = [self.sa_session.query(self.app.model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] + in_users = [self.sa_session.query(model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] if None in in_users: raise ActionInputError("One or more invalid user id has been provided.") - in_groups = [self.sa_session.query(self.app.model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] + in_groups = [self.sa_session.query(model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] if None in in_groups: raise ActionInputError("One or more invalid group id has been provided.") - self.app.quota_agent.set_entity_quota_associations(quotas=[quota], users=in_users, groups=in_groups) + self.quota_agent.set_entity_quota_associations(quotas=[quota], users=in_users, groups=in_groups) self.sa_session.refresh(quota) - message = "Quota '%s' has been updated with %d associated users and %d associated groups." % (quota.name, len(in_users), len(in_groups)) + message = f"Quota '{quota.name}' has been updated with {len(in_users)} associated users and {len(in_groups)} associated groups." return message - def edit_quota(self, quota, params): + def edit_quota(self, quota, params) -> str: if params.amount.lower() in ('unlimited', 'none', 'no limit'): new_amount = None else: @@ -154,7 +158,7 @@ class QuotaManager: raise ActionInputError('Enter a valid amount.') elif new_amount is False: raise ActionInputError('Unable to parse the provided amount.') - elif params.operation not in self.app.model.Quota.valid_operations: + elif params.operation not in model.Quota.valid_operations: raise ActionInputError('Enter a valid operation.') else: quota.amount = new_amount @@ -164,12 +168,12 @@ class QuotaManager: message = f"Quota '{quota.name}' is now '{quota.operation}{quota.display_amount}'." return message - def set_quota_default(self, quota, params): - if params.default != 'no' and params.default not in self.app.model.DefaultQuotaAssociation.types.__dict__.values(): + def set_quota_default(self, quota, params) -> str: + if params.default != 'no' and params.default not in model.DefaultQuotaAssociation.types.__members__.values(): raise ActionInputError('Enter a valid default type.') else: if params.default != 'no': - self.app.quota_agent.set_default_quota(params.default, quota) + self.quota_agent.set_default_quota(params.default, quota) message = f"Quota '{quota.name}' is now the default for {params.default} users." else: if quota.default: @@ -181,7 +185,7 @@ class QuotaManager: message = f"Quota '{quota.name}' is not a default." return message - def unset_quota_default(self, quota, params=None): + def unset_quota_default(self, quota, params=None) -> str: if not quota.default: raise ActionInputError(f"Quota '{quota.name}' is not a default.") else: @@ -191,7 +195,7 @@ class QuotaManager: self.sa_session.flush() return message - def delete_quota(self, quota, params=None): + def delete_quota(self, quota, params=None) -> str: quotas = util.listify(quota) names = [] for q in quotas: @@ -201,7 +205,7 @@ class QuotaManager: raise ActionInputError(f"Quota '{names[0]}' is a default, please unset it as a default before deleting it.") elif len(names) > 1: raise ActionInputError(f"Quotas are defaults, please unset them as defaults before deleting them: {', '.join(names)}") - message = "Deleted %d quotas: " % len(quotas) + message = f"Deleted {len(quotas)} quotas: " for q in quotas: q.deleted = True self.sa_session.add(q) @@ -210,7 +214,7 @@ class QuotaManager: message += ', '.join(names) return message - def undelete_quota(self, quota, params=None): + def undelete_quota(self, quota, params=None) -> str: quotas = util.listify(quota) names = [] for q in quotas: @@ -258,10 +262,10 @@ class QuotaManager: message += ', '.join(names) return message - def get_quota(self, trans, id, check_ownership=False, check_accessible=False, deleted=None): + def get_quota(self, trans, id, deleted=None) -> model.Quota: return base.get_object(trans, id, 'Quota', check_ownership=False, check_accessible=False, deleted=deleted) - def get_params(self, kwargs): + def get_params(self, kwargs) -> util.Params: params = util.Params(kwargs) # set defaults if unset updates = dict(webapp=params.get('webapp', 'galaxy'), @@ -270,7 +274,7 @@ class QuotaManager: params.update(updates) return params - def get_quota_params(self, kwargs): + def get_quota_params(self, kwargs) -> util.Params: params = self.get_params(kwargs) updates = dict(name=util.restore_text(params.get('name', '')), description=util.restore_text(params.get('description', '')), From 61344d56635e71e436f480f4ca9b81a2b942fa54 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:42:53 +0100 Subject: [PATCH 11/23] Refactor QuotasController to use QuotasManager --- lib/galaxy/managers/quotas.py | 267 +++++++++++++++--------- lib/galaxy/quota/_schema.py | 34 ++- lib/galaxy/webapps/galaxy/api/quotas.py | 112 ++-------- 3 files changed, 209 insertions(+), 204 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 6b66394036a..7ba3fdb87eb 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -4,54 +4,42 @@ Manager and Serializers for Quotas. For more information about quotas: https://galaxyproject.org/admin/disk-quotas/ """ import logging -from typing import List, Tuple +from typing import ( + Optional, + Tuple, + Union, + cast, +) + +from sqlalchemy import ( + false, + true +) from galaxy import model, util from galaxy.app import StructuredApp from galaxy.exceptions import ActionInputError from galaxy.managers import base from galaxy.managers.context import ProvidesUserContext +from galaxy.quota import DatabaseQuotaAgent from galaxy.quota._schema import ( - CreateQuotaPayload, + CreateQuotaParams, CreateQuotaResult, + DefaultQuotaValues, + DeleteQuotaPayload, QuotaDetails, + QuotaOperation, QuotaSummaryList, - UpdateQuotaPayload, + UpdateQuotaParams, ) from galaxy.schema.fields import EncodedDatabaseIdField +from galaxy.webapps.base.controller import ( + url_for, +) log = logging.getLogger(__name__) -class QuotasManager: - """Interface/service object shared by controllers for interacting with quotas.""" - - # TODO refactor move here code from lib/galaxy/webapps/galaxy/api/quotas.py::QuotaAPIController - def index(self, trans: ProvidesUserContext, deleted: bool = False) -> QuotaSummaryList: - """Displays a collection (list) of quotas.""" - raise NotImplementedError - - def show(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, deleted: bool = False) -> QuotaDetails: - """Displays information about a quota.""" - raise NotImplementedError - - def create(self, trans: ProvidesUserContext, payload: CreateQuotaPayload) -> CreateQuotaResult: - """Creates a new quota.""" - raise NotImplementedError - - def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: UpdateQuotaPayload) -> List[str]: - """Modifies a quota.""" - raise NotImplementedError - - def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: - """Marks a quota as deleted.""" - raise NotImplementedError - - def undelete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> List[str]: - """Restores a previously deleted quota.""" - raise NotImplementedError - - class QuotaManager: """Interface/service object to interact with Quotas.""" @@ -63,59 +51,55 @@ class QuotaManager: return self.app.model.context @property - def quota_agent(self): - return self.app.quota_agent + def quota_agent(self) -> DatabaseQuotaAgent: + return cast(DatabaseQuotaAgent, self.app.quota_agent) - def create_quota(self, params, decode_id=None) -> Tuple[model.Quota, str]: - if params.amount.lower() in ('unlimited', 'none', 'no limit'): - create_amount = None - else: - try: - create_amount = util.size_to_bytes(params.amount) - except AssertionError: - create_amount = False - if not params.name or not params.description: - raise ActionInputError("Enter a valid name and a description.") - elif self.sa_session.query(model.Quota).filter(model.Quota.name == params.name).first(): - raise ActionInputError("Quota names must be unique and a quota with that name already exists, so choose another name.") - elif not params.get('amount', None): - raise ActionInputError("Enter a valid quota amount.") + def create_quota(self, payload: dict, decode_id=None) -> Tuple[model.Quota, str]: + params = CreateQuotaParams(**payload) + create_amount = self._parse_amount(params.amount) + if self.sa_session.query(model.Quota).filter(model.Quota.name == params.name).first(): + raise ActionInputError("Quota names must be unique and a quota with that name already exists, please choose another name.") elif create_amount is False: raise ActionInputError("Unable to parse the provided amount.") elif params.operation not in model.Quota.valid_operations: raise ActionInputError("Enter a valid operation.") - elif params.default != 'no' and params.default not in model.DefaultQuotaAssociation.types.__members__.values(): - raise ActionInputError("Enter a valid default type.") - elif params.default != 'no' and params.operation != '=': + elif params.default != DefaultQuotaValues.NO and params.operation != QuotaOperation.EXACT: raise ActionInputError("Operation for a default quota must be '='.") - elif create_amount is None and params.operation != '=': + elif create_amount is None and params.operation != QuotaOperation.EXACT: raise ActionInputError("Operation for an unlimited quota must be '='.") + # Create the quota + quota = model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) + self.sa_session.add(quota) + # If this is a default quota, create the DefaultQuotaAssociation + if params.default != DefaultQuotaValues.NO: + self.quota_agent.set_default_quota(params.default, quota) + message = f"Default quota '{quota.name}' has been created." else: - # Create the quota - quota = model.Quota(name=params.name, description=params.description, amount=create_amount, operation=params.operation) - self.sa_session.add(quota) - # If this is a default quota, create the DefaultQuotaAssociation - if params.default != 'no': - self.quota_agent.set_default_quota(params.default, quota) - message = f"Default quota '{quota.name}' has been created." - else: - # Create the UserQuotaAssociations - in_users = [self.sa_session.query(model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] - in_groups = [self.sa_session.query(model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] - if None in in_users: - raise ActionInputError("One or more invalid user id has been provided.") - for user in in_users: - uqa = model.UserQuotaAssociation(user, quota) - self.sa_session.add(uqa) - # Create the GroupQuotaAssociations - if None in in_groups: - raise ActionInputError("One or more invalid group id has been provided.") - for group in in_groups: - gqa = model.GroupQuotaAssociation(group, quota) - self.sa_session.add(gqa) - message = f"Quota '{quota.name}' has been created with {len(in_users)} associated users and {len(in_groups)} associated groups." - self.sa_session.flush() - return quota, message + # Create the UserQuotaAssociations + in_users = [self.sa_session.query(model.User).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_users)] + in_groups = [self.sa_session.query(model.Group).get(decode_id(x) if decode_id else x) for x in util.listify(params.in_groups)] + if None in in_users: + raise ActionInputError("One or more invalid user id has been provided.") + for user in in_users: + uqa = model.UserQuotaAssociation(user, quota) + self.sa_session.add(uqa) + # Create the GroupQuotaAssociations + if None in in_groups: + raise ActionInputError("One or more invalid group id has been provided.") + for group in in_groups: + gqa = model.GroupQuotaAssociation(group, quota) + self.sa_session.add(gqa) + message = f"Quota '{quota.name}' has been created with {len(in_users)} associated users and {len(in_groups)} associated groups." + self.sa_session.flush() + return quota, message + + def _parse_amount(self, amount: str) -> Optional[Union[int, bool]]: + if amount.lower() in ('unlimited', 'none', 'no limit'): + return None + try: + return util.size_to_bytes(amount) + except AssertionError: + return False def rename_quota(self, quota, params) -> str: if not params.name: @@ -262,28 +246,113 @@ class QuotaManager: message += ', '.join(names) return message - def get_quota(self, trans, id, deleted=None) -> model.Quota: + def get_quota(self, trans, id: EncodedDatabaseIdField, deleted: Optional[bool] = None) -> model.Quota: return base.get_object(trans, id, 'Quota', check_ownership=False, check_accessible=False, deleted=deleted) - def get_params(self, kwargs) -> util.Params: - params = util.Params(kwargs) - # set defaults if unset - updates = dict(webapp=params.get('webapp', 'galaxy'), - message=util.restore_text(params.get('message', '')), - status=util.restore_text(params.get('status', 'done'))) - params.update(updates) - return params - def get_quota_params(self, kwargs) -> util.Params: - params = self.get_params(kwargs) - updates = dict(name=util.restore_text(params.get('name', '')), - description=util.restore_text(params.get('description', '')), - amount=util.restore_text(params.get('amount', '').strip()), - operation=params.get('operation', ''), - default=params.get('default', ''), - in_users=util.listify(params.get('in_users', [])), - out_users=util.listify(params.get('out_users', [])), - in_groups=util.listify(params.get('in_groups', [])), - out_groups=util.listify(params.get('out_groups', []))) - params.update(updates) - return params +class QuotasManager: + """Interface/service object shared by controllers for interacting with quotas.""" + + def __init__(self, app: StructuredApp): + self.quota_manager: QuotaManager = QuotaManager(app) + + def index(self, trans: ProvidesUserContext, deleted: bool = False) -> QuotaSummaryList: + """Displays a collection (list) of quotas.""" + rval = [] + query = trans.sa_session.query(model.Quota) + if deleted: + route = 'deleted_quota' + query = query.filter(model.Quota.deleted == true()) + else: + route = 'quota' + query = query.filter(model.Quota.deleted == false()) + for quota in query: + item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) + encoded_id = trans.security.encode_id(quota.id) + item['url'] = url_for(route, id=encoded_id) + rval.append(item) + return QuotaSummaryList.parse_obj(rval) + + def show(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, deleted: bool = False) -> QuotaDetails: + """Displays information about a quota.""" + quota = self.quota_manager.get_quota(trans, id, deleted=deleted) + rval = quota.to_dict(view='element', value_mapper={'id': trans.security.encode_id, 'total_disk_usage': float}) + return QuotaDetails.parse_obj(rval) + + def create(self, trans: ProvidesUserContext, payload: dict) -> CreateQuotaResult: + """Creates a new quota.""" + self.validate_in_users_and_groups(trans, payload) + quota, message = self.quota_manager.create_quota(payload) + item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) + item['url'] = url_for('quota', id=trans.security.encode_id(quota.id)) + item['message'] = message + return CreateQuotaResult.parse_obj(item) + + def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: dict) -> str: + """Modifies a quota.""" + self.validate_in_users_and_groups(trans, payload) + quota = self.quota_manager.get_quota(trans, id, deleted=False) + + params = UpdateQuotaParams(**payload) + # FIXME: Doing it this way makes the update non-atomic if a method fails after an earlier one has succeeded. + methods = [] + if params.name or params.description: + methods.append(self.quota_manager.rename_quota) + if params.amount: + methods.append(self.quota_manager.edit_quota) + if params.default == DefaultQuotaValues.NO: + methods.append(self.quota_manager.unset_quota_default) + elif params.default: + methods.append(self.quota_manager.set_quota_default) + if params.in_users or params.in_groups: + methods.append(self.quota_manager.manage_users_and_groups_for_quota) + + messages = [] + for method in methods: + message = method(quota, params) + messages.append(message) + return '; '.join(messages) + + def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: DeleteQuotaPayload) -> str: + """Marks a quota as deleted.""" + quota = self.quota_manager.get_quota(trans, id, deleted=False) # deleted quotas are not technically members of this collection + message = self.quota_manager.delete_quota(quota) + if payload.purge: + message += self.quota_manager.purge_quota(quota) + return message + + def undelete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField) -> str: + """Restores a previously deleted quota.""" + quota = self.quota_manager.get_quota(trans, id, deleted=True) + return self.quota_manager.undelete_quota(quota) + + def validate_in_users_and_groups(self, trans, payload): + """ + For convenience, in_users and in_groups can be encoded IDs or emails/group names in the API. + """ + def get_id(item, model_class, column): + try: + return trans.security.decode_id(item) + except Exception: + pass # maybe an email/group name + # this will raise if the item is invalid + return trans.sa_session.query(model_class).filter(column == item).first().id + new_in_users = [] + new_in_groups = [] + invalid = [] + for item in util.listify(payload.get('in_users', [])): + try: + new_in_users.append(get_id(item, model.User, model.User.email)) + except Exception: + invalid.append(item) + for item in util.listify(payload.get('in_groups', [])): + try: + new_in_groups.append(get_id(item, model.Group, model.Group.name)) + except Exception: + invalid.append(item) + if invalid: + msg = f"The following value(s) for associated users and/or groups could not be parsed: {', '.join(invalid)}." + msg += " Valid values are email addresses of users, names of groups, or IDs of both." + raise Exception(msg) + payload['in_users'] = list(map(str, new_in_users)) + payload['in_groups'] = list(map(str, new_in_groups)) diff --git a/lib/galaxy/quota/_schema.py b/lib/galaxy/quota/_schema.py index b5f07f21412..0a6f11e3d33 100644 --- a/lib/galaxy/quota/_schema.py +++ b/lib/galaxy/quota/_schema.py @@ -48,7 +48,7 @@ QuotaNameField = Field( ) QuotaDescriptionField = Field( - "", + ..., title="Description", description="Detailed text description for this Quota.", ) @@ -161,9 +161,9 @@ class CreateQuotaResult(QuotaSummary): ) -class CreateQuotaPayload(BaseModel): +class CreateQuotaParams(BaseModel): name: str = QuotaNameField - description: Optional[str] = QuotaDescriptionField + description: str = QuotaDescriptionField amount: str = Field( ..., title="Amount", @@ -191,15 +191,19 @@ class CreateQuotaPayload(BaseModel): ) -class UpdateQuotaPayload(BaseModel): +class UpdateQuotaParams(BaseModel): name: Optional[str] = Field( default=None, title="Name", description="The new name of the quota. This must be unique within a Galaxy instance.", ) - description: Optional[str] = QuotaDescriptionField + description: Optional[str] = Field( + None, + title="Description", + description="Detailed text description for this Quota.", + ) amount: Optional[str] = Field( - ..., + None, title="Amount", description="Quota size (E.g. ``10000MB``, ``99 gb``, ``0.2T``, ``unlimited``)", ) @@ -212,7 +216,7 @@ class UpdateQuotaPayload(BaseModel): ) ) default: Optional[DefaultQuotaValues] = Field( - default=DefaultQuotaValues.NO, + default=None, title="Default", description=( "Whether or not this is a default quota. Valid values" @@ -222,13 +226,21 @@ class UpdateQuotaPayload(BaseModel): " passing this parameter is equivalent to passing ``no``." ), ) - in_users: List[str] = Field( - default=[], + in_users: Optional[List[str]] = Field( + default=None, title="Users", description="A list of user IDs or user emails to associate with this quota.", ) - in_groups: List[str] = Field( - default=[], + in_groups: Optional[List[str]] = Field( + default=None, title="Groups", description="A list of group IDs or names to associate with this quota.", ) + + +class DeleteQuotaPayload(BaseModel): + purge: bool = Field( + False, + title="Purge", + description="Whether to also purge the Quota after deleting it.", + ) diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index 8295f4a10c1..c49295a8806 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -7,27 +7,19 @@ from fastapi import Path # TODO: replace with Router after merging #11219 from fastapi_utils.cbv import cbv from fastapi_utils.inferring_router import InferringRouter as APIRouter -from paste.httpexceptions import HTTPBadRequest -from sqlalchemy import ( - false, - true -) from galaxy import ( util, - web + web, ) -from galaxy.exceptions import ActionInputError from galaxy.managers.context import ProvidesUserContext -from galaxy.managers.quotas import QuotaManager, QuotasManager +from galaxy.managers.quotas import QuotasManager from galaxy.quota._schema import ( - CreateQuotaPayload, - UpdateQuotaPayload, + CreateQuotaParams, + DeleteQuotaPayload, + UpdateQuotaParams, ) from galaxy.schema.fields import EncodedDatabaseIdField -from galaxy.webapps.base.controller import ( - url_for, -) from . import ( AdminUserRequired, BaseGalaxyAPIController, @@ -110,7 +102,7 @@ class FastAPITags: ) def create( self, - payload: CreateQuotaPayload, + payload: CreateQuotaParams, trans: ProvidesUserContext = DependsOnTrans, ): """Creates a new quota.""" @@ -123,7 +115,7 @@ class FastAPITags: ) def update( self, - payload: UpdateQuotaPayload, + payload: UpdateQuotaParams, id: EncodedDatabaseIdField = QuotaIdPathParam, trans: ProvidesUserContext = DependsOnTrans, ): @@ -137,11 +129,12 @@ class FastAPITags: ) def delete( self, + payload: DeleteQuotaPayload, id: EncodedDatabaseIdField = QuotaIdPathParam, trans: ProvidesUserContext = DependsOnTrans, ): """Deletes an existing quota.""" - self.manager.delete(trans, id) + self.manager.delete(trans, id, payload) @_router.post( '/api/quotas/deleted/{id}/undelete', @@ -159,7 +152,7 @@ class FastAPITags: class QuotaAPIController(BaseGalaxyAPIController): - quota_manager: QuotaManager = depends(QuotaManager) + manager: QuotasManager = depends(QuotasManager) @web.require_admin @web.expose_api @@ -169,21 +162,8 @@ class QuotaAPIController(BaseGalaxyAPIController): GET /api/quotas/deleted Displays a collection (list) of quotas. """ - rval = [] deleted = util.string_as_bool(deleted) - query = trans.sa_session.query(trans.app.model.Quota) - if deleted: - route = 'deleted_quota' - query = query.filter(trans.app.model.Quota.deleted == true()) - else: - route = 'quota' - query = query.filter(trans.app.model.Quota.deleted == false()) - for quota in query: - item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) - encoded_id = trans.security.encode_id(quota.id) - item['url'] = url_for(route, id=encoded_id) - rval.append(item) - return rval + return self.manager.index(trans, deleted) @web.require_admin @web.expose_api @@ -193,8 +173,8 @@ class QuotaAPIController(BaseGalaxyAPIController): GET /api/quotas/deleted/{encoded_quota_id} Displays information about a quota. """ - quota = self.quota_manager.get_quota(trans, id, deleted=util.string_as_bool(deleted)) - return quota.to_dict(view='element', value_mapper={'id': trans.security.encode_id, 'total_disk_usage': float}) + deleted = util.string_as_bool(deleted) + return self.manager.show(trans, id, deleted) @web.require_admin @web.expose_api @@ -203,19 +183,7 @@ class QuotaAPIController(BaseGalaxyAPIController): POST /api/quotas Creates a new quota. """ - try: - self.validate_in_users_and_groups(trans, payload) - except Exception as e: - raise HTTPBadRequest(detail=util.unicodify(e)) - params = self.quota_manager.get_quota_params(payload) - try: - quota, message = self.quota_manager.create_quota(params) - except ActionInputError as e: - raise HTTPBadRequest(detail=util.unicodify(e)) - item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) - item['url'] = url_for('quota', id=trans.security.encode_id(quota.id)) - item['message'] = message - return item + return self.manager.create(trans, payload) @web.require_admin @web.expose_api @@ -224,36 +192,7 @@ class QuotaAPIController(BaseGalaxyAPIController): PUT /api/quotas/{encoded_quota_id} Modifies a quota. """ - try: - self.validate_in_users_and_groups(trans, payload) - except Exception as e: - raise HTTPBadRequest(detail=util.unicodify(e)) - - quota = self.quota_manager.get_quota(trans, id, deleted=False) - - # FIXME: Doing it this way makes the update non-atomic if a method fails after an earlier one has succeeded. - payload['id'] = id - params = self.quota_manager.get_quota_params(payload) - methods = [] - if payload.get('name', None) or payload.get('description', None): - methods.append(self.quota_manager.rename_quota) - if payload.get('amount', None): - methods.append(self.quota_manager.edit_quota) - if payload.get('default', None) == 'no': - methods.append(self.quota_manager.unset_quota_default) - elif payload.get('default', None): - methods.append(self.quota_manager.set_quota_default) - if payload.get('in_users', None) or payload.get('in_groups', None): - methods.append(self.quota_manager.manage_users_and_groups_for_quota) - - messages = [] - for method in methods: - try: - message = method(quota, params) - except ActionInputError as e: - raise HTTPBadRequest(detail=util.unicodify(e)) - messages.append(message) - return '; '.join(messages) + return self.manager.update(trans, id, payload) @web.require_admin @web.expose_api @@ -262,20 +201,9 @@ class QuotaAPIController(BaseGalaxyAPIController): DELETE /api/quotas/{encoded_quota_id} Deletes a quota """ - quota = self.quota_manager.get_quota(trans, id, deleted=False) # deleted quotas are not technically members of this collection - # a request body is optional here - payload = kwd.get('payload', {}) - payload['id'] = id - params = self.quota_manager.get_quota_params(payload) - - try: - message = self.quota_manager.delete_quota(quota, params) - if util.string_as_bool(payload.get('purge', False)): - message += self.quota_manager.purge_quota(quota, params) - except ActionInputError as e: - raise HTTPBadRequest(detail=util.unicodify(e)) - return message + payload = DeleteQuotaPayload(**kwd.get('payload', {})) + return self.manager.delete(trans, id, payload) @web.require_admin @web.expose_api @@ -284,8 +212,4 @@ class QuotaAPIController(BaseGalaxyAPIController): POST /api/quotas/deleted/{encoded_quota_id}/undelete Undeletes a quota """ - quota = self.quota_manager.get_quota(trans, id, deleted=True) - try: - return self.quota_manager.undelete_quota(quota) - except ActionInputError as e: - raise HTTPBadRequest(detail=util.unicodify(e)) + return self.manager.undelete(trans, id) From 5d3f7b888e7232132777376c82e12349559fde5b Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:44:50 +0100 Subject: [PATCH 12/23] Remove validate_in_users_and_groups from BaseAPIController Currently is used only for Quotas so moving it to the manager --- lib/galaxy/webapps/base/controller.py | 31 --------------------------- 1 file changed, 31 deletions(-) diff --git a/lib/galaxy/webapps/base/controller.py b/lib/galaxy/webapps/base/controller.py index 2ed05dbf854..6a54c4b29a2 100644 --- a/lib/galaxy/webapps/base/controller.py +++ b/lib/galaxy/webapps/base/controller.py @@ -190,37 +190,6 @@ class BaseAPIController(BaseController): log.exception("Exception in get_object check for %s %s.", class_name, str(id)) raise HTTPInternalServerError(comment=util.unicodify(e)) - def validate_in_users_and_groups(self, trans, payload): - """ - For convenience, in_users and in_groups can be encoded IDs or emails/group names in the API. - """ - def get_id(item, model_class, column): - try: - return trans.security.decode_id(item) - except Exception: - pass # maybe an email/group name - # this will raise if the item is invalid - return trans.sa_session.query(model_class).filter(column == item).first().id - new_in_users = [] - new_in_groups = [] - invalid = [] - for item in util.listify(payload.get('in_users', [])): - try: - new_in_users.append(get_id(item, trans.app.model.User, trans.app.model.User.table.c.email)) - except Exception: - invalid.append(item) - for item in util.listify(payload.get('in_groups', [])): - try: - new_in_groups.append(get_id(item, trans.app.model.Group, trans.app.model.Group.name)) - except Exception: - invalid.append(item) - if invalid: - msg = f"The following value(s) for associated users and/or groups could not be parsed: {', '.join(invalid)}." - msg += " Valid values are email addresses of users, names of groups, or IDs of both." - raise Exception(msg) - payload['in_users'] = list(map(str, new_in_users)) - payload['in_groups'] = list(map(str, new_in_groups)) - def not_implemented(self, trans, **kwd): raise HTTPNotImplemented() From 8e3a37ec27211c37df39492488649e789d67bbeb Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:48:02 +0100 Subject: [PATCH 13/23] Fix Quota description lost after rename --- lib/galaxy/managers/quotas.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 7ba3fdb87eb..9d5552f6f30 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -109,7 +109,8 @@ class QuotaManager: else: old_name = quota.name quota.name = params.name - quota.description = params.description + if params.description: + quota.description = params.description self.sa_session.add(quota) self.sa_session.flush() message = f"Quota '{old_name}' has been renamed to '{params.name}'." From 269480354b2344cfe6646b91c0d0faadcda15d56 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 2 Mar 2021 14:24:12 +0100 Subject: [PATCH 14/23] Use encoded id in schema.UserModel I assumed the user.id must be encoded in the response. This affects the api/whoami in configuration.py which is the also using schema.UserModel --- lib/galaxy/managers/quotas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 9d5552f6f30..e46345a8e39 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -5,10 +5,10 @@ For more information about quotas: https://galaxyproject.org/admin/disk-quotas/ """ import logging from typing import ( + cast, Optional, Tuple, Union, - cast, ) from sqlalchemy import ( From df6d0a94e373bae15cf3d9b7bb0bf84feb7e8984 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 2 Mar 2021 15:46:41 +0100 Subject: [PATCH 15/23] Fix package import --- lib/galaxy/managers/quotas.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index e46345a8e39..311184c2222 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -33,9 +33,7 @@ from galaxy.quota._schema import ( UpdateQuotaParams, ) from galaxy.schema.fields import EncodedDatabaseIdField -from galaxy.webapps.base.controller import ( - url_for, -) +from galaxy.web import url_for log = logging.getLogger(__name__) From 6490747f8e6b5f75e2808ca914ec4f245bd580af Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Thu, 11 Mar 2021 18:53:09 +0100 Subject: [PATCH 16/23] Use galaxy Router --- lib/galaxy/webapps/galaxy/api/quotas.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index c49295a8806..21712483c11 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -4,9 +4,6 @@ API operations on Quota objects. import logging from fastapi import Path -# TODO: replace with Router after merging #11219 -from fastapi_utils.cbv import cbv -from fastapi_utils.inferring_router import InferringRouter as APIRouter from galaxy import ( util, @@ -25,13 +22,14 @@ from . import ( BaseGalaxyAPIController, depends, DependsOnTrans, + Router, ) log = logging.getLogger(__name__) # TODO: This FastAPI router is disabled. -_router = APIRouter(tags=['quotas']) +_router = Router(tags=['quotas']) QuotaIdPathParam: EncodedDatabaseIdField = Path( @@ -41,7 +39,7 @@ QuotaIdPathParam: EncodedDatabaseIdField = Path( ) -@cbv(_router) +@_router.cbv class FastAPITags: manager: QuotasManager = depends(QuotasManager) From 7fc2832e02623b9c13e01797ce58748a6603e2d4 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 12 Mar 2021 12:02:28 +0100 Subject: [PATCH 17/23] Use json format in tests payloads --- test/integration/test_quota.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/test_quota.py b/test/integration/test_quota.py index 1aee9be235c..a41a3511424 100644 --- a/test/integration/test_quota.py +++ b/test/integration/test_quota.py @@ -66,9 +66,9 @@ class QuotaIntegrationTestCase(integration_util.IntegrationTestCase): quota_id = quota["id"] new_quota_name = "updated-quota-name" - update_payload = { + update_payload = json.dumps({ 'name': new_quota_name, - } + }) put_response = self._put(f"quotas/{quota_id}", data=update_payload) put_response.raise_for_status() assert "has been renamed to" in put_response.text @@ -156,7 +156,7 @@ class QuotaIntegrationTestCase(integration_util.IntegrationTestCase): def _create_quota_with_name(self, quota_name: str, is_default: bool = False): payload = self._build_quota_payload_with_name(quota_name, is_default) - create_response = self._post("quotas", data=payload) + create_response = self._post("quotas", data=payload, json=True) create_response.raise_for_status() return create_response.json() From 9fdeb07a7362e7b0119201f206284c8a3d3f6764 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Fri, 12 Mar 2021 12:05:24 +0100 Subject: [PATCH 18/23] Enable FastAPI router --- lib/galaxy/managers/quotas.py | 10 ++-- lib/galaxy/webapps/galaxy/api/quotas.py | 67 +++++++++++++------------ 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 311184c2222..d8aa8e3267f 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -278,8 +278,9 @@ class QuotasManager: rval = quota.to_dict(view='element', value_mapper={'id': trans.security.encode_id, 'total_disk_usage': float}) return QuotaDetails.parse_obj(rval) - def create(self, trans: ProvidesUserContext, payload: dict) -> CreateQuotaResult: + def create(self, trans: ProvidesUserContext, params: CreateQuotaParams) -> CreateQuotaResult: """Creates a new quota.""" + payload = params.dict() self.validate_in_users_and_groups(trans, payload) quota, message = self.quota_manager.create_quota(payload) item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) @@ -287,8 +288,9 @@ class QuotasManager: item['message'] = message return CreateQuotaResult.parse_obj(item) - def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: dict) -> str: + def update(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, params: UpdateQuotaParams) -> str: """Modifies a quota.""" + payload = params.dict() self.validate_in_users_and_groups(trans, payload) quota = self.quota_manager.get_quota(trans, id, deleted=False) @@ -312,11 +314,11 @@ class QuotasManager: messages.append(message) return '; '.join(messages) - def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: DeleteQuotaPayload) -> str: + def delete(self, trans: ProvidesUserContext, id: EncodedDatabaseIdField, payload: Optional[DeleteQuotaPayload] = None) -> str: """Marks a quota as deleted.""" quota = self.quota_manager.get_quota(trans, id, deleted=False) # deleted quotas are not technically members of this collection message = self.quota_manager.delete_quota(quota) - if payload.purge: + if payload and payload.purge: message += self.quota_manager.purge_quota(quota) return message diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index 21712483c11..ddf3bcdbcbd 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -4,6 +4,7 @@ API operations on Quota objects. import logging from fastapi import Path +from fastapi.param_functions import Body from galaxy import ( util, @@ -13,7 +14,10 @@ from galaxy.managers.context import ProvidesUserContext from galaxy.managers.quotas import QuotasManager from galaxy.quota._schema import ( CreateQuotaParams, + CreateQuotaResult, DeleteQuotaPayload, + QuotaDetails, + QuotaSummaryList, UpdateQuotaParams, ) from galaxy.schema.fields import EncodedDatabaseIdField @@ -28,8 +32,7 @@ from . import ( log = logging.getLogger(__name__) -# TODO: This FastAPI router is disabled. -_router = Router(tags=['quotas']) +router = Router(tags=['quotas']) QuotaIdPathParam: EncodedDatabaseIdField = Path( @@ -39,11 +42,11 @@ QuotaIdPathParam: EncodedDatabaseIdField = Path( ) -@_router.cbv -class FastAPITags: +@router.cbv +class FastAPIQuota: manager: QuotasManager = depends(QuotasManager) - @_router.get( + @router.get( '/api/quotas', summary="Displays a list with information of quotas that are currently active.", dependencies=[AdminUserRequired], @@ -51,11 +54,11 @@ class FastAPITags: def index( self, trans: ProvidesUserContext = DependsOnTrans, - ): + ) -> QuotaSummaryList: """Displays a list with information of quotas that are currently active.""" - self.manager.index(trans) + return self.manager.index(trans) - @_router.get( + @router.get( '/api/quotas/deleted', summary="Displays a list with information of quotas that have been deleted.", dependencies=[AdminUserRequired], @@ -63,11 +66,11 @@ class FastAPITags: def index_deleted( self, trans: ProvidesUserContext = DependsOnTrans, - ): + ) -> QuotaSummaryList: """Displays a list with information of quotas that have been deleted.""" - self.manager.index(trans, deleted=True) + return self.manager.index(trans, deleted=True) - @_router.get( + @router.get( '/api/quotas/{id}', summary="Displays details on a particular active quota.", dependencies=[AdminUserRequired], @@ -76,11 +79,11 @@ class FastAPITags: self, trans: ProvidesUserContext = DependsOnTrans, id: EncodedDatabaseIdField = QuotaIdPathParam - ): + ) -> QuotaDetails: """Displays details on a particular active quota.""" - self.manager.show(trans, id) + return self.manager.show(trans, id) - @_router.get( + @router.get( '/api/quotas/deleted/{id}', summary="Displays details on a particular quota that has been deleted.", dependencies=[AdminUserRequired], @@ -89,11 +92,11 @@ class FastAPITags: self, trans: ProvidesUserContext = DependsOnTrans, id: EncodedDatabaseIdField = QuotaIdPathParam, - ): + ) -> QuotaDetails: """Displays details on a particular quota that has been deleted.""" - self.manager.show(trans, id, deleted=True) + return self.manager.show(trans, id, deleted=True) - @_router.post( + @router.post( '/api/quotas', summary="Creates a new quota.", dependencies=[AdminUserRequired], @@ -102,11 +105,11 @@ class FastAPITags: self, payload: CreateQuotaParams, trans: ProvidesUserContext = DependsOnTrans, - ): + ) -> CreateQuotaResult: """Creates a new quota.""" - self.manager.create(trans, payload) + return self.manager.create(trans, payload) - @_router.put( + @router.put( '/api/quotas/{id}', summary="Updates an existing quota.", dependencies=[AdminUserRequired], @@ -116,25 +119,25 @@ class FastAPITags: payload: UpdateQuotaParams, id: EncodedDatabaseIdField = QuotaIdPathParam, trans: ProvidesUserContext = DependsOnTrans, - ): + ) -> str: """Updates an existing quota.""" - self.manager.update(trans, id, payload) + return self.manager.update(trans, id, payload) - @_router.delete( + @router.delete( '/api/quotas/{id}', summary="Deletes an existing quota.", dependencies=[AdminUserRequired], ) def delete( self, - payload: DeleteQuotaPayload, id: EncodedDatabaseIdField = QuotaIdPathParam, trans: ProvidesUserContext = DependsOnTrans, - ): + payload: DeleteQuotaPayload = Body(None), # Optional + ) -> str: """Deletes an existing quota.""" - self.manager.delete(trans, id, payload) + return self.manager.delete(trans, id, payload) - @_router.post( + @router.post( '/api/quotas/deleted/{id}/undelete', summary="Restores a previously deleted quota.", dependencies=[AdminUserRequired], @@ -143,9 +146,9 @@ class FastAPITags: self, id: EncodedDatabaseIdField = QuotaIdPathParam, trans: ProvidesUserContext = DependsOnTrans, - ): + ) -> str: """Restores a previously deleted quota.""" - self.manager.undelete(trans, id) + return self.manager.undelete(trans, id) class QuotaAPIController(BaseGalaxyAPIController): @@ -181,7 +184,8 @@ class QuotaAPIController(BaseGalaxyAPIController): POST /api/quotas Creates a new quota. """ - return self.manager.create(trans, payload) + params = CreateQuotaParams(**payload) + return self.manager.create(trans, params) @web.require_admin @web.expose_api @@ -190,7 +194,8 @@ class QuotaAPIController(BaseGalaxyAPIController): PUT /api/quotas/{encoded_quota_id} Modifies a quota. """ - return self.manager.update(trans, id, payload) + params = UpdateQuotaParams(**payload) + return self.manager.update(trans, id, params) @web.require_admin @web.expose_api From 2c4648b1b048fcba6328633d82b1d57b644cc774 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 11 May 2021 14:38:28 +0200 Subject: [PATCH 19/23] Rename QuotasManager to QuotasService for consistency --- lib/galaxy/managers/quotas.py | 2 +- lib/galaxy/webapps/galaxy/api/quotas.py | 34 ++++++++++++------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index d8aa8e3267f..ded7d0da6f5 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -249,7 +249,7 @@ class QuotaManager: return base.get_object(trans, id, 'Quota', check_ownership=False, check_accessible=False, deleted=deleted) -class QuotasManager: +class QuotasService: """Interface/service object shared by controllers for interacting with quotas.""" def __init__(self, app: StructuredApp): diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index ddf3bcdbcbd..71026d75c0e 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -11,7 +11,7 @@ from galaxy import ( web, ) from galaxy.managers.context import ProvidesUserContext -from galaxy.managers.quotas import QuotasManager +from galaxy.managers.quotas import QuotasService from galaxy.quota._schema import ( CreateQuotaParams, CreateQuotaResult, @@ -44,7 +44,7 @@ QuotaIdPathParam: EncodedDatabaseIdField = Path( @router.cbv class FastAPIQuota: - manager: QuotasManager = depends(QuotasManager) + service: QuotasService = depends(QuotasService) @router.get( '/api/quotas', @@ -56,7 +56,7 @@ class FastAPIQuota: trans: ProvidesUserContext = DependsOnTrans, ) -> QuotaSummaryList: """Displays a list with information of quotas that are currently active.""" - return self.manager.index(trans) + return self.service.index(trans) @router.get( '/api/quotas/deleted', @@ -68,7 +68,7 @@ class FastAPIQuota: trans: ProvidesUserContext = DependsOnTrans, ) -> QuotaSummaryList: """Displays a list with information of quotas that have been deleted.""" - return self.manager.index(trans, deleted=True) + return self.service.index(trans, deleted=True) @router.get( '/api/quotas/{id}', @@ -81,7 +81,7 @@ class FastAPIQuota: id: EncodedDatabaseIdField = QuotaIdPathParam ) -> QuotaDetails: """Displays details on a particular active quota.""" - return self.manager.show(trans, id) + return self.service.show(trans, id) @router.get( '/api/quotas/deleted/{id}', @@ -94,7 +94,7 @@ class FastAPIQuota: id: EncodedDatabaseIdField = QuotaIdPathParam, ) -> QuotaDetails: """Displays details on a particular quota that has been deleted.""" - return self.manager.show(trans, id, deleted=True) + return self.service.show(trans, id, deleted=True) @router.post( '/api/quotas', @@ -107,7 +107,7 @@ class FastAPIQuota: trans: ProvidesUserContext = DependsOnTrans, ) -> CreateQuotaResult: """Creates a new quota.""" - return self.manager.create(trans, payload) + return self.service.create(trans, payload) @router.put( '/api/quotas/{id}', @@ -121,7 +121,7 @@ class FastAPIQuota: trans: ProvidesUserContext = DependsOnTrans, ) -> str: """Updates an existing quota.""" - return self.manager.update(trans, id, payload) + return self.service.update(trans, id, payload) @router.delete( '/api/quotas/{id}', @@ -135,7 +135,7 @@ class FastAPIQuota: payload: DeleteQuotaPayload = Body(None), # Optional ) -> str: """Deletes an existing quota.""" - return self.manager.delete(trans, id, payload) + return self.service.delete(trans, id, payload) @router.post( '/api/quotas/deleted/{id}/undelete', @@ -148,12 +148,12 @@ class FastAPIQuota: trans: ProvidesUserContext = DependsOnTrans, ) -> str: """Restores a previously deleted quota.""" - return self.manager.undelete(trans, id) + return self.service.undelete(trans, id) class QuotaAPIController(BaseGalaxyAPIController): - manager: QuotasManager = depends(QuotasManager) + service: QuotasService = depends(QuotasService) @web.require_admin @web.expose_api @@ -164,7 +164,7 @@ class QuotaAPIController(BaseGalaxyAPIController): Displays a collection (list) of quotas. """ deleted = util.string_as_bool(deleted) - return self.manager.index(trans, deleted) + return self.service.index(trans, deleted) @web.require_admin @web.expose_api @@ -175,7 +175,7 @@ class QuotaAPIController(BaseGalaxyAPIController): Displays information about a quota. """ deleted = util.string_as_bool(deleted) - return self.manager.show(trans, id, deleted) + return self.service.show(trans, id, deleted) @web.require_admin @web.expose_api @@ -185,7 +185,7 @@ class QuotaAPIController(BaseGalaxyAPIController): Creates a new quota. """ params = CreateQuotaParams(**payload) - return self.manager.create(trans, params) + return self.service.create(trans, params) @web.require_admin @web.expose_api @@ -195,7 +195,7 @@ class QuotaAPIController(BaseGalaxyAPIController): Modifies a quota. """ params = UpdateQuotaParams(**payload) - return self.manager.update(trans, id, params) + return self.service.update(trans, id, params) @web.require_admin @web.expose_api @@ -206,7 +206,7 @@ class QuotaAPIController(BaseGalaxyAPIController): """ # a request body is optional here payload = DeleteQuotaPayload(**kwd.get('payload', {})) - return self.manager.delete(trans, id, payload) + return self.service.delete(trans, id, payload) @web.require_admin @web.expose_api @@ -215,4 +215,4 @@ class QuotaAPIController(BaseGalaxyAPIController): POST /api/quotas/deleted/{encoded_quota_id}/undelete Undeletes a quota """ - return self.manager.undelete(trans, id) + return self.service.undelete(trans, id) From 3e2562de2b33d7b49dc59317bc5e10b837677b1d Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 11 May 2021 16:00:17 +0200 Subject: [PATCH 20/23] Add url_for deprecation --- lib/galaxy/managers/quotas.py | 10 ++++++++-- lib/galaxy/quota/_schema.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index ded7d0da6f5..9cbc5461351 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -268,7 +268,7 @@ class QuotasService: for quota in query: item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) encoded_id = trans.security.encode_id(quota.id) - item['url'] = url_for(route, id=encoded_id) + item['url'] = self._url_for(route, id=encoded_id) rval.append(item) return QuotaSummaryList.parse_obj(rval) @@ -284,7 +284,7 @@ class QuotasService: self.validate_in_users_and_groups(trans, payload) quota, message = self.quota_manager.create_quota(payload) item = quota.to_dict(value_mapper={'id': trans.security.encode_id}) - item['url'] = url_for('quota', id=trans.security.encode_id(quota.id)) + item['url'] = self._url_for('quota', id=trans.security.encode_id(quota.id)) item['message'] = message return CreateQuotaResult.parse_obj(item) @@ -357,3 +357,9 @@ class QuotasService: raise Exception(msg) payload['in_users'] = list(map(str, new_in_users)) payload['in_groups'] = list(map(str, new_in_groups)) + + def _url_for(self, *args, **kargs): + try: + return url_for(*args, **kargs) + except AttributeError: + return "*deprecated attribute not filled in by FastAPI server*" diff --git a/lib/galaxy/quota/_schema.py b/lib/galaxy/quota/_schema.py index 0a6f11e3d33..9e1c10beda9 100644 --- a/lib/galaxy/quota/_schema.py +++ b/lib/galaxy/quota/_schema.py @@ -113,6 +113,7 @@ class QuotaSummary(QuotaBase): ..., title="URL", description="The relative URL to get this particular Quota details from the rest API.", + deprecated=True, ) From 81be6ff1fc2f2e55e3b0b86185a110c57dc16b78 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Tue, 25 May 2021 12:28:15 +0200 Subject: [PATCH 21/23] Add GroupModel pydantic model to schema,py --- lib/galaxy/schema/schema.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/galaxy/schema/schema.py b/lib/galaxy/schema/schema.py index 505f573c137..bc1dd9c0c49 100644 --- a/lib/galaxy/schema/schema.py +++ b/lib/galaxy/schema/schema.py @@ -33,6 +33,7 @@ from galaxy.schema.fields import ( ) USER_MODEL_CLASS_NAME = "User" +GROUP_MODEL_CLASS_NAME = "Group" HDA_MODEL_CLASS_NAME = "HistoryDatasetAssociation" DC_MODEL_CLASS_NAME = "DatasetCollection" DCE_MODEL_CLASS_NAME = "DatasetCollectionElement" @@ -176,6 +177,21 @@ class UserModel(Model): model_class: str = ModelClassField(USER_MODEL_CLASS_NAME) +class GroupModel(BaseModel): + """User group model""" + model_class: str = ModelClassField(GROUP_MODEL_CLASS_NAME) + id: EncodedDatabaseIdField = Field( + ..., # Required + title='ID', + description='Encoded group ID', + ) + name: str = Field( + ..., # Required + title="Name", + description="The name of the group.", + ) + + class JobSourceType(str, Enum): """Available types of job sources (model classes) that produce dataset collections.""" Job = "Job" From e711d5d566c6c527768f199f8d9c54e5fd426c84 Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 2 Jun 2021 13:31:48 +0200 Subject: [PATCH 22/23] Use require_admin flag in endpoints instead of dependencies property --- lib/galaxy/webapps/galaxy/api/quotas.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/quotas.py b/lib/galaxy/webapps/galaxy/api/quotas.py index 71026d75c0e..a2761b40afb 100644 --- a/lib/galaxy/webapps/galaxy/api/quotas.py +++ b/lib/galaxy/webapps/galaxy/api/quotas.py @@ -22,7 +22,6 @@ from galaxy.quota._schema import ( ) from galaxy.schema.fields import EncodedDatabaseIdField from . import ( - AdminUserRequired, BaseGalaxyAPIController, depends, DependsOnTrans, @@ -38,7 +37,7 @@ router = Router(tags=['quotas']) QuotaIdPathParam: EncodedDatabaseIdField = Path( ..., # Required title="Quota ID", - description="The encoded indentifier of the Quota." + description="The encoded identifier of the Quota." ) @@ -49,7 +48,7 @@ class FastAPIQuota: @router.get( '/api/quotas', summary="Displays a list with information of quotas that are currently active.", - dependencies=[AdminUserRequired], + require_admin=True, ) def index( self, @@ -61,7 +60,7 @@ class FastAPIQuota: @router.get( '/api/quotas/deleted', summary="Displays a list with information of quotas that have been deleted.", - dependencies=[AdminUserRequired], + require_admin=True, ) def index_deleted( self, @@ -73,7 +72,7 @@ class FastAPIQuota: @router.get( '/api/quotas/{id}', summary="Displays details on a particular active quota.", - dependencies=[AdminUserRequired], + require_admin=True, ) def show( self, @@ -86,7 +85,7 @@ class FastAPIQuota: @router.get( '/api/quotas/deleted/{id}', summary="Displays details on a particular quota that has been deleted.", - dependencies=[AdminUserRequired], + require_admin=True, ) def show_deleted( self, @@ -99,7 +98,7 @@ class FastAPIQuota: @router.post( '/api/quotas', summary="Creates a new quota.", - dependencies=[AdminUserRequired], + require_admin=True, ) def create( self, @@ -112,7 +111,7 @@ class FastAPIQuota: @router.put( '/api/quotas/{id}', summary="Updates an existing quota.", - dependencies=[AdminUserRequired], + require_admin=True, ) def update( self, @@ -126,7 +125,7 @@ class FastAPIQuota: @router.delete( '/api/quotas/{id}', summary="Deletes an existing quota.", - dependencies=[AdminUserRequired], + require_admin=True, ) def delete( self, @@ -140,7 +139,7 @@ class FastAPIQuota: @router.post( '/api/quotas/deleted/{id}/undelete', summary="Restores a previously deleted quota.", - dependencies=[AdminUserRequired], + require_admin=True, ) def undelete( self, From 9a45c2ad1b4442336024bd607537880674d1b62a Mon Sep 17 00:00:00 2001 From: davelopez <46503462+davelopez@users.noreply.github.com> Date: Wed, 2 Jun 2021 13:34:14 +0200 Subject: [PATCH 23/23] Fix create quota from Admin UI controller The util.Params object was causing problems with the pydantic deserialization. --- lib/galaxy/managers/quotas.py | 2 +- lib/galaxy/quota/_schema.py | 6 +++--- lib/galaxy/webapps/galaxy/controllers/admin.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/managers/quotas.py b/lib/galaxy/managers/quotas.py index 9cbc5461351..64d9b5acf1d 100644 --- a/lib/galaxy/managers/quotas.py +++ b/lib/galaxy/managers/quotas.py @@ -53,7 +53,7 @@ class QuotaManager: return cast(DatabaseQuotaAgent, self.app.quota_agent) def create_quota(self, payload: dict, decode_id=None) -> Tuple[model.Quota, str]: - params = CreateQuotaParams(**payload) + params = CreateQuotaParams.parse_obj(payload) create_amount = self._parse_amount(params.amount) if self.sa_session.query(model.Quota).filter(model.Quota.name == params.name).first(): raise ActionInputError("Quota names must be unique and a quota with that name already exists, please choose another name.") diff --git a/lib/galaxy/quota/_schema.py b/lib/galaxy/quota/_schema.py index 9e1c10beda9..88fad0c01bc 100644 --- a/lib/galaxy/quota/_schema.py +++ b/lib/galaxy/quota/_schema.py @@ -172,7 +172,7 @@ class CreateQuotaParams(BaseModel): ) operation: QuotaOperation = QuotaOperationField default: DefaultQuotaValues = Field( - default="no", + default=DefaultQuotaValues.NO, title="Default", description=( "Whether or not this is a default quota. Valid values" @@ -180,12 +180,12 @@ class CreateQuotaParams(BaseModel): " equivalent to ``no``." ), ) - in_users: List[str] = Field( + in_users: Optional[List[str]] = Field( default=[], title="Users", description="A list of user IDs or user emails to associate with this quota.", ) - in_groups: List[str] = Field( + in_groups: Optional[List[str]] = Field( default=[], title="Groups", description="A list of group IDs or names to associate with this quota.", diff --git a/lib/galaxy/webapps/galaxy/controllers/admin.py b/lib/galaxy/webapps/galaxy/controllers/admin.py index df55acb7457..fd03be55a6f 100644 --- a/lib/galaxy/webapps/galaxy/controllers/admin.py +++ b/lib/galaxy/webapps/galaxy/controllers/admin.py @@ -705,7 +705,7 @@ class AdminGalaxy(controller.JSAppLauncher): build_select_input('in_users', 'Users', all_users, [])]} else: try: - quota, message = self.quota_manager.create_quota(util.Params(payload), decode_id=trans.security.decode_id) + quota, message = self.quota_manager.create_quota(payload, decode_id=trans.security.decode_id) return {'message': message} except ActionInputError as e: return self.message_exception(trans, e.err_msg)