diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index e36fdfcaf9392..85e5d100f5d56 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -2,8 +2,11 @@ from __future__ import annotations +from abc import abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass +from datetime import timedelta +import logging from typing import Any from pyportainer import Portainer @@ -30,23 +33,40 @@ PortainerCoordinator, PortainerCoordinatorData, ) -from .entity import PortainerContainerEntity +from .entity import PortainerContainerEntity, PortainerEndpointEntity + +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class PortainerButtonDescription(ButtonEntityDescription): """Class to describe a Portainer button entity.""" + # Note to reviewer: I am keeping the third argument a str, in order to keep mypy happy :) press_action: Callable[ [Portainer, int, str], Coroutine[Any, Any, None], ] -BUTTONS: tuple[PortainerButtonDescription, ...] = ( +ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = ( + PortainerButtonDescription( + key="images_prune", + translation_key="images_prune", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, _: portainer.images_prune( + endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0) + ) + ), + ), +) + +CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( PortainerButtonDescription( key="restart", - name="Restart Container", + translation_key="restart_container", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=( @@ -66,22 +86,43 @@ async def async_setup_entry( """Set up Portainer buttons.""" coordinator = entry.runtime_data + def _async_add_new_endpoints(endpoints: list[PortainerCoordinatorData]) -> None: + """Add new endpoint binary sensors.""" + async_add_entities( + PortainerEndpointButton( + coordinator, + entity_description, + endpoint, + ) + for entity_description in ENDPOINT_BUTTONS + for endpoint in endpoints + ) + def _async_add_new_containers( containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]], ) -> None: """Add new container button sensors.""" async_add_entities( - PortainerButton( + PortainerContainerButton( coordinator, entity_description, container, endpoint, ) for (endpoint, container) in containers - for entity_description in BUTTONS + for entity_description in CONTAINER_BUTTONS ) + coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints) coordinator.new_containers_callbacks.append(_async_add_new_containers) + + _async_add_new_endpoints( + [ + endpoint + for endpoint in coordinator.data.values() + if endpoint.id in coordinator.known_endpoints + ] + ) _async_add_new_containers( [ (endpoint, container) @@ -91,47 +132,83 @@ def _async_add_new_containers( ) -class PortainerButton(PortainerContainerEntity, ButtonEntity): - """Defines a Portainer button.""" +class PortainerBaseButton(ButtonEntity): + """Common base for Portainer buttons. Basically to ensure the async_press logic isn't duplicated.""" entity_description: PortainerButtonDescription + coordinator: PortainerCoordinator - def __init__( - self, - coordinator: PortainerCoordinator, - entity_description: PortainerButtonDescription, - device_info: PortainerContainerData, - via_device: PortainerCoordinatorData, - ) -> None: - """Initialize the Portainer button entity.""" - self.entity_description = entity_description - super().__init__(device_info, coordinator, via_device) - - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + @abstractmethod + async def _async_press_call(self) -> None: + """Abstract method used per Portainer button class.""" async def async_press(self) -> None: """Trigger the Portainer button press service.""" try: - await self.entity_description.press_action( - self.coordinator.portainer, - self.endpoint_id, - self.container_data.container.id, - ) + await self._async_press_call() except PortainerConnectionError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect", - translation_placeholders={"error": repr(err)}, + translation_key="cannot_connect_no_details", ) from err except PortainerAuthenticationError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="invalid_auth", - translation_placeholders={"error": repr(err)}, + translation_key="invalid_auth_no_details", ) from err except PortainerTimeoutError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="timeout_connect", - translation_placeholders={"error": repr(err)}, + translation_key="timeout_connect_no_details", ) from err + + +class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): + """Defines a Portainer endpoint button.""" + + entity_description: PortainerButtonDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerButtonDescription, + device_info: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer endpoint button entity.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + + async def _async_press_call(self) -> None: + """Call the endpoint button press action.""" + await self.entity_description.press_action( + self.coordinator.portainer, self.device_id, "" + ) + + +class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): + """Defines a Portainer button.""" + + entity_description: PortainerButtonDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerButtonDescription, + device_info: PortainerContainerData, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer button entity.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + + async def _async_press_call(self) -> None: + """Call the container button press action.""" + await self.entity_description.press_action( + self.coordinator.portainer, + self.endpoint_id, + self.container_data.container.id, + ) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 1e9b9a86bcc52..9d295ab196758 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -61,6 +61,14 @@ "name": "Status" } }, + "button": { + "images_prune": { + "name": "Prune unused images" + }, + "restart_container": { + "name": "Restart container" + } + }, "sensor": { "api_version": { "name": "API version" @@ -138,11 +146,20 @@ "cannot_connect": { "message": "An error occurred while trying to connect to the Portainer instance: {error}" }, + "cannot_connect_no_details": { + "message": "An error occurred while trying to connect to the Portainer instance." + }, "invalid_auth": { "message": "An error occurred while trying to authenticate: {error}" }, + "invalid_auth_no_details": { + "message": "An error occurred while trying to authenticate." + }, "timeout_connect": { "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" + }, + "timeout_connect_no_details": { + "message": "A timeout occurred while trying to connect to the Portainer instance." } } } diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 6d08b509dee02..44e2d12af579f 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -65,6 +65,7 @@ def mock_portainer_client() -> Generator[AsyncMock]: ) client.restart_container = AsyncMock(return_value=None) + client.images_prune = AsyncMock(return_value=None) yield client diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr index 83d4f65aaf24f..93305534672a3 100644 --- a/tests/components/portainer/snapshots/test_button.ambr +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -24,12 +24,12 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Restart Container', + 'original_name': 'Restart container', 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart_container', 'unique_id': 'portainer_test_entry_123_focused_einstein_restart', 'unit_of_measurement': None, }) @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'restart', - 'friendly_name': 'focused_einstein Restart Container', + 'friendly_name': 'focused_einstein Restart container', }), 'context': , 'entity_id': 'button.focused_einstein_restart_container', @@ -73,12 +73,12 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Restart Container', + 'original_name': 'Restart container', 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart_container', 'unique_id': 'portainer_test_entry_123_funny_chatelet_restart', 'unit_of_measurement': None, }) @@ -87,7 +87,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'restart', - 'friendly_name': 'funny_chatelet Restart Container', + 'friendly_name': 'funny_chatelet Restart container', }), 'context': , 'entity_id': 'button.funny_chatelet_restart_container', @@ -97,6 +97,55 @@ 'state': 'unknown', }) # --- +# name: test_all_button_entities_snapshot[button.my_environment_prune_unused_images-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.my_environment_prune_unused_images', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Prune unused images', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'images_prune', + 'unique_id': 'portainer_test_entry_123_1_images_prune', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.my_environment_prune_unused_images-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'my-environment Prune unused images', + }), + 'context': , + 'entity_id': 'button.my_environment_prune_unused_images', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.practical_morse_restart_container-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -122,12 +171,12 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Restart Container', + 'original_name': 'Restart container', 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart_container', 'unique_id': 'portainer_test_entry_123_practical_morse_restart', 'unit_of_measurement': None, }) @@ -136,7 +185,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'restart', - 'friendly_name': 'practical_morse Restart Container', + 'friendly_name': 'practical_morse Restart container', }), 'context': , 'entity_id': 'button.practical_morse_restart_container', @@ -171,12 +220,12 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Restart Container', + 'original_name': 'Restart container', 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart_container', 'unique_id': 'portainer_test_entry_123_serene_banach_restart', 'unit_of_measurement': None, }) @@ -185,7 +234,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'restart', - 'friendly_name': 'serene_banach Restart Container', + 'friendly_name': 'serene_banach Restart container', }), 'context': , 'entity_id': 'button.serene_banach_restart_container', @@ -220,12 +269,12 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Restart Container', + 'original_name': 'Restart container', 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart_container', 'unique_id': 'portainer_test_entry_123_stoic_turing_restart', 'unit_of_measurement': None, }) @@ -234,7 +283,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'restart', - 'friendly_name': 'stoic_turing Restart Container', + 'friendly_name': 'stoic_turing Restart container', }), 'context': , 'entity_id': 'button.stoic_turing_restart_container', diff --git a/tests/components/portainer/test_button.py b/tests/components/portainer/test_button.py index bd9a0cbe1495f..a4fd9732117df 100644 --- a/tests/components/portainer/test_button.py +++ b/tests/components/portainer/test_button.py @@ -49,7 +49,7 @@ async def test_all_button_entities_snapshot( ("restart", "restart_container"), ], ) -async def test_buttons( +async def test_buttons_containers( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -81,7 +81,7 @@ async def test_buttons( (PortainerTimeoutError("timeout"), "restart_container"), ], ) -async def test_buttons_exceptions( +async def test_buttons_containers_exceptions( hass: HomeAssistant, mock_portainer_client: AsyncMock, mock_config_entry: MockConfigEntry, @@ -104,3 +104,63 @@ async def test_buttons_exceptions( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("action", "client_method"), + [ + ("prune", "images_prune"), + ], +) +async def test_buttons_endpoint( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + client_method: str, +) -> None: + """Test pressing a Portainer endpoint action button triggers client call. Click, click!""" + await setup_integration(hass, mock_config_entry) + + entity_id = f"button.my_environment_{action}_unused_images" + method_mock = getattr(mock_portainer_client, client_method) + pre_calls = len(method_mock.mock_calls) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(method_mock.mock_calls) == pre_calls + 1 + + +@pytest.mark.parametrize( + ("exception", "client_method"), + [ + (PortainerAuthenticationError("auth"), "images_prune"), + (PortainerConnectionError("conn"), "images_prune"), + (PortainerTimeoutError("timeout"), "images_prune"), + ], +) +async def test_buttons_endpoints_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + client_method: str, +) -> None: + """Test that Portainer buttons, but this time when they will do boom for sure.""" + await setup_integration(hass, mock_config_entry) + + method_mock = getattr(mock_portainer_client, client_method) + method_mock.side_effect = exception + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.my_environment_prune_unused_images"}, + blocking=True, + )