1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
|
# 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.*
|