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
|
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`.
|
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
|
### 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.
|
- Entities are deliberately NOT exposed to the conversation agent yet.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ DASHBOARD_PAUSE = "/p/dashboard/pause-deliveries"
|
|||||||
SHOP_ITEM = "/p/shop/item/{item_id}/x"
|
SHOP_ITEM = "/p/shop/item/{item_id}/x"
|
||||||
|
|
||||||
SUBMIT_SKIP = "/s/submit/pause-delivery"
|
SUBMIT_SKIP = "/s/submit/pause-delivery"
|
||||||
|
SUBMIT_RESTORE = "/s/submit/restore-delivery"
|
||||||
SUBMIT_DONATE = "/s/submit/donate-basket"
|
SUBMIT_DONATE = "/s/submit/donate-basket"
|
||||||
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
SUBMIT_SUBSCRIBE = "/s/submit/item-frequency"
|
||||||
SUBMIT_HOLD = "/s/submit/pause-range-add"
|
SUBMIT_HOLD = "/s/submit/pause-range-add"
|
||||||
@@ -210,7 +211,9 @@ class FreshHarvestActions:
|
|||||||
raise FreshHarvestActionError("no skippable delivery on this account")
|
raise FreshHarvestActionError("no skippable delivery on this account")
|
||||||
|
|
||||||
for token in dict.fromkeys(tokens):
|
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)
|
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_SKIP)
|
||||||
if form is None:
|
if form is None:
|
||||||
# Several tokens on the page are for other popups and fall
|
# Several tokens on the page are for other popups and fall
|
||||||
@@ -254,6 +257,43 @@ class FreshHarvestActions:
|
|||||||
"cutoff and locked for packing"
|
"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
|
# ---------------------------------------------------------------- donate
|
||||||
|
|
||||||
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
async def async_donate(self, dry_run: bool = True) -> ActionResult:
|
||||||
@@ -262,7 +302,9 @@ class FreshHarvestActions:
|
|||||||
m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page)
|
m = re.search(r'openPopup\("donate-delivery","([^"]+)"', page)
|
||||||
if not m:
|
if not m:
|
||||||
raise FreshHarvestActionError("no donatable delivery")
|
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)
|
form = _find_form(BeautifulSoup(popup, "html.parser"), SUBMIT_DONATE)
|
||||||
if form is None:
|
if form is None:
|
||||||
raise FreshHarvestActionError("donate form not found")
|
raise FreshHarvestActionError("donate form not found")
|
||||||
@@ -317,9 +359,8 @@ class FreshHarvestActions:
|
|||||||
detail=f"{mode} item {item_id}",
|
detail=f"{mode} item {item_id}",
|
||||||
)
|
)
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
# A fragment, not a page: no Sign Out control to detect, so bypass
|
# A fragment, not a page — see async_fetch(is_page=...).
|
||||||
# the signed-in check. The item fetch above already renewed the session.
|
await self._client.async_fetch(url, is_page=False)
|
||||||
await self._client._get(url) # noqa: SLF001 — same package
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# --------------------------------------------------------- subscriptions
|
# --------------------------------------------------------- subscriptions
|
||||||
|
|||||||
@@ -366,18 +366,23 @@ class FreshHarvestClient:
|
|||||||
"""
|
"""
|
||||||
return "sign out" in body.lower()
|
return "sign out" in body.lower()
|
||||||
|
|
||||||
async def async_fetch(self, path: str) -> str:
|
async def async_fetch(self, path: str, *, is_page: bool = True) -> str:
|
||||||
"""Fetch any portal page, re-authenticating once if the session lapsed.
|
"""Fetch from the portal, re-authenticating once if the session lapsed.
|
||||||
|
|
||||||
The shared primitive for reads and for the token-scraping that every
|
The shared primitive for reads and for the token-scraping that every
|
||||||
write action in `actions.py` has to do first.
|
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:
|
if not self._authenticated:
|
||||||
await self.async_login()
|
await self.async_login()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await self._get(path)
|
body = await self._get(path)
|
||||||
if not self._signed_in(body):
|
if is_page and not self._signed_in(body):
|
||||||
self._authenticated = False
|
self._authenticated = False
|
||||||
await self.async_login()
|
await self.async_login()
|
||||||
body = await self._get(path)
|
body = await self._get(path)
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"box": {
|
"add_ons": {
|
||||||
"name": "Box"
|
"name": "Add-ons"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,17 +76,19 @@ class FreshHarvestSkip(FreshHarvestEntity, SwitchEntity):
|
|||||||
await self.coordinator.async_request_refresh()
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Un-skip — deliberately not implemented.
|
"""Un-skip the delivery."""
|
||||||
|
order = self.coordinator.data.open_order
|
||||||
The portal's restore control only appears once an order is already
|
if order is None or order.delivery_date is None:
|
||||||
skipped, so its endpoint has never been observed. Guessing at the way
|
raise HomeAssistantError("no delivery to restore")
|
||||||
back from a skip is exactly the kind of thing that should fail loudly
|
try:
|
||||||
rather than post something plausible at a real order.
|
result = await self.coordinator.actions.async_restore(
|
||||||
"""
|
order.delivery_date, dry_run=False
|
||||||
raise HomeAssistantError(
|
)
|
||||||
"un-skipping is not supported yet: the restore endpoint has not "
|
except FreshHarvestError as err:
|
||||||
"been confirmed. Restore it on freshharvest.com for now."
|
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:
|
def _fire(self, action: str, ok: bool, target: str, detail: str) -> None:
|
||||||
self.hass.bus.async_fire(
|
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
|
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.
|
`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
|
from __future__ import annotations
|
||||||
@@ -51,7 +63,7 @@ async def async_setup_entry(
|
|||||||
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
||||||
"""Everything in the upcoming delivery, as a list."""
|
"""Everything in the upcoming delivery, as a list."""
|
||||||
|
|
||||||
_attr_translation_key = "box"
|
_attr_translation_key = "add_ons"
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||||
@@ -60,18 +72,23 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
self, coordinator: FreshHarvestCoordinator, entry: FreshHarvestConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(coordinator, entry, "box")
|
super().__init__(coordinator, entry, "add_ons")
|
||||||
self._entry = entry
|
self._entry = entry
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def todo_items(self) -> list[TodoItem] | None:
|
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
|
snapshot = self.coordinator.data
|
||||||
order = snapshot.open_order or snapshot.next_order
|
order = snapshot.open_order
|
||||||
if order is None:
|
if order is None:
|
||||||
return None
|
return None
|
||||||
items: list[TodoItem] = []
|
items: list[TodoItem] = []
|
||||||
for item in order.all_items:
|
for item in order.addons:
|
||||||
label = " ".join(
|
label = " ".join(
|
||||||
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
p for p in (str(item.quantity or ""), item.name, item.unit) if p
|
||||||
)
|
)
|
||||||
@@ -81,8 +98,8 @@ class FreshHarvestBox(FreshHarvestEntity, TodoListEntity):
|
|||||||
TodoItem(
|
TodoItem(
|
||||||
summary=label,
|
summary=label,
|
||||||
uid=item.name,
|
uid=item.name,
|
||||||
# The box is a standing order, not a checklist: nothing here
|
# Not a checklist: nothing here is ever "done", an add-on
|
||||||
# is ever "done", it either is or is not in the delivery.
|
# either is or is not in the delivery.
|
||||||
status=TodoItemStatus.NEEDS_ACTION,
|
status=TodoItemStatus.NEEDS_ACTION,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,8 +81,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"box": {
|
"add_ons": {
|
||||||
"name": "Box"
|
"name": "Add-ons"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user