From 813ce2fe32beaa62df644b82f1973cf0b049ab3b Mon Sep 17 00:00:00 2001 From: Robert Segal Date: Thu, 2 Oct 2025 10:23:52 -0600 Subject: [PATCH] Added Accounts users endpoints --- mpt_api_client/resources/accounts/accounts.py | 11 ++ mpt_api_client/resources/accounts/mixins.py | 52 ++++++ mpt_api_client/resources/accounts/users.py | 81 +++++++++ setup.cfg | 2 +- tests/resources/accounts/test_accounts.py | 13 +- tests/resources/accounts/test_mixins.py | 156 ++++++++++++++++++ tests/resources/accounts/test_users.py | 145 ++++++++++++++++ 7 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 mpt_api_client/resources/accounts/users.py create mode 100644 tests/resources/accounts/test_users.py diff --git a/mpt_api_client/resources/accounts/accounts.py b/mpt_api_client/resources/accounts/accounts.py index faf14400..812e1ad9 100644 --- a/mpt_api_client/resources/accounts/accounts.py +++ b/mpt_api_client/resources/accounts/accounts.py @@ -1,5 +1,6 @@ from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.resources.accounts.account import AccountsService, AsyncAccountsService +from mpt_api_client.resources.accounts.users import AsyncUsersService, UsersService class Accounts: @@ -13,6 +14,11 @@ def accounts(self) -> AccountsService: """Accounts service.""" return AccountsService(http_client=self.http_client) + @property + def users(self) -> UsersService: + """Users service.""" + return UsersService(http_client=self.http_client) + class AsyncAccounts: """Async Accounts MPT API Module.""" @@ -24,3 +30,8 @@ def __init__(self, *, http_client: AsyncHTTPClient): def accounts(self) -> AsyncAccountsService: """Accounts service.""" return AsyncAccountsService(http_client=self.http_client) + + @property + def users(self) -> AsyncUsersService: + """Users service.""" + return AsyncUsersService(http_client=self.http_client) diff --git a/mpt_api_client/resources/accounts/mixins.py b/mpt_api_client/resources/accounts/mixins.py index 263f4f86..8d4b7921 100644 --- a/mpt_api_client/resources/accounts/mixins.py +++ b/mpt_api_client/resources/accounts/mixins.py @@ -71,6 +71,32 @@ def validate(self, resource_id: str, resource_data: ResourceData | None = None) ) +class BlockableMixin[Model]: + """Blockable mixin for blocking and unblocking resources.""" + + def block(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Block a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "block", json=resource_data + ) + + def unblock(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Unblock a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "unblock", json=resource_data + ) + + class AsyncActivatableMixin[Model]: """Async activatable mixin for activating, enabling, disabling and deactivating resources.""" @@ -138,3 +164,29 @@ async def validate(self, resource_id: str, resource_data: ResourceData | None = return await self._resource_action( # type: ignore[attr-defined, no-any-return] resource_id, "POST", "validate", json=resource_data ) + + +class AsyncBlockableMixin[Model]: + """Asynchronous Blockable mixin for blocking and unblocking resources.""" + + async def block(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Block a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "block", json=resource_data + ) + + async def unblock(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Unblock a resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "unblock", json=resource_data + ) diff --git a/mpt_api_client/resources/accounts/users.py b/mpt_api_client/resources/accounts/users.py new file mode 100644 index 00000000..5409c398 --- /dev/null +++ b/mpt_api_client/resources/accounts/users.py @@ -0,0 +1,81 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import ( + AsyncDeleteMixin, + AsyncUpdateMixin, + DeleteMixin, + UpdateMixin, +) +from mpt_api_client.models import Model +from mpt_api_client.models.model import ResourceData +from mpt_api_client.resources.accounts.mixins import ( + AsyncBlockableMixin, + BlockableMixin, +) + + +class User(Model): + """User resource.""" + + +class UsersServiceConfig: + """Users service configuration.""" + + _endpoint = "/public/v1/accounts/users" + _model_class = User + _collection_key = "data" + + +class UsersService( + UpdateMixin[User], + DeleteMixin, + BlockableMixin[User], + Service[User], + UsersServiceConfig, +): + """Users service.""" + + def sso(self, resource_id: str, resource_data: ResourceData | None = None) -> User: + """Perform SSO action for a user. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action(resource_id, "POST", "sso", json=resource_data) + + def sso_check(self, resource_id: str, resource_data: ResourceData | None = None) -> User: + """Perform SSO check action for a user. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action(resource_id, "POST", "sso-check", json=resource_data) + + +class AsyncUsersService( + AsyncUpdateMixin[User], + AsyncDeleteMixin, + AsyncBlockableMixin[User], + AsyncService[User], + UsersServiceConfig, +): + """Async Users service.""" + + async def sso(self, resource_id: str, resource_data: ResourceData | None = None) -> User: + """Perform SSO action for a user. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action(resource_id, "POST", "sso", json=resource_data) + + async def sso_check(self, resource_id: str, resource_data: ResourceData | None = None) -> User: + """Perform SSO check action for a user. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action(resource_id, "POST", "sso-check", json=resource_data) diff --git a/setup.cfg b/setup.cfg index c863dc31..80617e8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,10 +32,10 @@ extend-ignore = per-file-ignores = - mpt_api_client/mpt_client.py: WPS214 WPS235 mpt_api_client/http/mixins.py: WPS202 mpt_api_client/resources/*: WPS215 + mpt_api_client/resources/accounts/*.py: WPS202 WPS215 mpt_api_client/resources/billing/*.py: WPS202 WPS204 WPS214 WPS215 mpt_api_client/resources/catalog/*.py: WPS110 WPS214 WPS215 mpt_api_client/resources/catalog/products.py: WPS204 WPS214 WPS215 diff --git a/tests/resources/accounts/test_accounts.py b/tests/resources/accounts/test_accounts.py index 00121d5a..824b9612 100644 --- a/tests/resources/accounts/test_accounts.py +++ b/tests/resources/accounts/test_accounts.py @@ -2,6 +2,7 @@ from mpt_api_client.resources.accounts.account import AccountsService, AsyncAccountsService from mpt_api_client.resources.accounts.accounts import Accounts, AsyncAccounts +from mpt_api_client.resources.accounts.users import AsyncUsersService, UsersService @pytest.fixture @@ -15,7 +16,11 @@ def async_accounts(async_http_client): @pytest.mark.parametrize( - ("property_name", "expected_service_class"), [("accounts", AccountsService)] + ("property_name", "expected_service_class"), + [ + ("accounts", AccountsService), + ("users", UsersService), + ], ) def test_accounts_properties(accounts, property_name, expected_service_class): """Test that Accounts properties return correct instances.""" @@ -26,7 +31,11 @@ def test_accounts_properties(accounts, property_name, expected_service_class): @pytest.mark.parametrize( - ("property_name", "expected_service_class"), [("accounts", AsyncAccountsService)] + ("property_name", "expected_service_class"), + [ + ("accounts", AsyncAccountsService), + ("users", AsyncUsersService), + ], ) def test_async_accounts_properties(async_accounts, property_name, expected_service_class): """Test that AsyncAccounts properties return correct instances.""" diff --git a/tests/resources/accounts/test_mixins.py b/tests/resources/accounts/test_mixins.py index a20de45f..5e24564f 100644 --- a/tests/resources/accounts/test_mixins.py +++ b/tests/resources/accounts/test_mixins.py @@ -6,8 +6,10 @@ from mpt_api_client.resources.accounts.mixins import ( ActivatableMixin, AsyncActivatableMixin, + AsyncBlockableMixin, AsyncEnablableMixin, AsyncValidateMixin, + BlockableMixin, EnablableMixin, ValidateMixin, ) @@ -68,6 +70,24 @@ class DummyAsyncValidateService( _collection_key = "data" +class DummyBlockableService( + BlockableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/blockable/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncBlockableService( + AsyncBlockableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/blockable/" + _model_class = DummyModel + _collection_key = "data" + + @pytest.fixture def activatable_service(http_client): return DummyActivatableService(http_client=http_client) @@ -98,6 +118,16 @@ def async_validate_service(async_http_client): return DummyAsyncValidateService(http_client=async_http_client) +@pytest.fixture +def blockable_service(http_client): + return DummyBlockableService(http_client=http_client) + + +@pytest.fixture +def async_blockable_service(async_http_client): + return DummyAsyncBlockableService(http_client=async_http_client) + + @pytest.mark.parametrize( ("action", "input_status"), [ @@ -450,3 +480,129 @@ async def test_async_validate_resource_actions_no_data( assert request.content == request_expected_content assert validate_obj.to_dict() == response_expected_data assert isinstance(validate_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("block", {"id": "OBJ-0000-0001", "status": "update"}), + ("unblock", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +def test_blockable_resource_actions(blockable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/blockable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + blockable_obj = getattr(blockable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("block", None), + ("unblock", None), + ], +) +def test_blockable_resource_actions_no_data(blockable_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/blockable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + blockable_obj = getattr(blockable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("block", {"id": "OBJ-0000-0001", "status": "update"}), + ("unblock", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +async def test_async_blockable_resource_actions(async_blockable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/blockable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + blockable_obj = await getattr(async_blockable_service, action)( + "OBJ-0000-0001", input_status + ) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("block", None), + ("unblock", None), + ], +) +async def test_async_blockable_resource_actions_no_data( + async_blockable_service, action, input_status +): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/blockable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + blockable_obj = await getattr(async_blockable_service, action)( + "OBJ-0000-0001", input_status + ) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, DummyModel) diff --git a/tests/resources/accounts/test_users.py b/tests/resources/accounts/test_users.py new file mode 100644 index 00000000..6fff635c --- /dev/null +++ b/tests/resources/accounts/test_users.py @@ -0,0 +1,145 @@ +import httpx +import pytest +import respx + +from mpt_api_client.resources.accounts.users import AsyncUsersService, User, UsersService + + +@pytest.fixture +def users_service(http_client): + return UsersService(http_client=http_client) + + +@pytest.fixture +def async_users_service(async_http_client): + return AsyncUsersService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "method", ["get", "update", "delete", "block", "unblock", "sso", "sso_check"] +) +def test_mixins_present(users_service, method): + assert hasattr(users_service, method) + + +@pytest.mark.parametrize( + "method", ["get", "update", "delete", "block", "unblock", "sso", "sso_check"] +) +def test_async_mixins_present(async_users_service, method): + assert hasattr(async_users_service, method) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("sso", {"id": "OBJ-0000-0001", "status": "update"}), + ("sso_check", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +def test_sso_resource_actions(users_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"accounts/users/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + blockable_obj = getattr(users_service, action)("OBJ-0000-0001", input_status) + + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, User) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("sso", None), + ("sso_check", None), + ], +) +def test_sso_resource_actions_no_data(users_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"accounts/users/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + blockable_obj = getattr(users_service, action)("OBJ-0000-0001", input_status) + + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, User) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("sso", {"id": "OBJ-0000-0001", "status": "update"}), + ("sso_check", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +async def test_async_sso_resource_actions(async_users_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"accounts/users/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + blockable_obj = await getattr(async_users_service, action)("OBJ-0000-0001", input_status) + + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, User) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("sso", None), + ("sso_check", None), + ], +) +async def test_async_sso_resource_actions_no_data(async_users_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + "https://api.example.com/public/v1/" + f"accounts/users/OBJ-0000-0001/{action.replace('_', '-')}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + json=response_expected_data, + ) + ) + blockable_obj = await getattr(async_users_service, action)("OBJ-0000-0001", input_status) + + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert blockable_obj.to_dict() == response_expected_data + assert isinstance(blockable_obj, User)