diff --git a/cookidoo_api/__init__.py b/cookidoo_api/__init__.py index 89a429e..2892c4f 100644 --- a/cookidoo_api/__init__.py +++ b/cookidoo_api/__init__.py @@ -27,6 +27,7 @@ CookidooCollection, CookidooConfig, CookidooCookingActivity, + CookidooCookingHistoryEntry, CookidooCookState, CookidooDevice, CookidooIngredient, @@ -57,6 +58,7 @@ "CookidooAuthData", "CookidooCookingActivity", "CookidooCookState", + "CookidooCookingHistoryEntry", "cooking_activity_from_push", "CookidooAdditionalItem", "CookidooIngredientItem", diff --git a/cookidoo_api/const.py b/cookidoo_api/const.py index dcca7ba..a72a76b 100644 --- a/cookidoo_api/const.py +++ b/cookidoo_api/const.py @@ -106,6 +106,12 @@ REMOVE_RECIPE_FROM_CUSTOM_COLLECTION_PATH: Final = ( "organize/{language}/api/custom-list/{id}/recipes/{recipe}" ) +# "Last cooked": the recipes the account has cooked, newest first. The service +# returns the whole history in one response (it takes no page/limit params). +COOKING_HISTORY_PATH: Final = "organize/{language}/api/cooking-history" +COOKING_HISTORY_PATH_ACCEPT: Final = ( + "application/vnd.vorwerk.organize.cooking-history.mobile+json" +) MANAGED_COLLECTIONS_PATH: Final = "organize/{language}/api/managed-list" MANAGED_COLLECTIONS_PATH_ACCEPT: Final = ( "application/vnd.vorwerk.organize.managed-list.mobile+json" diff --git a/cookidoo_api/cookidoo.py b/cookidoo_api/cookidoo.py index efa15cd..793830d 100644 --- a/cookidoo_api/cookidoo.py +++ b/cookidoo_api/cookidoo.py @@ -24,6 +24,7 @@ from cookidoo_api.const import ( CIAM_BASE_URL, CIAM_LOGIN_SRV_URL, + COOKING_HISTORY_PATH_ACCEPT, CUSTOM_COLLECTIONS_PATH_ACCEPT, CUSTOM_RECIPES_PATH_ACCEPT, DEFAULT_API_HEADERS, @@ -52,6 +53,7 @@ cookidoo_additional_item_from_json, cookidoo_calendar_day_from_json, cookidoo_collection_from_json, + cookidoo_cooking_history_entry_from_json, cookidoo_custom_recipe_from_json, cookidoo_device_from_json, cookidoo_ingredient_item_from_json, @@ -67,6 +69,7 @@ AdditionalItemJSON, CalendarDayJSON, CommunityProfileJSON, + CookingHistoryEntryJSON, CustomCollectionJSON, CustomRecipeJSON, CustomRecipesJSON, @@ -84,6 +87,7 @@ CookidooCalendarDay, CookidooCollection, CookidooConfig, + CookidooCookingHistoryEntry, CookidooCustomRecipe, CookidooDevice, CookidooIngredientItem, @@ -2017,6 +2021,51 @@ async def get_managed_collections(self, page: int = 0) -> list[CookidooCollectio ], ) + async def get_cooking_history(self) -> list[CookidooCookingHistoryEntry]: + """Get the cooking history ("last cooked") of the account. + + The service returns the whole history in a single response, newest + entry first; it accepts no pagination parameters. + + Returns + ------- + list[CookidooCookingHistoryEntry] + The cooked recipes, most recently cooked first + + Raises + ------ + CookidooAuthException + When the access token is not valid anymore + CookidooRequestException + If the request fails. + CookidooParseException + If the parsing of the request response fails. + + """ + + await self._ensure_endpoints() + url = self.api_endpoint / self._path("organize:api-cooking-history").format( + **self._cfg.localization.__dict__ + ) + result = self._ensure_mapping( + await self._request_json( + "get", + url, + "loading cooking history", + headers={"ACCEPT": COOKING_HISTORY_PATH_ACCEPT}, + ), + "loading cooking history", + ) + return self._parse_result( + "loading cooking history", + lambda: [ + cookidoo_cooking_history_entry_from_json( + cast(CookingHistoryEntryJSON, entry), self._cfg.localization + ) + for entry in cast(Sequence[object], result["entries"]) + ], + ) + async def add_managed_collection( self, managed_collection_id: str, diff --git a/cookidoo_api/helpers.py b/cookidoo_api/helpers.py index 0604b54..dda12c0 100644 --- a/cookidoo_api/helpers.py +++ b/cookidoo_api/helpers.py @@ -16,6 +16,7 @@ CalendarDayJSON, CalenderDayRecipeJSON, CommunityProfileJSON, + CookingHistoryEntryJSON, CustomCollectionJSON, CustomRecipeContentJSON, CustomRecipeJSON, @@ -40,6 +41,7 @@ CookidooChapterRecipe, CookidooCollection, CookidooCookingActivity, + CookidooCookingHistoryEntry, CookidooCookState, CookidooCustomRecipe, CookidooDevice, @@ -677,6 +679,44 @@ def _to_day_recipe(recipe: CalenderDayRecipeJSON) -> CookidooCalendarDayRecipe: ) +def cookidoo_cooking_history_entry_from_json( + entry: CookingHistoryEntryJSON, + localization: CookidooLocalizationConfig | None = None, +) -> CookidooCookingHistoryEntry: + """Convert a cooking history entry received from the API to a cookidoo item.""" + recipe = entry["recipe"] + + assets = recipe.get("assets") + thumbnail, image = None, None + descriptive_assets = [assets["images"]] if assets and assets["images"] else None + if descriptive_assets is not None: + thumbnail, image = _extract_images_from_descriptive_assets(descriptive_assets) + + # The service reports the duration as a stringified float of seconds + # (e.g. ``"5100.0"``), unlike the planning endpoints' plain int. + try: + total_time = int(float(recipe["totalTime"])) + except (TypeError, ValueError): + total_time = 0 + + cooked_at = _push_timestamp(entry["details"]["timestamp"]) + if cooked_at is None: + raise ValueError( + f"Cooking history entry for recipe {recipe['id']} has an " + f"unparsable timestamp: {entry['details']['timestamp']!r}" + ) + + return CookidooCookingHistoryEntry( + id=recipe["id"], + name=recipe["title"], + cooked_at=cooked_at, + total_time=total_time, + thumbnail=thumbnail, + image=image, + url=_construct_recipe_url(localization, recipe["id"]), + ) + + async def __get_localization_options( country: str | None = None, language: str | None = None, diff --git a/cookidoo_api/raw_types.py b/cookidoo_api/raw_types.py index 06ee044..2a409aa 100644 --- a/cookidoo_api/raw_types.py +++ b/cookidoo_api/raw_types.py @@ -297,6 +297,37 @@ class CalenderDayRecipeJSON(TypedDict): assets: CalenderDayRecipeAssetsJSON | None +class CookingHistoryRecipeJSON(TypedDict): + """The json for a recipe in the cooking history in the API.""" + + id: str + title: str + totalTime: str + type: str + locale: str + assets: CalenderDayRecipeAssetsJSON | None + + +class CookingHistoryDetailsJSON(TypedDict): + """The json for the details of a cooking history entry in the API.""" + + timestamp: str + + +class CookingHistoryEntryJSON(TypedDict): + """The json for a cooking history entry in the API.""" + + details: CookingHistoryDetailsJSON + recipe: CookingHistoryRecipeJSON + + +class CookingHistoryJSON(TypedDict): + """The json for the cooking history in the API.""" + + userId: str + entries: list[CookingHistoryEntryJSON] + + class CalendarDayJSON(TypedDict): """The json for a calendar day in the API.""" diff --git a/cookidoo_api/types.py b/cookidoo_api/types.py index 2bb7fa0..63784db 100644 --- a/cookidoo_api/types.py +++ b/cookidoo_api/types.py @@ -580,6 +580,38 @@ class CookidooCalendarDayRecipe: url: str +@dataclass +class CookidooCookingHistoryEntry: + """Cookidoo cooking history ("last cooked") entry. + + Attributes + ---------- + id + The id of the recipe + name + The label of the recipe + cooked_at + When the recipe was cooked (timezone-aware, UTC) + total_time + The time for the recipe, in seconds + thumbnail + The thumbnail image URL (small preview) + image + The full-size image URL + url + The URL of the recipe + + """ + + id: str + name: str + cooked_at: datetime + total_time: int + thumbnail: str | None + image: str | None + url: str + + @dataclass class CookidooCalendarDay: """Cookidoo calendar day type. diff --git a/cookidoo_api/well_known.py b/cookidoo_api/well_known.py index a1f450d..b6ab87e 100644 --- a/cookidoo_api/well_known.py +++ b/cookidoo_api/well_known.py @@ -29,6 +29,7 @@ ADD_INGREDIENT_ITEMS_FOR_RECIPES_PATH, ADD_RECIPES_TO_CALENDER_PATH, COMMUNITY_PROFILE_PATH, + COOKING_HISTORY_PATH, CUSTOM_COLLECTIONS_PATH, CUSTOM_RECIPE_PATH, CUSTOM_RECIPES_PATH, @@ -110,6 +111,7 @@ "organize", REMOVE_RECIPE_FROM_CUSTOM_COLLECTION_PATH, ), + "organize:api-cooking-history": ("organize", COOKING_HISTORY_PATH), "organize:api-managed-list": ("organize", MANAGED_COLLECTIONS_PATH), "organize:api-managed-list-single": ("organize", REMOVE_MANAGED_COLLECTION_PATH), "planning:api-my-week-from-date": ("planning", RECIPES_IN_CALENDAR_WEEK_PATH), diff --git a/docs/raw-api-requests/get-cooking-history.txt b/docs/raw-api-requests/get-cooking-history.txt new file mode 100644 index 0000000..1dff3e2 --- /dev/null +++ b/docs/raw-api-requests/get-cooking-history.txt @@ -0,0 +1,29 @@ +GET https://cookidoo.de/organize/de-DE/api/cooking-history HTTP/2.0 +accept: application/vnd.vorwerk.organize.cooking-history.mobile+json +authorization: Bearer +accept-encoding: gzip +content-length: 0 + +# Notes +# - The service takes no pagination parameters: `?page=` / `?limit=` are +# accepted but ignored, and the full history comes back in one response. +# - Entries are ordered newest-cooked first. +# - `totalTime` is seconds, serialised as a stringified float ("5100.0"). +# - Requesting any other representation yields 406 with: +# Acceptable representations: [ +# application/vnd.vorwerk.organize.cooking-history.mobile+json, +# application/json +# ] +# `application/json` returns the same entries with extra legacy image +# fields (asciiTitle, squareImage, responsiveImageSrcset, ...). +# - OPTIONS reports `Allow: POST,GET,HEAD,OPTIONS` here, and +# `Allow: POST,OPTIONS` on .../cooking-history/multiple. Only the GET is +# implemented by this library; the POSTs (recording a cook) are untested. + + +HTTP/2.0 200 +content-type: application/vnd.vorwerk.organize.cooking-history.mobile+json +x-content-type-options: nosniff +vary: Accept-Encoding + +{"userId":"00000000-0000-0000-0000-000000000000","entries":[{"details":{"timestamp":"2026-09-05T05:31:47.529Z"},"recipe":{"assets":{"images":{"square":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/27C5A685-9083-4864-A0BE-F751B99CA7D3/Derivates/2AEAE2E5-BB52-4A3A-A6C1-33D08A874419","portrait":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/27C5A685-9083-4864-A0BE-F751B99CA7D3/Derivates/2AEAE2E5-BB52-4A3A-A6C1-33D08A874419","landscape":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/27C5A685-9083-4864-A0BE-F751B99CA7D3/Derivates/2AEAE2E5-BB52-4A3A-A6C1-33D08A874419"}},"id":"r59322","locale":"","title":"Vollkorn-Toastbrötchen","totalTime":"5100.0","type":"VORWERK"}},{"details":{"timestamp":"2026-08-28T13:56:30.168Z"},"recipe":{"assets":{"images":{"square":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/A1B2C3D4-0000-0000-0000-000000000000/Derivates/B2C3D4E5-0000-0000-0000-000000000000","portrait":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/A1B2C3D4-0000-0000-0000-000000000000/Derivates/B2C3D4E5-0000-0000-0000-000000000000","landscape":"https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/A1B2C3D4-0000-0000-0000-000000000000/Derivates/B2C3D4E5-0000-0000-0000-000000000000"}},"id":"r54743","locale":"","title":"Pizzateig","totalTime":"900.0","type":"VORWERK"}}]} diff --git a/example.py b/example.py index ac00826..6fccd77 100755 --- a/example.py +++ b/example.py @@ -105,6 +105,9 @@ async def main(): _managed_collections = await cookidoo.get_managed_collections() await cookidoo.remove_managed_collection("col500401") + # Cooking history ("last cooked") + _cooking_history = await cookidoo.get_cooking_history() + # Recipe details _recipe_details = await cookidoo.get_recipe_details("r59322") diff --git a/smoke_test/test_2_methods.py b/smoke_test/test_2_methods.py index ff5dc61..3e1d2e2 100644 --- a/smoke_test/test_2_methods.py +++ b/smoke_test/test_2_methods.py @@ -208,6 +208,21 @@ async def test_cookidoo_managed_collections(self, cookidoo: Cookidoo) -> None: assert isinstance(managed_collections, list) assert len(managed_collections) == 0 + async def test_cookidoo_cooking_history(self, cookidoo: Cookidoo) -> None: + """Test cookidoo cooking history.""" + cooking_history = await cookidoo.get_cooking_history() + assert isinstance(cooking_history, list) + + # The account may legitimately have never cooked anything; only assert + # the shape and ordering when there is something to look at. + for entry in cooking_history: + assert entry.id + assert entry.name + assert entry.cooked_at.tzinfo is not None + + timestamps = [entry.cooked_at for entry in cooking_history] + assert timestamps == sorted(timestamps, reverse=True) + async def test_cookidoo_custom_collections(self, cookidoo: Cookidoo) -> None: """Test cookidoo custom collections.""" added_custom_collection = await cookidoo.add_custom_collection( diff --git a/tests/responses.py b/tests/responses.py index e4ff11c..0c0db9d 100644 --- a/tests/responses.py +++ b/tests/responses.py @@ -1465,6 +1465,41 @@ ] } +COOKIDOO_TEST_RESPONSE_GET_COOKING_HISTORY = { + "userId": "00000000-0000-0000-0000-000000000000", + "entries": [ + { + "details": {"timestamp": "2026-09-05T05:31:47.529Z"}, + "recipe": { + "id": "r59322", + "title": "Vollkorn-Toastbrötchen", + # The service reports seconds as a stringified float. + "totalTime": "5100.0", + "type": "VORWERK", + "locale": "", + "assets": { + "images": { + "square": "https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/square", + "portrait": "https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/portrait", + "landscape": "https://assets.tmecosys.com/image/upload/{transformation}/img/recipe/ras/Assets/landscape", + } + }, + }, + }, + { + "details": {"timestamp": "2026-08-28T13:56:30.168Z"}, + "recipe": { + "id": "r54743", + "title": "Pizzateig", + "totalTime": "900.0", + "type": "VORWERK", + "locale": "", + "assets": {"images": None}, + }, + }, + ], +} + COOKIDOO_TEST_RESPONSE_GET_MANAGED_COLLECTIONS = { "managedlists": [ { diff --git a/tests/test_cookidoo.py b/tests/test_cookidoo.py index 0b21dec..d5a900f 100644 --- a/tests/test_cookidoo.py +++ b/tests/test_cookidoo.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Callable -from datetime import datetime +from datetime import UTC, datetime from http import HTTPStatus import pathlib import re @@ -63,6 +63,7 @@ COOKIDOO_TEST_RESPONSE_EDIT_ADDITIONAL_ITEMS_OWNERSHIP, COOKIDOO_TEST_RESPONSE_EDIT_INGREDIENTS_OWNERSHIP, COOKIDOO_TEST_RESPONSE_GET_ADDITIONAL_ITEMS, + COOKIDOO_TEST_RESPONSE_GET_COOKING_HISTORY, COOKIDOO_TEST_RESPONSE_GET_CUSTOM_COLLECTIONS, COOKIDOO_TEST_RESPONSE_GET_CUSTOM_RECIPE, COOKIDOO_TEST_RESPONSE_GET_INGREDIENTS_FOR_CUSTOM_RECIPES, @@ -2978,6 +2979,139 @@ async def test_parse_exception( await cookidoo.get_managed_collections() +class TestGetCookingHistory: + """Tests for get_cooking_history method.""" + + async def test_get_cooking_history( + self, mocked: aioresponses, cookidoo: Cookidoo + ) -> None: + """Test for get_cooking_history.""" + + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + payload=COOKIDOO_TEST_RESPONSE_GET_COOKING_HISTORY, + status=HTTPStatus.OK, + ) + + data = await cookidoo.get_cooking_history() + assert isinstance(data, list) + assert len(data) == 2 + + first = data[0] + assert first.id == "r59322" + assert first.name == "Vollkorn-Toastbrötchen" + assert first.cooked_at == datetime(2026, 9, 5, 5, 31, 47, 529000, tzinfo=UTC) + # "5100.0" seconds, normalised to a plain int + assert first.total_time == 5100 + assert first.thumbnail + assert first.image + assert first.url == "https://cookidoo.ch/recipes/recipe/de-CH/r59322" + + # An entry without images still parses, with both URLs unset. + second = data[1] + assert second.id == "r54743" + assert second.total_time == 900 + assert second.thumbnail is None + assert second.image is None + + async def test_get_cooking_history_empty( + self, mocked: aioresponses, cookidoo: Cookidoo + ) -> None: + """Test an account that has not cooked anything yet.""" + + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + payload={"userId": "00000000-0000-0000-0000-000000000000", "entries": []}, + status=HTTPStatus.OK, + ) + + assert await cookidoo.get_cooking_history() == [] + + async def test_get_cooking_history_bad_timestamp( + self, mocked: aioresponses, cookidoo: Cookidoo + ) -> None: + """An unparsable timestamp surfaces as a parse exception, not a crash.""" + + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + payload={ + "userId": "00000000-0000-0000-0000-000000000000", + "entries": [ + { + "details": {"timestamp": "not-a-timestamp"}, + "recipe": { + "id": "r59322", + "title": "Vollkorn-Toastbrötchen", + "totalTime": "5100.0", + "type": "VORWERK", + "locale": "", + "assets": {"images": None}, + }, + } + ], + }, + status=HTTPStatus.OK, + ) + + with pytest.raises(CookidooParseException): + await cookidoo.get_cooking_history() + + @pytest.mark.parametrize( + "exception", + [ + TimeoutError, + ClientError, + ], + ) + async def test_request_exception( + self, mocked: aioresponses, cookidoo: Cookidoo, exception: Exception + ) -> None: + """Test request exceptions.""" + + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + exception=exception, + ) + + with pytest.raises(CookidooRequestException): + await cookidoo.get_cooking_history() + + async def test_unauthorized(self, mocked: aioresponses, cookidoo: Cookidoo) -> None: + """Test unauthorized exception.""" + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + status=HTTPStatus.UNAUTHORIZED, + payload={"error_description": ""}, + ) + with pytest.raises(CookidooAuthException): + await cookidoo.get_cooking_history() + + @pytest.mark.parametrize( + ("status", "exception"), + [ + (HTTPStatus.OK, CookidooParseException), + (HTTPStatus.UNAUTHORIZED, CookidooAuthException), + ], + ) + async def test_parse_exception( + self, + mocked: aioresponses, + cookidoo: Cookidoo, + status: HTTPStatus, + exception: type[CookidooException], + ) -> None: + """Test parse exceptions.""" + mocked.get( + "https://cookidoo.ch/organize/de-CH/api/cooking-history", + status=status, + body="not json", + content_type="application/json", + ) + + with pytest.raises(exception): + await cookidoo.get_cooking_history() + + class TestAddManagedCollection: """Tests for add_managed_collection method.""" diff --git a/well-known-snapshots/latest.json b/well-known-snapshots/latest.json index abcb94f..5d4abc6 100644 --- a/well-known-snapshots/latest.json +++ b/well-known-snapshots/latest.json @@ -4,6 +4,7 @@ "customer-recipes:recipe-create (created-recipes)": "https://cookidoo.de/created-recipes/{lang}", "customer-recipes:recipe-details (created-recipes)": "https://cookidoo.de/created-recipes/{lang}/{id}", "fint:login (profile)": "/profile/{lang}/login{?redirectAfterLogin}", + "organize:api-cooking-history (organize)": "https://cookidoo.de/organize/{lang}/api/cooking-history", "organize:api-custom-list (organize)": "https://cookidoo.de/organize/{lang}/api/custom-list", "organize:api-custom-list-modify (organize)": "https://cookidoo.de/organize/{lang}/api/custom-list/{id}", "organize:api-custom-list-recipe (organize)": "https://cookidoo.de/organize/{lang}/api/custom-list/{id}/recipes/{recipeId}",