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:
@@ -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__)
|
||||
|
||||
|
||||
103
homeassistant/components/cookidoo/calendar.py
Normal file
103
homeassistant/components/cookidoo/calendar.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
26
tests/components/cookidoo/fixtures/calendar_week.json
Normal file
26
tests/components/cookidoo/fixtures/calendar_week.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
69
tests/components/cookidoo/snapshots/test_calendar.ambr
Normal file
69
tests/components/cookidoo/snapshots/test_calendar.ambr
Normal 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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
|
||||
83
tests/components/cookidoo/test_calendar.py
Normal file
83
tests/components/cookidoo/test_calendar.py
Normal 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
|
||||
Reference in New Issue
Block a user