Add restore, and separate the produce box from add-ons
Restore is POST /s/submit/restore-delivery, wired to switch.turn_off. Its popup only exists once an order is actually skipped, which is why it could not be found earlier; verified end to end by skipping Aug 18 and restoring it. Fixes two bugs found while testing that. async_fetch treated popup and AJAX fragments as full pages, so the signed-in heuristic read them as logged out and skip could never run. And the to-do list mixed box produce with add-ons, which would have invited deletes with no endpoint behind them: only add-ons are add/removable, so the entity is now scoped to those.
This commit is contained in:
+21
-2
@@ -35,10 +35,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
heading row, so an account with a live subscription reported zero. Cells are
|
||||
now picked by semantic class. Regression covered in `tests/test_actions.py`.
|
||||
|
||||
- Restore (un-skip) via `POST /s/submit/restore-delivery`, wired to
|
||||
`switch.turn_off`. The restore popup only exists once an order is actually
|
||||
skipped, which is why it could not be found until one was. Verified end to
|
||||
end against a live order: skipped Aug 18, restored it, confirmed the account
|
||||
returned to its previous state.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Subscription parsing matched `.account-item-multi-fields`, which is the
|
||||
heading row, so an account with a live subscription reported zero. Cells are
|
||||
now picked by semantic class. Regression covered in `tests/test_actions.py`.
|
||||
- `async_fetch` treated popup bodies and AJAX replies as full pages. Those are
|
||||
fragments with no navigation, so the signed-in heuristic read every one as
|
||||
logged out, re-authenticated pointlessly and then failed. They now pass
|
||||
`is_page=False`. This blocked skip entirely.
|
||||
- The to-do list mixed produce-box contents with add-ons. Only add-ons can be
|
||||
added and removed — the box is chosen, not assembled — so listing produce
|
||||
invited deletes with no endpoint behind them. The entity is now
|
||||
`todo.fresh_harvest_add_ons`; box contents stay read-only on
|
||||
`sensor.*_next_delivery_items`.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- Un-skip raises rather than guessing: the portal's restore control only
|
||||
appears once an order is skipped, so its endpoint has never been observed.
|
||||
- Entities are deliberately NOT exposed to the conversation agent yet.
|
||||
|
||||
### Notes
|
||||
|
||||
@@ -39,6 +39,7 @@ DASHBOARD_PAUSE = "/p/dashboard/pause-deliveries"
|
||||
SHOP_ITEM = "/p/shop/item/{item_id}/x"
|
||||
|
||||
SUBMIT_SKIP = "/s/submit/pause-delivery"
|
||||
SUBMIT_RESTORE = "/s/submit/restore-delivery"
|
||||
SUBMIT_DONATE = "/s/submit/donate-basket"
|
||||
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
||||
SUBMIT_HOLD = "/s/submit/pause-range-add"
|
||||
@@ -210,7 +211,9 @@ class FreshHarvestActions:
|
||||
raise FreshHarvestActionError("no skippable delivery on this account")
|
||||
|
||||
for token in dict.fromkeys(tokens):
|
||||
popup = await self._client.async_fetch(f"/x/popup/pause-delivery/{token}")
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/pause-delivery/{token}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_SKIP)
|
||||
if form is None:
|
||||
# Several tokens on the page are for other popups and fall
|
||||
@@ -254,6 +257,43 @@ class FreshHarvestActions:
|
||||
"cutoff and locked for packing"
|
||||
)
|
||||
|
||||
async def async_restore(
|
||||
self, delivery_date: date, dry_run: bool = True
|
||||
) -> ActionResult:
|
||||
"""Un-skip a delivery.
|
||||
|
||||
The restore popup only exists once an order is actually skipped — it is
|
||||
not on the page beforehand — so this is the exact inverse of a skip and
|
||||
raises when there is nothing to restore.
|
||||
"""
|
||||
page = await self._client.async_fetch(DASHBOARD_ORDERS)
|
||||
tokens = re.findall(r'openPopup\("restore-delivery","([^"]+)"', page)
|
||||
if not tokens:
|
||||
raise FreshHarvestActionError("no skipped delivery to restore")
|
||||
|
||||
for token in dict.fromkeys(tokens):
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/restore-delivery/{token}", is_page=False
|
||||
)
|
||||
soup = BeautifulSoup(popup, "html.parser")
|
||||
form = _find_form(soup, SUBMIT_RESTORE)
|
||||
if form is None:
|
||||
continue
|
||||
stated = _confirmation_date(soup.get_text(" ", strip=True))
|
||||
if stated is not None and stated != delivery_date:
|
||||
continue
|
||||
payload = _hidden_fields(form) | {"Continue": "Confirm"}
|
||||
result = ActionResult(
|
||||
action="restore", ok=True, target=delivery_date.isoformat(),
|
||||
submitted=payload, dry_run=dry_run,
|
||||
detail=f"restore {delivery_date}",
|
||||
)
|
||||
if not dry_run:
|
||||
await self._post(SUBMIT_RESTORE, payload)
|
||||
return result
|
||||
|
||||
raise FreshHarvestActionError(f"no restore token matched {delivery_date}")
|
||||
|
||||
# ---------------------------------------------------------------- donate
|
||||
|
||||
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
||||
@@ -262,7 +302,9 @@ class FreshHarvestActions:
|
||||
m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page)
|
||||
if not m:
|
||||
raise FreshHarvestActionError("no donatable delivery")
|
||||
popup = await self._client.async_fetch(f"/x/popup/donate-delivery/{m.group(1)}")
|
||||
popup = await self._client.async_fetch(
|
||||
f"/x/popup/donate-delivery/{m.group(1)}", is_page=False
|
||||
)
|
||||
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_DONATE)
|
||||
if form is None:
|
||||
raise FreshHarvestActionError("donate form not found")
|
||||
@@ -317,9 +359,8 @@ class FreshHarvestActions:
|
||||
detail=f"{mode} item {item_id}",
|
||||
)
|
||||
if not dry_run:
|
||||
# A fragment, not a page: no Sign Out control to detect, so bypass
|
||||
# the signed-in check. The item fetch above already renewed the session.
|
||||
await self._client._get(url) # noqa: SLF001 — same package
|
||||
# A fragment, not a page — see async_fetch(is_page=...).
|
||||
await self._client.async_fetch(url, is_page=False)
|
||||
return result
|
||||
|
||||
# --------------------------------------------------------- subscriptions
|
||||
|
||||
@@ -366,18 +366,23 @@ class FreshHarvestClient:
|
||||
"""
|
||||
return "sign out" in body.lower()
|
||||
|
||||
async def async_fetch(self, path: str) -> str:
|
||||
"""Fetch any portal page, re-authenticating once if the session lapsed.
|
||||
async def async_fetch(self, path: str, *, is_page: bool = True) -> str:
|
||||
"""Fetch from the portal, re-authenticating once if the session lapsed.
|
||||
|
||||
The shared primitive for reads and for the token-scraping that every
|
||||
write action in `actions.py` has to do first.
|
||||
|
||||
Set ``is_page=False`` for popup bodies and AJAX replies. Those are HTML
|
||||
*fragments* with no navigation, so they never contain a Sign Out control
|
||||
and the signed-in heuristic would read every one of them as logged out —
|
||||
re-authenticating pointlessly and then failing.
|
||||
"""
|
||||
if not self._authenticated:
|
||||
await self.async_login()
|
||||
|
||||
try:
|
||||
body = await self._get(path)
|
||||
if not self._signed_in(body):
|
||||
if is_page and not self._signed_in(body):
|
||||
self._authenticated = False
|
||||
await self.async_login()
|
||||
body = await self._get(path)
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"box": {
|
||||
"name": "Box"
|
||||
"add_ons": {
|
||||
"name": "Add-ons"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,17 +76,19 @@ class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Un-skip — deliberately not implemented.
|
||||
|
||||
The portal's restore control only appears once an order is already
|
||||
skipped, so its endpoint has never been observed. Guessing at the way
|
||||
back from a skip is exactly the kind of thing that should fail loudly
|
||||
rather than post something plausible at a real order.
|
||||
"""
|
||||
raise HomeAssistantError(
|
||||
"un-skipping is not supported yet: the restore endpoint has not "
|
||||
"been confirmed. Restore it on freshharvest.com for now."
|
||||
)
|
||||
"""Un-skip the delivery."""
|
||||
order = self.coordinator.data.open_order
|
||||
if order is None or order.delivery_date is None:
|
||||
raise HomeAssistantError("no delivery to restore")
|
||||
try:
|
||||
result = await self.coordinator.actions.async_restore(
|
||||
order.delivery_date, dry_run=False
|
||||
)
|
||||
except FreshHarvestError as err:
|
||||
self._fire("restore", False, str(order.delivery_date), str(err))
|
||||
raise HomeAssistantError(f"could not restore: {err}") from err
|
||||
self._fire("restore", True, str(order.delivery_date), result.detail)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def _fire(self, action: str, ok: bool, target: str, detail: str) -> None:
|
||||
self.hass.bus.async_fire(
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
"""The upcoming box as a to-do list.
|
||||
"""The order's ADD-ONS as a to-do list.
|
||||
|
||||
This exists so Home Assistant's own conversation agent can manage the order with
|
||||
Two different things arrive in a delivery and only one of them is a list you
|
||||
can edit:
|
||||
|
||||
* The **produce box** — the Georgia Grown Small Box and its contents. Fresh
|
||||
Harvest fills it; you pick the box, not the carrots in it. Swapping it is
|
||||
"Change Basket" on the portal, and its contents are read-only here, exposed
|
||||
as the `produce` attribute of `sensor.*_next_delivery_items`.
|
||||
* The **add-ons** — everything you chose individually. These are genuinely
|
||||
addable and removable, one endpoint each way.
|
||||
|
||||
This entity is the add-ons, because those are the ones where "add X" and
|
||||
"remove X" mean something. Listing box produce here would invite a delete that
|
||||
has no endpoint behind it, and the failure would surface as a confusing error
|
||||
rather than "that is not a thing you can do".
|
||||
|
||||
It exists so Home Assistant's own conversation agent can manage the order with
|
||||
no bespoke voice work: the built-in assistant already knows how to add and
|
||||
remove to-do items, so "add bananas to my Fresh Harvest box" routes through
|
||||
remove to-do items, so "add bananas to my Fresh Harvest add-ons" routes through
|
||||
`HassListAddItem` and lands here.
|
||||
|
||||
Adding an item resolves the spoken name against the site's own search index,
|
||||
then adds the match to the open order. Removing one takes it back out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -51,7 +63,7 @@ async def async_setup_entry(
|
||||
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
"""Everything in the upcoming delivery, as a list."""
|
||||
|
||||
_attr_translation_key = "box"
|
||||
_attr_translation_key = "add_ons"
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
@@ -60,18 +72,23 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
def __init__(
|
||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||
) -> None:
|
||||
super().__init__(coordinator, entry, "box")
|
||||
super().__init__(coordinator, entry, "add_ons")
|
||||
self._entry = entry
|
||||
|
||||
@property
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""The open order's contents, or the next delivery's once it locks."""
|
||||
"""The open order's add-ons — the part of a delivery you control.
|
||||
|
||||
Scoped to the *open* order rather than the next arriving one, because a
|
||||
delete has to act on the order actually being shown; the next delivery
|
||||
is usually already locked for packing.
|
||||
"""
|
||||
snapshot = self.coordinator.data
|
||||
order = snapshot.open_order or snapshot.next_order
|
||||
order = snapshot.open_order
|
||||
if order is None:
|
||||
return None
|
||||
items: list[TodoItem] = []
|
||||
for item in order.all_items:
|
||||
for item in order.addons:
|
||||
label = " ".join(
|
||||
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
||||
)
|
||||
@@ -81,8 +98,8 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||
TodoItem(
|
||||
summary=label,
|
||||
uid=item.name,
|
||||
# The box is a standing order, not a checklist: nothing here
|
||||
# is ever "done", it either is or is not in the delivery.
|
||||
# Not a checklist: nothing here is ever "done", an add-on
|
||||
# either is or is not in the delivery.
|
||||
status=TodoItemStatus.NEEDS_ACTION,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"box": {
|
||||
"name": "Box"
|
||||
"add_ons": {
|
||||
"name": "Add-ons"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user