diet-guard/diet_guard/_kuchnia_orders.py
Krzysztof kuhy Rudnicki 26f1770090 feat: import Kuchnia Wikinga catering deliveries into the food bank
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.

A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.

Three surfaces:
  - `diet-guard kuchnia [--log] [--yes]`
  - the lock screen's "Today's delivery" button, which prefills the meal form
    dish by dish (the user still clicks Log & Continue)
  - a warm-up after each logged meal, on the existing background thread

All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.

The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.

Load-bearing details:

* Macros are per portion and `weight` is that portion in grams -- proven, not
  assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
  the captured day. `_energy_is_consistent` re-runs that check per dish and
  drops any that fails; a per-100g mix-up is invisible in a payload and would
  silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
  which restamps `t` unconditionally. Without the compare, every refresh
  republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
  `log_meal` mints a fresh uuid, so a re-import after marker loss would merge
  as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
  `gate_is_due()` never reads the food bank, so a refresh there could not
  change the lock decision while adding a third-party round trip to the ~105ms
  `gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
  mode 600, outside the synced tree. The credentials file is written by hand,
  never by the package -- the same contract as sync_token.

`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.

Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.

989 tests, 100% branch coverage, pre-commit clean.
2026-08-22 23:04:22 +02:00

96 lines
3.4 KiB
Python

"""Walk the panel's endpoints to today's delivered dishes.
Three calls, and the shape of the walk is not what the endpoint names suggest:
1. ``company/customer/order/active-ids`` -> ``[orderId]``.
2. ``company/customer/order/{orderId}`` -> the order, which **embeds every
delivery** with its date. No enumeration call is needed, but the embedded
``deliveryMeals`` carry ids only -- no dish names, no macros.
3. ``company/general/menus/delivery/{deliveryId}/new`` -> the day's actual
menu, with names and per-portion nutrition.
Step 3 is the important correction: the obvious-looking
``.../deliveries/{id}/details`` **404s** (and 400s when keyed by date), which
was confirmed against three separate delivery days. The menu endpoint is keyed
by the opaque ``deliveryId``, never by the date.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._kuchnia_errors import KuchniaError
from diet_guard._kuchnia_parse import parse_menu
if TYPE_CHECKING: # pragma: no cover - typing only
from datetime import date
from diet_guard._kuchnia_client import PanelSession
from diet_guard._kuchnia_parse import Dish
def _active_order_id(session: PanelSession) -> object | None:
"""Return the first active order id, or None when there is no active order."""
payload = session.get_json("company/customer/order/active-ids")
if not isinstance(payload, list) or not payload:
return None
return payload[0]
def _delivery_id_for(payload: object, wanted: str) -> object | None:
"""Return the delivery id whose date is ``wanted``.
Args:
payload: The decoded order.
wanted: An ISO ``YYYY-MM-DD`` date.
Returns:
The delivery id, or None when that day has no (undeleted) delivery.
"""
if not isinstance(payload, dict):
return None
deliveries = payload.get("deliveries")
if not isinstance(deliveries, list):
return None
for delivery in deliveries:
if not isinstance(delivery, dict):
continue
# ``deleted`` marks a cancelled day; it still appears in the list.
if delivery.get("deleted"):
continue
if str(delivery.get("date")) == wanted:
return delivery.get("deliveryId")
return None
def fetch_dishes(session: PanelSession, day: date) -> list[Dish]:
"""Return the dishes delivered on ``day``.
Args:
session: An authenticated session.
day: The delivery date to fetch.
Returns:
The day's dishes, empty when nothing is delivered that day.
Raises:
KuchniaError: When the panel cannot be read.
"""
order_id = _active_order_id(session)
if order_id is None:
return []
order = session.get_json(f"company/customer/order/{order_id}")
delivery_id = _delivery_id_for(order, day.isoformat())
if delivery_id is None:
return []
menu = session.get_json(f"company/general/menus/delivery/{delivery_id}/new")
dishes = parse_menu(menu)
if not dishes:
# A delivery exists but nothing survived parsing: either the menu is
# not published yet, or its units failed the consistency check. Both
# are "no data", not a crash -- but they are worth distinguishing from
# "no delivery" in the caller's message.
msg = f"catering menu for {day.isoformat()} has no usable dishes"
raise KuchniaError(msg)
return dishes