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
9 changes: 8 additions & 1 deletion custom_components/rohlikcz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util

from .const import (
DOMAIN, CONF_ANALYTICS, DEFAULT_ANALYTICS,
CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED,
SERVICE_ADD_TO_CART,
SERVICE_ADD_TO_CART, CONF_SITE, get_site,
)
from .hub import RohlikAccount
from .services import register_services
Expand Down Expand Up @@ -41,6 +42,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RohlikConfigEntry) -> bo
analytics = entry.options.get(CONF_ANALYTICS, DEFAULT_ANALYTICS)
top_n = int(entry.options.get(CONF_TOP_N, DEFAULT_TOP_N))
hide_discontinued = entry.options.get(CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED)
site = entry.data.get(CONF_SITE)

# Load the shop's timezone off the event loop (reading tzdata is file I/O);
# the coordinator then picks it up from the cache.
await dt_util.async_get_time_zone(get_site(site).timezone)

rohlik_hub = RohlikAccount(
hass,
Expand All @@ -50,6 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RohlikConfigEntry) -> bo
top_n=top_n,
hide_discontinued=hide_discontinued,
entry=entry,
site=site,
)

# Performs the first refresh; raises ConfigEntryNotReady on connection
Expand Down
4 changes: 2 additions & 2 deletions custom_components/rohlikcz/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def _update_events(self) -> None:
if order.get("items_count") is not None:
description_parts.append(f"Items: {order['items_count']}")
if order.get("price") is not None:
description_parts.append(f"Price: {order['price']} CZK")
description_parts.append(f"Price: {order['price']} {self._rohlik_account.currency}")
description = "\n".join(description_parts) if description_parts else None

event = CalendarEvent(
Expand Down Expand Up @@ -196,7 +196,7 @@ def _update_events(self) -> None:
description_parts.append(f"Items: {order['itemsCount']}")
price_amount = order.get("priceComposition", {}).get("total", {}).get("amount")
if price_amount is not None:
description_parts.append(f"Price: {price_amount} CZK")
description_parts.append(f"Price: {price_amount} {self._rohlik_account.currency}")
description = "\n".join(description_parts) if description_parts else None

event = CalendarEvent(
Expand Down
68 changes: 66 additions & 2 deletions custom_components/rohlikcz/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .const import (
DOMAIN, CONF_ANALYTICS, ANALYTICS_OPTIONS, DEFAULT_ANALYTICS,
CONF_TOP_N, DEFAULT_TOP_N, CONF_HIDE_DISCONTINUED, DEFAULT_HIDE_DISCONTINUED,
CONF_SITE, DEFAULT_SITE, SITES, get_site,
)
from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError

Expand All @@ -31,7 +32,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
Returns the account title and unique user id on success.
"""
# A one-shot client that owns (and on close fully tears down) its session.
client = RohlikAPI(data[CONF_EMAIL], data[CONF_PASSWORD])
client = RohlikAPI(
data[CONF_EMAIL],
data[CONF_PASSWORD],
base_url=get_site(data.get(CONF_SITE)).base_url,
)
try:
reply = await client.login()
user = reply["data"]["user"]
Expand All @@ -40,6 +45,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
await client.close()


SITE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=list(SITES),
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_SITE,
)
)

ANALYTICS_SCHEMA = vol.Schema({
vol.Optional(CONF_ANALYTICS, default=DEFAULT_ANALYTICS): SelectSelector(
SelectSelectorConfig(
Expand Down Expand Up @@ -96,6 +109,7 @@ async def async_step_user(
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required(CONF_SITE, default=DEFAULT_SITE): SITE_SELECTOR,
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}),
Expand Down Expand Up @@ -137,7 +151,7 @@ async def async_step_reauth_confirm(

if user_input is not None:
data = {
CONF_EMAIL: reauth_entry.data[CONF_EMAIL],
**reauth_entry.data,
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
Expand All @@ -161,6 +175,56 @@ async def async_step_reauth_confirm(
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
)

async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Move an existing entry to another shop (e.g. Knuspr.de).

Entries created before site selection existed all point at Rohlík.cz;
this lets them switch without being removed and re-added.
"""
entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}

if user_input is not None:
data = {
**entry.data,
CONF_SITE: user_input[CONF_SITE],
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
try:
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"
else:
await self.async_set_unique_id(info["user_id"])
if entry.unique_id is None:
# Entries from before unique ids existed have nothing to
# compare against: adopt the id unless another entry has it.
self._abort_if_unique_id_configured()
return self.async_update_reload_and_abort(
entry, unique_id=info["user_id"], data=data
)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(entry, data=data)

return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({
vol.Required(
CONF_SITE, default=entry.data.get(CONF_SITE, DEFAULT_SITE)
): SITE_SELECTOR,
vol.Required(CONF_PASSWORD): str,
}),
errors=errors,
description_placeholders={"email": entry.data[CONF_EMAIL]},
)

@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
Expand Down
32 changes: 32 additions & 0 deletions custom_components/rohlikcz/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,44 @@
from __future__ import annotations

from aiohttp import ClientTimeout
from dataclasses import dataclass
from typing import Final


HTTP_TIMEOUT: Final = ClientTimeout(total=10)
DOMAIN = "rohlikcz"

""" Sites """


@dataclass(frozen=True)
class Site:
"""A Rohlík Group shop. All of them run the same backend API."""

base_url: str
name: str
currency: str
timezone: str


CONF_SITE = "site"
DEFAULT_SITE = "cz"
SITES: Final[dict[str, Site]] = {
"cz": Site("https://www.rohlik.cz", "Rohlík.cz", "CZK", "Europe/Prague"),
"de": Site("https://www.knuspr.de", "Knuspr.de", "EUR", "Europe/Berlin"),
"at": Site("https://www.gurkerl.at", "Gurkerl.at", "EUR", "Europe/Vienna"),
"hu": Site("https://www.kifli.hu", "Kifli.hu", "HUF", "Europe/Budapest"),
"ro": Site("https://www.sezamo.ro", "Sezamo.ro", "RON", "Europe/Bucharest"),
}


def get_site(key: str | None) -> Site:
"""Return the site for a config entry's site key.

Entries created before site selection existed have no key and are Rohlík.cz.
"""
return SITES.get(key or DEFAULT_SITE, SITES[DEFAULT_SITE])

"""Icons"""
ICON_PARENTCLUB = "mdi:human-male-female-child"
ICON_CREDIT = "mdi:cash-multiple"
Expand Down
28 changes: 22 additions & 6 deletions custom_components/rohlikcz/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from homeassistant.util import dt as dt_util
from rohlik_api import InvalidCredentialsError, RohlikAPI, RohlikAPIError

from .const import DOMAIN
from .const import DOMAIN, Site, get_site

#: How often the integration refreshes data from the Rohlik API.
UPDATE_INTERVAL = timedelta(seconds=600)
Expand Down Expand Up @@ -174,7 +174,7 @@ def process_orders(self, orders: list) -> int:

if new_count > 0:
if not self._data["tracking_since"]:
self._data["tracking_since"] = datetime.now(ZoneInfo("Europe/Prague")).isoformat()
self._data["tracking_since"] = dt_util.now().isoformat()
_LOGGER.info(f"Added {new_count} new orders to store. Total: {len(self._data['orders'])}")

return new_count
Expand Down Expand Up @@ -383,7 +383,7 @@ def item_totals(self, year: str | None = None, hide_discontinued: bool = False)
class RohlikAccount(DataUpdateCoordinator[dict]):
"""RohlikCZ account modelled as a Home Assistant data update coordinator."""

def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None, top_n: int = 10, hide_discontinued: bool = True, entry: ConfigEntry | None = None) -> None:
def __init__(self, hass: HomeAssistant, username: str, password: str, analytics: list[str] | None = None, top_n: int = 10, hide_discontinued: bool = True, entry: ConfigEntry | None = None, site: str | None = None) -> None:
"""Initialize account info."""
super().__init__(
hass,
Expand All @@ -394,12 +394,18 @@ def __init__(self, hass: HomeAssistant, username: str, password: str, analytics:
)
self._username: str = username
self._password: str = password
self._site: Site = get_site(site)
# get_time_zone is cached; async_setup_entry preloads the zone off the
# event loop, so this does no file I/O there.
self._timezone: ZoneInfo = dt_util.get_time_zone(self._site.timezone)
# 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._client = RohlikAPI(
self._username, self._password, base_url=self._site.base_url, session=self._session
)
self._order_store: OrderStore | None = None
self._last_refresh: datetime | None = None
# When each delivery announcement (keyed by order ID and text) was first
Expand Down Expand Up @@ -434,14 +440,24 @@ def hide_discontinued(self) -> bool:
"""Whether to exclude discontinued products from top N."""
return self._hide_discontinued

@property
def currency(self) -> str:
"""ISO currency code of the account's shop, e.g. ``CZK``."""
return self._site.currency

@property
def timezone(self) -> ZoneInfo:
"""Local timezone of the account's shop."""
return self._timezone

@property
def has_address(self) -> bool:
return bool((self.data or {}).get("next_delivery_slot"))

@property
def device_info(self) -> DeviceInfo:
""" Provides a device info. """
return {"identifiers": {(DOMAIN, self.data["login"]["data"]["user"]["id"])}, "name": self.data["login"]["data"]["user"]["name"], "manufacturer": "Rohlík.cz"}
return {"identifiers": {(DOMAIN, self.data["login"]["data"]["user"]["id"])}, "name": self.data["login"]["data"]["user"]["name"], "manufacturer": self._site.name}

@property
def account_name(self) -> str:
Expand Down Expand Up @@ -518,7 +534,7 @@ async def _async_update_data(self) -> dict:
except RohlikAPIError as err:
raise UpdateFailed(str(err)) from err

self._last_refresh = datetime.now(ZoneInfo("Europe/Prague"))
self._last_refresh = datetime.now(self._timezone)

# Initialize order store on first update (only if analytics enabled)
if self._analytics and not self._order_store and data.get("login"):
Expand Down
Loading
Loading