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..c7efc82 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={quote(category_type, safe='')}" + + @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..0a701b9 100644 --- a/rohlik_api/models.py +++ b/rohlik_api/models.py @@ -303,6 +303,68 @@ 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"), + # 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", + ) + + # --------------------------------------------------------------------------- # Recipes (Rohlík Chef) # --------------------------------------------------------------------------- diff --git a/rohlik_api/services/products.py b/rohlik_api/services/products.py index 758922e..c0eb431 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 isinstance(product_ids, list) or 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..a788e34 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -323,6 +323,140 @@ 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_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()