mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
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.
137 lines
4.9 KiB
Python
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
|