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
19 changes: 13 additions & 6 deletions custom_components/rohlikcz/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS,
CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED,
)
from .errors import InvalidCredentialsError
from .rohlik_api import RohlikCZAPI
from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError

_LOGGER = logging.getLogger(__name__)

Expand All @@ -31,10 +30,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,

Returns the account title and unique user id on success.
"""
api = RohlikCZAPI(data[CONF_EMAIL], data[CONF_PASSWORD])
reply = await api.get_data()
user = reply["login"]["data"]["user"]
return {"title": user["name"], "user_id": str(user["id"])}
# A one-shot client that owns (and on close fully tears down) its session.
client = RohlikAPI(data[CONF_EMAIL], data[CONF_PASSWORD])
try:
reply = await client.login()
user = reply["data"]["user"]
return {"title": user["name"], "user_id": str(user["id"])}
finally:
await client.close()


ANALYTICS_SCHEMA = vol.Schema({
Expand Down Expand Up @@ -78,6 +81,8 @@ async def async_step_user(
info = await validate_input(self.hass, user_input)
except InvalidCredentialsError:
errors["base"] = "invalid_auth"
except RohlikAPIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
Expand Down Expand Up @@ -139,6 +144,8 @@ async def async_step_reauth_confirm(
info = await validate_input(self.hass, data)
except InvalidCredentialsError:
errors["base"] = "invalid_auth"
except RohlikAPIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unknown exception")
errors["base"] = "unknown"
Expand Down
21 changes: 0 additions & 21 deletions custom_components/rohlikcz/errors.py

This file was deleted.

157 changes: 115 additions & 42 deletions custom_components/rohlikcz/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@
import json
import logging
import os
from dataclasses import asdict
from datetime import datetime, timedelta
from typing import Any, Optional, Dict
from typing import Any
from zoneinfo import ZoneInfo

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError

from .const import DOMAIN
from .errors import InvalidCredentialsError, APIRequestFailedError, RohlikczError
from .rohlik_api import RohlikCZAPI

#: How often the integration refreshes data from the Rohlik API.
UPDATE_INTERVAL = timedelta(seconds=600)
Expand Down Expand Up @@ -391,7 +393,12 @@ def __init__(self, hass: HomeAssistant, username: str, password: str, analytics:
)
self._username: str = username
self._password: str = password
self._rohlik_api = RohlikCZAPI(self._username, self._password)
# A dedicated, HA-managed aiohttp session (own cookie jar) keeps each
# account's auth cookies isolated from other integrations and from a
# second Rohlik account. The client logs in lazily and re-authenticates
# transparently on a 401, reusing this session across calls.
self._session = async_create_clientsession(hass)
self._client = RohlikAPI(self._username, self._password, session=self._session)
self._order_store: OrderStore | None = None
self._last_refresh: datetime | None = None
# _store_lock guards brief in-memory store mutations (contended by the
Expand Down Expand Up @@ -462,11 +469,11 @@ def last_refresh(self) -> datetime | None:
async def _async_update_data(self) -> dict:
"""Fetch data from the Rohlik API (called by the coordinator)."""
try:
data = await self._rohlik_api.get_data()
data = await self._client.get_data()
except InvalidCredentialsError as err:
# Credentials are no longer valid - trigger the reauth flow.
raise ConfigEntryAuthFailed(str(err)) from err
except (APIRequestFailedError, RohlikczError) as err:
except RohlikAPIError as err:
raise UpdateFailed(str(err)) from err

self._last_refresh = datetime.now(ZoneInfo("Europe/Prague"))
Expand Down Expand Up @@ -512,7 +519,7 @@ async def _auto_enrich_new_orders(self, new_count: int) -> None:
return

# Fetch item details (network I/O, store lock released).
items_map = await self._rohlik_api.enrich_orders_with_items(recent_unenriched)
items_map = await self._fetch_order_items(recent_unenriched)

# Apply item results and find products needing categories.
async with self._store_lock:
Expand All @@ -523,7 +530,7 @@ async def _auto_enrich_new_orders(self, new_count: int) -> None:
# Fetch categories (network I/O, store lock released).
cat_map = {}
if uncategorized:
cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized)
cat_map = await self._fetch_product_categories(uncategorized)

# Persist all results.
enriched = False
Expand All @@ -547,7 +554,7 @@ async def fetch_full_order_history(self, hass=None) -> dict:
"""
async with self._enrich_lock:
# Step 1: Fetch order list (network I/O, store lock released)
all_orders = await self._rohlik_api.fetch_all_delivered_orders()
all_orders = await self._client.orders.get_all_delivered()
new_orders = 0
if self._order_store and all_orders:
async with self._store_lock:
Expand Down Expand Up @@ -615,7 +622,7 @@ async def _enrich_order_details(self, hass=None) -> dict:
self._t("phase1").format(count=len(unenriched)),
self._t("title_progress"))
_LOGGER.info(f"Enriching {len(unenriched)} orders with item details...")
items_map = await self._rohlik_api.enrich_orders_with_items(unenriched)
items_map = await self._fetch_order_items(unenriched)
async with self._store_lock:
for order_id, items in items_map.items():
if self._order_store.add_items_to_order(order_id, items):
Expand All @@ -642,7 +649,7 @@ async def progress_cb(done, total):
self._t("title_progress"))

_LOGGER.info(f"Fetching categories for {total_products} products...")
cat_map = await self._rohlik_api.fetch_product_categories_batch(uncategorized, progress_callback=progress_cb)
cat_map = await self._fetch_product_categories(uncategorized, progress_callback=progress_cb)
async with self._store_lock:
new_cats = self._order_store.update_product_categories(cat_map)
stats["products_categorized"] = new_cats
Expand Down Expand Up @@ -672,6 +679,65 @@ async def progress_cb(done, total):
"products_categorized_this_run": stats["products_categorized"],
}

async def _fetch_order_items(self, order_ids: list[str]) -> dict[str, list]:
"""Fetch line items for a list of order IDs.

Returns ``{order_id: items_list}`` where each item is normalised to the
shape the OrderStore expects. Orders are fetched one at a time with a
short delay to stay polite to the API.
"""
results: dict[str, list] = {}
total = len(order_ids)
for i, order_id in enumerate(order_ids):
try:
detail = await self._client.orders.get_detail(int(order_id))
except (ValueError, RohlikAPIError) as err:
_LOGGER.warning("Failed to fetch items for order %s: %s", order_id, err)
detail = None
if detail and detail.get("items"):
results[order_id] = [
{
"id": item.get("id"),
"name": item.get("name", "Unknown"),
"quantity": item.get("amount", 1),
"price": item.get("priceComposition", {}).get("total", {}).get("amount", 0),
"unit_price": item.get("priceComposition", {}).get("unit", {}).get("amount", 0),
"textual_amount": item.get("textualAmount", ""),
}
for item in detail["items"]
]
if i and i % 50 == 0:
_LOGGER.info("Fetched items for %d/%d orders", i, total)
await asyncio.sleep(0.2)
_LOGGER.info("Item fetch complete: %d/%d orders", len(results), total)
return results

async def _fetch_product_categories(self, product_ids: list[int], progress_callback=None) -> dict[int, list]:
"""Fetch the category hierarchy for a batch of product IDs.

Returns ``{product_id: categories_list}``. A product the API no longer
knows about (``get_categories`` returns ``None``) is recorded with a
sentinel "Discontinued" category so it isn't retried every run.
"""
results: dict[int, list] = {}
total = len(product_ids)
for i, pid in enumerate(product_ids):
try:
cats = await self._client.products.get_categories(pid)
except RohlikAPIError as err:
_LOGGER.debug("Failed to fetch categories for product %s: %s", pid, err)
cats = []
if cats is None:
# Product discontinued (404) - mark with sentinel category.
results[pid] = [{"level": 1, "name": "Discontinued"}]
elif cats:
results[pid] = cats
if progress_callback and i and i % 50 == 0:
await progress_callback(i, total)
await asyncio.sleep(0.2)
_LOGGER.info("Category fetch complete: %d/%d products", len(results), total)
return results

async def refresh_slots(self) -> None:
"""Cheaply refresh only the delivery-slot data (for express-slot polling).

Expand All @@ -680,52 +746,59 @@ async def refresh_slots(self) -> None:
"""
if not self.data:
return
result = await self._rohlik_api.get_timeslots()
result = await self._client.delivery.get_next_slots()
if result is not None:
self.data["next_delivery_slot"] = result
self.async_update_listeners()

async def async_close(self) -> None:
"""Release resources held by the API client (called on unload)."""
await self._rohlik_api.async_close()
try:
# Logs out (best-effort) but leaves the injected session open...
await self._client.close()
finally:
# ...so always close the HA-managed session we created here, even
# if logout/close raised.
await self._session.close()

# New service methods
async def add_to_cart(self, product_id: int, quantity: int) -> Dict:
async def add_to_cart(self, product_id: int, quantity: int) -> dict:
"""Add a product to the shopping cart."""
product_list = [{"product_id": product_id, "quantity": quantity}]
result = await self._rohlik_api.add_to_cart(product_list)
added = await self._client.cart.add_items(
[{"product_id": product_id, "quantity": quantity}]
)
await self.async_update()
return result

async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False) -> Optional[Dict[str, Any]]:
"""Search for a product by name."""
result = await self._rohlik_api.search_product(product_name, limit, favourite)
return result
return {"added_products": added}

async def get_shopping_list(self, shopping_list_id: str) -> Dict[str, Any]:
"""Get a shopping list by ID."""
result = await self._rohlik_api.get_shopping_list(shopping_list_id)
return result
async def search_product(self, product_name: str, limit: int = 10, favourite: bool = False):
"""Search for a product by name. Returns a SearchResults model (or None)."""
return await self._client.products.search(product_name, limit, favourite)

async def get_cart_content(self) -> Dict:
""" Retrieves cart content. """
result = await self._rohlik_api.get_cart_content()
return result
async def get_shopping_list(self, shopping_list_id: str):
"""Get a shopping list by ID. Returns a ShoppingList model."""
return await self._client.account.get_shopping_list(shopping_list_id)

async def search_and_add(self, product_name: str, quantity: int, favourite: bool = False) -> Dict | None:
""" Searches for product by name and adds to cart"""
async def get_cart_content(self):
"""Retrieve cart content. Returns a Cart model."""
return await self._client.cart.get_content()

searched_product = await self.search_product(product_name, limit = 5, favourite=favourite)
async def search_and_add(self, product_name: str, quantity: int, favourite: bool = False) -> dict | None:
"""Search for a product by name and add the top match to the cart."""
results = await self.search_product(product_name, limit=5, favourite=favourite)

if searched_product:
await self.add_to_cart(searched_product["search_results"][0]["id"], quantity)
return {"success": True, "message": "", "added_to_cart": [searched_product["search_results"][0]]}
if results and results.results:
first = results.results[0]
await self.add_to_cart(first.id, quantity)
return {"success": True, "message": "", "added_to_cart": [asdict(first)]}

else:
return {"success": False, "message": f'No product matched when searching for "{product_name}"{' in favourites' if favourite else ''}.', "added_to_cart": []}
in_fav = " in favourites" if favourite else ""
return {
"success": False,
"message": f'No product matched when searching for "{product_name}"{in_fav}.',
"added_to_cart": [],
}

async def delete_from_cart(self, order_field_id: str) -> Dict:
async def delete_from_cart(self, order_field_id: str) -> None:
"""Delete a product from the shopping cart using orderFieldId."""
result = await self._rohlik_api.delete_from_cart(order_field_id)
await self.async_update() # Refresh data after deletion
return result
await self._client.cart.delete_item(order_field_id)
await self.async_update() # Refresh data after deletion
2 changes: 1 addition & 1 deletion custom_components/rohlikcz/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"documentation": "https://github.com/dvejsada/HA-RohlikCZ",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/dvejsada/HA-RohlikCZ/issues",
"requirements": [],
"requirements": ["rohlik-api==0.2.0"],
"version": "0.5.0"
}
Loading
Loading