1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Add Cookidoo planned meals calendar (#159456)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
surfingbytes
2025-12-29 13:54:36 +01:00
committed by GitHub
parent 097d190750
commit 183bc31125
9 changed files with 340 additions and 2 deletions

View File

@@ -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__)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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"
},

View File

@@ -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

View File

@@ -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
}
]
}
]
}

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'calendar',
'entity_category': None,
'entity_id': 'calendar.cookidoo_meal_plan',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'calendar.cookidoo_meal_plan',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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',
}),
]),
}),
})
# ---

View File

@@ -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',

View File

@@ -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