diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index e5ee1bc9e99a03..d043ecbf539c3b 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -94,7 +94,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo await statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = MealieData( - client, mealplan_coordinator, shoppinglist_coordinator, statistics_coordinator + client, + version, + mealplan_coordinator, + shoppinglist_coordinator, + statistics_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 4d5325f235f4d6..9831bb8105ac72 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -5,6 +5,7 @@ from datetime import datetime from aiomealie import Mealplan, MealplanEntryType +from awesomeversion import AwesomeVersion from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -15,13 +16,6 @@ PARALLEL_UPDATES = 0 -SUPPORTED_MEALPLAN_ENTRY_TYPES = [ - MealplanEntryType.BREAKFAST, - MealplanEntryType.DINNER, - MealplanEntryType.LUNCH, - MealplanEntryType.SIDE, -] - async def async_setup_entry( hass: HomeAssistant, @@ -30,10 +24,24 @@ async def async_setup_entry( ) -> None: """Set up the calendar platform for entity.""" coordinator = entry.runtime_data.mealplan_coordinator + version = entry.runtime_data.version + + supported_mealplan_entry_types: list[MealplanEntryType] + if version.valid and version < AwesomeVersion("v3.7.0"): + # Prior to Mealie 3.7.0, only these mealplan entry types were supported + supported_mealplan_entry_types = [ + MealplanEntryType.BREAKFAST, + MealplanEntryType.DINNER, + MealplanEntryType.LUNCH, + MealplanEntryType.SIDE, + ] + else: + # For Mealie 3.7.0 and newer and nightlies, add all current mealplan entry types + supported_mealplan_entry_types = list(MealplanEntryType) async_add_entities( MealieMealplanCalendarEntity(coordinator, entry_type) - for entry_type in SUPPORTED_MEALPLAN_ENTRY_TYPES + for entry_type in supported_mealplan_entry_types ) diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index ae5b9cd8c97c5d..b7e49fe324e144 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -16,6 +16,7 @@ ShoppingList, Statistics, ) +from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -33,6 +34,7 @@ class MealieData: """Mealie data type.""" client: MealieClient + version: AwesomeVersion mealplan_coordinator: MealieMealplanCoordinator shoppinglist_coordinator: MealieShoppingListCoordinator statistics_coordinator: MealieStatisticsCoordinator diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 37b485e18f2149..cdee30950c4f4f 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -10,6 +10,7 @@ MealieValidationError, MealplanEntryType, ) +from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -127,6 +128,27 @@ def _async_get_entry(call: ServiceCall) -> MealieConfigEntry: return cast(MealieConfigEntry, entry) +def _validate_mealplan_type(version: AwesomeVersion, entry_type: str) -> None: + """Validate mealplan entry type, if prior to 3.7.0.""" + + if ( + version.valid + and version < AwesomeVersion("v3.7.0") + and entry_type + not in { + MealplanEntryType.BREAKFAST.value, + MealplanEntryType.DINNER.value, + MealplanEntryType.LUNCH.value, + MealplanEntryType.SIDE.value, + } + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_mealplan_entry_type", + translation_placeholders={"mealplan_type": entry_type}, + ) + + async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse: """Get the mealplan for a specific range.""" entry = _async_get_entry(call) @@ -219,6 +241,9 @@ async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: mealplan_date = call.data[ATTR_DATE] entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) client = entry.runtime_data.client + + _validate_mealplan_type(entry.runtime_data.version, entry_type.value) + try: mealplan = await client.random_mealplan(mealplan_date, entry_type) except MealieConnectionError as err: @@ -237,6 +262,9 @@ async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse: mealplan_date = call.data[ATTR_DATE] entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) client = entry.runtime_data.client + + _validate_mealplan_type(entry.runtime_data.version, entry_type.value) + try: mealplan = await client.set_mealplan( mealplan_date, diff --git a/homeassistant/components/mealie/services.yaml b/homeassistant/components/mealie/services.yaml index 6a78564a578311..31181c0d0917e6 100644 --- a/homeassistant/components/mealie/services.yaml +++ b/homeassistant/components/mealie/services.yaml @@ -78,6 +78,9 @@ set_random_mealplan: - lunch - dinner - side + - dessert + - snack + - drink translation_key: mealplan_entry_type set_mealplan: @@ -98,6 +101,9 @@ set_mealplan: - lunch - dinner - side + - dessert + - snack + - drink translation_key: mealplan_entry_type recipe_id: selector: diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index 653414d913245e..a9a636f28920fb 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -71,14 +71,23 @@ "breakfast": { "name": "Breakfast" }, + "dessert": { + "name": "Dessert" + }, "dinner": { "name": "Dinner" }, + "drink": { + "name": "Drink" + }, "lunch": { "name": "Lunch" }, "side": { "name": "Side" + }, + "snack": { + "name": "Snack" } }, "sensor": { @@ -126,6 +135,9 @@ "integration_not_found": { "message": "Integration \"{target}\" not found in registry." }, + "invalid_mealplan_entry_type": { + "message": "Entry type {mealplan_type} is not valid for this Mealie version." + }, "item_not_found_error": { "message": "Item {shopping_list_item} not found." }, @@ -161,9 +173,12 @@ "mealplan_entry_type": { "options": { "breakfast": "[%key:component::mealie::entity::calendar::breakfast::name%]", + "dessert": "[%key:component::mealie::entity::calendar::dessert::name%]", "dinner": "[%key:component::mealie::entity::calendar::dinner::name%]", + "drink": "[%key:component::mealie::entity::calendar::drink::name%]", "lunch": "[%key:component::mealie::entity::calendar::lunch::name%]", - "side": "[%key:component::mealie::entity::calendar::side::name%]" + "side": "[%key:component::mealie::entity::calendar::side::name%]", + "snack": "[%key:component::mealie::entity::calendar::snack::name%]" } } }, diff --git a/tests/components/mealie/fixtures/about.json b/tests/components/mealie/fixtures/about.json index 1ffac4bdd5a37e..443e13e7a9afdb 100644 --- a/tests/components/mealie/fixtures/about.json +++ b/tests/components/mealie/fixtures/about.json @@ -1,3 +1,3 @@ { - "version": "v2.0.0" + "version": "v3.7.0" } diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index e97fd583db7c94..ad1fdcc07bc8b3 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -5,10 +5,18 @@ 'entity_id': 'calendar.mealie_breakfast', 'name': 'Mealie Breakfast', }), + dict({ + 'entity_id': 'calendar.mealie_dessert', + 'name': 'Mealie Dessert', + }), dict({ 'entity_id': 'calendar.mealie_dinner', 'name': 'Mealie Dinner', }), + dict({ + 'entity_id': 'calendar.mealie_drink', + 'name': 'Mealie Drink', + }), dict({ 'entity_id': 'calendar.mealie_lunch', 'name': 'Mealie Lunch', @@ -17,6 +25,10 @@ 'entity_id': 'calendar.mealie_side', 'name': 'Mealie Side', }), + dict({ + 'entity_id': 'calendar.mealie_snack', + 'name': 'Mealie Snack', + }), ]) # --- # name: test_api_events @@ -175,6 +187,60 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_dessert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_dessert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dessert', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dessert', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_dessert', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_dessert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', + 'end_time': '2024-01-24 00:00:00', + 'friendly_name': 'Mealie Dessert', + 'location': '', + 'message': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', + 'start_time': '2024-01-23 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_dessert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_entities[calendar.mealie_dinner-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -229,6 +295,60 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_drink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_drink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Drink', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'drink', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_drink', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_drink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'end_time': '2024-01-23 00:00:00', + 'friendly_name': 'Mealie Drink', + 'location': '', + 'message': 'Einfacher Nudelauflauf mit Brokkoli', + 'start_time': '2024-01-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_drink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_entities[calendar.mealie_lunch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -337,3 +457,57 @@ 'state': 'off', }) # --- +# name: test_entities[calendar.mealie_snack-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_snack', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Snack', + 'platform': 'mealie', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'snack', + 'unique_id': 'bf1c62fe-4941-4332-9886-e54e88dbdba0_snack', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_snack-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'end_time': '2024-01-23 00:00:00', + 'friendly_name': 'Mealie Snack', + 'location': '', + 'message': 'Mousse de saumon', + 'start_time': '2024-01-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_snack', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index 42a0eccf13b815..b06de79edc0e4f 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -2,7 +2,7 @@ # name: test_entry_diagnostics dict({ 'about': dict({ - 'version': 'v2.0.0', + 'version': 'v3.7.0', }), 'mealplans': dict({ 'breakfast': list([ diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index 18824686ababc9..ce8035f289b0d4 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -26,7 +26,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'sw_version': 'v2.0.0', + 'sw_version': 'v3.7.0', 'via_device_id': None, }) # --- diff --git a/tests/components/mealie/test_calendar.py b/tests/components/mealie/test_calendar.py index cca4fcca6734ba..ece87460965874 100644 --- a/tests/components/mealie/test_calendar.py +++ b/tests/components/mealie/test_calendar.py @@ -4,7 +4,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, patch -from aiomealie import MealplanResponse +from aiomealie import About, MealplanResponse from syrupy.assertion import SnapshotAssertion from homeassistant.const import STATE_OFF, Platform @@ -85,3 +85,25 @@ async def test_api_events( assert response.status == HTTPStatus.OK events = await response.json() assert events == snapshot + + +async def test_legacy_calendars( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that only legacy calendars are created for Mealie versions prior to 3.7.0.""" + + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + with patch("homeassistant.components.mealie.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, mock_config_entry) + + assert entity_registry.async_get("calendar.mealie_dessert") is None + assert entity_registry.async_get("calendar.mealie_drink") is None + assert entity_registry.async_get("calendar.mealie_snack") is None + assert entity_registry.async_get("calendar.mealie_breakfast") is not None + assert entity_registry.async_get("calendar.mealie_lunch") is not None + assert entity_registry.async_get("calendar.mealie_dinner") is not None + assert entity_registry.async_get("calendar.mealie_side") is not None diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 8c5d073e3e9758..b69d37233c1355 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock from aiomealie import ( + About, MealieConnectionError, MealieNotFoundError, MealieValidationError, @@ -272,6 +273,31 @@ async def test_service_set_random_mealplan( ) +async def test_service_set_random_mealplan_invalid_entry_type( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the set_random_mealplan service with invalid entry types for version.""" + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_RANDOM_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "dessert", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.random_mealplan.assert_not_called() + + @pytest.mark.parametrize( ("payload", "kwargs"), [ @@ -343,6 +369,32 @@ async def test_service_set_mealplan( ) +async def test_service_set_mealplan_invalid_entry_type( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the set_mealplan service with invalid entry types for version.""" + mock_mealie_client.get_about.return_value = About(version="v3.6.0") + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEALPLAN, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + ATTR_DATE: "2023-10-21", + ATTR_ENTRY_TYPE: "dessert", + ATTR_NOTE_TITLE: "Note Title", + }, + blocking=True, + return_response=True, + ) + mock_mealie_client.set_mealplan.assert_not_called() + + @pytest.mark.parametrize( ("service", "payload", "function", "exception", "raised_exception", "message"), [