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>
14 KiB
Research: How the Food app works today
Snapshot taken 2026-06-22, reading the code on
master(HEAD96d9c4b). 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()callsfull_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 noteCookLogdoes not overridesave(), soclean()only runs when something callsfull_clean()— DRF does, thelog_cookendpoint does not (it usesobjects.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 asready✅ /partial⚠️ /missing❌ and sorts ready-first. Staple base ingredients are assumed always available. Expiry warnings attached per ingredient.POST /api/log-cook/— creates aCookLog; ifdeduct: true, subtracts used quantities from the pantry via_deduct_ingredient. Accepts meta_recipe_id or recipe_id,slot_choices,servings,notes. Does not acceptratingeven 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 writesShoppingListItemrows.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 withrecipe_scrapers, optionally creates aRecipe. See the gotcha in §7 — it never createsRecipeIngredientrows.
_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 fromwhat_can_i_cookin 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)
-
Two copies of the "what can I cook" matcher.
views.what_can_i_cook(JSON, includes fixed recipes, configurable servings) andviews_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. -
Recipe URL import creates a
Recipewith zeroRecipeIngredients.import_recipe_urlstores 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, sowhat_can_i_cookconsiders it always "ready" (nothing to be missing). Linking ingredients is a manual admin/API step that nothing automates yet. -
Three different "expiring soon" windows.
what_can_i_cookuses ≤2 days,PantryItem.expiringAPI 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. -
log_cookbypassesCookLog.clean()(uses.create, nofull_clean), so the "exactly one recipe link" invariant isn't enforced on that path, andratingcan't be set through it. -
Deduction order contradicts its comment (see §5).
-
is_staple/ restock relies on quantity reaching exactly 0. Staple restock logic filtersquantity=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. -
Declared
filterset_fields/search_fieldsdo 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 = Trueand a literaldjango-insecure-…SECRET_KEYchecked intosettings.py. No env-var override.ALLOWED_HOSTSincludesfood.tomflux.xyzandfood.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. Addlogin_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.