Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions rohlik_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
IngredientProductGroup,
IngredientProducts,
NutritionalValue,
ProductCard,
ProductComposition,
ProductPrice,
ProductSearchResult,
Expand Down Expand Up @@ -51,6 +52,7 @@
"NutritionalValue",
"Allergens",
"ProductPrice",
"ProductCard",
"RecipeSearchResults",
"RecipeSummary",
"RecipeDetail",
Expand Down
14 changes: 14 additions & 0 deletions rohlik_api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
62 changes: 62 additions & 0 deletions rohlik_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
78 changes: 77 additions & 1 deletion rohlik_api/services/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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.).

Expand Down
134 changes: 134 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading