Research of current state, requirements for the web-first pivot (pantry polish -> auth -> MCP), and the phased implementation plan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
264 lines
14 KiB
Markdown
264 lines
14 KiB
Markdown
# Research: How the Food app works today
|
|
|
|
> Snapshot taken 2026-06-22, reading the code on `master` (HEAD `96d9c4b`).
|
|
> Purpose: establish ground truth before making changes. Where the shipped
|
|
> docs (`Meal Planning System.md`) disagree with the code, the **code** is
|
|
> treated as authoritative and the discrepancy is called out.
|
|
|
|
---
|
|
|
|
## 1. What this is
|
|
|
|
A single-user Django app for Tom: pantry inventory + "meta-recipes"
|
|
(cooking templates with swappable ingredient slots) + a smart shopping list +
|
|
a cook log. Two front doors:
|
|
|
|
- **REST API** (`/api/`) — token auth, built for Caine (the Matrix assistant).
|
|
- **HTMX web app** (`/app/`) — server-rendered HTML fragments, no auth.
|
|
|
|
Plus Django admin (`/admin/`) as the full-CRUD fallback.
|
|
|
|
There is **one Django app**, `kitchen`, holding everything. ~1000 lines of
|
|
Python total. No Celery, no JS build, no separate frontend — it's deliberately
|
|
small.
|
|
|
|
## 2. Layout
|
|
|
|
```
|
|
food_project/ Django project config
|
|
settings.py single settings file, DEBUG=True (see §8)
|
|
urls.py routes /admin/, /api/, /api-auth/, /app/
|
|
kitchen/
|
|
models.py all 11 models
|
|
admin.py admin registrations + inlines
|
|
serializers.py DRF serializers (read + nested write)
|
|
views.py DRF viewsets + the "smart" API endpoints (JSON)
|
|
urls.py /api/ router + custom endpoints
|
|
views_htmx.py HTML-fragment views for the web app
|
|
urls_htmx.py /app/ routes
|
|
templates/kitchen/ base.html + 4 pages + 5 partials
|
|
static/kitchen/ htmx.min.js (vendored)
|
|
management/commands/seed.py initial data loader
|
|
migrations/ 0001_initial, 0002_add_rating_and_preferences
|
|
deploy/food.service systemd unit (gunicorn, runs as user `openclaw`)
|
|
db.sqlite3.backup-… committed snapshot = seed state only (see §9)
|
|
cookbooks/recipes_extracted.json 75 scraped Roasting Tin recipes (data only)
|
|
```
|
|
|
|
Note the README/design-doc say the project lives at `/var/lib/food/` in
|
|
production. This working copy is `/home/tom/srcs/food`. The DB
|
|
(`db.sqlite3`), `venv/`, and `staticfiles/` are gitignored.
|
|
|
|
## 3. Tech stack (from requirements.txt)
|
|
|
|
- Django 5.2.12 (LTS), SQLite, Django REST Framework 3.17.1
|
|
- HTMX 2.x (vendored JS), templates rendered server-side
|
|
- whitenoise (static files), gunicorn (prod)
|
|
- recipe_scrapers 15.11 + requests/lxml/extruct for URL recipe import
|
|
|
|
## 4. Data model (`models.py`)
|
|
|
|
The system models two kinds of meal: **meta-recipes** (templates) and
|
|
**fixed recipes** (traditional ingredient lists). Everything else supports
|
|
those plus inventory.
|
|
|
|
| Model | Role | Key fields / rules |
|
|
|---|---|---|
|
|
| `Tag` | ingredient category | just a unique name (protein, carb, veg, …) |
|
|
| `Ingredient` | master ingredient | `name` (unique, stored lowercased), `default_unit`, `tags` (M2M), `shelf_life_days`, `aliases` (JSON list), `preferences` (free text — how Tom likes it cooked) |
|
|
| `PantryItem` | current stock | FK ingredient, `quantity` (Decimal), `unit`, `location` ∈ {fridge,freezer,cupboard}, `stored_date` (auto), `expiry_date`, `is_staple`, `notes` |
|
|
| `MetaRecipe` | template | name, method (markdown text), times, `default_servings`, `gear_needed`, `tags` (JSON) |
|
|
| `Slot` | swappable category in a template | FK meta_recipe, name, `required`, `max_choices` |
|
|
| `SlotOption` | an ingredient that can fill a slot | FK slot+ingredient, `quantity_per_serving`, `unit`, `notes` |
|
|
| `MetaRecipeBase` | always-needed ingredients | FK meta_recipe+ingredient, `quantity_per_serving`, `unit` (onion/garlic/oil etc.) |
|
|
| `Recipe` | fixed recipe | name, method, times, `servings`, `source_url`, `source_book`, `tags` |
|
|
| `RecipeIngredient` | line in a fixed recipe | FK recipe+ingredient, `quantity`, `unit`, `optional` |
|
|
| `CookLog` | what was cooked | date (auto), FK to **either** meta_recipe **or** recipe, `slot_choices` (JSON), `servings`, `rating` (1-5), `notes` |
|
|
| `ShoppingListItem` | shopping line | optional FK ingredient + fallback `name`, qty/unit, `reason`, `checked` |
|
|
|
|
### Model-level business rules (enforced in `clean()`/`save()`)
|
|
|
|
- **`PantryItem.save()` calls `full_clean()`** on every save, so the rules
|
|
below fire on API writes, admin writes, and HTMX writes alike.
|
|
- Freezer item **must not** have an expiry date → raises ValidationError.
|
|
- Fridge item with no expiry auto-fills from `ingredient.shelf_life_days`
|
|
(if set); otherwise left null (no error).
|
|
- **`CookLog.clean()`**: must link to exactly one of meta_recipe / recipe
|
|
(not zero, not both). *But note `CookLog` does not override `save()`, so
|
|
`clean()` only runs when something calls `full_clean()` — DRF does, the
|
|
`log_cook` endpoint does **not** (it uses `objects.create`). So the
|
|
"exactly one" rule is not actually enforced on the main cook-logging path.*
|
|
|
|
## 5. The API (`views.py`, `/api/`)
|
|
|
|
Auth: `TokenAuthentication` + `SessionAuthentication`, **all endpoints
|
|
`IsAuthenticated`**. Caine uses a token; browser/admin uses session.
|
|
|
|
**Plain CRUD viewsets** (DefaultRouter): tags, ingredients, pantry,
|
|
meta-recipes, slots, slot-options, meta-recipe-bases, recipes,
|
|
recipe-ingredients, cook-log, shopping-list. `filterset_fields` are declared
|
|
on a couple of viewsets but **django-filter is not installed** (not in
|
|
requirements) and not in `INSTALLED_APPS`, so those filters are effectively
|
|
inert — query params like `?location=fridge` are silently ignored. Same for
|
|
`search_fields` without a `SearchFilter` backend configured.
|
|
|
|
**Custom endpoints** (the interesting logic):
|
|
|
|
- `GET /api/what-can-i-cook/?servings=N` — builds an in-memory pantry lookup
|
|
(ingredient_id → list of stock rows with expiry flags), then for every
|
|
meta-recipe checks each base ingredient and each slot, and for every fixed
|
|
recipe checks each line. Classifies as `ready` ✅ / `partial` ⚠️ /
|
|
`missing` ❌ and sorts ready-first. Staple base ingredients are assumed
|
|
always available. Expiry warnings attached per ingredient.
|
|
- `POST /api/log-cook/` — creates a `CookLog`; if `deduct: true`, subtracts
|
|
used quantities from the pantry via `_deduct_ingredient`. Accepts
|
|
meta_recipe_id **or** recipe_id, `slot_choices`, `servings`, `notes`.
|
|
**Does not accept `rating`** even though the model has the field.
|
|
- `POST /api/bulk-pantry-add/` — photo-intake path. Resolves each item by
|
|
name/alias, **auto-creates the ingredient if unknown**, computes fridge
|
|
expiry from shelf life, and merges into an existing same-location stock row
|
|
if one exists (adds quantity, refreshes expiry). Marks known staples via a
|
|
hardcoded name set.
|
|
- `GET /api/generate-shopping-list/?recipes=…&servings=…&add=true` —
|
|
suggests: (1) staples at qty 0, (2) items expiring within 2 days,
|
|
(3) per requested recipe, the first option of any required slot that can't
|
|
currently be filled + missing base ingredients. Dedupes by name, sorts by
|
|
section+priority, optionally writes `ShoppingListItem` rows.
|
|
- `POST /api/create-meta-recipe/` (POST=create, PUT=update) — builds a whole
|
|
meta-recipe (slots + options + bases) in one nested call. On PUT it
|
|
**deletes and rebuilds** all slots and bases. Auto-creates ingredients by
|
|
name, can tag them.
|
|
- `POST /api/import-recipe/` — fetches a URL, parses with `recipe_scrapers`,
|
|
optionally creates a `Recipe`. **See the gotcha in §7 — it never creates
|
|
`RecipeIngredient` rows.**
|
|
|
|
`_deduct_ingredient` ordering note: the docstring/comment says "prefer fridge,
|
|
then cupboard, then freezer", but the queryset only does
|
|
`.order_by("expiry_date")`. In SQLite NULLs sort first, and freezer items have
|
|
null expiry — so **freezer stock is actually consumed first**, the opposite of
|
|
the stated intent.
|
|
|
|
## 6. The HTMX web app (`views_htmx.py`, `/app/`)
|
|
|
|
Four pages, server-rendered, each swapping HTML partials over HTMX:
|
|
|
|
- **Pantry** (`/app/`) — add item (auto-creates ingredient, merges into
|
|
existing stock), delete, move fridge↔freezer (freezer clears expiry; fridge
|
|
sets +shelf_life or +7d), inline-edit expiry. Items grouped fridge /
|
|
freezer / cupboard with expired/expiring colour badges.
|
|
- **Recipes** (`/app/recipes/`) — "what can I cook", **meta-recipes only**
|
|
(fixed recipes are *not* shown here, unlike the API endpoint). Servings is
|
|
**hardcoded to 2**. This re-implements the matching logic from
|
|
`what_can_i_cook` in a second place (see §7).
|
|
- **Shopping** (`/app/shopping/`) — generate (staples + expiring + recipe
|
|
gaps, with a summary count), toggle checked, clear checked.
|
|
- **Cook Log** (`/app/log/`) — read-only history of the last 50 entries with
|
|
star ratings. **There is no way to create a cook log from the web app** —
|
|
only via API or admin.
|
|
|
|
All HTMX action views are `@csrf_exempt` (commit `6786502` — "localhost-only
|
|
app"). CSRF token is still wired into `base.html` and sent, but the server
|
|
ignores it.
|
|
|
|
Front-end gaps worth knowing before changing things: !! this has become more important since no more caine
|
|
- No UI to log a cook, set a rating, or pick slot choices for a real meal.
|
|
- No UI for fixed recipes at all (browse, import, or cook).
|
|
- No UI to edit ingredients, aliases, preferences, tags, or staples.
|
|
- No UI to manage/create meta-recipes (admin or API only).
|
|
- Servings is fixed at 2 across the recipes page.
|
|
|
|
## 7. Duplication & correctness traps (read before editing)
|
|
|
|
1. **Two copies of the "what can I cook" matcher.** `views.what_can_i_cook`
|
|
(JSON, includes fixed recipes, configurable servings) and
|
|
`views_htmx.recipes_page` (HTML, meta-only, servings=2) implement the same
|
|
logic separately. They have already drifted. Any change to matching rules
|
|
must be made in both, or the logic should be extracted into one shared
|
|
helper first.
|
|
|
|
2. **Recipe URL import creates a `Recipe` with zero `RecipeIngredient`s.**
|
|
`import_recipe_url` stores the scraped ingredient list only as raw text in
|
|
the response ("needs_review"); it never links ingredients to the model.
|
|
Consequence: an imported fixed recipe has an empty ingredient list, so
|
|
`what_can_i_cook` considers it **always "ready"** (nothing to be missing).
|
|
Linking ingredients is a manual admin/API step that nothing automates yet.
|
|
|
|
3. **Three different "expiring soon" windows.** `what_can_i_cook` uses ≤2
|
|
days, `PantryItem.expiring` API action uses ≤3 days, shopping generation
|
|
uses ≤2 days, and the HTMX pantry/recipe views use ≤2 days. No single
|
|
constant — easy to make them inconsistent further.
|
|
|
|
4. **`log_cook` bypasses `CookLog.clean()`** (uses `.create`, no
|
|
`full_clean`), so the "exactly one recipe link" invariant isn't enforced
|
|
on that path, and `rating` can't be set through it.
|
|
|
|
5. **Deduction order contradicts its comment** (see §5).
|
|
|
|
6. **`is_staple` / restock relies on quantity reaching exactly 0.** Staple
|
|
restock logic filters `quantity=0`. Items are decremented by deduction and
|
|
merged on add, but nothing deletes a depleted row, so a staple at 0 stays
|
|
as a 0-qty row — which is what restock/shopping keys off. Fine, but means
|
|
stock rows accumulate rather than disappear.
|
|
|
|
7. **Declared `filterset_fields`/`search_fields` do nothing** (no
|
|
django-filter / filter backend installed) — don't rely on them.
|
|
|
|
## 8. Security / production state
|
|
|
|
Matches the "TODO" list in the design doc — none of these are done in code:
|
|
|
|
|
|
- `DEBUG = True` and a literal `django-insecure-…` `SECRET_KEY` checked into
|
|
`settings.py`. No env-var override.
|
|
- `ALLOWED_HOSTS` includes `food.tomflux.xyz` and `food.jihakuz.xyz`.
|
|
- **HTMX app has no authentication** and is CSRF-exempt. The design doc says
|
|
it's exposed at `food.tomflux.xyz`; protection relies entirely on nginx
|
|
blocking `/api/` and `/admin/`, and on nobody pointing at `/app/`. Anyone
|
|
who can reach `/app/` can read and mutate the whole pantry.
|
|
- API is properly auth'd (token/session, IsAuthenticated).
|
|
- Secrets/hardening (DEBUG off, real SECRET_KEY, session-auth on `/app/`) are
|
|
all still open.
|
|
|
|
## 9. Seed data vs. the live database (important)
|
|
|
|
`seed.py` creates: 8 tags, 29 ingredients, 22 pantry items, and **only 2
|
|
meta-recipes** (Stir Fry, Traybake) — no fixed recipes, no cook logs.
|
|
|
|
The committed `db.sqlite3.backup-20260402-174550` matches the seed exactly:
|
|
2 meta-recipes, 0 fixed recipes, 0 cook logs, 0 shopping items, 16 slot
|
|
options.
|
|
|
|
The design doc, however, describes **6 meta-recipes** (the 4 extra from "The
|
|
Quick Roasting Tin": Baked Pasta, One-Tin Curry, Roasted Fish, Traybake
|
|
Meatballs), 30+ extra ingredients, and 75 imported recipes. **None of that is
|
|
in this repo** — it was created on the production DB via the
|
|
`create-meta-recipe` / import APIs and never re-seeded. `cookbooks/
|
|
recipes_extracted.json` holds the raw scraped data but nothing loads it
|
|
automatically.
|
|
|
|
Implication: a fresh `migrate && seed` here gives you the minimal 2-recipe
|
|
system, *not* what's described in the docs or running in prod. If you need
|
|
parity, you'd pull the prod DB or replay the API calls.
|
|
|
|
## 10. Things to decide before changing (open questions)
|
|
|
|
These come straight out of the gaps above — likely candidates for "the
|
|
changes" depending on intent:
|
|
|
|
- **Auth on `/app/`** — the standing TODO. Add `login_required` (session
|
|
auth) or keep relying on nginx?
|
|
- **Cook logging from the web** — currently API/admin only; no rating capture
|
|
on the main path.
|
|
- **Fixed recipes** are half-built: importable but not ingredient-linked, not
|
|
shown in the web UI, and trivially "always cookable".
|
|
- **Unify the two matchers** before touching matching behaviour.
|
|
- **Production hardening** (DEBUG, SECRET_KEY) untouched.
|
|
- **Shopping list UX** — the design doc explicitly notes "Tom not happy with
|
|
current flow, needs more thought."
|
|
|
|
---
|
|
|
|
*Method: read every Python module, all templates, settings, urls, migrations,
|
|
the seed command, the deploy unit, and inspected the committed SQLite backup.
|
|
No code was changed.*
|