diet-guard/diet_guard/_kuchnia_import.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

137 lines
4.9 KiB
Python

"""Fetch the day's catering dishes and bank them.
Holds the module's single fail-closed boundary, :func:`refresh_delivery`: every
:class:`~diet_guard._kuchnia_errors.KuchniaError` is turned into a reason string
here, so no caller can be broken by a catering outage. That is the same idiom
as ``pull_peer_logs`` and ``publish_after_log``.
Imports go **only** to the curated bank
(:mod:`diet_guard._foodbank_manual`). The derived bank is rewritten from the
log on every meal, so anything written there would vanish at the next entry.
The idempotency guard is load-bearing and easy to get wrong.
``add_manual_entry`` restamps ``t`` unconditionally and rewrites the whole
file, and the sync merge derives each record's HLC from that ``t`` -- so
calling it for unchanged dishes would republish the *entire* curated bank to
every peer on every refresh. :func:`bank_dishes` therefore compares the
nutritional fields first and writes only what actually differs.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from diet_guard._foodbank_manual import add_manual_entry, read_manual_bank
from diet_guard._kuchnia_client import PanelSession
from diet_guard._kuchnia_config import last_import_day, record_import_day
from diet_guard._kuchnia_errors import KuchniaError
from diet_guard._kuchnia_orders import fetch_dishes
if TYPE_CHECKING: # pragma: no cover - typing only
from collections.abc import Sequence
from datetime import date
from diet_guard._kuchnia_parse import Dish
#: Fields compared to decide whether a bank record changed. ``t`` is excluded
#: deliberately -- it is the edit stamp, not part of the record's meaning, and
#: including it would make every comparison differ.
_COMPARED = ("desc", "kcal", "protein_g", "carbs_g", "fat_g", "grams")
def dish_to_record(dish: Dish) -> dict[str, object]:
"""Return the curated-bank record for ``dish``.
``count`` is 0: the bank's count ranks foods by how often they were
*eaten*, and a delivered dish has not been eaten yet. Logging it later
bumps the derived bank's own count normally.
"""
return {
"desc": dish.name,
"kcal": dish.kcal,
"protein_g": dish.protein_g,
"carbs_g": dish.carbs_g,
"fat_g": dish.fat_g,
"grams": dish.grams,
"count": 0,
}
def _matches(existing: dict[str, object], record: dict[str, object]) -> bool:
"""Return True when the banked record already says the same thing."""
return all(existing.get(key) == record.get(key) for key in _COMPARED)
def bank_dishes(dishes: Sequence[Dish]) -> int:
"""Add dishes to the curated bank, skipping ones already banked unchanged.
Args:
dishes: The dishes to bank.
Returns:
How many records were actually written.
"""
bank = read_manual_bank()
written = 0
for dish in dishes:
record = dish_to_record(dish)
existing = bank.get(dish.name.strip().casefold())
if isinstance(existing, dict) and _matches(existing, record):
continue
add_manual_entry(dish.name, record)
written += 1
return written
def refresh_delivery(day: date) -> tuple[list[Dish], str | None]:
"""Fetch ``day``'s dishes and bank them, never raising.
Args:
day: The delivery date to fetch.
Returns:
``(dishes, None)`` on success, or ``([], reason)`` when the catering
panel could not be read. Callers surface the reason and carry on.
"""
try:
session = PanelSession()
session.authenticate()
dishes = fetch_dishes(session, day)
except KuchniaError as exc:
return [], str(exc)
bank_dishes(dishes)
return dishes, None
def refresh_delivery_once(day: date) -> tuple[list[Dish], str | None]:
"""Fetch ``day``'s dishes unless today's have already been fetched.
The automatic triggers -- the gate opening, and the after-log publish --
can both fire many times a day, and each :func:`refresh_delivery` is a full
login plus a three-request walk against a third party. This wrapper makes
them cheap: the first call of the day pays, the rest are free.
The marker is a date on disk rather than a look in the curated bank. The
bank is keyed by dish name, so it can answer "is this dish known?" but
never "has today's delivery been fetched?" -- and knowing today's names
requires the very fetch being avoided.
The CLI deliberately does **not** use this: an explicit ``diet-guard
kuchnia`` should always go and look.
Args:
day: The delivery date to fetch.
Returns:
``([], None)`` when today was already fetched -- "nothing new", not an
error. Otherwise whatever :func:`refresh_delivery` returns.
"""
stamp = day.isoformat()
if last_import_day() == stamp:
return [], None
dishes, reason = refresh_delivery(day)
if reason is None:
# Only a clean fetch counts: an outage must not suppress the retry.
record_import_day(stamp)
return dishes, reason