From 3284d157f1979d7abd37f22cde0870c3a83b2690 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 10:12:43 +0000 Subject: [PATCH 1/2] Add bulk product cards and weekly sales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add products.get_cards(): fetch basic product data for many products in a single request via /api/v1/products/card (the bulk endpoint the library previously lacked). Results are mapped to a new typed ProductCard model — a trimmed, meaningful subset (name, brand, amount, unit, current/original/ unit price, sale flag and validity, stock) with website display cruft dropped — and returned in the requested order. Add products.get_week_sales(): fetch the "Akce týdne" deal ids and enrich them through get_cards in one follow-up request. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018KXnkpGcLfWU4p12TcZjfY --- rohlik_api/__init__.py | 2 + rohlik_api/endpoints.py | 14 +++++ rohlik_api/models.py | 60 +++++++++++++++++++++ rohlik_api/services/products.py | 78 ++++++++++++++++++++++++++- tests/test_services.py | 93 +++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) diff --git a/rohlik_api/__init__.py b/rohlik_api/__init__.py index ad1f1cd..d5d10e4 100644 --- a/rohlik_api/__init__.py +++ b/rohlik_api/__init__.py @@ -22,6 +22,7 @@ IngredientProductGroup, IngredientProducts, NutritionalValue, + ProductCard, ProductComposition, ProductPrice, ProductSearchResult, @@ -51,6 +52,7 @@ "NutritionalValue", "Allergens", "ProductPrice", + "ProductCard", "RecipeSearchResults", "RecipeSummary", "RecipeDetail", diff --git a/rohlik_api/endpoints.py b/rohlik_api/endpoints.py index 67add9c..0b23a57 100644 --- a/rohlik_api/endpoints.py +++ b/rohlik_api/endpoints.py @@ -86,6 +86,20 @@ def product_price(cls, product_id: int) -> str: """Build product price endpoint URL.""" return f"/api/v1/products/{product_id}/prices" + @classmethod + def product_cards(cls, product_ids: list[int], category_type: str = "normal") -> str: + """Build the bulk product-card endpoint URL for several products.""" + params = "&".join(f"products={int(pid)}" for pid in product_ids) + return f"/api/v1/products/card?{params}&categoryType={category_type}" + + @classmethod + def week_sales(cls, page: int = 0, size: int = 30, sort: str = "recommended") -> str: + """Build the 'deals of the week' (Akce týdne) component URL.""" + return ( + f"/api/v1/categories/sales/components/week-sales" + f"?page={page}&size={size}&sort={sort}" + ) + @classmethod def shopping_list(cls, shopping_list_id: str) -> str: """Build shopping list endpoint URL.""" diff --git a/rohlik_api/models.py b/rohlik_api/models.py index d01abb9..d45d403 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -303,6 +303,66 @@ def from_api(cls, data: dict[str, Any]) -> ProductPrice: ) +@dataclass(slots=True) +class ProductCard: + """Basic product information from the bulk product-card endpoint. + + A trimmed, meaningful subset of the website's product card — name, packaging + and pricing (including any active sale) — with display-only fields dropped. + + Attributes: + id: Product ID. + name: Product name. + brand: Brand name, if known. + amount: Textual packaging/amount, e.g. ``"cca 420 g"``. + unit: Base unit the product is sold by, e.g. ``"kg"`` or ``"ks"``. + price: Current price (the sale price when on sale, otherwise the regular + price), in :attr:`currency`. + original_price: Regular price before any discount, in :attr:`currency`. + unit_price: Price per base unit (e.g. per kg), in :attr:`currency`. + currency: ISO currency code, e.g. ``"CZK"``. + on_sale: True when the product currently has a sale price. + sale_valid_till: ISO timestamp the sale is valid until, if on sale. + in_stock: True when the product is available to order. + """ + + id: int | None + name: str | None + brand: str | None + amount: str | None + unit: str | None + price: float | None + original_price: float | None + unit_price: float | None + currency: str | None + on_sale: bool = False + sale_valid_till: str | None = None + in_stock: bool = True + + @classmethod + def from_api(cls, data: dict[str, Any]) -> ProductCard: + """Build a :class:`ProductCard` from a product-card entry.""" + prices = data.get("prices") or {} + sale_price = prices.get("salePrice") + original_price = prices.get("originalPrice") + on_sale = sale_price is not None + stock = data.get("stock") or {} + return cls( + id=data.get("productId"), + name=data.get("name"), + brand=data.get("brand"), + amount=data.get("textualAmount"), + unit=data.get("unit"), + price=sale_price if on_sale else original_price, + original_price=original_price, + unit_price=prices.get("unitPrice"), + currency=prices.get("currency"), + on_sale=on_sale, + sale_valid_till=prices.get("saleValidTill"), + in_stock=stock.get("availabilityStatus") == "AVAILABLE", + ) + + # --------------------------------------------------------------------------- # Recipes (Rohlík Chef) # --------------------------------------------------------------------------- diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 758922e..2eec4dd 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -7,7 +7,14 @@ from ..endpoints import Endpoints from ..http_client import HTTP_ERRORS -from ..models import AISummary, ProductComposition, ProductPrice, ProductSearchResult, SearchResults +from ..models import ( + AISummary, + ProductCard, + ProductComposition, + ProductPrice, + ProductSearchResult, + SearchResults, +) from .base import BaseService _LOGGER = logging.getLogger(__name__) @@ -127,6 +134,75 @@ async def get_price(self, product_id: int) -> ProductPrice | None: _LOGGER.warning("Error fetching price for product %s: %s", product_id, err) return None + async def get_cards( + self, product_ids: list[int], category_type: str = "normal" + ) -> list[ProductCard] | None: + """Get basic product cards for several products in a single request. + + Args: + product_ids: The product IDs to look up. + category_type: The ``categoryType`` query parameter (default "normal"). + + Returns: + A list of :class:`ProductCard` in the same order as ``product_ids`` + (IDs the API did not return are skipped), an empty list if + ``product_ids`` is empty, or None if the request fails. + """ + await self._ensure_logged_in() + + if not product_ids: + return [] + + try: + url = Endpoints.product_cards(product_ids, category_type=category_type) + response = await self._http.get(url) + response.raise_for_status() + payload = response.json() + except HTTP_ERRORS as err: + _LOGGER.warning("Error fetching product cards: %s", err) + return None + + if not isinstance(payload, list): + return None + + by_id = {card.id: card for card in (ProductCard.from_api(item) for item in payload)} + return [by_id[pid] for pid in product_ids if pid in by_id] + + async def get_week_sales( + self, page: int = 0, size: int = 30, sort: str = "recommended" + ) -> list[ProductCard] | None: + """Get this week's deals ("Akce týdne"), enriched with basic product data. + + The deals endpoint returns only product IDs; these are enriched via the + bulk product-card endpoint in a single follow-up request. + + Args: + page: Result page (default 0). + size: Maximum number of products (default 30). + sort: Sort order (default "recommended"). + + Returns: + A list of :class:`ProductCard` for the products on sale, an empty + list if there are none, or None if the request fails. + """ + await self._ensure_logged_in() + + try: + url = Endpoints.week_sales(page=page, size=size, sort=sort) + response = await self._http.get(url) + response.raise_for_status() + payload = response.json() + except HTTP_ERRORS as err: + _LOGGER.warning("Error fetching week sales: %s", err) + return None + + data = payload.get("data", payload) if isinstance(payload, dict) else {} + product_ids = data.get("products") if isinstance(data, dict) else None + if not product_ids: + return [] + + return await self.get_cards(product_ids) + async def get_detail(self, product_id: int) -> dict[str, Any] | None: """Get the full product detail (brand, attributes, etc.). diff --git a/tests/test_services.py b/tests/test_services.py index 37eb6ae..6dea046 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -323,6 +323,99 @@ async def test_get_detail_returns_none_on_404(self, mock_http, mock_auth): assert result is None + # Trimmed real payload from /api/v1/products/card: one regular, one on sale. + _CARDS = [ + { + "productId": 1353975, + "name": "Kachní prso Mulard", + "brand": None, + "unit": "kg", + "textualAmount": "cca 420 g", + "prices": { + "originalPrice": 293.68, + "salePrice": None, + "unitPrice": 699.9, + "saleValidTill": None, + "currency": "CZK", + }, + "stock": {"availabilityStatus": "AVAILABLE"}, + }, + { + "productId": 1476819, + "name": "Amadori BIO Kuřecí prsní plátky innerfilet", + "brand": None, + "unit": "kg", + "textualAmount": "cca 400 g", + "prices": { + "originalPrice": 321.24, + "salePrice": 273.05, + "unitPrice": 679.91, + "saleValidTill": "2026-07-30T23:59:00+02:00", + "currency": "CZK", + }, + "stock": {"availabilityStatus": "AVAILABLE"}, + }, + ] + + async def test_get_cards_parses_and_preserves_request_order(self, mock_http, mock_auth): + """get_cards maps the card payload and returns it in the requested order.""" + resp = MagicMock() + resp.json.return_value = list(reversed(self._CARDS)) # API order differs + resp.raise_for_status = MagicMock() + mock_http.get.return_value = resp + + service = ProductService(mock_http, mock_auth) + result = await service.get_cards([1353975, 1476819]) + + assert [c.id for c in result] == [1353975, 1476819] + regular, sale = result + assert regular.on_sale is False + assert regular.price == 293.68 + assert regular.amount == "cca 420 g" + assert regular.in_stock is True + assert sale.on_sale is True + assert sale.price == 273.05 # current price is the sale price + assert sale.original_price == 321.24 + assert sale.currency == "CZK" + url = mock_http.get.call_args[0][0] + assert "/api/v1/products/card?" in url + assert "products=1353975" in url and "categoryType=normal" in url + + async def test_get_cards_empty_makes_no_request(self, mock_http, mock_auth): + """get_cards short-circuits on an empty id list.""" + service = ProductService(mock_http, mock_auth) + assert await service.get_cards([]) == [] + mock_http.get.assert_not_called() + + async def test_get_week_sales_enriches_ids(self, mock_http, mock_auth): + """get_week_sales resolves the deal ids then enriches them via get_cards.""" + sales_resp = MagicMock() + sales_resp.json.return_value = {"products": [1353975, 1476819]} + sales_resp.raise_for_status = MagicMock() + cards_resp = MagicMock() + cards_resp.json.return_value = self._CARDS + cards_resp.raise_for_status = MagicMock() + mock_http.get.side_effect = [sales_resp, cards_resp] + + service = ProductService(mock_http, mock_auth) + result = await service.get_week_sales(size=2) + + assert [c.id for c in result] == [1353975, 1476819] + assert result[1].on_sale is True + assert "week-sales" in mock_http.get.call_args_list[0][0][0] + assert "/api/v1/products/card?" in mock_http.get.call_args_list[1][0][0] + + async def test_get_week_sales_empty_when_no_products(self, mock_http, mock_auth): + """get_week_sales returns [] (no card request) when there are no deals.""" + resp = MagicMock() + resp.json.return_value = {"products": []} + resp.raise_for_status = MagicMock() + mock_http.get.return_value = resp + + service = ProductService(mock_http, mock_auth) + assert await service.get_week_sales() == [] + assert mock_http.get.call_count == 1 # only the week-sales call + async def test_get_categories_returns_hierarchy(self, mock_http, mock_auth): """Test get_categories returns the inner category list.""" mock_response = MagicMock() From 3ce398f55f08d8a28c27099df91cd4186495aab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 11:57:44 +0000 Subject: [PATCH 2/2] Address review: in_stock default, URL-encode category_type, guard product_ids - ProductCard.from_api: default to in-stock when availabilityStatus is absent, matching the field default (in_stock=True). - Endpoints.product_cards: URL-encode category_type defensively. - get_week_sales: guard that product_ids is a non-empty list before enriching. - Add error-path tests: get_cards on HTTP error / non-list response, and get_week_sales when the sales call or the enrichment call fails. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018KXnkpGcLfWU4p12TcZjfY --- rohlik_api/endpoints.py | 2 +- rohlik_api/models.py | 4 +++- rohlik_api/services/products.py | 2 +- tests/test_services.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/rohlik_api/endpoints.py b/rohlik_api/endpoints.py index 0b23a57..c7efc82 100644 --- a/rohlik_api/endpoints.py +++ b/rohlik_api/endpoints.py @@ -90,7 +90,7 @@ def product_price(cls, product_id: int) -> str: def product_cards(cls, product_ids: list[int], category_type: str = "normal") -> str: """Build the bulk product-card endpoint URL for several products.""" params = "&".join(f"products={int(pid)}" for pid in product_ids) - return f"/api/v1/products/card?{params}&categoryType={category_type}" + return f"/api/v1/products/card?{params}&categoryType={quote(category_type, safe='')}" @classmethod def week_sales(cls, page: int = 0, size: int = 30, sort: str = "recommended") -> str: diff --git a/rohlik_api/models.py b/rohlik_api/models.py index d45d403..0a701b9 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -359,7 +359,9 @@ def from_api(cls, data: dict[str, Any]) -> ProductCard: currency=prices.get("currency"), on_sale=on_sale, sale_valid_till=prices.get("saleValidTill"), - in_stock=stock.get("availabilityStatus") == "AVAILABLE", + # Default to in-stock when the status is absent, matching the field + # default (in_stock=True) so hand-built and parsed cards agree. + in_stock=stock.get("availabilityStatus", "AVAILABLE") == "AVAILABLE", ) diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 2eec4dd..c0eb431 100644 --- a/rohlik_api/services/products.py +++ b/rohlik_api/services/products.py @@ -198,7 +198,7 @@ async def get_week_sales( data = payload.get("data", payload) if isinstance(payload, dict) else {} product_ids = data.get("products") if isinstance(data, dict) else None - if not product_ids: + if not isinstance(product_ids, list) or not product_ids: return [] return await self.get_cards(product_ids) diff --git a/tests/test_services.py b/tests/test_services.py index 6dea046..a788e34 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -416,6 +416,47 @@ async def test_get_week_sales_empty_when_no_products(self, mock_http, mock_auth) assert await service.get_week_sales() == [] assert mock_http.get.call_count == 1 # only the week-sales call + async def test_get_cards_returns_none_on_error(self, mock_http, mock_auth): + """get_cards returns None when the request fails.""" + import aiohttp + + mock_http.get.side_effect = aiohttp.ClientError("Connection failed") + + service = ProductService(mock_http, mock_auth) + assert await service.get_cards([1, 2]) is None + + async def test_get_cards_returns_none_on_non_list(self, mock_http, mock_auth): + """get_cards returns None when the API response is not a list.""" + resp = MagicMock() + resp.json.return_value = {"unexpected": "shape"} + resp.raise_for_status = MagicMock() + mock_http.get.return_value = resp + + service = ProductService(mock_http, mock_auth) + assert await service.get_cards([1, 2]) is None + + async def test_get_week_sales_returns_none_when_sales_call_fails(self, mock_http, mock_auth): + """get_week_sales returns None when the week-sales request fails.""" + import aiohttp + + mock_http.get.side_effect = aiohttp.ClientError("Connection failed") + + service = ProductService(mock_http, mock_auth) + assert await service.get_week_sales() is None + + async def test_get_week_sales_returns_none_when_enrichment_fails(self, mock_http, mock_auth): + """get_week_sales returns None when IDs are fetched but the card call fails.""" + import aiohttp + + sales_resp = MagicMock() + sales_resp.json.return_value = {"products": [1353975, 1476819]} + sales_resp.raise_for_status = MagicMock() + # week-sales succeeds, the enrichment (get_cards) call fails. + mock_http.get.side_effect = [sales_resp, aiohttp.ClientError("Connection failed")] + + service = ProductService(mock_http, mock_auth) + assert await service.get_week_sales() is None + async def test_get_categories_returns_hierarchy(self, mock_http, mock_auth): """Test get_categories returns the inner category list.""" mock_response = MagicMock()