diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index bff4c8123d6..2129d1d8ed5 100644 --- a/homeassistant/components/cookidoo/__init__.py +++ b/homeassistant/components/cookidoo/__init__.py @@ -14,7 +14,12 @@ from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator from .helpers import cookidoo_from_config_entry -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.CALENDAR, + Platform.SENSOR, + Platform.TODO, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py new file mode 100644 index 00000000000..0035e225e8f --- /dev/null +++ b/homeassistant/components/cookidoo/calendar.py @@ -0,0 +1,103 @@ +"""Calendar platform for the Cookidoo integration.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +import logging + +from cookidoo_api import CookidooAuthException, CookidooException +from cookidoo_api.types import CookidooCalendarDayRecipe + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator +from .entity import CookidooBaseEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: CookidooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + coordinator = config_entry.runtime_data + + async_add_entities([CookidooCalendarEntity(coordinator)]) + + +def recipe_to_event(day_date: date, recipe: CookidooCalendarDayRecipe) -> CalendarEvent: + """Convert a Cookidoo recipe to a CalendarEvent.""" + return CalendarEvent( + start=day_date, + end=day_date + timedelta(days=1), # All-day event + summary=recipe.name, + description=f"Total Time: {recipe.total_time}", + ) + + +class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity): + """A calendar entity.""" + + _attr_translation_key = "meal_plan" + + def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + assert coordinator.config_entry.unique_id + self._attr_unique_id = coordinator.config_entry.unique_id + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.data.week_plan: + return None + + today = date.today() + for day_data in self.coordinator.data.week_plan: + day_date = date.fromisoformat(day_data.id) + if day_date >= today and day_data.recipes: + recipe = day_data.recipes[0] + return recipe_to_event(day_date, recipe) + return None + + async def _fetch_week_plan(self, week_day: date) -> list: + """Fetch a single Cookidoo week plan, retrying once on auth failure.""" + try: + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooAuthException: + await self.coordinator.cookidoo.refresh_token() + return await self.coordinator.cookidoo.get_recipes_in_calendar_week( + week_day + ) + except CookidooException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="calendar_fetch_failed", + ) from e + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + events: list[CalendarEvent] = [] + current_day = start_date.date() + while current_day <= end_date.date(): + week_plan = await self._fetch_week_plan(current_day) + for day_data in week_plan: + day_date = date.fromisoformat(day_data.id) + if start_date.date() <= day_date <= end_date.date(): + events.extend( + recipe_to_event(day_date, recipe) for recipe in day_data.recipes + ) + current_day += timedelta(days=7) # Move to the next week + return events diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 2ce61306afe..940c6e36f71 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import timedelta +from datetime import date, timedelta import logging from cookidoo_api import ( @@ -16,6 +16,7 @@ from cookidoo_api import ( CookidooSubscription, CookidooUserInfo, ) +from cookidoo_api.types import CookidooCalendarDay from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -37,6 +38,7 @@ class CookidooData: ingredient_items: list[CookidooIngredientItem] additional_items: list[CookidooAdditionalItem] subscription: CookidooSubscription | None + week_plan: list[CookidooCalendarDay] class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): @@ -81,6 +83,7 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ingredient_items = await self.cookidoo.get_ingredient_items() additional_items = await self.cookidoo.get_additional_items() subscription = await self.cookidoo.get_active_subscription() + week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) except CookidooAuthException: try: await self.cookidoo.refresh_token() @@ -106,4 +109,5 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]): ingredient_items=ingredient_items, additional_items=additional_items, subscription=subscription, + week_plan=week_plan, ) diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json index b05cafcbc71..5de0703dd23 100644 --- a/homeassistant/components/cookidoo/strings.json +++ b/homeassistant/components/cookidoo/strings.json @@ -54,6 +54,11 @@ "name": "Clear shopping list and additional purchases" } }, + "calendar": { + "meal_plan": { + "name": "Meal plan" + } + }, "sensor": { "expires": { "name": "Subscription expiration date" @@ -80,6 +85,9 @@ "button_clear_todo_failed": { "message": "Failed to clear all items from the Cookidoo shopping list" }, + "calendar_fetch_failed": { + "message": "Failed to fetch Cookidoo meal plan" + }, "setup_authentication_exception": { "message": "Authentication failed for {email}, check your email and password" }, diff --git a/tests/components/cookidoo/conftest.py b/tests/components/cookidoo/conftest.py index 7d84e7ac83e..a349fbd5c05 100644 --- a/tests/components/cookidoo/conftest.py +++ b/tests/components/cookidoo/conftest.py @@ -11,6 +11,7 @@ from cookidoo_api import ( CookidooSubscription, CookidooUserInfo, ) +from cookidoo_api.types import CookidooCalendarDay, CookidooCalendarDayRecipe import pytest from homeassistant.components.cookidoo.const import DOMAIN @@ -65,6 +66,21 @@ def mock_cookidoo_client() -> Generator[AsyncMock]: client.login.return_value = CookidooAuthResponse( **load_json_object_fixture("login.json", DOMAIN) ) + client.get_recipes_in_calendar_week.return_value = [ + CookidooCalendarDay( + id=day["id"], + title=day["title"], + recipes=[ + CookidooCalendarDayRecipe( + id=recipe["id"], + name=recipe["name"], + total_time=recipe["total_time"], + ) + for recipe in day["recipes"] + ], + ) + for day in load_json_object_fixture("calendar_week.json", DOMAIN)["data"] + ] yield client diff --git a/tests/components/cookidoo/fixtures/calendar_week.json b/tests/components/cookidoo/fixtures/calendar_week.json new file mode 100644 index 00000000000..b8b1d5b6aaa --- /dev/null +++ b/tests/components/cookidoo/fixtures/calendar_week.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": "2025-03-04", + "title": "2025-03-04", + "recipes": [ + { + "id": "r1", + "name": "Waffles", + "total_time": 1500 + } + ] + }, + { + "id": "2025-03-05", + "title": "2025-03-05", + "recipes": [ + { + "id": "r2", + "name": "Mint Tea", + "total_time": 1500 + } + ] + } + ] +} diff --git a/tests/components/cookidoo/snapshots/test_calendar.ambr b/tests/components/cookidoo/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..6d1dd2bb70b --- /dev/null +++ b/tests/components/cookidoo/snapshots/test_calendar.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_calendar[calendar.cookidoo_meal_plan-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.cookidoo_meal_plan', + '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': 'Meal plan', + 'platform': 'cookidoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meal_plan', + 'unique_id': 'sub_uuid', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar[calendar.cookidoo_meal_plan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cookidoo Meal plan', + }), + 'context': , + 'entity_id': 'calendar.cookidoo_meal_plan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_get_events + dict({ + 'calendar.cookidoo_meal_plan': dict({ + 'events': list([ + dict({ + 'description': 'Total Time: 1500', + 'end': '2025-03-05', + 'start': '2025-03-04', + 'summary': 'Waffles', + }), + dict({ + 'description': 'Total Time: 1500', + 'end': '2025-03-06', + 'start': '2025-03-05', + 'summary': 'Mint Tea', + }), + ]), + }), + }) +# --- diff --git a/tests/components/cookidoo/snapshots/test_diagnostics.ambr b/tests/components/cookidoo/snapshots/test_diagnostics.ambr index 3dc799c1108..dbe1197475b 100644 --- a/tests/components/cookidoo/snapshots/test_diagnostics.ambr +++ b/tests/components/cookidoo/snapshots/test_diagnostics.ambr @@ -27,6 +27,30 @@ 'subscription_source': 'COMMERCE', 'type': 'REGULAR', }), + 'week_plan': list([ + dict({ + 'id': '2025-03-04', + 'recipes': list([ + dict({ + 'id': 'r1', + 'name': 'Waffles', + 'total_time': 1500, + }), + ]), + 'title': '2025-03-04', + }), + dict({ + 'id': '2025-03-05', + 'recipes': list([ + dict({ + 'id': 'r2', + 'name': 'Mint Tea', + 'total_time': 1500, + }), + ]), + 'title': '2025-03-05', + }), + ]), }), 'entry_data': dict({ 'country': 'CH', diff --git a/tests/components/cookidoo/test_calendar.py b/tests/components/cookidoo/test_calendar.py new file mode 100644 index 00000000000..b86ed56e88b --- /dev/null +++ b/tests/components/cookidoo/test_calendar.py @@ -0,0 +1,83 @@ +"""Test for calendar platform of the Cookidoo integration.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def calendar_only() -> Generator[None]: + """Enable only the calendar platform.""" + with patch( + "homeassistant.components.cookidoo.PLATFORMS", + [Platform.CALENDAR], + ): + yield + + +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_calendar( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of calendar platform.""" + + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, cookidoo_config_entry.entry_id + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_cookidoo_client") +async def test_get_events( + hass: HomeAssistant, + cookidoo_config_entry: MockConfigEntry, + mock_cookidoo_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test fetching events from Cookidoo calendar.""" + + with patch("homeassistant.components.cookidoo.PLATFORMS", [Platform.CALENDAR]): + await setup_integration(hass, cookidoo_config_entry) + + assert cookidoo_config_entry.state is ConfigEntryState.LOADED + + entities = er.async_entries_for_config_entry( + entity_registry, cookidoo_config_entry.entry_id + ) + assert len(entities) == 1 + entity_id = entities[0].entity_id + + resp = await hass.services.async_call( + "calendar", + "get_events", + { + "start_date_time": datetime(2025, 3, 4, tzinfo=UTC), + "end_date_time": datetime(2025, 3, 6, tzinfo=UTC), + }, + target={"entity_id": entity_id}, + blocking=True, + return_response=True, + ) + + assert resp == snapshot